Compare commits

...
Author SHA1 Message Date
Dylan M. Taylor 5933793982 security: prevent script injection in fuzz workflow (SonarCloud S7630)
The Fuzz step interpolated the user-controlled workflow_dispatch input
${{ inputs.fuzz_seconds }} directly into a run: shell command, allowing
script injection by whoever triggers the workflow. Pass it via an env var
(FUZZ_SECONDS) and reference "$FUZZ_SECONDS" so the value is treated as
data, not shell. Resolves SonarCloud githubactions:S7630 (BLOCKER).
2026-10-21 17:08:18 -04:00
skylar taylor-barrick 6d8cbac7dc Merge pull request #2945 from diegochagas/feat/xcf-read
Open GIMP XCF files: layers, groups, masks, blend modes, channels and selection
2026-10-21 14:35:32 -06:00
skylar taylor-barrick 18e2d45420 Merge pull request #6318 from robertoffmoura/fix/floating-selection-move-lag
Fix lag when dragging a floating selection with the Move tool
2026-10-21 14:25:55 -06:00
Dylan M. Taylor 87fa191818 ci: add sonar-project.properties for CI-based analysis
Marks the project ineligible for SonarCloud Automatic Analysis, resolving the
CI-vs-automatic-analysis conflict so the SonarCloud workflow succeeds.
2026-10-21 16:00:47 -04:00
Diego Chagas ca18689304 Open GIMP XCF files: layers, groups, masks, modes, channels and selection (#1076)
PhotoCraft reads GIMP's .xcf (and .xcf.gz) files, read only, through a new
reader in photocraft-io written from the public XCF specification: versions
0 to 26, uncompressed, RLE and zlib tiles, 8/16/32-bit integer and
16/32/64-bit float precisions (linear precisions get a linear profile),
pixel layers at any position, groups by item path with pass-through,
layer masks applied or disabled, opacity, visibility, locks, colour tags,
the 2.10 and legacy blend modes mapped to Photoshop's (modes without an
equivalent open as Normal with a note), the ICC profile, resolution,
guides, the comment, extra channels and the saved selection; indexed
images open as RGB. Text, vector and link layers open as their pixels;
layer effects, paths and floating selections are reported.

Fixtures are written by GIMP 3.2 itself (scripts/xcf_fixtures.py), each
with GIMP's merged rendering as a PNG. In gamma-encoded blend and
composite spaces our composite matches GIMP within 0.5/255; GIMP's
linear-light default, its Clip to backdrop and its clamp-after-alpha
for unbounded modes are documented and measured in docs/xcf.md.
2026-10-21 16:57:22 -03:00
Dylan M. Taylor 8e518d8169 ci: add SonarCloud scan via shared reusable workflow
Calls storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
with secrets: inherit. Adds SonarCloud badge to README where missing and
removes the redundant sonar-project.properties (config now passed by the
reusable workflow).
2026-10-21 15:47:04 -04:00
Brandon ThomasandClaude Opus 5.5 6a2d027ee2 Release: version 0.12.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 13:59:16 -04:00
Roberto Moura f4ea3139d7 Keep selection snap targets when dragging a guide with the Move tool 2026-10-21 16:37:54 +00:00
aiongg 68348dcafc Merge pull request #8271 from aiongg/integration-8
Integration batch: 86 PRs on main
2026-10-21 11:02:54 -04:00
Roberto Moura 8352b26c1a Fix lag when dragging a floating selection with the Move tool
- Replace whole-region Vec<f32> read_region/write_region in CutParts::new
  with tile-parallel cut_piece + try_fill_surface/try_clear_surface,
  sharing interior Arc<Tile> allocations in O(1) and activating the
  solid_tile hole-fill fast path on Background and mask targets.
- Cache piece_bounds on CutParts so CutParts::place avoids scanning tile
  content bounds on every drag frame.
- Parallelize composite_over across top tiles with Rayon and share fully
  opaque top tiles by Arc<Tile> clone in O(1), preserving COW tile
  identity for GPU canvas uploads.
- Parallelize Surface::translated across target tiles with Rayon and
  prune cleared tiles in-place inside edit_tiles.
- Skip zero-offset floating preview generation on pointer-down in
  move_ui::floating_doc.
- Exclude SnapKind::Selection when moving selected pixels with the Move
  tool so small initial drags do not snap back to (0, 0).
- Keep navigator_plain active during Move/Brush/Patch drags on a GPU
  canvas so navigator_gpu is not evicted into a synchronous full-document
  CPU thumbnail composite on the first drag frame.
- Parallelize row normalization in compose::render_reduced_in_bands and
  add a direct read_rgba_into fast path for unmasked Normal layers over
  an empty backdrop.
2026-10-21 13:21:10 +00:00
Dylan M. Taylor 04d0212c7a Merge pull request #6448 from dylanmtaylor/ci/shared-nix-workflow
ci: switch to shared nix workflow from craft-common-actions
2026-10-21 03:40:26 -04:00
aiongg abd0582add Integration: the Type units test makes its type after the resolution change, for #7125
#7125 keeps existing type's pixel size across Image Size, so the #6491 test's 12 pt layer, made before the change to 144 ppi, became 6 pt. The test now creates its layer at 144 ppi; its assertions are unchanged.
2026-10-21 05:04:04 +00:00
aiongg 194dfcf0ec Integration: Clear History resets the History Brush source and Undo brings it back, for #3534 and #3536 2026-10-21 05:04:04 +00:00
aiongg c5176efd93 Integration: a scale beyond f32 range is refused, not clamped, for #7048
Main's 3f216a0cd1 rejects numeric overflow before document edits; #7048's test predates it and expected 1e39 to clamp to 150 %.
2026-10-21 05:04:04 +00:00
aiongg b1e408d28f Merge the smart-filter track (#7066 #6398 #6397 #6297 #6299 #6301 #6303 #6315 #6305 #6317)
# Conflicts:
#	docs/file-format-parity.md
2026-10-21 05:04:04 +00:00
aiongg a2c56f960f Integration: gaps.md preferences count from the regenerated scorecard, for #6208 and #6211 2026-10-21 05:04:04 +00:00
aiongg 8e04e6f21e Integration: regenerate docs/scorecard.md for the batch 2026-10-21 05:04:04 +00:00
aiongg cb8bea1636 Merge PR #7044: Conditional Mode Change: "gray" as a single source mode matches Grayscale (#7042)
# Conflicts:
#	crates/engine/src/file_cmds/tests.rs
2026-10-21 05:04:04 +00:00
aiongg 1d567a115f Merge PR #7015: Actions: deleting an action removes its function key (#7014)
# Conflicts:
#	crates/engine/src/actions_cmds.rs
2026-10-21 05:04:04 +00:00
aiongg 2a1ac6a6fb Merge PR #6208: Preferences: Save in Background off saves in the foreground (#6207)
# Conflicts:
#	docs/gaps.md
#	docs/scorecard.md
2026-10-21 05:04:04 +00:00
aiongg 050caf9ecd Merge PR #6681: Content-Aware Move: live preview for large selections (TOOL-213-4, #6680)
# Conflicts:
#	docs/gaps.md
#	docs/scorecard.md
#	docs/target-app-parity.md
2026-10-21 05:04:04 +00:00
aiongg 4804148ff9 Merge PR #3759: Pattern Stamp and Background Eraser draw live; Spot Healing shows its heal area (TOOL-213-7, #3754)
# Conflicts:
#	docs/gaps.md
#	docs/target-app-parity.md
2026-10-21 05:04:04 +00:00
aiongg f163e3ce0b Merge PR #6211: Zoom tool: hold to zoom continuously with Animated Zoom on (#6210)
# Conflicts:
#	docs/gaps.md
#	docs/scorecard.md
#	docs/ui-parity.md
2026-10-21 05:04:03 +00:00
aiongg 1a5d22ca94 Merge PR #6708: Paths: the path selected in the Paths panel is drawn on the canvas (#6550)
# Conflicts:
#	crates/ui-egui/src/vector_ui.rs
2026-10-21 05:04:03 +00:00
aiongg fc371711e6 Merge PR #5847: Shapes: Density and Feather on shape layers (#5846)
# Conflicts:
#	docs/scorecard.md
#	docs/target-app-parity.md
#	docs/ui-parity.md
2026-10-21 05:04:03 +00:00
aiongg cd710ca898 Merge PR #5622: Filters: Lens Correction, Adaptive Wide Angle and Camera Raw Filter respect locks on smart objects (#5621)
# Conflicts:
#	crates/engine/src/lens_cmds.rs
2026-10-21 05:04:02 +00:00
aiongg 34c5e7d49e Merge PR #3536: History: set the History Brush source from the panel (history.setSource) (#2966)
# Conflicts:
#	crates/engine/src/commands.rs
#	crates/ops/src/lib.rs
2026-10-21 05:04:02 +00:00
aiongg c23a6c9b90 Merge PR #6206: Performance: a cancelled Gaussian Blur job stops within budget (#6205)
# Conflicts:
#	docs/gaps.md
2026-10-21 05:04:02 +00:00
aiongg 375c5e1517 Merge PR #7085: Actions: an imported Crop step in a unit other than pixels is not applied as pixels (#7084) 2026-10-21 05:04:02 +00:00
aiongg 3d233afd31 Merge PR #7028: Transform Selection: an unknown reference point is an error (#7027) 2026-10-21 05:04:02 +00:00
aiongg 4699d3605c Merge PR #7026: Automation: place and layer/data-set export by index keep the active document (#7025) 2026-10-21 05:04:01 +00:00
aiongg 7db7f847c4 Merge PR #7024: Automation: doc.save rejects a bad quality instead of ignoring or clamping it (#7023) 2026-10-21 05:04:01 +00:00
aiongg 26844944b9 Merge PR #7005: Keyboard Shortcuts: switching sets keeps action function keys (#7004) 2026-10-21 05:04:01 +00:00
aiongg de03dd43f1 Merge PR #6861: CLI batch: skip shell-only Save and view steps inside played action sets (#6860) 2026-10-21 05:04:01 +00:00
aiongg 1637ae25e1 Merge PR #6805: Droplets: carry the actions that Conditional steps call (#6804) 2026-10-21 05:04:01 +00:00
aiongg 9d5bf2a82c Merge PR #6773: Actions: renaming an action updates the Conditional steps that call it (#6772) 2026-10-21 05:04:01 +00:00
aiongg dc8b8834e0 Merge PR #5889: Image: Bitmap dialog with Output resolution and per-method settings (#5559) 2026-10-21 05:04:01 +00:00
aiongg 40895532aa Merge PR #5840: Export As: Resample menu, incl. Nearest Neighbor (#5838)
# Conflicts:
#	docs/scorecard.md
2026-10-21 05:04:01 +00:00
aiongg 5ec2b90f1b Merge PR #7771: Save for Web: later slices cover earlier ones, covered slices split into pieces (#7770) 2026-10-21 05:03:49 +00:00
aiongg c633674b6d Integration: one smart-filter count (41 plus Hue/Saturation, Perspective Warp, Filter Gallery and Camera Raw) in the parity doc and FILE-215-2, for #6317 2026-10-21 01:21:19 +00:00
aiongg 47488dff8e Integration: recount KNOWN smart filters (41), for #6317 2026-10-21 01:21:06 +00:00
aiongg bfce71cddd Integration: name #6317's test item pixelate_item (measured_item is taken), for #6317 2026-10-21 01:18:45 +00:00
aiongg af3dff34a6 Merge PR #6317: PSD: Mezzotint and Color Halftone smart filters open and save like Photoshop (#6316)
# Conflicts:
#	crates/io/src/smart_map.rs
#	docs/file-format-parity.md
#	docs/scorecard.md
#	scorecard/file_compat.toml
2026-10-21 01:18:35 +00:00
aiongg 3fee618fd6 Integration: recount KNOWN smart filters (39), for #6305 2026-10-21 01:18:16 +00:00
aiongg bc248fbcec Integration: name #6305's range check long_in_range and its test item measured_named_item (int_in and measured_item are taken), for #6305 2026-10-21 01:18:07 +00:00
aiongg 2c87bd630b Merge PR #6305: PSD: Offset and Custom smart filters open and save like Photoshop (#6304)
# Conflicts:
#	crates/io/src/smart_map.rs
#	docs/file-format-parity.md
#	docs/scorecard.md
#	scorecard/file_compat.toml
2026-10-21 01:17:09 +00:00
aiongg c8979b69e8 Integration: recount KNOWN smart filters (37), for #6315 2026-10-21 01:16:51 +00:00
aiongg 5a8a2be4d5 Integration: Wind, Extrude and Tiles use the shared enum_from, enum_out, int_in and pi_in, for #6315 2026-10-21 01:16:41 +00:00
aiongg 2c0d232083 Merge PR #6315: PSD: Wind, Extrude and Tiles smart filters open and save like Photoshop (#6314)
# Conflicts:
#	crates/io/src/smart_map.rs
#	docs/file-format-parity.md
#	docs/scorecard.md
#	scorecard/file_compat.toml
2026-10-21 01:16:28 +00:00
aiongg 0dcf8e8e0f Integration: recount KNOWN smart filters (34), for #6303 2026-10-21 01:15:44 +00:00
aiongg 99c94f1435 Integration: drop the duplicate int_in and name the enum reader enum_from (enum_param is #6299's writer), for #6303 2026-10-21 01:12:32 +00:00
aiongg 7c92cb988c Merge PR #6303: PSD: Diffuse, Trace Contour, Crystallize and Pointillize smart filters open and save like Photoshop (#6302)
# Conflicts:
#	crates/io/src/smart_map.rs
#	docs/file-format-parity.md
#	docs/scorecard.md
#	scorecard/file_compat.toml
2026-10-21 01:11:06 +00:00
aiongg 47b282cf50 Integration: recount KNOWN smart filters (30), for #6301 2026-10-21 01:10:15 +00:00
aiongg 73a52e1aa4 Integration: drop the duplicate int_in and ps_item (kept from #6299), for #6301 2026-10-21 01:10:08 +00:00
aiongg 9fbb648f8a Merge PR #6301: PSD: Smart Sharpen and Dust & Scratches smart filters open and save like Photoshop (#6300)
# Conflicts:
#	crates/io/src/smart_map.rs
#	docs/file-format-parity.md
#	docs/scorecard.md
#	scorecard/file_compat.toml
2026-10-21 01:09:51 +00:00
aiongg 58dbb8f0af Integration: recount KNOWN smart filters (28), for #6299 2026-10-21 01:09:20 +00:00
aiongg 95256cc86a Merge PR #6299: PSD: Surface Blur, Smart Blur and Radial Blur smart filters open and save like Photoshop (#6298)
# Conflicts:
#	crates/io/src/smart_map.rs
#	docs/file-format-parity.md
#	docs/scorecard.md
#	scorecard/file_compat.toml
2026-10-21 01:09:08 +00:00
aiongg c7ece37b15 Merge PR #6297: PSD: parameterless smart filters (Blur, Sharpen, Find Edges, Solarize…) open and save like Photoshop (#6296)
# Conflicts:
#	docs/file-format-parity.md
#	docs/scorecard.md
#	scorecard/file_compat.toml
2026-10-21 01:08:09 +00:00
aiongg 3a3796af05 Merge PR #6397: PSD: Perspective Warp smart filters open and save like Photoshop (#6268)
# Conflicts:
#	crates/io/src/smart_map.rs
#	docs/file-format-parity.md
#	docs/scorecard.md
#	scorecard/file_compat.toml
2026-10-21 01:07:59 +00:00
aiongg 61ef780550 Merge PR #6398: PSD: Hue/Saturation smart filters open and save like Photoshop (#6267) 2026-10-21 01:07:14 +00:00
aiongg d1b602212a Merge PR #7066: PSD: Hue/Saturation export writes each triplet in its own slot (#7065) 2026-10-21 01:07:14 +00:00
aiongg 0028c303e9 Merge PR #6707: Shortcuts: a command shortcut leaves the focused menu bar and runs (#6573) 2026-10-21 01:06:18 +00:00
aiongg bc6a09da25 Merge PR #7037: Match Zoom: matched views stay within each document's zoom range (#7036) 2026-10-21 01:06:18 +00:00
aiongg 831afd2408 Merge PR #7008: Timeline: a longer duration moves a work area that reached the old end (#7006) 2026-10-21 01:06:18 +00:00
aiongg 12c397a4b9 Merge PR #6989: Control: ui.set rejects zoom, rotation, center and dockWidth beyond f32 range (#6988) 2026-10-21 01:06:18 +00:00
aiongg b5f48b3d7b Merge PR #6213: Tools: each painting tool's brush persists across launches; restored tool options are clamped (#6212)
# Conflicts:
#	docs/scorecard.md
2026-10-21 01:06:17 +00:00
aiongg 4f4dcb3a01 Merge PR #7748: Brush smoothing: catch-up compares the pose too, not only the position (#7747) 2026-10-21 01:06:17 +00:00
aiongg 8c33acf7cc Merge PR #7733: Painting symmetry: canvas edits move the axis with the pixels (#7732) 2026-10-21 01:06:17 +00:00
aiongg ac07901f1f Merge PR #6859: Guides: ruler guide snapping rebuilds its targets when snap options change (#6858) 2026-10-21 01:06:17 +00:00
aiongg ce9c669584 Merge PR #6822: Type: paragraph box handles are picked at the unsnapped press (#6819) 2026-10-21 01:06:17 +00:00
aiongg 13a615ca2f Merge PR #6357: Move tool: Ctrl-hover shows the distance to another layer (#4946) 2026-10-21 01:06:17 +00:00
aiongg 6cbfbfeda3 Merge PR #6721: Paths: drag a path row onto the Trash to delete it (#6553) 2026-10-21 01:06:16 +00:00
aiongg 3e6614f92b Merge PR #6719: Paths: Delete Path on a shape layer's path row deletes the layer (#6552) 2026-10-21 01:06:16 +00:00
aiongg ac9b33e8b9 Merge PR #5887: Effects: Gradient Overlay transparency follows Smoothness, midpoints and method (#1954) 2026-10-21 01:06:16 +00:00
aiongg 6c2bcd52e8 Merge PR #7079: Merge to HDR Pro and merged duplicates: the Background layer is a real Background (#7078) 2026-10-21 01:06:16 +00:00
aiongg c15c1e214e Merge PR #7766: Layer Style: a new Stroke is 1 px inside black, as in Photoshop (#7765) 2026-10-21 01:06:16 +00:00
aiongg ac11edc9ec Merge PR #7048: Layer Style: gradient overlay and stroke scale stay within 10-150 % (#7047) 2026-10-21 01:06:16 +00:00
aiongg 69954138f9 Merge PR #7001: Stamp Down keeps the lower layer's clipping, label and link (#6999) 2026-10-21 01:06:16 +00:00
aiongg 9b889e1a58 Merge PR #6640: Layers: toggling a clipped adjustment damages only its base (#6535) 2026-10-21 01:06:16 +00:00
aiongg 6172d967ae Merge PR #5784: Paint: Lock Transparency blends source-atop on semi-transparent pixels (#5783) 2026-10-21 01:06:16 +00:00
aiongg 56102a11fc Merge PR #5753: Fill layers: reject non-finite gradient angles and pattern angle/phase (#5752) 2026-10-21 01:06:16 +00:00
aiongg acdf17f858 Merge PR #5680: Layer masks: From Transparency and Mask All Objects convert the Background and match the document depth (#5679) 2026-10-21 01:06:16 +00:00
aiongg 8c942e481d Merge PR #7224: Image: Trim and merged Duplicate report an error on documents over 2^30 px (#7223) 2026-10-21 01:06:16 +00:00
aiongg 73a6dc7215 Merge PR #7125: Image Size: type keeps its pixel size when the resolution changes (#7124) 2026-10-21 01:06:15 +00:00
aiongg d3a78a374a Merge PR #6980: Image Size: Resolution with Resample on keeps the print size in physical units (#6979) 2026-10-21 01:06:15 +00:00
aiongg dd8770f96d Merge PR #6767: Fit Image: a width or height of 0 leaves that side unconstrained (#6766) 2026-10-21 01:06:15 +00:00
aiongg a91466ae3a Merge PR #7762: Perspective Crop: Color Sampler points move with the image (#7760) 2026-10-21 01:06:15 +00:00
aiongg ef91cec53e Merge PR #7136: Perspective Crop: W and H follow the Point/Pica Size preference (#7135) 2026-10-21 01:06:15 +00:00
aiongg 8b1d4e7eed Merge PR #7131: Perspective Crop: switching documents drops the pending box (#7130) 2026-10-21 01:06:15 +00:00
aiongg 41299afec4 Merge PR #7062: Perspective Crop: a drag without area leaves no crop box (#7061) 2026-10-21 01:06:15 +00:00
aiongg f1cc8ad423 Merge PR #7753: Crop: a typed ratio fits from the frame the edit started with (#7752) 2026-10-21 01:06:15 +00:00
aiongg a0078c3e1f Merge PR #7134: Crop: a refused crop keeps the frame for fixing (#7133) 2026-10-21 01:06:15 +00:00
aiongg a9b71c5e95 Merge PR #7731: Crop: Undo/Redo grey out when the frame has no step left (#7730) 2026-10-21 01:06:15 +00:00
aiongg fb578585fe Merge PR #7089: Crop: in default mode, box Undo/Redo keeps the box in place (#7088) 2026-10-21 01:06:15 +00:00
aiongg 2d14c7ceb7 Merge PR #7128: Crop: Straighten is one box Undo step (#7127) 2026-10-21 01:06:15 +00:00
aiongg 0c26c36e55 Merge PR #7746: Crop: a lost Straighten release no longer hijacks the next drag (#7745) 2026-10-21 01:06:14 +00:00
aiongg 5eafbccc21 Merge PR #7041: Crop: the turn readout never shows -0.0° or -180.0° (#7040) 2026-10-21 01:06:14 +00:00
aiongg 434c276eee Merge PR #7039: Crop: W x H x Resolution follows the Point/Pica Size preference (#7038) 2026-10-21 01:06:14 +00:00
aiongg 41819a01e2 Merge PR #7087: Crop / Canvas Size: extending keeps the Background's hidden pixels (#7086) 2026-10-21 01:06:14 +00:00
aiongg c92fa0a1c7 Merge PR #7723: Crop: a W x H crop on an Indexed Color document resamples with Nearest Neighbor (#7722) 2026-10-21 01:06:14 +00:00
aiongg 3807454782 Merge PR #7013: Crop: Color Samplers scale with a W x H crop (#7012) 2026-10-21 01:06:14 +00:00
aiongg d1bed2f98d Merge PR #6644: Hue/Saturation: the Lightness track runs black, grey, white (#6539) 2026-10-21 01:06:14 +00:00
aiongg ea1c6fce1f Merge PR #6781: Apply Image: write into the targeted layer mask (#6780) 2026-10-21 01:06:14 +00:00
aiongg 68f8be3967 Merge PR #5791: PSD Multichannel: alpha channels are stored as-is, not as ink (#5790) 2026-10-21 01:06:14 +00:00
aiongg 81c030f217 Merge PR #7003: Channels: Multichannel conversion keeps the targeted channel (#7002) 2026-10-21 01:06:14 +00:00
aiongg ab88a0e4bb Merge PR #6777: Auto-Align: warp layer masks as masks, keeping what lies outside revealed (#6776) 2026-10-21 01:06:14 +00:00
aiongg 954a7560ef Merge PR #7030: Lens Correction: smart filters keep the exact focal length (#7029) 2026-10-21 01:06:14 +00:00
aiongg f1784d1497 Merge PR #6722: PSD: open an adjustment layer of an unknown type as an unsupported adjustment (#6575) 2026-10-21 01:06:13 +00:00
aiongg ea5aa58fb6 Merge PR #6775: PSD: read colours stored as HSB (#6774) 2026-10-21 01:06:13 +00:00
aiongg de33a1e376 Merge PR #6803: PSD: layered TIFF byte-order conversion swaps every smart filter section (#6802) 2026-10-21 01:06:13 +00:00
aiongg e5f2107095 Merge PR #6765: PSD: layered TIFF byte-order conversion reads the class of Idnt/indx references (#6764) 2026-10-21 01:06:13 +00:00
aiongg b9404a6eda Merge PR #3534: History: panel menu, Step Forward/Backward and Clear History (#2966) 2026-10-21 01:06:12 +00:00
aiongg d4576a0c5a Merge PR #5110: Performance: brush dab on a CMYK document within budget (#5108) 2026-10-21 01:06:11 +00:00
aiongg 5d1f3ec479 Merge PR #5109: Tests: generated large-file fixtures and a P20 number (#5107)
# Conflicts:
#	docs/scorecard.md
2026-10-21 01:06:10 +00:00
aiongg 5c9ba773b4 Merge PR #3516: scorecard: five rows catch up with work already on main
# Conflicts:
#	docs/scorecard.md
2026-10-21 01:06:08 +00:00
aiongg b097800e78 Merge pull request #8095 from aiongg/integration-7
Integration batch: 9 PRs on main
2026-10-20 20:44:51 -04:00
aiongg 24ffcbc4bc Merge pull request #8094 from aiongg/integration-6
Integration batch: 26 PRs from the known-issues list (#7973) on main
2026-10-20 20:44:09 -04:00
aiongg 9ac1d5adc6 Integration: regenerate docs/scorecard.md for the batch
#7813's performance.gpu_adapter is read from the prefs file before the window opens (gpu_startup::read_gpu_adapter), which the prefs audit's heuristic doesn't see, so it counts as unread.
2026-10-20 22:07:10 +00:00
aiongg f679b4cf1f Integration: rustfmt the merged tree, for #6195 and #5857
cargo fmt --check failed on #6195's module order and canvas.rs guard, and on the IMPORT_ONLY line.
2026-10-20 22:07:10 +00:00
aiongg 1f6c7fff60 Integration: keep the folder pickers for Render Video and Video Frames to Layers, for #3223
view_cmds runs before menus.rs, so wrapping these ids replaced main's folder pickers with a text-field dialog and a file picker; file_dialog's render_video test covers the folder route.
2026-10-20 22:07:10 +00:00
aiongg bc1becfca6 Merge PR #3223: fix(ui): wire video menu pickers/dialogs and fix Timeline Stop playback 2026-10-20 22:07:10 +00:00
aiongg 6265d67de9 Integration: naming templates and more read-only formats use same_format, for #5857
With a naming template, Format Same still took the raw extension (IMG_0001_001.dng: unknown file format). HEIC, SVG, Cineon, PCX and XPM open but have no writer, so they save as PSD too; DDS has a writer and keeps its format.
2026-10-20 22:07:10 +00:00
aiongg f5c2f2091e Merge PR #5857: Batch: Format "Same" saves camera raws as PSD instead of failing (#5799) 2026-10-20 22:07:10 +00:00
aiongg b86f525724 Integration: drop the clipboard CI job and the GPU evidence test, for #5587
The ci.yml job is a CI-policy change outside the fix; the ignored evidence test only wrote PR screenshots.
2026-10-20 22:07:10 +00:00
aiongg 92513627da Merge PR #5587: Preserve editable clipboard contents when native reads fail 2026-10-20 22:07:10 +00:00
aiongg 3a8ea8d9ad Integration: barrel check reads only the pen, not the global right button, for #6195
GetAsyncKeyState(VK_RBUTTON) reads the physical button system-wide: with swapped buttons a primary click read as right-down, and a right button held over another window counted too.
2026-10-20 22:07:10 +00:00
aiongg 77cd7118ee Merge PR #6195: fix(tablet): support stylus barrel button and brush resize on Windows (#5905, #5364) 2026-10-20 22:07:10 +00:00
David Mann bc5e4d4c8d Merge PR #7948: PSB save: 8-byte-length global blocks use the 8B64 signature, so Photoshop opens the file (#7937)
Integration: #4816's fix-up made `block_signature(sig, version)` crate-visible for the
layerless Indexed/Bitmap writer (`multichannel_map::layerless_file`); #7948 adds the block
key so PSB long-length blocks get `8B64`. Kept #7948's function as `pub(crate)` and pass
the key from the layerless writer too, so an Indexed or Bitmap PSB gets the same fix.
2026-10-20 23:02:48 +02:00
David Mann d089eaa81b Merge PR #7693: perf(compose): Reuse bounded scratch and tone tables 2026-10-20 22:39:29 +02:00
David Mann 651c32b9eb Merge PR #7566: perf(compose, gpu): many-layer composites, P60/P61 on the GPU 111 -> 28 ms 2026-10-20 22:39:26 +02:00
David Mann 6db0473b43 Merge PR #7180: Brushes: accept .abr files up to 1 GiB 2026-10-20 22:39:24 +02:00
David Mann 34c37aa260 Merge PR #6913: PSD: read split ("quilt") warps of placed layers, so Replace Contents keeps their curvature (#4398) 2026-10-20 22:39:22 +02:00
David Mann f498375d52 Merge PR #6272: PSD: render constant RGB noise gradient fills 2026-10-20 22:39:17 +02:00
David Mann 0cbfb859ed Merge PR #6169: Preserve imported PSD font metadata during text edits 2026-10-20 22:39:15 +02:00
David Mann 2adfe0c4e2 Merge PR #5807: Type: keep superscript and subscript when a text layer is re-rendered (#5645) 2026-10-20 22:39:12 +02:00
David Mann e4692cd71c Merge PR #4816: PSD: save Indexed Color, Bitmap and Duotone documents in their own mode 2026-10-20 22:39:10 +02:00
baa854341b Integration: Indexed PSD keeps transparency, ICC and channels (#4816)
Saving an Indexed Color document in its own PSD mode lost data the RGB
file used to keep (the five open CodeRabbit findings, all reproduced):

- an opaque pixel whose colour matched the transparent entry (opaque
  black next to a black transparent entry, common in PNG-8 files) was
  written as that entry, so it turned transparent; a table whose only
  entry is the transparent one now keeps the RGB file;
- PhotoCraft reopened the file with resource 1047 on the table only, so
  transparent pixels came back opaque (Photoshop stores transparent
  pixels of an Indexed file as the 1047 entry, checked on a GIF it
  opened and saved as PSD);
- the ICC profile was dropped (Photoshop writes 1039 for Indexed); only
  an RGB profile is written, as it describes the table;
- alpha channels and the Quick Mask were dropped without a warning; the
  importer doesn't read extra channels of these modes back, so such
  documents keep the RGB / Grayscale file;
- without a transparent entry, empty areas became the nearest colour to
  transparent black instead of white;
- a PSB-sourced 8B64 global block stayed 8B64 in a PSD (Multichannel
  too), which Photoshop refuses.

Tests: one per point in psd_structure.rs; each fails without this commit.

Co-authored-by: Gert Pellin <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 22:14:08 +02:00
Akos Arinton 425dd76902 test(gpu): a requested backend fails the parity tests at every setup step
With WGPU_BACKEND set, a device or compositor that could not be created still skipped the
tests (`.ok()?`); like a missing adapter, it now fails them. Without WGPU_BACKEND the tests
skip as before.

Claude-Session: https://claude.ai/code/session_01Aab8aPCQgoN1sYrWPk8Kr6
2026-10-20 22:23:46 +03:00
aiongg 161d707c04 Integration: run retouch blur rows serially on wasm, for #5499
photocraft-algo declares rayon only for non-wasm targets; an unconditional rayon import broke the wasm build. Same cfg split as blur.rs.
2026-10-20 18:41:57 +00:00
aiongg d94c277566 Merge PR #5499: perf(retouch): row-parallel Blur/Sharpen dabs, 2.6-3.3x faster strokes (#5426) 2026-10-20 18:41:28 +00:00
aiongg db56aeb10a Merge PR #7813: Choose the graphics adapter on multi-GPU Linux machines (#1885)
# Conflicts:
#	apps/photocraft/src/gpu_startup.rs
#	crates/ui-egui/src/i18n/cs.tsv
#	crates/ui-egui/src/i18n/de.tsv
#	crates/ui-egui/src/i18n/el.tsv
#	crates/ui-egui/src/i18n/es.tsv
#	crates/ui-egui/src/i18n/fr.tsv
#	crates/ui-egui/src/i18n/hu.tsv
#	crates/ui-egui/src/i18n/id.tsv
#	crates/ui-egui/src/i18n/it.tsv
#	crates/ui-egui/src/i18n/ja.tsv
#	crates/ui-egui/src/i18n/ko.tsv
#	crates/ui-egui/src/i18n/nl.tsv
#	crates/ui-egui/src/i18n/pl.tsv
#	crates/ui-egui/src/i18n/pt-br.tsv
#	crates/ui-egui/src/i18n/ru.tsv
#	crates/ui-egui/src/i18n/sr.tsv
#	crates/ui-egui/src/i18n/tr.tsv
#	crates/ui-egui/src/i18n/uk.tsv
#	crates/ui-egui/src/i18n/vi.tsv
#	crates/ui-egui/src/i18n/zh-hans.tsv
#	crates/ui-egui/src/i18n/zh-hant.tsv
#	crates/ui-egui/src/prefs_ui.rs
2026-10-20 18:41:25 +00:00
aiongg 8d00c31ddf Merge PR #6711: fix(ui): disable Batch without a recorded action
# Conflicts:
#	crates/ui-egui/src/view_cmds.rs
2026-10-20 18:40:47 +00:00
aiongg 1ca8a8aad0 Merge PR #7964: Preserve near-black ICC colors on the GPU canvas
# Conflicts:
#	contributors/people.toml
2026-10-20 18:39:43 +00:00
aiongg 8e5b71e6e3 Merge PR #6909: perf(camera-raw): develop the preview off the UI thread (#5964)
# Conflicts:
#	contributors/people.toml
2026-10-20 18:39:39 +00:00
aiongg ad2d420be2 Merge PR #7683: Web: start when the server already inflated the app's gzip part (Apache mod_mime_magic) (#7639) 2026-10-20 18:39:30 +00:00
aiongg 9ddc2805f0 Merge PR #6878: Windows: maximized window lets an auto-hidden taskbar show (#4845) 2026-10-20 18:39:29 +00:00
aiongg 60764172aa Merge PR #7217: GPU startup: Auto on Windows leaves Vulkan out when an Intel adapter is present (#4) 2026-10-20 18:39:29 +00:00
aiongg 1e2bea1060 Merge PR #6395: Windows: PhotoCraft starts without Windows.Data.Pdf.dll (#5884) 2026-10-20 18:39:29 +00:00
aiongg 54a5146754 Merge PR #7728: Paragraph panel: grey the justify buttons on point text (#7713) 2026-10-20 18:39:29 +00:00
aiongg 44ef277439 Merge PR #6693: macOS: list fonts activated outside the font folders (#6683) 2026-10-20 18:39:29 +00:00
aiongg e2169d0dfd Merge PR #7847: Print: Portrait and Landscape in the Print dialog on Windows 2026-10-20 18:39:29 +00:00
aiongg c247a34bb6 Merge PR #7892: Indexed GIF opens in Indexed Color with its colour table, as in Photoshop (#7886) 2026-10-20 18:39:28 +00:00
aiongg c8cf9eaf0d Merge PR #7180: Brushes: accept .abr files up to 1 GiB 2026-10-20 18:39:28 +00:00
aiongg ea7553f031 Merge PR #5111: Large documents: a gigapixel New Document draws without freezing (#4017) 2026-10-20 18:39:28 +00:00
aiongg b82540845f Merge PR #7773: Give a new type layer a full Txt2 object and its own frame 2026-10-20 18:39:28 +00:00
aiongg 0ae688b59a Merge PR #6939: Mixer Brush: Clean Brush empties the brush, and the automatic Load and Clean can be switched off 2026-10-20 18:39:27 +00:00
aiongg 528ae50c7a Merge PR #6920: Pen: a finished path keeps its anchor squares on screen (#6884) 2026-10-20 18:39:27 +00:00
aiongg 20fb59b3f0 Merge PR #6222: Reveal newly created selections after hiding Extras 2026-10-20 18:39:27 +00:00
aiongg 40fed5e8ef Merge PR #5803: Snap: View › Snap off also disables smart-guide snapping (#5782) 2026-10-20 18:39:27 +00:00
aiongg c369483455 Merge PR #7225: Group all selected layers from the Layers panel footer 2026-10-20 18:39:27 +00:00
NetterSilverandClaude Opus 5.5 e86e6910ae fix(psd): bound the base style run a save copies per run
The base run comes from the file and is cloned once per character run, so
it now has to fit the template node budget, and each font is looked up
once per name. Also names the reopened-layer tuple in psd_kerning for
clippy::type_complexity.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-20 20:07:39 +02:00
f65cea0ea0 Integration: keep split warps past the quilt limit in place on PSD save (#6913)
The reader takes a `quiltWarp` of at most 64 patches along a side, but the
writer wrote any split warp and framed `Trnf` by its control box. A Warp
Grid of 64 plus one more split (65 patches) was refused on reopen while its
corners still framed the warped box, so the layer came back stretched and
shifted ([2,0,0,2,120,210] -> [2.4,0,0,2.4,100,200]). Such a warp is now
written as no warp, with a message, framed by the source rect, as before
#6913, and a stale imported `quiltWarp` is dropped.

Co-authored-by: Thang Nguyen <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 19:53:57 +02:00
David MannandClaude Opus 5.5 b3dfd1bf6a Regenerate docs/scorecard.md
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 19:51:52 +02:00
NetterSilverandClaude Opus 5.5 20c9103148 fix(psd): write complete Txt2 style runs from the layer, number only file indices as kept
Photoshop reads the character style from Txt2 and quit on the bare run
(`/0 0 /11 mode`) that new objects got. New and edited objects now get one
style run per character run, built on one of the object's own runs with
every key kept, and font, size, faux styles, leading, scales, tracking,
fill colour, OpenType keys and auto-kern mode set from the layer. A font
the block's font list lacks is appended to it. Edited objects therefore no
longer keep the old text's features by position.

build_tysh no longer writes TextIndex 0 for a layer without one: the
export took it as a kept number, so a Type tool layer landed on object 0
(a deleted layer's, or the one of a layer above it). set_text_index puts a
missing TextIndex right before EngineData, where Photoshop writes it.

pad_frames pads to at most MAX_TEXT_INDEX + 1 frames.

edited_text_keeps_the_files_txt2_object (#7220) now expects style runs
that follow the layer's runs and size.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-20 19:48:40 +02:00
David MannandClaude Opus 5.5 a9e779fd92 Regenerate docs/scorecard.md
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 19:42:32 +02:00
David MannandClaude Opus 5.5 2a4a15f3ab Regenerate docs/scorecard.md
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 19:40:18 +02:00
David MannandClaude Opus 5.5 a23d48f130 Merge main into docs/scorecard-stale-rows-2
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 19:25:37 +02:00
David MannandClaude Opus 5.5 d449d25d62 Merge main into feat/file-216-2-large-fixtures
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 19:25:10 +02:00
David MannandClaude Opus 5.5 b22b7b4645 Merge main into fix/5559-bitmap-dialog
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 19:24:57 +02:00
David MannandClaude Opus 5.5 8254fbbd3f Merge main into feat/file-215-2-noparam
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 19:24:32 +02:00
David MannandClaude Opus 5.5 b1b4a54e4a Merge main into fix/headless-save-quality
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 19:24:12 +02:00
David MannandClaude Opus 5.5 106600284f Merge main into feat/file-215-2-blur
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 19:23:52 +02:00
David MannandClaude Opus 5.5 f83bbd0a14 Merge main into feat/file-215-2-sharpen-noise
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 19:23:20 +02:00
David MannandClaude Opus 5.5 738541efda Merge main into perf/p25-blur-cancel
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 19:23:08 +02:00
David MannandClaude Opus 5.5 31dd6e7e41 Merge main into feat/ui-217-4-animated-zoom
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 19:23:08 +02:00
David MannandClaude Opus 5.5 a939785d63 Merge main into feat/ui-217-1-tool-options-persist
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 19:23:07 +02:00
David MannandClaude Opus 5.5 6ff19e55c0 Merge main into feat/file-215-2-stylize-pixelate
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 19:22:51 +02:00
David MannandClaude Opus 5.5 83aec38ba6 Merge main into feat/file-215-2-other
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 19:22:08 +02:00
David MannandClaude Opus 5.5 7240785492 Merge main into feat/file-215-2-wind-extrude-tiles
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 19:21:36 +02:00
David MannandClaude Opus 5.5 60ba61b1a4 Merge main into feat/file-215-2-mezzotint-halftone
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 19:21:06 +02:00
David MannandClaude Opus 5.5 f81230d86f Merge main into fix/6268-psd-perspective-warp-sf
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 19:20:37 +02:00
David MannandClaude Opus 5.5 c033313aae Merge main into fix/6267-psd-hue-sat-sf
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 19:19:06 +02:00
David MannandClaude Opus 5.5 73ba0b7a68 Merge main into feat/tool-213-4-cam-preview-large
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 19:12:13 +02:00
David Mann 38605ceac5 Merge main into feat/tool-213-7-live-retouch
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/ui-egui/src/canvas.rs
#	docs/gaps.md
#	docs/target-app-parity.md
2026-10-20 19:05:39 +02:00
David Mann c2ce75f9b7 Merge main into feat/ui-217-4-save-in-background
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	docs/gaps.md
#	docs/scorecard.md
2026-10-20 19:05:35 +02:00
Akos Arinton e4c2867494 fix(gpu): batch runs no longer depend on the prefix cache
`batch_runs` cut a run at the pass where the render captured its prefix checkpoint, so a cached
render grouped plain layers differently from an uncached one. On D3D12 with FXC a layer's bits
batched and drawn alone differ by about 1 ULP, so reuse stopped being exact there (#5286):
prefix_follows_an_edit_up_the_stack_and_stays_exact and
chunks_keep_their_own_checkpoint_after_a_partial_refresh failed on an RTX 5070 Ti.

The runs now depend on the plan alone and are computed once per render. `run_starts` marks the
passes a run starts at, and the prefix cache captures and resumes only at checkpoints there, so a
resumed render draws the passes above in the same runs as an uncached one. It may resume up to
three layers lower than before, one more batched pass.

The cache tests give their layers a reveal-all mask so each draws alone and keeps a checkpoint;
prefix_follows also runs on batched layers and checks that a capturing render batches like an
uncached one, which fails on the old executor on any backend.

Claude-Session: https://claude.ai/code/session_01Aab8aPCQgoN1sYrWPk8Kr6
2026-10-20 20:05:27 +03:00
David Mann 23b94c6f54 Merge main into fix/6573-menubar-shortcut
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/ui-egui/src/menu_keys_tests.rs
2026-10-20 19:05:23 +02:00
Akos Arinton bc1c75d303 test(gpu): parity tests on the backend WGPU_BACKEND asks for
wgpu's Instance::default() ignores WGPU_BACKEND and WGPU_DX12_COMPILER, so the parity tests
always took the default adapter: Vulkan on a Windows machine that has a driver for it, which hid a
D3D12-only difference. They now read both, and a backend asked for that has no adapter or can't
render Rgba32Float fails the tests instead of skipping them.

Claude-Session: https://claude.ai/code/session_01Aab8aPCQgoN1sYrWPk8Kr6
2026-10-20 20:05:00 +03:00
Guillem Hermida 872748fb5b Codecs: save DDS textures, uncompressed or BC1–BC3, with optional mipmaps (#1327) (#5993)
DDS textures can now be saved, not just opened: uncompressed (BGRA, BGRX, L8, A8L8; lossless) or BC1/BC2/BC3 (DXT1/3/5) from a clean-room block encoder, with an optional box-filtered mip chain, in the legacy header. Save As and Save a Copy ask with DDS Options; scripts pass `ddsCompression`/`ddsMipmaps` (file commands, control `app.save`, RPC `doc.save`, MCP `doc_save`/`doc_export`), and the CLI takes `--dds-compression`/`--dds-mipmaps`. Targa and DDS answers travel together as `FormatChoices` through the Save As flow.

Review follow-ups:
- File › Save behind DDS or Targa Options records a plain Save with its answer, and replaying `file.save` applies it (#3531).
- The block encoder finds the principal axis from every channel, so a channel that varies on its own can't hide the others' variation.
- DDS joins the shared round-trip suite; the header is built before the output buffer is allocated.
- Enter on a focused Cancel in DDS, Targa or TIFF Options cancels instead of saving.
2026-10-20 11:02:39 -06:00
David MannandClaude Opus 5.5 ce7e4f3752 Shape Density/Feather test: drag the curved Feather track from main (#6545)
Main made the Properties Feather slider's track cubic (#6727), so a
1% drag no longer reaches 10 px; drag a fifth of the way (8 px) as
main's own mask test does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 17:44:35 +02:00
Brandon ThomasandClaude Opus 5.5 9b9643764f Skip CodeRabbit reviews on Dependabot PRs (#7991)
Adds .coderabbit.yaml so CodeRabbit no longer reviews or comments on PRs opened by dependabot[bot].

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-20 11:38:22 -04:00
Dylan M. Taylor 8e7841554e Merge pull request #7429 from dylanmtaylor/docs/add-sonar-coderabbit-badges
docs: add SonarCloud and CodeRabbit badges to README
2026-10-20 11:30:45 -04:00
David Mann e7f66e46b7 Merge main into feat/file-216-2-large-fixtures 2026-10-20 17:29:22 +02:00
David Mann ca506335e0 Merge main into fix/fill-layer-angle-finite 2026-10-20 17:18:33 +02:00
David Mann 1b0b3e218c Merge main into fix/6267-psd-hue-sat-sf 2026-10-20 17:13:41 +02:00
David Mann 0b39361076 Merge main into feat/tool-214-12-shape-feather 2026-10-20 17:12:29 +02:00
David Mann a3196466a3 Merge main into fix/6268-psd-perspective-warp-sf 2026-10-20 17:09:56 +02:00
David Mann 2cb994459f Merge main into feat/file-215-2-mezzotint-halftone 2026-10-20 17:01:53 +02:00
David Mann f119922f01 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-20 16:58:51 +02:00
David Mann dd9671e8c1 Merge main into feat/file-215-2-wind-extrude-tiles 2026-10-20 16:57:02 +02:00
David Mann 19d69fd2c7 Merge main into feat/file-215-2-other 2026-10-20 16:52:07 +02:00
David Mann f5c78f7419 Merge main into feat/file-215-2-stylize-pixelate 2026-10-20 16:47:59 +02:00
David MannandClaude Opus 5.5 2f96260c99 Crop: the rotated target-size sampler test expects the turned pixel (10, 9) doubled (#7012)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 16:45:42 +02:00
Thang Nguyen f8d5345a30 fix(psd): write split warps back as quiltWarp
Reading `quiltWarp` left the save path behind, so two warps didn't survive
save and reopen:

- A split warp was written as `warpNone` while `Trnf` was the image of its
  control-point box, so the layer came back stretched and shifted.
- A template's `quiltWarp` was kept when `warp` was rewritten, and reading
  prefers it, so a warp changed to Arc, to none or to another mesh came back
  as the imported split.

`SoLd` now writes a split warp as Photoshop does: a `quiltWarp`
(`deformNumCols` x `deformNumRows` control points, the patch boundaries in
`quiltSliceX` / `quiltSliceY`) before a `warpNone` `warp`. An imported
`quiltWarp` stays as stored while the layer's warp is unchanged, keeps its
slices (Photoshop's units and padding) when the mesh is edited on the same
grid, and is rewritten or dropped otherwise. `PlLd`, which has no split
warp, stores none, with its corners framed by the source rect.
2026-10-20 21:43:56 +07:00
David Mann 7d4f756d2c Merge main into feat/file-215-2-sharpen-noise 2026-10-20 16:36:42 +02:00
David Mann 8aaf526108 Merge main into feat/file-215-18-export-resample 2026-10-20 16:36:25 +02:00
David Mann a738fcb9fe Merge remote-tracking branch 'origin/main' into HEAD 2026-10-20 16:34:25 +02:00
NetterSilverandClaude Opus 5.5 760109e23c fix(psd): give a new Txt2 object its own style and a number past the file's objects
A new object kept the template's style runs, and PhotoCraft's reader takes
font features from Txt2, so a new type layer came back with the template
layer's features (salt, ss01). Its style runs are now generated like the
stub's; only the template's structure is copied.

New type layers were numbered past the kept TextIndex values only, so after
deleting the layer with the highest index a new layer landed on its object
and took its style. They are now numbered past the file's Txt2 objects too.

txt2_extras_survive_a_resave_of_unchanged_text now gives its layer the TySh
index a file's layer has; without one the layer is new and no longer lands
on object 0.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-20 15:57:10 +02:00
Derek GliwaandClaude Opus 5.5 d53101c9a1 Brushes: keep the 512 MiB .abr cap on 32-bit targets
On wasm32 a near-1 GiB .abr arrives as base64 and is copied several times
before decoding; with a 1 GiB sample budget the import could pass 4 GiB and
abort the tab instead of failing cleanly. Pick the decoder budget by pointer
width (like MAX_DECODED_BYTES) and derive the engine's file cap from it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 08:53:55 -05:00
Rene Leonhardt a686a932c0 perf(compose): reuse bounded scratch and tone tables
Reuse tile scratch and tone tables across rendering work. Skip fully masked
adjustments and identity float curves. Previous-path tests preserve output
across sample depths, masks and clipping.
2026-10-20 15:52:44 +02:00
Derek Gliwa 4992a4305e Merge remote-tracking branch 'upstream/main' into abr-1gib-limit 2026-10-20 08:35:56 -05:00
David MannandClaude Opus 5.5 11d3026eeb Merge main into feat/file-215-2-blur
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 15:13:56 +02:00
David MannandClaude Opus 5.5 e05142c450 Merge main into feat/file-215-2-noparam
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 15:11:38 +02:00
Brandon ThomasandClaude Opus 5.5 46e538b3a2 Release 0.11.1
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 08:47:46 -04:00
Brandon ThomasandClaude Opus 5.5 8ede07b096 Format: build on FreeBSD (rustix has no xattr calls there)
The 0.11.0 release's FreeBSD job failed: copy_xattrs used rustix's
fsetxattr/flistxattr/fgetxattr, which rustix offers only on Linux,
Android and Apple systems. The real copy is now gated to those, and
other Unix systems get a no-op copy_xattrs, as Windows has. The xattr
tests are gated the same way; the Unix mode test now also checks that
no temporary file is left behind.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 08:47:42 -04:00
Brandon ThomasandClaude Opus 5.5 cd79a5a9e8 Release 0.11.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 08:23:58 -04:00
ha1des 472a8d6f29 Preserve near-black ICC colors on the GPU canvas 2026-10-20 14:18:00 +02:00
NetterSilverandClaude Opus 5.5 304f055021 fix(psd): point a new Txt2 object's frame strike at its own frame
new_object copied the template's rendered strikes, whose frame strike
(/99 /F) still named the template's frame in /10. It now names the new
object's frame, like the view's /0.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-20 14:04:33 +02:00
Korg1ch 09749739ad test(io): a PSB's lnk2/pths/cinf are written as 8B64 with an 8-byte length (#7937) 2026-10-20 13:38:41 +03:00
Korg1ch c706938bde fix(io): PSB writes 8-byte-length global blocks with the 8B64 signature (#7937) 2026-10-20 13:38:07 +03:00
Anim86 8fc62f55ab fix(tablet): support stylus barrel button and brush resize on Windows (#5905, #5364)
On Windows with Windows Ink (WM_POINTER), winit drops penFlags (PEN_FLAG_BARREL) and reports stylus contact as WindowEvent::Touch, which egui-winit maps exclusively to PointerButton::Primary. Additionally, Windows Ink does not update GetAsyncKeyState(VK_RBUTTON) when pressing the pen barrel button. Consequently, Alt + Barrel Button drag was recognized as Alt + Left Drag (triggering the eyedropper and painting strokes instead of brush resize), and right-clicks with the stylus were treated as left-clicks.

This commit resolves the issue by:
1. Adding a native Windows message hook (WH_GETMESSAGE) in crates/tablet/src/windows.rs calling GetPointerPenInfo to intercept PEN_FLAG_BARREL (0x00000001) and POINTER_FLAG_SECONDBUTTON.
2. Exposing 
ight_button_down in Services combining the pointer hook and GetAsyncKeyState(VK_RBUTTON).
3. Updating canvas.rs and paint_mouse.rs to prevent strokes_live and alt_eyedropper from hijacking the stylus gesture when the barrel button is held, and correctly routing resize events.
4. Adding tests asserting that Alt + stylus barrel drag resizes diameter horizontally and adjusts hardness vertically without triggering eyedropper or painting.
2026-10-20 11:17:58 +02:00
NetterSilverandClaude Opus 5.5 78cc36f24a fix(psd): keep preserved TextIndex values when a type layer is added below them
Text indices were assigned in one bottom-up pass, so a new layer took the
next free number before the layers above it claimed the numbers their TySh
keeps. Each of those was then renumbered onto its neighbour's Txt2 object
and took its style (70 of 74 layers on the #7698 file after adding a layer
at index 5). Kept numbers are now claimed first and new layers get numbers
past them.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-20 10:51:26 +02:00
fjamespriceandClaude Opus 5.5 2eb68c7872 fix(camera-raw): a later preview frame clears a worker failure from the status bar
A failed proxy develop on the worker set the status error, and nothing took
it down once a later develop succeeded. The dialog now remembers the message
and the preview revision it failed on, and clears the status when a newer
frame is published, unless something else has reported since.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 04:39:28 -04:00
fjamespriceandClaude Opus 5.5 c57947d6d8 test(camera-raw): settle() fails when the proxy worker's develop fails
poll_render clears the pending develop before it reports the worker's
error, so previewPending turned false with no new frame and settle()
returned as if one had landed. It now fails with the status message
when the dialog reported an error. With the worker forced to fail, all
21 scope tests passed before this change; now the one that settles
after a worker develop fails with "the Camera Raw preview worker
failed".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 00:44:33 -04:00
jonathanwxh-cell 8f4db4bc4f fix: integrate latest upstream and preserve editable clipboard copies 2026-10-20 11:50:34 +08:00
Korg1ch e92e5164a8 test(io): an indexed GIF keeps its table and transparent entry; malformed GIFs give no table (#7886) 2026-10-20 06:18:48 +03:00
jonathanwxh-cell 40130ae99e fix: reconcile PR with upstream changes 2026-10-20 11:16:40 +08:00
aiongg 03d0c7e3b3 Merge pull request #7823 from aiongg/integration-5
Integration batch: 8 PRs on main
2026-10-19 23:11:09 -04:00
fjamespriceandClaude Opus 5.5 bdf2e6cb31 test(camera-raw): wait for the proxy worker before recording a revision
The dialog's own controls develop the preview on a worker (#5964), so after
the scope tone drags its last frame could land after the test recorded
previewRevision and bump it during the hover check that must not re-render.
A settle() helper waits until previewPending is false; the fixture and the
drag test call it before taking a baseline.

With a 20 ms delay added to the worker, the old test failed 9 of 10 runs
with the reviewer's assertion; with settle() all 21 tests passed 10 of 10.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 23:02:04 -04:00
fjamespriceandClaude Opus 5.5 eafc410ce2 contributors: credit fjamesprice by GitHub profile name
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 23:02:04 -04:00
fjamespriceandClaude Opus 5.5 f708151c91 perf(camera-raw): develop the preview off the UI thread (#5964)
The Camera Raw dialog developed its preview proxy on the UI thread at the
start of every frame after a control changed. With Shadows or Highlights
set that develop takes 190-250 ms on a 24 MP document (858x572 proxy), so
each frame of a slider drag blocked for that long.

The dialog's own controls now hand the develop, histogram and display
conversion to a worker. One runs at a time; settings changed meanwhile are
developed when it finishes, and the preview shows the latest finished
frame. The control channel and the first frame still render on the UI
thread so a reply describes its own settings, and such a render drops any
worker still running with older settings. The wasm build keeps rendering
synchronously. describe() reports previewPending.

Release build, 6000x4000 document, 20 Highlights/Shadows edits:
worst UI frame 237 ms before, 35.5 ms after.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 23:01:57 -04:00
Korg1ch 04a2169b44 fix(io): an indexed GIF opens in Indexed Color with its colour table (#7886) 2026-10-20 05:45:16 +03:00
Michael Higgins b05b641122 Merge branch 'storytold:main' into fix/5799-batch-same-format-raw 2026-10-19 19:34:48 -04:00
jonathnwxh 28f27f5a11 Index imported font metadata fallback by name 2026-10-20 07:14:09 +08:00
Hector VillarrealandClaude Opus 5.5 a2f63703a1 Re-run CI
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 16:28:29 -06:00
WolfandClaude Opus 5.5 f8117a535c Print: a turn takes settings and paper from one answer (#7847 review)
The Orientation buttons stored the turned settings from file.print.page
and then asked the driver again (driver_page) for the paper. If that
second query failed, the dialog kept the new settings with the old paper.
take_turned_page now takes both, and the orientation, from the one
answer; an answer without both leaves the dialog as it was and shows why.

The no-driver test used "00ff", which fails when the settings are read,
before any printer is asked, so it never tested the failed-driver path.
It now uses valid settings (a minimal DEVMODEW) and asserts the error
comes from the printer, not from reading the settings; with "00ff" it
fails. a_turn_takes_settings_and_paper_together covers the success path
and partial answers.

Reported by CodeRabbit in review.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 22:54:46 +02:00
Ernie SmithandClaude Sonnet 5.5 53effe3c83 Review: keep the missing-adapter note, and forward the override in CPU mode
- A requested adapter that couldn't be used, followed by a software window
  adapter, lost its System Info note to the software-graphics message. Append
  instead of replacing.
- PHOTOCRAFT_GPU_ADAPTER only forces its own process when startup acts on it
  (Linux, outside CPU mode); in CPU mode it is ignored, so the launch may
  still go to the running window.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-19 16:31:57 -04:00
Gert Pellin 3f3d61a7ae Merge branch 'main' into feat/4814-psd-palette-modes 2026-10-19 22:27:21 +02:00
Ernie SmithandClaude Sonnet 5.5 0e97ac4ca8 Make adapter choice Linux-only everywhere, not just the dropdown
The Preferences row was Linux-only, but PHOTOCRAFT_GPU_ADAPTER and
performance.gpuAdapter were honoured on every platform, so Windows and macOS
could pick a non-default adapter without being tested for it. Ignore the
request off Linux (and in CPU mode, as before), and don't make a launch
standalone for a variable that is ignored there.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-19 16:19:51 -04:00
Ernie SmithandClaude Sonnet 5.5 2644d06081 Review: rustfmt, cap the env adapter name, docs in their own paragraph
- cargo fmt on the adapter selection and its launch code.
- PHOTOCRAFT_GPU_ADAPTER is capped at 256 characters like the saved preference.
- Put the multi-GPU paragraph in docs/development.md on its own instead of
  appending to the --safe-gpu paragraph, so the diff only adds lines.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-19 16:07:04 -04:00
WolfandClaude Opus 5.5 2f08860383 Print: Portrait and Landscape in the Print dialog on Windows
On Windows the Print dialog takes the paper and its orientation from the
printer driver, and had no way to change the orientation short of the
driver's own Print Settings… dialog. Photoshop has Layout buttons for it.
A print laid out landscape on portrait driver settings (from a script,
the CLI or another app) stopped with "the driver paper or orientation
differs from the print layout".

- The dialog has an Orientation row (Portrait, Landscape) under Print
  Settings…. It turns the driver settings through file.print.page and
  takes the paper from the driver again, so the paper and preview turn.
- file.print.page takes "orientation": "portrait|landscape" and returns
  the turned settings as printerSettings, with the page they give.
- print_windows turns the settings (the given ones or the printer's
  defaults) to the layout's orientation when the driver has the layout's
  paper the other way round; another paper is still refused.

Turning sets dmOrientation and its dmFields bit in the DEVMODE and keeps
every other byte, the driver's private settings included; the driver
validates the result as it does settings from Print Settings…. No new
UI strings: "Orientation", "Portrait" and "Landscape" are in every
catalog.

Tests: the turned settings (orientation set, all else kept, with and
without private driver bytes), when a paper counts as turned, an unknown
orientation refused before any printer is asked, the dialog's reading of
the driver paper and its error path, and, run by hand on Windows, a
real "Microsoft Print to PDF" driver turning for a landscape layout.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 21:46:41 +02:00
aiongg 46f7e2422e Integration: drop #7690's benchmark report, as #7690 did before it merged 2026-10-19 18:41:15 +00:00
Michael Higgins 7a8ec58e38 Merge branch 'storytold:main' into fix/6884-pen-tool 2026-10-19 14:32:11 -04:00
Brandon ThomasandClaude Opus 5.5 3a896a5efc docs: regenerate the scorecard (Show File Name In Title adds a preference) (#7829)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 14:23:47 -04:00
d5c7edacdf Layered TIFF: write PSD-style layer data unless a length overflows (Photoshop opened only the composite) (#5514)
The layered TIFF writer let the PSD exporter pick PSB from its size estimate, which counts every
layer plane uncompressed. A document with many canvas-sized layers passes 2 GB there while its
real layer data is a small fraction of it (a 3000×3441 mockup with 180 layers: estimate ~2.57 GB,
real data ~111 MB), so tag 37724 got the PSB-style header ("Adobe Photoshop Document Data
V0002", 8-byte lengths). Photoshop does not read that in a TIFF: it opens only the composite, as
one locked Background.

PsdExportOptions gains `size_limit_psb` (default true, .psd saves unchanged). The layered TIFF
turns it off and writes PSD-style data ("Document Data Block"), falling back to PSB only past
30000 px, when forced, or when a length really overflows 32 bits.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-19 14:21:01 -04:00
Marco Arnulfo 4f15642ee8 Toolbar: Edit Toolbar, Quick Mask and Screen Mode in every theme (part of #4653) (#4680) 2026-10-19 14:20:48 -04:00
David MannandClaude Opus 5.5 e306bcd685 Crop: a turned crop rotates only what the frame keeps (#5006) (#5007)
A turned crop rotated the whole canvas, then cropped to the frame. With
Delete Cropped Pixels on, every layer, mask and channel is now trimmed to
the frame's source (plus the interpolation kernel's reach) before the
turn, so the rotation costs the frame's size: a 512x442 frame turned 3°
on a 3859x2594 two-layer document goes from 541 ms to 42 ms.

Rotate Canvas also fills the Background with shared tiles instead of a
canvas-sized float buffer.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-19 14:20:42 -04:00
MarekSpodBiedry 1a03735cc6 fix(engine): apply document-wide image and colour conversions to stored video frames (#4956) (#5078) 2026-10-19 14:20:35 -04:00
Nicolas FalesyandClaude Opus 5.5 8bed609ef0 Flatpak on Wayland: say why a paste finds nothing, and how to paste anyway (#5031) (#5279)
In a Flatpak on Wayland, arboard can't open at all: the sandbox hides the
data-control protocol from it, and --socket=fallback-x11 gives no X server
while a compositor runs. Paste then reported "the clipboard holds no image"
even when it held one. Now the clipboard service reports that it had no
clipboard to read, and the paste shows a notice that explains it and gives
the XWayland command for this install.

Also adds my contributors/people.toml entry.


Claude-Session: https://claude.ai/code/session_01AbyXia7KGdFgbNjBeggY8J

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 14:20:29 -04:00
Persi f5af2f3ef5 PSD export: check the Background's opacity from its tiles (#5998) 2026-10-19 14:20:22 -04:00
David MannandClaude Opus 5.5 c136d2dd3d Guides: Ctrl/Cmd drags a guide with every tool that moves guides, and the cursor shows it (#6288) (#6294)
With Ctrl (Cmd on the Mac) held over a guide, Photoshop's tools that turn Ctrl into
the Move tool show the guide cursor and drag the guide. Three gaps remained:

- The Frame and Artboard tools showed the guide cursor under Ctrl but took the
  press themselves (a new frame or board), so the guide never moved. Their pointer
  handlers checked the selected tool instead of the effective one.
- The Gradient tool's live mode took a Ctrl press sent with the event (automation,
  `ui.pointer` with `command: true`) instead of handing it to the Move tool.
- With Window > Modifier Keys' latched Ctrl, the press took the guide but the
  pointer kept the tool's own cursor: `command_guide_at`, the decision the cursor
  and the guide context menu use, knew only the selection tools.

`command_guide_at` now covers every `hold_keys::cmd_moves` tool, and not during a
stroke or type editing. Frame, Artboard and live Gradient step aside for a Ctrl
press when they have no drag of their own under way. Tools that keep Ctrl for
themselves (Pen, Hand, Zoom, Crop, type, shapes, slices) and Lock Guides are
unchanged.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-19 14:20:16 -04:00
David MannandClaude Opus 5.5 5e1d90d86e Preferences: a wrongly typed value resets only that field (#6611) (#6641)
One value of the wrong type in prefs.json (say "recentFileCount": "ten")
made the whole file fail to deserialize, so every preference fell back
to its default for the session, and since the file was never rewritten
it happened again on every launch.

load_prefs_json now reads leniently when the file doesn't deserialize as
a whole: it starts from the defaults and lays the file's values over
them one at a time, descending into section objects and map entries,
keeping every value that still reads and leaving out only the ones that
don't. Colour settings get the same treatment instead of resetting as a
whole. It returns the left-out values (keys and original value); text
that isn't a JSON object still fails as before.

On launch the app shows a notice naming the reset paths and saves once:
the three-way merge base gets the unreadable values, so the merge
replaces (or removes) exactly those in the stored file and keeps
everything else, including keys this version doesn't know.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-19 14:20:09 -04:00
David MannandClaude Opus 5.5 77b845e098 Actions: loading a set renames each Play target once (#6806) (#6807)
Load Actions applied each rename as its own pass over every action in the set, so chained
renames ("A" -> "A 2", "A 2" -> "A 2 2") retargeted a step twice, and with replace:false it
also rewrote steps of actions already in the set. Now one original->new map is applied once
to the newly loaded actions' Play and Conditional (then/else) steps and to function keys.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-19 14:20:02 -04:00
MarekSpodBiedry 2cdc8cb73d Content-Aware Fill: preview at full resolution so it matches the result (#6530) (#6838) 2026-10-19 14:19:55 -04:00
FinnNkandClaude Opus 5.5 e3224000ca fix(psd): make packbits::decode_into crate-private and overflow-safe (#6950)
decode_into computed out.len() + expected unchecked. With a non-empty
buffer and an expected length near usize::MAX it panicked in debug builds
and, in release, wrapped and returned Ok having decoded nothing. Compute
the target with checked_add and report LimitExceeded, and test each packet
as len > target - out.len() so the bound checks cannot overflow either.

The only callers are decode and decode_rle in this module, and the crate
README documents packbits::{encode, decode} as the public API, so
decode_into becomes pub(crate).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 14:19:48 -04:00
David MannandClaude Opus 5.5 bf638a7a22 Crop: Enter during a drag ends the drag (#7055) (#7056)
Pressing Enter while the frame is still being dragged commits the crop; commit_crop now ends the drag, so the release no longer restores a frame in the old document's coordinates as a pending crop.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 14:19:42 -04:00
David MannandClaude Opus 5.5 b67502e20f Reveal All: one History step named "Reveal All" (#7145) (#7147)
image.revealAll ran image.crop, whose step is labelled "Crop". It now
runs it inside analysis_cmds::compound with the label "Reveal All".

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 14:19:35 -04:00
Michael HigginsandClaude Opus 5.5 73b79953e6 Print: Save as PDF gets a Browse… button (#5328) (#7586)
Save as PDF was only a free text field, so saving a PDF meant typing a
full path by hand. Browse… opens the standard Save dialog (pick_save),
suggesting the document's name as a PDF, and puts the chosen path in the
field. file_ui::body now gets the dialog id so the answer can be written
back into the Print dialog on a later frame.

Refs storytold/photocraft#5328

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 14:19:28 -04:00
NetterSilverandClaude Haiku 5.5 5a649f8e4e Apply background jobs to the layers they started on (#7620)
Fixes #7366

Co-authored-by: Claude Haiku 5.5 <[email protected]>
2026-10-19 14:19:22 -04:00
David MannandClaude Opus 5.5 2cc107d05f Crop: a ui.pointer click on the frame leaves it alone (#7740) (#7741)
`to_press` and `control_point` read a point in the press-time view through
`remap`, which rounds it through f32 and the view even when the view hasn't
moved. A `ui.pointer` down + up at the same point then came back slightly
apart: the frame moved by a hair and a box Undo step was pushed that undid
nothing visible. Skip the remap while the view is still the press view.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 14:19:15 -04:00
98d3ebc1f5 perf(codecs): Avoid redundant PNG filter passes (#7690)
* perf(codecs): Avoid redundant PNG filter passes

Keep the previous filter path as an exact byte oracle and measure the filter kernel independently of compression.

* docs(perf): measure PNG filtering on real images

* perf(codecs): stop PNG candidates at the winning cost

* Drop the local benchmark report

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 14:19:05 -04:00
209a384ef0 Home screen: fit the card and stack the buttons in a narrow window (#5774)
* Home screen: fit the card and stack the buttons in a narrow window

The Home screen's card was a fixed 460 points wide, centred on the canvas, with two
190-point buttons side by side. A tiling window manager can give PhotoCraft a canvas
narrower than that (Hyprland on Omarchy gave a 728-point window, below the 760-point
minimum), so the New document and Open buttons were clipped at the edges.

The card is now as wide as the canvas when the canvas is narrower than 460 points. The
buttons are sized from their labels (so longer translations still fit) and are stacked
when two of them don't fit side by side; the card grows by one row when they stack.

Adds start_screen_buttons_stay_inside_a_narrow_canvas, which fails before this change
(New document starts at x = -1 in a 390-point canvas).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

* contributors: add TaylorBurke

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

---------

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-19 14:18:33 -04:00
dfe94d6ab9 Actions: export Box Blur, Surface Blur, Flatten Image and Black & White under ids Photoshop runs (#6330) (#6331)
Save Actions wrote these four steps under the four-char codes BoxB, SrfB,
Fltn and BlnW, which Photoshop does not run: executeAction fails with "the
command is not currently available" (measured in Photoshop on a document
with a filled pixel layer; Fltn resolves to the flatness property, not the
flatten event). The names boxblur, surfaceBlur and blackAndWhite and the
code FltI run.

- Box Blur, Surface Blur and Black & White now export by name
  (boxblur, surfaceBlur, blackAndWhite); Flatten Image exports as FltI.
  The descriptors are unchanged.
- Import accepts the names, FltI and flattenImage, and still accepts the
  old codes (a LEGACY_CODES table consulted only for four-char codes), so
  actions PhotoCraft saved before keep loading.
- Tests: each step exports the new id and imports back to the same command
  and params under the new id, the name and the legacy code; a set mixing
  Photoshop-shaped and legacy steps loads and re-saves with the new ids;
  near-miss ids (case, padding, empty, NULs) stay Photoshop steps; broken
  descriptors don't panic.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-19 14:18:18 -04:00
Vikram BhaskaranandBrandon Thomas 7200f64bf1 Explain the Memory Usage budget and GPU caps (#6546) (#6892)
* Explain the per-document memory limit and automatic GPU budget caps.

* Add catalog entries for the Memory Usage explanation (#6546).

The new preference strings need a row in every complete-menu language
for the i18n tests.

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-19 14:17:49 -04:00
Omar SaidahmedandBrandon Thomas cec0e843e6 refactor(algo): route the splitmix64 copies through one function (#7603)
* route the splitmix64 copies through one function

* document the splitmix64 generators

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-19 14:17:32 -04:00
Ernie SmithandClaude Sonnet 5.5 0b6480cdc5 Address review: adapter override process, wasm build, saved-name warning
- PHOTOCRAFT_GPU_ADAPTER now counts as a per-process launch option, so a
  second launch with it set doesn't forward to the running window and drop it.
- Gate RenderState::available_adapters to native targets (it doesn't exist on
  wasm, which broke the web build).
- Share the adapter name matcher (engine prefs) between startup and
  Preferences, so a saved fragment like "nvidia" isn't flagged as missing when
  it matches a listed adapter.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-19 14:13:55 -04:00
Michael Higgins 5e14702c09 Merge branch 'storytold:main' into fix/6884-pen-tool 2026-10-19 14:08:22 -04:00
aiongg 3f509f06ed Integration: regenerate docs/scorecard.md 2026-10-19 18:01:48 +00:00
2ef8a9d3f5 Reject exporting a partial Play Action call to .atn (#7602)
Fixes #7186

Co-authored-by: Claude Haiku 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-19 13:45:07 -04:00
Korg1chandBrandon Thomas 990483a95e Actions: Apply Layer Comp is recorded by the comp's name, so it plays in any document (#7181) (#7790)
* fix(engine): record Apply Layer Comp by the comp's name, not its document-local id (#7181)

* test(engine): a recorded Apply Layer Comp applies the comp of that name in another document (#7181)

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-19 13:44:51 -04:00
Hector VillarrealandClaude Opus 5.5 f11e51b899 Re-run CI
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 11:34:06 -06:00
Khawar Zamman WaniandClaude Opus 5.5 caa1539030 Tools: Sample All Layers is each tool's own, as in Photoshop (#5524) (#5570)
One ToolOptions::sample_all_layers was shared by twelve tools, so ticking
it for the Magic Wand or Paint Bucket also turned it on for Blur, Sharpen
and Smudge. A dab of those on an annotation layer then pasted an opaque
copy of the composite there, which sat above the picture and hid every
later blur of it (the sharp round patch in #5524).

ToolOptions::sample_all is now a SampleAllLayers with one flag per tool,
all off by default: Magic Wand, Quick and Object Selection (and Object
Finder), Paint Bucket, Magic Eraser, Spot Healing, Patch, Content-Aware
Move, Remove, Blur, Sharpen and Smudge. Each options bar and command
reads its own tool's flag, and Reset Tool resets only that tool's. The
new key means older preferences drop the shared flag, so every tool
starts with it off.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 13:33:14 -04:00
FinnNkandClaude Opus 5.5 8c3c3237f4 refactor(engine): drop the second damage pass after single-edit duplicates and pastes (#5880)
Session::edit has reported a new layer's bounds as damage since #3847, so
Duplicate Layer, Duplicate Layers and Paste no longer need to keep the
document from before the edit and work the damage out again with
note_insert. Paste Into still does: it is three edits.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 13:33:06 -04:00
Omar Saidahmed 1c74da7baa keep layer-based slices when duplicating a document (#6133) 2026-10-19 13:32:59 -04:00
David MannandClaude Opus 5.5 3cafc63656 Free Transform: W and H take a typed length in percent view (#6284) (#6293)
W and H showing percent rejected a typed length ("40px", "2 in", "5cm"),
since the shared unit parser has no length to convert a percentage
against. The fields now carry the box's original side as the length 100 %
stands for, so a typed length becomes that share of it at the document's
resolution, and "50%" typed into a length view is half the original side.
The field keeps its chosen unit; the aspect link and the scale limits
apply as before. Other percent fields (opacity, fill) still reject lengths.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-19 13:32:51 -04:00
Josephand00xJ0 80cd36846e GPU canvas: redraw mip levels of 128 px or less whole; fixes AMD Vulkan corruption after partial updates (#6418) (#6438)
* GPU canvas: redraw mip levels of 128 px or less whole; AMD Vulkan corrupted them after partial updates (#6418)

On an RX 6900 XT with Vulkan (driver 26.8.1), a regenerate_mips pass that drew only the damaged
part of a small mip level corrupted texels outside its scissor, at the level's top-left; DX12 on
the same card was fine. A sweep of 15 canvas sizes x 8-bit/float x 6 dabs found 66 of 180 cases
corrupt on Vulkan, every corrupted level at most 64 px; sampling the levels in a shader shows the
same pixels, so it is the texture contents, not the readback.

mip_damage (regenerate_mips's damage arithmetic) now redraws levels of at most WHOLE_MIP_SIDE
(128 px) whole on every adapter: no new passes or textures, only a larger scissor on the
smallest levels. With it the sweep is clean on both backends.

Tests: small_mip_levels_are_redrawn_whole (the rule, no GPU) and
partial_updates_match_a_full_rebuild_at_every_mip_level (fails on the Radeon over Vulkan without
the change, passes with it, like canvas_mips_preserve_opaque_pixels_and_partial_updates).

* contributors: add 00xJS (GitHub profile name)

* Mip sweep test: hold the GPU lock like the other GPU tests (#6438 review)

---------

Co-authored-by: 00xJ0 <[email protected]>
2026-10-19 13:32:42 -04:00
Marco Arnulfo a012894505 Layer Mask Properties: the Feather slider has fine steps near 0 px, as in Photoshop (#6545) (#6727) 2026-10-19 13:32:35 -04:00
MarekSpodBiedry daa9abfe43 Disable Save for Web while a Free Transform is pending (#6737) (#6832) 2026-10-19 13:32:29 -04:00
Vikram Bhaskaran e1c209eadb Hide the Hyphenate checkbox until layout hyphenates (#6710) (#6869)
* Hide the Hyphenate checkbox until layout actually hyphenates (#6710).

The flag still round-trips to PSD and ⌥⇧⌘H still toggles it, but the
Paragraph and Properties panels no longer advertise a no-op.

* Assert Hyphenate stays hidden until layout hyphenates (#6710).

The Paragraph properties and paragraph style options editor now have
tests that fail if the no-op control is shown again.

* Drop the duplicate #[test] on hyphenate_is_not_shown (#6710).

Clippy -D warnings failed on duplicate-macro-attributes and unused mut
on the harness that only queries the label.
2026-10-19 13:32:21 -04:00
Korg1ch 8c2cfdba6a fix(linux): keep crash-recovery autosaves in $XDG_STATE_HOME, out of the config folder (#6612) (#6941)
* fix(linux): keep crash-recovery autosaves in $XDG_STATE_HOME, out of the config folder (#6612)

* test(linux): cover the XDG state recovery folder (#6612)
2026-10-19 13:32:14 -04:00
David MannandClaude Opus 5.5 4252fb7743 Presets: Migrate Presets reads tool presets like the launch loader (#7375) (#7376)
Migrate Presets deserialized the incoming tool presets all or nothing, so
one damaged entry dropped every good one, and it skipped the checks the
launch loader applies: blank or overlong names were imported and then lost
on restart, and a large file pushed the library past MAX_PRESETS. Read the
list with `presets::tools::load` and stop at MAX_PRESETS.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 13:32:07 -04:00
zrsix1andClaude Opus 5.5 c78ec47f4b Type: a click inside the paragraph box off the glyphs moves the caret (#7606)
While editing paragraph text, a click inside the box that missed the glyphs
(left of a line's start, or below the last line) committed the edit. Like
Photoshop, it now places the caret at the nearest position; a click outside
the box still commits.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 13:31:56 -04:00
NetterSilverandClaude Haiku 5.5 fe64e5a2bd Keep arbitrary canvas rotation interpolation in ATN export (#7630)
Fixes #7504

Co-authored-by: Claude Haiku 5.5 <[email protected]>
2026-10-19 13:31:49 -04:00
David MannandClaude Opus 5.5 cbe6c4e131 Guides: colours follow Crop, Canvas Size and rotations (#7724) (#7725)
transform_doc_marks moved guide positions but not their colour entries,
which are keyed by exact position: after a crop the coloured guide fell
back to the preference colour and a guide that landed on its old place
took the colour. Map the entries with the same expressions as the
guides (swap orientation on quarter turns, clear on arbitrary angles).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 13:31:41 -04:00
Khawar Zamman WaniandClaude Opus 5.5 81d5c514e2 perf(photomerge): build multi-band bands in bounded batches (#7758) (#7763)
* perf(photomerge): build multi-band bands in bounded batches (#7758)

panorama::multiband built every source's Laplacian and weight pyramids
at once and kept them all until accumulation, so Photomerge's peak
memory grew with the image count (~5 GB for four 24 MP sources). Build
the bands in batches under a 2 GiB estimate (at least one image) and
add each batch before building the next. Each pixel still adds the
bands in image order, so the output is bit-identical.

Planes now move into the pyramids instead of being cloned, and the
coverage and push-pull masks are freed once used: with one image built
at a time those clones were serial passes. seam_blend shifts its ROIs
in place instead of cloning every source for the blend.

Peak during the blend: 4x24 MP 5.04 -> 2.81 GB, 8x12 MP 5.50 -> 2.61 GB,
at +5% time (2x24 MP unchanged). New example bench_multiband measures it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* perf(photomerge): let up to four 24 MP sources build their bands together

A 2 GiB band budget built every 24 MP source alone, which made the usual
2-4 image Photomerge 12-36% slower on a 32-thread desktop. The native
budget is now 5 GiB, so those still build side by side and only larger
stacks are batched. wasm32 has no threads, so it adds each band as soon
as it is built.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 13:31:33 -04:00
5b1f937287 Actions: a function key can take Shift and Command (#7646) (#7674)
Photoshop's Action Options pair the function key with Shift and Command
checkboxes. The Actions row menu offered plain F-keys only, and .atn import
dropped any key with a modifier. The row menu gets Shift and Command (Ctrl off
the Mac) toggles, and .atn load and save keep the modifiers.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-19 13:31:26 -04:00
acb3a66fd5 Crop: a preset picked in Tool Presets fits the frame to its ratio (#7143) (#7144)
select_tool_preset only merged the preset's tool options, so a crop
preset's ratio was set without refitting the frame; the options bar's
dropdown already refits. It now compares the ratio before and after and
calls crop_ui::apply_ratio when it changed, as the dropdown does.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-19 13:31:10 -04:00
7b99c32923 Color Lookup: warn when it opens without its usable table (part of #1763) (#6322)
* fix(io): warn when a Color Lookup opens without its usable table

A Color Lookup whose table is shorter than its size-cubed grid (or
missing altogether) silently rendered as the identity. Native .pcraft
loads and native-path automation opens carried no import warnings, so
the loss was silent. The new io::open_warnings reports it naming the
layer, and both load seams surface it.

Also add a Photoshop-layout clrL regression test: a version-1 block
carrying a CUBE descriptor round-trips through PSB and reopens as a
working Color Lookup that recolors a red backdrop (#1763).

* cargo fmt

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 13:30:56 -04:00
aiongg acc853b517 Merge upstream main into integration-5
docs/file-format-parity.md: keeps both 2026-10-15 rows, main's #6063 PNG sTER row first, then #5976's PSD guides row
2026-10-19 17:20:29 +00:00
aiongg b8e856dffe Integration: #7022 write_region stays serial; the text rasterizer uses the parallel variant 2026-10-19 17:18:46 +00:00
Ernie SmithandClaude Sonnet 5.5 b892c0412e Choose the graphics adapter on multi-GPU Linux machines (#1885)
With the backend on auto, Linux installed no adapter selector, so wgpu took
the first discrete GPU the driver listed. On a machine with an eGPU, a
laptop dGPU and an iGPU that was not the one the user wanted.

- performance.gpuAdapter: a case-insensitive piece of the adapter's name
  (several words must all appear); empty is automatic.
- PHOTOCRAFT_GPU_ADAPTER overrides it for one launch.
- Preferences > Performance > Advanced > GPU adapter lists the adapters the
  instance found; a saved adapter that is absent stays listed, marked.
- A missing adapter, or one that can't present to the window, falls back to
  the automatic choice with a note in System Info. CPU mode ignores it.
- Shown on Linux only; tested on Linux only. Windows and macOS need their own
  work (the startup code honours the setting there, but it is untested).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-19 13:16:53 -04:00
125d15961e Actions: Smart Blur quality uses Photoshop's SBQL/SBQM/SBQH codes (#6312) (#6313)
A Smart Blur step saved to .atn wrote its quality as `Hgh `/`Mdm `/`Low `,
codes Photoshop ignores (it plays them back with its fallback, low
quality), and Photoshop's own `SmBQ` values did not import as a quality.

The quality table now writes `SBQL`/`SBQM`/`SBQH` (enum type `SmBQ`) and
reads both those and the old `Hgh `/`Mdm `/`Low `, so actions PhotoCraft
wrote before still load. Unknown codes stay kept-verbatim Photoshop steps.

Measured in Photoshop by rendering: Smart Blur applied through Action
Manager with each code; SBQH and SBQM each give a distinct image, while
`Hgh `, `Mdm `, `Low `, a nonsense code and SBQL give the identical
(fallback) image. The smart filter Photoshop saves uses the same SmBQ
values (applied through Action Manager to a smart object, saved, filterFX
read back).

Tests: every quality round-trips through .atn as SBQ*; Photoshop-shaped
SmrB steps and the legacy codes import as the right quality; unknown,
wrong-type, NaN and missing values never panic; Smart Blur joins the
export/import round-trip table.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-19 13:08:50 -04:00
Hector VillarrealandClaude Opus 5.5 f4a81d3c16 Add Liquify's Show Backdrop Use and Mode (#7609) (#7644)
* Add Liquify's Show Backdrop Use and Mode (#7609)

Show Backdrop only had an Opacity: it always drew the other layers'
composite behind the image, faded. Photoshop's Liquify also has Use (All
Layers, or one layer) and Mode (In Front, Behind, Blend). Use and Mode
are now in the View Options and the control channel (backdropUse,
backdropMode), and each mode layers the backdrop and the image as
Photoshop's does: In Front lays the backdrop over the image at the
Opacity, Behind the image over the backdrop, Blend fades the image to
100 - Opacity under the backdrop. All Layers is the document as it is,
as in Photoshop.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Liquify backdrop: show a hidden Use layer, forget the layer between documents

A layer picked in Use, or one in a hidden group, now shows: the
one-layer backdrop makes it and its groups visible, as Photoshop does.
The remembered settings keep the backdrop Mode and Opacity but no longer
a layer id, which belongs to one document: Liquify opens on All Layers,
as Photoshop's does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Liquify backdrop: a control-channel Use naming no layer is All Layers

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Liquify backdrop: normalize an unknown Use even when another setting fails

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 13:04:59 -04:00
David MannandClaude Opus 5.5 5c1b9425d1 Gradient Overlay: repaint the whole layer when a stroke moves its frame (#4053) (#5340)
A Gradient Overlay (and gradient strokes, linked patterns, linked bevel
textures) is laid out over the layer's bounds. Painting grows those
bounds, but the stroke commands reported only the stroke as damage, and
the canvas's committed-stroke path repainted only what the live stroke
had not shown. The canvas therefore kept the old gradient around earlier
strokes: one gradient per stroke instead of one over the whole layer.

Session dispatch now grows a command's reported damage by
photocraft_compose::relayout_damage(before, after): the whole composite
of any visible layer whose effects span its frame, when that frame moved.
The canvas adds the same area to a committed live stroke. Strokes inside
the existing bounds and frame-independent effects keep tight damage.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-19 13:04:52 -04:00
David MannandClaude Opus 5.5 bc4f2a783d Scripts: Flatten All Layer Effects bakes with the document's Global Light and patterns (#5719) (#5720)
`bake` rendered each layer through `render_layer`, which has no document context (default Global
Light, no patterns, the render rect as canvas). It now uses the new doc-aware `render_layer_in`.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-19 13:04:46 -04:00
Michael HigginsandClaude Opus 5.5 ac78e0bbca Color Settings: the working-space menus list the installed profiles of their space (#6323) (#6359)
* Color Settings: the working-space menus list the installed profiles of their space (#6323)

Like Convert to Profile and Proof Setup, Edit › Color Settings' RGB, CMYK and Gray working-space
menus now list the profiles installed in the system after the built-ins, by description, so a
press condition (GRACoL, SWOP, FOGRA39) can be the CMYK working space as in Photoshop. Each menu
lists only profiles of its colour space; RGB lists display and colour-space profiles, not
scanner or printer ones. The engine already took .icc paths for these settings.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* rustfmt: format after merging main (CI Format step)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 13:04:38 -04:00
Perchenko Anton bf321f0f5e Affinity: bound compound geometry expansion and reject cycles (#6486)
* fix(affinity): bound compound geometry expansion and reject cycles

* fix(affinity): charge every compound operand slot before filtering
2026-10-19 13:04:32 -04:00
David MannandClaude Opus 5.5 566dbb3f83 Histogram: count the document's pixels, not a blurred thumbnail (#6538) (#6724)
The Histogram panel binned a 384 px area-averaged thumbnail, so the cell
holding a black/white edge came out mid-grey: a spike near 128 and
slightly wrong Mean/Std Dev. Documents up to PROXY_THUMBNAIL_PIXELS are
now composited in bands and every pixel is counted (Cache Level 1, as
Photoshop's uncached histogram); larger ones count a nearest-neighbour
proxy composite, which only holds values the layers have, and report its
cache level. The Pixels row shows the pixels actually counted.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-19 13:04:26 -04:00
codexistence 7efaa50455 fix: match Photoshop Gradient Fill scale limits (#6797) 2026-10-19 13:04:20 -04:00
Vikram Bhaskaran 4bf0625b04 Export and import saved tool presets (#6560) (#6889)
* Include tool presets in export and import so crop presets are portable.

* Pass presets_io_body to the harness without a redundant closure (#6560).

CI clippy -D warnings failed on clippy::redundant_closure in the
presets export/import regression.
2026-10-19 13:04:14 -04:00
David MannandClaude Opus 5.5 aa8621788e Minimum / Maximum: CMYK spreads paper white with Maximum, ink with Minimum (#7074) (#7075)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 13:04:02 -04:00
UnemarsandClaude Fable 5.1 5071472e4d Layers: ⌘-click on the Background thumbnail is a plain click, as in Photoshop (#7165)
* Layers: ⌘-click on the Background thumbnail is a plain click, as in Photoshop

⌘-clicking a layer thumbnail loads its transparency as a selection (⇧ adds,
⌥ subtracts, ⇧⌥ intersects) without changing the selected layer. Measured on
Photoshop 25.4: the Background, which has no transparency, is the exception —
the click loads nothing and selects the row like a plain click, where
PhotoCraft selected the whole canvas. A hidden layer still loads.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* Layers: the Background thumbnail's ⌘-click selects the Background alone

Review: the fallback to the row handler kept ⌘ set, so `select_mode` toggled
the Background into (or out of) the selected set instead of the plain click's
replace. The Background thumbnail's ⌘-click now selects it alone; the test
asserts the selected set, including when the Background was already part of a
multi-selection.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-19 13:03:56 -04:00
Omar Saidahmed 6385fced3d refactor: use photocraft-color's srgb transfer functions in algo and proof simulation (#7604)
* share the srgb transfer functions from photocraft-color

* document the touched srgb helpers
2026-10-19 13:03:48 -04:00
KnottyDyes c9d7f1d984 Docs: install the CLI and register it with MCP clients (#7663) 2026-10-19 13:03:40 -04:00
David MannandClaude Opus 5.5 baa005d476 Type: formatting commands respect Lock All (#7718) (#7720)
Character (type.setStyle), Warp Text, Anti-Alias, Orientation, OpenType
and Load Default Type Styles all run through with_text_targets, which
never checked Lock All, so they changed a locked type layer and added a
history step. Photoshop refuses all of them ("the layer is locked").
Check every target with check_text_unlocked before the edit, as
type.edit already does.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 13:03:32 -04:00
David MannandClaude Opus 5.5 94a135bc6b Free Transform: show a flip as -100 % on the flipped axis (#7749) (#7750)
The options bar read the box's X scale as never negative and dropped
the sign of the Y scale, so Flip Horizontal read W 100 %, H 100 %, 180°
(typing 0° then flipped the layer vertically) and Flip Vertical read
the same as an untouched box. A mirrored box now reads as a negative W
or H, whichever keeps the angle nearer 0, as in Photoshop (Flip
Horizontal: W -100 % at 0°, Flip Vertical: H -100 % at 0°). Typed W/H
divide by a sign-preserving guard instead of max(1e-9).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 13:03:26 -04:00
adbd40ef55 Actions: Photoshop Shadows/Highlights steps play (#5830) (#5854)
Imported actions stopped at Shadows/Highlights ("adaptCorrect has no
PhotoCraft equivalent yet"), although Image > Adjustments > Shadows/Highlights
exists. The step now maps to it both ways: shadowMode/highlightMode (amount,
width, radius), black/white clip, midtone contrast and colour correction,
each read as its char ID or string ID.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-19 13:03:20 -04:00
96c151525a feat(prefs): Show File Name In Title keeps the title bar to the app name (#5037) (#5129)
Co-authored-by: highperfocused <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-19 13:02:53 -04:00
Tarek MohamedandBrandon Thomas 5c513b8c88 fix(actions): flush pending recorded steps on save (#7193) (#7244)
When actions.save was called while recording was active, it exported only
already-committed action steps, silently omitting any pending steps shown
in the Actions panel.

Call flush_recording at the beginning of actions.save to commit pending
journal entries to the action and advance the recording pointer, ensuring
all displayed recorded steps are saved while allowing ongoing recording to
continue uninterrupted.

Fixes #7193

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-19 13:02:37 -04:00
640873de7b PNG: keep a stereo file's sTER pairing on save (#6063) (#6285)
* PNG: keep a stereo file's sTER pairing on save (#6063)

Read the sTER chunk (PNG Extensions 1.4.0 §3.6) when a PNG opens, store it
on the document's FileEncoding, and write it back when saving, so a stereo
PNG still says it is a stereo pair. Covers the ordinary and the palette
PNG encoders, including interlaced output.

* io tests: pass the new sTER argument to encode_png_indexed

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: highperfocused <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-19 13:02:18 -04:00
aiongg 3544721eb0 Merge PR #7692: perf(raster): Avoid redundant default pixel work 2026-10-19 16:40:42 +00:00
Elvis33LE 716d1e24c9 fix(io): keep variable noise GdFl fills as fill layers
constant_rgb_noise_color's None propagated out of fill_from_desc for every
noise gradient that is not provably constant - Photoshop's default Noise
gradient included - so the layer imported as pixels and the export dropped
its GdFl (RASTER_STALE_KEYS): an open-and-save data loss. The exact colour
now applies only when provable; everything else keeps the placeholder ramp,
the fill_cache pixels and the raw GdFl block, which the re-save writes back
byte for byte. Round-trip test included (fails on the old head: the layer
came back as pixels without a GdFl).
2026-10-19 17:24:19 +02:00
Rene Leonhardt 83aff80f35 fix(raster): support sample counts beyond stack capacity 2026-10-19 17:19:18 +02:00
Akos Arinton dfeba9e974 test(compose): compare NaN as NaN in the vector span test, not by its bits 2026-10-19 18:11:13 +03:00
David Mann c30f9180e8 Merge PR #7692: perf(raster): Avoid redundant default pixel work 2026-10-19 16:51:48 +02:00
David Mann c0072ca2da Merge PR #7691: perf(io): Interleave PSD pixels in the final buffer 2026-10-19 16:51:45 +02:00
David Mann b2269e3bdb Merge PR #7690: perf(codecs): Avoid redundant PNG filter passes 2026-10-19 16:51:43 +02:00
David Mann 8e54897d43 Merge PR #7022: perf(text): rasterize a type layer's pixels on all cores, bit for bit the same 2026-10-19 16:51:41 +02:00
David Mann ca915bc096 Merge PR #6950: fix(psd): make packbits::decode_into crate-private and overflow-safe 2026-10-19 16:51:39 +02:00
David Mann cfece486fc Merge PR #6797: Fix live gradient fill scale limits to match OG program 2026-10-19 16:51:36 +02:00
David Mann 10d012b843 Merge PR #6193: fix(perf): validate regression minimum threshold 2026-10-19 16:51:34 +02:00
David Mann 3c9f825269 Merge PR #5998: PSD save: check the Background's opacity without copying the canvas 2026-10-19 16:51:32 +02:00
David Mann b107b172e3 Merge PR #5984: Brushes: importing an .abr file numbers repeated brush names in linear time 2026-10-19 16:51:29 +02:00
David Mann 284ee60705 Merge PR #5976: PSD: keep guide resources 1092/1097 only while they match the guides (#5966) 2026-10-19 16:51:27 +02:00
David Mann 06445a21b8 Merge PR #5880: refactor(engine): drop the second damage pass after single-edit duplicates and pastes 2026-10-19 16:51:25 +02:00
David Mann c2f3e23a40 Merge PR #5477: perf: chisel bevel size changes, CPU passes on threads and GPU fields share their sharp edges (results unchanged) 2026-10-19 16:51:22 +02:00
David Mann 676761c435 Merge PR #5413: Limit blend-mode hover refreshes to the visible canvas region 2026-10-19 16:51:20 +02:00
Gert Pellin bfffc365c6 Merge branch 'main' into feat/4814-psd-palette-modes 2026-10-19 16:33:16 +02:00
David MannandClaude Opus 5.5 ebfe3d98da Save for Web: later slices cover earlier ones, covered slices split into pieces (#7770)
`slices::resolve()` only clipped each stored slice to the canvas, so a slice under a later
one was numbered and exported in full and Save for Web wrote the overlap twice (the HTML
path cut pieces itself, named `<name>_01`, `_02`). As Photoshop does, resolve() now cuts a
covered slice into its visible pieces: the first piece in reading order keeps the name,
every further piece is `<name>-NN` with its own slice number, and auto slices count the
pieces in their numbering (measured in Photoshop: sliceA, <doc>_02, sliceA-03, sliceB,
<doc>_05).

- Save for Web exports resolve()'s pieces as they are, with or without HTML; a no-image
  cell's text goes into its first piece only.
- Slice Options and slice.list show the slice's own name for every piece; slice.set accepts
  a slice that ends up wholly under later ones (number null) instead of calling it off the
  canvas.
- Slice Select drags the whole slice, not the piece that was hit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 16:02:32 +02:00
NetterSilverandClaude Opus 5.5 604007490b fix(psd): replace an earlier save's Txt2 stub also when its text is edited
The stub filter applied only to the kept object, so an edited layer whose
slot held a stub from an earlier save went through edited_object and kept
the bare stub. The slot is now filtered before both lookups.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-19 15:33:05 +02:00
David MannandClaude Opus 5.5 41fb9f986c Layer Style: a new Stroke is 1 px inside black, as in Photoshop (#7765)
`layer.layerStyle.stroke {}` built a red 3 px outside stroke, while the
dialog's factory default (defaultFor / Reset to Default) was black 3 px
outside. Photoshop's Reset to Default on the Stroke page is 1 px,
Inside, Normal, 100 %, black; both tables now give that, and a test
checks that every effect kind's bare build equals its dialog default.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 15:14:51 +02:00
NetterSilverandClaude Opus 5.5 198fa59c7d fix(psd): a new type layer gets a full Txt2 object and its own frame (#7698)
A type layer the file's Txt2 has no object for (one added in PhotoCraft)
was written as a bare stub with no view, no paragraph runs and no frame
in /0 /8 /0. Photopea and Photoshop refused the file once the objects
outnumbered the frames.

build_txt2 now gives such a layer an object shaped like one of the
file's own (refitted with edited_object from #7220), with its view
naming its own frame, and pads the frame list to one per object. A stub
written by an earlier save is replaced the same way, so re-saving an
affected file repairs it.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-19 14:56:15 +02:00
David MannandClaude Opus 5.5 745f535fda Perspective Crop: Color Sampler points move with the image (#7760)
Perspective Crop set no sampler map, so the edit only dropped the
points now off the smaller canvas and left the rest at their old
coordinates, reading unrelated pixels. As in Photoshop, each point now
follows its pixel through the crop's homography (fractional positions
kept), and the points outside the quad are deleted and counted for the
status message.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 14:51:25 +02:00
David MannandClaude Opus 5.5 e8a9f579f7 Crop: a typed ratio fits from the frame the edit started with (#7752)
The options-bar ratio fields apply every keystroke, and each ratio was
fitted inside the frame the previous keystroke left: typing 16 over 4 in
a 400 x 300 frame went 1:3 (100 x 300), then 16:3 inside that
(100 x 18.75) instead of 400 x 75. The bar now keeps the frame from when
the W/H edit (typing, arrows or scrubbing) started and fits each new
ratio from it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 14:44:02 +02:00
David MannandClaude Opus 5.5 de2cb1f0c0 Compose: a clipped adjustment inside a pass-through clip base damages the whole group (#6535)
A pass-through group that is the base of a clipping group renders isolated while it holds a
visible adjustment (`clip_base_adjusts`), so showing, hiding, adding or removing a clipped
adjustment inside it can switch the group's other children (a Multiply layer beside the base)
between isolated and pass-through rendering. `change_bounds_in` now takes the list holding the
parent group and reports that group's change bounds in this case; `layers_damage`, `step_damage`
and `one_sided_bounds` pass it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 14:14:06 +02:00
David MannandClaude Opus 5.5 d67a949a54 Crop: a lost Straighten release no longer hijacks the next drag (#7745)
A Straighten line whose release went to Space's temporary Hand stayed
set, so the next plain drag fed its moves and release to the old line:
the frame turned instead of moving and the crop gesture stayed stuck.
A new press now drops any leftover line first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 14:10:07 +02:00
David MannandClaude Opus 5.5 c4e8eb2b86 Brush smoothing: catch-up compares the pose too, not only the position (#7747)
Since #5726 the smoothed brush pulls its pressure, tilt, rotation and wheel
towards the pointer like its position, but Stroke Catch-Up (`lagging`) and
Catch-Up On Stroke End (`finish`) still compared x/y only. A pen that changed
only its pressure in place got no catch-up time and no end point, so the
stroke ended at an in-between pressure (1.0 -> 0.0 ended at 0.64).

Both checks now also compare the pose (rotation on the circle).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 14:07:15 +02:00
Rene Leonhardt 4d50e31efd perf(codecs): stop PNG candidates at the winning cost 2026-10-19 13:43:19 +02:00
David MannandClaude Opus 5.5 b61a6124d9 Crop: Undo/Redo grey out when the frame has no step left (#7730)
While either crop-box stack has a step, intercept takes both Undo and
Redo, but is_enabled reported only the stack it would pop and otherwise
fell back to the document's can-undo: Undo looked enabled, popped
nothing and left the document's history unreachable. Grey the empty
direction instead, as the Free Transform box does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 13:40:23 +02:00
David MannandClaude Opus 5.5 c7c2c31416 Painting symmetry: canvas edits move the axis with the pixels (#7732)
Crop, Trim, Canvas Size, Image Size, Flip and Rotate Canvas mapped only the
Color Sampler points; the painting symmetry kept its old document
coordinates, so strokes mirrored about a different line (and a path axis
left its own moved path). Session::edit now maps the symmetry through the
same edit's geometry map: preset centre and angle (a flip mirrors it), or
the sampled path segments. Flips and rotations pass their edge-coordinate
map separately from the samplers' pixel-centre map.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 13:40:05 +02:00
Hector VillarrealandClaude Opus 5.5 b862323a8e Justify buttons: grey when any formatted type layer is point text
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 05:39:46 -06:00
Rene Leonhardt dd552f76b1 docs(raster): document touched read helpers 2026-10-19 13:33:01 +02:00
David MannandClaude Opus 5.5 d20a0782da Crop: a W x H crop on an Indexed Color document resamples with Nearest Neighbor (#7722)
The Crop tool's W x H x Resolution mode resampled with the chosen filter
(Bicubic by default) even on an Indexed Color document, so the result held
blends outside the colour table. Like Image Size (#6019), it now falls back
to Nearest Neighbor there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 13:28:32 +02:00
Hector VillarrealandClaude Opus 5.5 c8907e2cd2 Paragraph panel: grey the justify buttons on point text (#7713)
Justification needs a paragraph box: Photoshop greys the four justify buttons
for point text, which lays out left whatever its alignment. PhotoCraft offered
them, and choosing one did nothing visible.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 05:23:03 -06:00
Rene Leonhardt b5b5776a30 perf(io): remove the PSD export inlining hint 2026-10-19 13:15:06 +02:00
Rene Leonhardt 78a9982eec perf(raster): share contiguous region reads 2026-10-19 13:15:06 +02:00
Rene Leonhardt 6b1aa81561 docs(perf): measure PNG filtering on real images 2026-10-19 13:15:05 +02:00
Akos Arinton 80f896210a perf(compose): write band tiles as they finish, not after the band
Parallel bands each collected their finished tiles until the band's last
one was done, so with work stealing many bands could hold most of their
tiles at once, close to a second copy of the output. Each tile is now put
into its band behind a lock as soon as it is rendered; only the tiles in
flight are held, as before the bands ran in parallel.
2026-10-19 13:34:32 +03:00
Korg1ch af0857372b test(web): the boot module tells gzip from wasm the browser already inflated (#7639) 2026-10-19 12:37:55 +03:00
Korg1ch cb1b479452 fix(web): start the app when the server already inflated its gzip part (#7639) 2026-10-19 12:37:54 +03:00
Rene Leonhardt 76a6405f9b perf(io): Interleave PSD pixels in the final buffer
Remove intermediate band buffers while retaining byte-for-byte equivalence with the previous path at every sample depth.
2026-10-19 11:31:59 +02:00
Rene Leonhardt f6e7fc3c2a perf(raster): Avoid redundant default pixel work
Preserve default sample bits while skipping absent zero tiles and redundant default-pixel copies. Add the previous read path as the oracle.
2026-10-19 11:31:59 +02:00
Rene Leonhardt 850bf9fdaa perf(codecs): Avoid redundant PNG filter passes
Keep the previous filter path as an exact byte oracle and measure the filter kernel independently of compression.
2026-10-19 11:31:59 +02:00
jonathnwxh 49d48fc43c Fix blend preview coverage for periodic tiles and multiple views 2026-10-19 16:11:51 +08:00
David MannandClaude Opus 5.5 8534f0c935 Compose: a clipped adjustment with no base inside a pass-through group reaches everything (#6535)
The compositor draws a clipped adjustment that has no unclipped layer
beneath it as an ordinary adjustment, but change_bounds counted it as
contained in a pass-through group. Hiding the layer clipped to such a
group (which ends its isolation) or the group itself then recomposited
only the group's pixels and left the rest of the canvas stale.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 10:03:54 +02:00
David Mann 2aea78b657 Merge remote-tracking branch 'origin/main' into fix/6535-clipped-adjust-damage 2026-10-19 09:57:13 +02:00
David MannandClaude Opus 5.5 0604f39d97 Performance: hand single-part tiles to put without a copy (#5108)
Only CMYK and Lab tiles are split into bands; every other format's one part
went through concat, an extra allocation and copy per tile on the UI thread
(8-bit RGBA, 64 tiles: 2.7 -> 3.3 ms). Now it passes straight through.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 09:43:36 +02:00
aiongg d95fdf3181 Merge pull request #7569 from aiongg/integration-4
Integration batch: 12 PRs on main
2026-10-18 23:29:05 -04:00
Brian TorresandClaude Sonnet 5.5 ea0eec98a3 macOS: list fonts activated outside the font folders (#6683)
The installed-font scan read only /System/Library/Fonts, /Library/Fonts and
~/Library/Fonts (plus PingFang). Fonts macOS offers from anywhere else, such
as those a font manager, Adobe Fonts or Office activated, never reached the
font menus although other apps showed them.

load_system_fonts now also registers every file Core Text offers. The list
comes from fontique's Core Text backend, enabled for macOS only, used as a
scratch collection whose families are discarded: only its file paths are read
and registered in our own collection, so generic-family defaults and the merge
of installed faces into the bundled families are unchanged. The reserved UI
fonts under /System/Library/PrivateFrameworks are skipped (fontique cannot
draw their outlines).

On a stock macOS install the folder scan already listed all 183 Core Text
families; the gap is fonts Core Text knows from elsewhere. With Hack activated
from a scratch folder via CTFontManagerRegisterFontsForURL, the list had 313
families before and 314 after (Hack added, none removed), type.fonts lists it
and a type layer renders in it. The scan stays 37-57 ms.

Tests: font_files_of_lists_each_font_file_once (all platforms);
coretext_lists_the_system_font_files and every_family_coretext_offers_is_listed
(macOS). The last fails without the call when a font is activated elsewhere
("lacks: Hack") and passes with it.

Cargo.lock gains fontique's optional per-platform dependencies for the
`system` feature; `cargo tree` shows the feature only for aarch64-apple-darwin.

Still open: Windows fonts outside the two folders (#3336) and the web build.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 16:36:36 -07:00
Elvis33LE e2adb18165 fix(psd): render constant noise gradient fills 2026-10-19 01:05:13 +02:00
Higgs32584andClaude Opus 5.5 732070b22d Print: let the Windows PDF test pass where the system DLL cannot load (#5884)
A stripped Windows image can have Windows.Data.Pdf.dll present but not
loadable; that is the case `require_windows_pdf` reports, so the test now
checks that the result follows the loader instead of assuming the DLL loads.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 18:51:08 -04:00
skylar taylor-barrick 4d4de45305 Merge pull request #7206 from skylartaylor/save-replace-metadata
Save: keep the replaced file's metadata
2026-10-18 16:48:40 -06:00
Higgs32584andClaude Opus 5.5 eeeeb13f0e Print: restore the winsafe dependency, the DLL check and its tests lost in merges (#5884)
The merges from main dropped three parts of 86f6199b:
- `winsafe` from crates/engine/Cargo.toml (Cargo.lock still listed it, so every
  `--locked` CI step failed). It now sits in the engine's existing `cfg(windows)`
  dependency table next to `same-file`.
- the `require_windows_pdf()` call. Main moved `craft_print::submit` into
  `spool_job`, so the check now runs there, right before the Windows spooler
  route; that is the only call that reaches winprint's `WinPdfPrinter`
  (Windows.Data.Pdf.dll). The native RGB route, IPP, the app spooler and
  Save as PDF don't need the DLL and are unchanged.
- the Windows load test and the no-op test for other platforms.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 18:25:22 -04:00
skylar taylor-barrick 6cec0cab24 Save: a read-only file is refused, so its metadata test asserts that
main's 589941a5 (#6600) refuses to replace a read-only file before anything
is written. This branch's test wrote to a 0o444 file and expected the save to
succeed, so the merge of the two failed on Linux:

    cannot save .../doc.psd: the file is read-only; the original file was
    not changed

No bug on either side: the two changes disagreed about what a read-only save
should do, and main's answer is the deliberate one. The test now asserts the
refusal, that the original is untouched, and that the attribute and mode survive
— which is what it set out to check. The writable cases alongside it still cover
the metadata copy itself.

The windows-latest failure was the roll-up gate reporting this same failure, not
a second one.
2026-10-18 16:24:03 -06:00
Higgs32584 ac5611bc80 Merge remote-tracking branch 'upstream/main' into fix/5884-delay-load-windows-data-pdf 2026-10-18 18:22:11 -04:00
c6fd71241d docs: add user docs in docs/user (#3951)
* docs: add user docs for PhotoCraft

Adds docs/user/ (published at getartcraft.com/docs/photocraft), the docs
check and publish workflows (copied from craft-launcher), and a User docs
rule in AGENTS.md. Checker: 0 error(s), 0 warning(s).

Source notes per page:
- index.md: README (intro, status, Crafting Apps table), website
  crafting-apps.ts photocraft entry, links.rs (Discord, issues), menus.rs
  (Help > Report an Issue).
- getting-started/index.md: menus.rs and menu_catalog.rs labels, panels.rs
  (Layers, Properties, History), dialogs.rs (Create button).
- getting-started/install.md: README Downloads and Get started sections,
  gh release view v0.7.0 asset list, craft-launcher catalog.rs (lists
  PhotoCraft).
- guides/open-and-save-files.md: apps/photocraft/src/services.rs (open and
  save filters), README Formats and PSD sections, docs/file-format-parity.md.
- guides/adjust-colors-with-adjustment-layers.md: menu_catalog.rs, panels.rs
  (footer button, adjustment list), lib.rs (Properties shows new layer).
- guides/select-and-mask.md: menu_catalog.rs Select and Layer Mask menus,
  state.rs tool names, retouch_ui.rs (Select Subject button and message).
- guides/add-and-style-text.md: state.rs (Horizontal Type Tool, T),
  type_tool.rs, menu_catalog.rs Layer Style and Type menus, dialogs.rs.
- guides/export-an-image.md: export_dialog.rs (labels, formats, options).
- troubleshooting/taskbar-turns-black.md: issues 3550, 3864, 3430, 3144,
  PR 3308 (git: not an ancestor of v0.7.0), prefs_ui.rs (System title bar).
- troubleshooting/text-moves-when-editing.md: issues 3795, 1317, PR 3819
  (open).
- troubleshooting/get-help.md: menus.rs, links.rs, gpu_status.rs (System
  Info content).
- faq/*: README (status, license badge, offline, formats), LICENSE-MIT and
  LICENSE-APACHE, docs/file-format-parity.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(user): apply final review fixes

Part 1: verified = latest release tag policy in docs/user/AGENTS.md; released-pages sentence in root AGENTS.md. Part 4 items 1-9: remove x86 row and TGA resolution, formats, new photocraft-does-not-start page, mask label, taskbar workaround, wording, Affinity read-only, index.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(user): use the standard section title

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(user): describe PhotoCraft 0.7.0 startup behaviour

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(user): tighten PhotoCraft startup wording

Drop the unsourced "slower" claim for CPU mode and give the exact
Preferences path on Windows and macOS.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs: pin the docs checker to a commit and its SHA-256

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs: drop the docs workflows until the checker is on artcraft-services main

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs: re-verify against 0.8.2

- taskbar and PSD text pages: both fixes ship in 0.8.2; tell users to
  update and drop the "not in a release yet" wording and workarounds
- does not start: 0.8.2 fixes the Intel Iris Pro/Monterey Device(Lost)
  case; OpenCore Legacy Patcher Macs still have no fix
- install: Windows 10 or later, 32-bit Windows builds, RISC-V deb/rpm,
  Wayland paste and XWayland notice
- open and save: Krita and Paint.NET filters, Krita/PCX/FITS formats,
  save progress, JPEG/WebP encoding kept on Save
- export: TGA Resolution, multi-layer Export As and Quick Export
- text: Shift-click starts new text over existing type
- CLAUDE.md is now a regular file pointing to AGENTS.md
- verified: 0.8.2 on every page

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Drop the root AGENTS.md docs rule; keep the PR to docs/user/

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(user): drop the stale missing-tools count from the Photoshop FAQ

Re-verified the page against v0.9.0.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 16:15:53 -06:00
Korg1ch f3fb5781ef Lens Blur: an alpha channel as the depth map source, as in Photoshop (#7400) (#7508)
* fix(filters): Lens Blur reads its depth from an alpha channel, listed in Depth Map › Source as in Photoshop (#7400)

* test(filters): Lens Blur depth from an alpha channel by name or index, its errors, and the Source menu (#7400)
2026-10-18 17:23:32 -04:00
Brian TandClaude Opus 5.5 ca69059dd0 Edit: Copy with no selection keeps fill and adjustment layers (#2344) (#7497)
Copy refused fill and adjustment layers ("the active layer has no
pixels"). With no pixel selection they are now taken whole, like smart
objects, type, shape and group layers, and Paste recreates them in this
or another document. An adjustment layer's clip spans the canvas, so
paste placement and the OS-clipboard mirror stay consistent.

Scorecard UI-217-23 note, gaps.md G9 and target-app-parity.md updated;
the row stays partial (a single pixel layer still copies pixels only).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 17:23:26 -04:00
Nguyễn Minh Long 72a3423a7e Preferences: Enable Gestures off ignores touch-screen pinch and twist (#204) (#7478)
Edit > Preferences > Enhanced Controls > Enable Gestures (enhancedControls.touchGestures)
was saved but read by nothing, so it was hidden from the dialog. Now, with it off, two
fingers on a touch screen no longer zoom the canvas or the Navigator, nor turn the view.
The mouse and trackpad behave as before, including a trackpad pinch or Rotate event that
arrives while fingers rest on the screen.

- wheel_nav: pinch_factor() drops egui's multi-touch zoom when gestures are off and keeps
  the trackpad's own Event::Zoom (Windows' folded Ctrl + wheel pinch included);
  read_with() takes the preference.
- rotate_view: trackpad_delta() only falls back to the multi-touch rotation while gestures
  are on; Rotate events always count.
- canvas, Navigator: pass the preference through.
- prefs: touchGestures leaves HIDDEN_UNTIL_IMPLEMENTED, so the dialog shows it.
- Tests: synthetic two-finger pinch and twist, on and off, and the trackpad events with
  gestures off.
- docs/scorecard.md regenerated: settings that do nothing 26 -> 25 of 168.
2026-10-18 17:23:21 -04:00
Marco Arnulfo 8736098d38 Curves: Cmd/Ctrl-click on the image adds a point at the pixel's tone (#7473)
* Curves: Cmd/Ctrl-click on the image adds a point at the pixel's tone (part of #7325)

With Image > Adjustments > Curves open, a Cmd-click (Ctrl-click off the Mac) on
the image adds a point to the channel shown in the dialog, at the pixel's tone
and at the curve's current output there; Shift adds one point on each of Red,
Green and Blue. Only the press adds, an armed eyedropper still wins, and the
control channel's ui.pointer does the same with command: true.

* Curves: Cmd/Ctrl-click adds the point with the Hand tool too; Space and middle button still pan

The Hand tool took the Cmd-click press for a pan, so no point was added; the
press now counts before the Hand. On the control channel, ui.pointer with
space or the middle button pans as the mouse does instead of adding a point.

* Curves: the Cmd/Ctrl-click point's comment says where the curve can still change
2026-10-18 17:23:16 -04:00
Marco Arnulfo 0eb0387d53 Save for Web and Quick Export: "All" keeps the photo's EXIF (fixes #7436) (#7471) 2026-10-18 17:23:10 -04:00
JulienandClaude Opus 5.5 bbfeae98e4 Type: right-click on committed text opens the type layer menu (#7324) (#7430)
* Type: right-click on committed text opens the type layer menu (#7324)

With the Type tool, a right-click only opened a menu while text was being
edited; over committed type nothing opened. The menu was keyed on the edit
session alone (`editing_text`).

Over committed type, a Type tool (not a Type Mask tool) now opens
TYPE_LAYER_MENU: the type edit menu's layer rows, without the edit rows
(Undo, Cut, Copy, Paste, Select All), which would act on the document, and
without Faux Bold/Italic, which act on the characters being edited. Type
outside the selected layers becomes the target first, as a click with the
Type tool makes it. The control channel's right button takes the same path.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Type layer menu: the clicked type becomes the active layer in a multiselection (#7324)

The structural rows (Rasterize Type, Convert to Shape, Create Work Path, the
paragraph/point conversions) act on the active layer. When the right-clicked
type was already selected but another selected type layer was active, they
acted on that other layer. Make the clicked type the active layer while
keeping the selection (layer_multi_cmds::set_selection), so the formatting
rows still act on every selected type layer. Found by CodeRabbit on #7430.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 17:23:05 -04:00
skylar taylor-barrick 915779b948 Merge storytold/main into save-replace-metadata
No textual conflicts, but the merge did not compile. main's 589941a5 added a
second AtomicFs test impl using copy_permissions, while this branch renames that
trait method to copy_metadata. Git merged both cleanly because they sit in
different parts of the file, giving a tree whose trait and impl disagree:
E0407, E0046 and E0599 in photocraft-format's lib test. That one error is what
failed all four CI jobs.

main's new impl is renamed to copy_metadata to match.

Verified with --features heif, which is what CI builds with and what I had
previously failed to pass locally: clippy --workspace --all-targets --locked
--features heif -- -D warnings is clean, and the workspace tests pass.
2026-10-18 15:12:41 -06:00
Korg1ch f8e4a1344f Print: the last print's settings survive a relaunch, as in Photoshop (#7410) (#7526)
* fix(print): the last print's settings are kept in the preferences, so Print opens with them after a relaunch (#7410)

* test(print): the last print's settings survive a relaunch for Print and Print One Copy (#7410)
2026-10-18 16:57:24 -04:00
NetterSilverandClaude Haiku 5.5 92721dd8c4 Refuse strict .atn export of plain events whose target is not the layer pixels (#7564)
Fixes #7272

Co-authored-by: Claude Haiku 5.5 <[email protected]>
2026-10-18 16:57:19 -04:00
David MannandClaude Opus 5.5 5225b592fe Perspective Crop: draw the crop quad's side of the horizon (#7557) (#7558)
When the crop quad's horizon crossed the layer and most of the layer
(or its centre) lay beyond it, warp picked the source's majority side
and skipped every output pixel, so the crop came out blank. A clip
wholly on one side of the inverse horizon only ever samples that side,
so draw it; a straddling clip keeps the majority rule (#4863).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 16:57:14 -04:00
JGF b48a916b13 fix(algo): normalize nonpositive filter tile sizes (#7554) 2026-10-18 16:57:08 -04:00
David MannandClaude Opus 5.5 d77a989ef4 image.crop: accept whole-number float targetWidth/targetHeight (#7551) (#7552)
The target size was read with as_u64 only, so 100.0 (json.dumps of a
float, many agent clients) was refused as not a whole number, while the
crop rectangle and image.imageSize accept it. Fractional, negative,
zero and out-of-range values are still refused.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 16:57:03 -04:00
Alexis 449b99a74e fix(shortcuts): plain , and ; bindings follow the typed character on AZERTY (#7521)
* fix(shortcuts): plain , and ; bindings follow the typed character on AZERTY

The positional match for , and . (so Shift+Cmd+. works on Nordic/German layouts)
applied to every shortcut. On AZERTY, ; sits on the US comma key, so ; fired the
command bound to , (brush hardness up/down did the same thing). Only shortcuts
with Cmd now follow the key position.

* contributors: credit Byxis by their GitHub profile name
2026-10-18 16:56:58 -04:00
Khawar Zamman WaniandClaude Opus 5.5 5095babf89 perf(canvas): composite and upload an inline filter preview once (#7490) (#7501)
The synchronous filter-preview path (web, and sessions with background
jobs off) flattened each changed preview, uploaded it and recorded it,
then flattened it again for publish_filter_preview, which uploaded and
recorded it a second time. Flatten once, as the background worker does,
and let publish_filter_preview do the upload and the perf record.

This removes one full composite and one texture upload per changed
preview, and the frame's refresh time no longer counts the preview twice.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 16:56:53 -04:00
NetterSilverandClaude Haiku 5.5 0807441e52 Make .pcraft reload f64 coordinates bit-exact (#7498)
Found while testing crates/format/Cargo.toml:19 (serde_json features), used for the manifest in crates/format/src/manifest.rs and store.rs

Co-authored-by: Claude Haiku 5.5 <[email protected]>
2026-10-18 16:56:48 -04:00
59c8bd3b26 Fix/7381 action worker failure 20261018t2254jkt (#7465)
* fix(actions): stop resumed playback when a dialog's worker fails

* test(actions): reject suffix after failing background dialog filter

* fix(actions): propagate the failed worker result through dialog confirmation

* fix(dialogs): return action worker failures to callers

* Collapse the nested if in actions::resume (clippy::collapsible_if)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 16:56:44 -04:00
fdd4ca7cbc Remove: cancel queued strokes when the paint target changes (#7380) (#7464)
* fix(remove): cancel waiting strokes when their target changes

* fix(remove): store pending target identity with waiting strokes

* test(remove): cover queued strokes across layer and channel switches

* Tests: format retouch_ui.rs and drop the unreachable! arm clippy denies

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 16:56:39 -04:00
8ea77bdfd6 Fade: restore uniformly inverted layer masks (#7383) (#7463)
* fix(fade): detect uniform mask changes and blend full canvas

* test(fade): cover uniform mask inversion and Fade across bit depths

* style(fade): keep default-pixel comparison readable

* Format edit_menu_cmds.rs with rustfmt

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 16:56:33 -04:00
Hodd 140230b141 Replacing selected type keeps its size, as in Photoshop (#7457)
Typing or pasting over a selection now takes the style of the first selected character instead of the one before it, so retyping a smaller second line no longer takes the first line's size from the line break. Fixes #7413.
2026-10-18 16:56:26 -04:00
Khawar Zamman WaniandClaude Opus 5.5 502d055c6c file.saveACopy refuses the document's own file without "overwrite" (#7437) (#7456)
* file.saveACopy refuses the document's own file without "overwrite" (#7437)

Save a Copy through the engine (control channel, MCP, serve, headless
action playback) silently replaced the open document's own file when
given its path, so a lossy re-encode could not be undone. It now returns
BadParams when `path` is the active document's file (same text, ./ or ..
spelling, hard link, case on Windows via same_file) unless the params
pass "overwrite": true, and nothing is written. The desktop dialog writes
through the shell after the OS confirms a replace, so it is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* same_file: compare file identity on Windows, so a hard link is the same file

On Windows `same_file` compared canonical path strings, so a hard link to
the input (another name for the same file) passed the new convert guard
without --in-place. It now compares the volume serial number and file index
(the `same-file` crate, already in the dependency tree; std's file_index is
unstable), as unix compares dev and inode, falling back to the
case-insensitive canonical path when a file won't open. The engine's
same_file test now checks hard links on every platform. (Review on #7454.)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* saveACopy own-file test: hard link on every platform, doc state unchanged after a refusal

Each refused copy (same path, ./ spelling, hard link) now also checks the
document keeps its path, revision and unsaved state. The hard link is no
longer unix-only: same_file compares file identity on Windows too. (Review
on #7456.)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 16:56:21 -04:00
driftee ecc1a9e47b fix(transform): detach clipped preview ancestors (#7438) 2026-10-18 16:56:15 -04:00
Korg1ch a4436074c9 Font field: ↑/↓ step through the fonts after a pick, as in Photoshop (#7407) (#7426)
* fix(type): ↑/↓ on the focused font field step through the families after a pick, as in Photoshop (#7407)

* test(type): the font field keeps the focus after a pick and ↑/↓ step the families (#7407)
2026-10-18 16:56:10 -04:00
Korg1ch 9b4648e195 Font menu: hovering a font no longer grows its row and makes the list jump (#7406) (#7425)
* fix(type): font menu rows keep their size when hovered or selected, so the list no longer jumps (#7406)

* test(type): hovering a font row moves no row and keeps the menu width (#7406)
2026-10-18 16:56:04 -04:00
.CooCoo 89b9e72640 Tests: the authentication deadline test writes its first line before the deadline starts (#7428)
an_authenticated_connection_outlives_the_authentication_deadline started
its 200 ms deadline after it spawned the thread that writes the first
line. When the OS started that thread late, the first read failed with
TimedOut at crates/automation/src/security.rs:447. On a loaded machine
the photocraft-automation lib tests failed 5 of 30 runs this way.

The test thread now writes the first line before the deadline starts,
and the limit is 1 s. The sender thread writes the second line after
the test thread has called authenticated() and 300 ms after the
deadline, so the second read still waits past the deadline. The test
still fails when authenticated() is not called. The product's
AUTH_DEADLINE is unchanged.
2026-10-18 16:55:59 -04:00
.CooCoo 617264a124 Tests: the slow-job frame loop test waits for the worker's progress message (#7424)
the_frame_loop_keeps_running_during_a_slow_job_and_esc_cancels_it
stepped frames until 400 ms of wall time and 10 frames had passed, and
then looked for the job's status message, "Filtering tiles", in the
progress dialog. The job's worker reports that message as its first
statement, on a thread of its own. The dialog appears after 250 ms
whether or not the worker has run, and it reads "Working…" until the
message arrives. On a loaded machine the worker can start after the
400 ms window, and the test then fails with "the job's status
message".

The test now steps at least 10 frames and until the dialog shows
"Filtering tiles", through a new helper, frames_until, which fails the
test when the condition does not hold within 60 s. It also checks that
the job is still running at that point. The helper has the same
signature as the frames_until in #3422.
2026-10-18 16:55:54 -04:00
Finn Newick 8626ad9ae0 Merge remote-tracking branch 'origin/main' into refactor/engine-drop-note-insert 2026-10-18 21:34:40 +01:00
aiongg 1c2193f562 Merge PR #7011: Artboards to Files: paths, notes, slices and marks move with the board (#7010) 2026-10-18 20:33:44 +00:00
aiongg b3b8c7b082 Merge PR #6809: Transform: drop a fill layer's cached render when its frame changes (#6808) 2026-10-18 20:33:44 +00:00
aiongg 74e6f54b38 Merge PR #6800: Mesh warp: about twice as fast, same pixels (tiles encoded in parallel, direct in-bounds reads) (#1015) 2026-10-18 20:33:44 +00:00
aiongg 4e517c054e Merge PR #6779: Image Size: a gradient fill's centre offset stays a fraction of the frame (#6778) 2026-10-18 20:33:44 +00:00
aiongg 978404424e Merge PR #6769: Presets: refuse renaming a group to a name another group has (#6768) 2026-10-18 20:33:43 +00:00
aiongg 5caeea8c1b Merge PR #6670: fix(engine): refuse a Content-Aware Fill with nothing to sample instead of filling black (#6532) 2026-10-18 20:33:43 +00:00
aiongg 5235544baf Merge PR #6256: Menu bar: zooming with Alt+scroll, then holding Space to pan, no longer opens New Document 2026-10-18 20:33:43 +00:00
aiongg fdb3d97031 Merge PR #5983: Codecs: cap what tiff_exif::read copies from overlapping EXIF values (#5982) 2026-10-18 20:33:43 +00:00
aiongg fc990e5818 Merge PR #5726: Brush smoothing: the lagging brush keeps the pressure of where it is (#5713) 2026-10-18 20:33:43 +00:00
aiongg 34e5b4b42f Merge PR #5658: Video: Interpret Footage and New Video Layer from File reject an invalid frame rate (#5657) 2026-10-18 20:33:43 +00:00
aiongg baee303af1 Merge PR #5656: Image > Adjustments: locked smart objects refuse new smart filters (#5655) 2026-10-18 20:33:43 +00:00
aiongg fd44b1c873 Merge PR #5620: Smart Filters: deleting the last filter removes its filter mask (#5619) 2026-10-18 20:33:43 +00:00
skylar taylor-barrick 92c4457b16 Preferences: the settings fill the resized dialog, buttons at the bottom (#7420) (#7462)
* Preferences: the settings fill the resized dialog, buttons at the bottom (#7420)

#5170 made Preferences resizable, but its contents still sized themselves
from the app window (width capped at 1040, settings area at 680 tall), and
the dialog opened at a 960x900 default taller than that. So it opened with
empty space below the buttons, and enlarging it left the settings and the
buttons stuck in the top-left corner.

The settings now take their width and height from the resizable area,
leaving room for the button row, and Preferences opens at 1040x800. The
settings area stays a few points short of the space it is given: egui grows
the area to fit its contents, so asking for more would make the dialog grow
every frame.

* Preferences: a rendered test drags the resize grip and checks the buttons stay in the corner (#7420)

Opens Preferences in a kittest harness, drags its grip wider and then smaller, and checks the button row stays in the frame's bottom-right corner each time and that the dialog keeps its size afterwards. Fails with the sizing from before the fix.

* Preferences resize test: the buttons must also stay inside the frame

A button row past the frame's bottom or right edge gave a negative gap and passed; now each gap must be 0 to 24 pt.
2026-10-18 14:33:13 -06:00
Finn Newick f1b1896d20 Merge remote-tracking branch 'origin/main' into refactor/engine-drop-note-insert 2026-10-18 21:21:25 +01:00
Finn Newick 098337b48a Merge remote-tracking branch 'origin/main' into upd/psd-packbits-decode-into 2026-10-18 21:20:09 +01:00
David MannandClaude Opus 5.5 37a4491c63 Merge main into perf/p25-blur-cancel
Conflict in crates/engine/src/filters.rs: main made keep_alpha pub(crate) for the
plug-in runner (#6770); kept the PR's parallel, cancellable keep_alpha with that
visibility, and the plug-in caller now passes a never-cancelled check and propagates
the Result.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 21:57:28 +02:00
David MannandClaude Opus 5.5 4bf11b1293 Merge origin/main into fix/fill-layer-angle-finite
Resolved commands.rs: keep main's finite-checked color_param(cmd, ...)
and this branch's angle_param for the gradient fill angle; drop the
now-unused f32_or.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 21:51:27 +02:00
David MannandClaude Opus 5.5 7835a027e3 Merge main into fix/tiff-filter-effects-sections
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 21:45:14 +02:00
David MannandClaude Opus 5.5 42877b18e4 Merge main into docs/scorecard-stale-rows-2
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 21:39:36 +02:00
David MannandClaude Opus 5.5 c82137d418 Merge main into fix/crop-straighten-box-undo
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-18 21:26:41 +02:00
Akos Arinton 33dd5f0103 perf(gpu): blend plain layers several to a pass, P60/P61 111 -> 28 ms
- LayerBlend: a plain layer is blended straight from its texture in one pass
  instead of a Content pass to a scratch chunk and a Blend pass.
- fs_layerbatch: runs of up to four plain layers that only feed each other
  are drawn as one pass, reading and writing the chunk once (the uniform
  record grows to its 256-byte stride to hold the extra layers).
- Drop shadows and outer glows alone: the merge builds the layer from its
  shape (F_LAYER_SHAPE), and nothing is copied back when the effect region
  covers the canvas. Such a layer takes 3 passes instead of 6.
- fs_layerbatch_normal: runs of Normal layers use a shader with Normal's code
  alone. Per-pass timestamps showed the batch spent 80% of its time on the
  register cost of carrying every blend mode, not on the arithmetic; Normal
  goes through the same function in every shader, so a layer gives the same
  bits whichever pass draws it.

P60 / P61 with the GPU compositor (8 GB budget, M4 Pro): 111 / 113 ms ->
28.2 / 28.1 ms. With the app's default Memory Usage these documents still
fall back to the CPU compositor on unified memory, so the budgets stay
unenforced.

Output: over the GPU parity suite (1620 renders) 1038 are bit-identical to
before and the rest differ by at most 1e-4/255, except one pixel one 8-bit
step apart where an adjustment rounds at .5; the mean difference from the CPU
reference is unchanged. New parity tests cover runs of every mode, Normal
runs at 8/16/32-bit and Lab, and exterior effects at any fill.
2026-10-18 22:26:06 +03:00
Akos Arinton 5418399dda perf(compose): blend pixel layers row by row, Normal in 8-pixel vector blocks
A pixel layer with nothing clipped to it is read from its tiles a row at a
time and blended straight onto the backdrop, instead of being copied into a
tile-sized buffer first (a megabyte per layer per 256² render tile). Normal
RGB rows go through a branch-free kernel over blocks of 8 pixels that the
compiler vectorizes; few-band renders also run their bands in parallel.

Bit for bit the same: the row kernel is tested against the scalar loop (NaN,
infinities, subnormals, signed zeros), the row path against the tile copy at
8/16/32-bit with every kind of mode, fill and mask, and the whole GPU parity
suite's CPU references (1620 renders) are unchanged.

P60 on the CPU compositor: 627 -> 530 ms (the render without effects
300 -> 199 ms).
2026-10-18 22:26:05 +03:00
David MannandClaude Opus 5.5 43a718ffab Merge main into fix/crop-target-size-samplers
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-18 21:25:20 +02:00
jonathnwxh 99e828b9f7 Merge upstream and retain selection and arranged-view regressions 2026-10-19 03:05:19 +08:00
Higgs32584andClaude Opus 5.5 b3bee7496e Batch Same format: an input without an extension is saved as PNG
same_format took everything after the last dot of the whole path, so a file name without an
extension (or a dot in a folder name) gave a bogus output extension and the documented PNG
fallback never ran. It now uses file_cmds::extension on the file name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 15:04:16 -04:00
Dylan M. Taylor 050c8ebed8 docs: put SonarCloud and CodeRabbit badges on their own line 2026-10-18 14:47:45 -04:00
Dylan M. Taylor 9185b64b94 ci: configure Dependabot for Cargo and GitHub Actions
Co-authored-by: Dylan M. Taylor <[email protected]>
2026-10-18 12:31:45 -06:00
aiongg 56dbc89a38 Merge pull request #7477 from aiongg/fix/shared-dock-color-fill
Color panel field fills its group in a custom workspace too
2026-10-18 13:36:50 -04:00
aiongg fa99e183b6 Color panel field fills its group in a custom workspace too
#2455 stretched the pro Color panel's SV field and hue strip to the dock
group's height, but only the legacy dock told the field that height. The
shared docking renderer from #3097 drew the panel without it, so once the
layout was customized the field fell back to 120 pt. Both docks now offer
the group body's height through one helper.
2026-10-18 17:04:55 +00:00
skylar taylor-barrick 5c7c847923 perf(canvas): report how long the GPU spends on a composite (#7275)
`composite_ms` measures encoding and submitting a composite, not running it: on
a 24 MP refresh it read 7.7 ms for work the GPU spent ~171 ms on, so nothing in
the app could see a regression that moved work onto the GPU.

A timestamp is written on the GPU timeline at the start and end of every command
encoder a refresh submits, and the spans are summed and read back
asynchronously. Nothing waits, so a measurement arrives a frame or two later and
carries the pixel count of the refresh it belongs to. It is reported as a new
`Option<f64>` `perf.gpu_ms`; `composite_ms` keeps its meaning, so its consumers
are untouched. At 24 MP it reports 83.9 ms against a waited wall clock of
88.4 ms, where the app previously said 3.1 ms.

What it leaves out, documented in the module: queue-level uploads, which wgpu
runs in its own command buffer ahead of ours, and the end-of-batch writeback
after the last timestamp. That is ~5 % of the reported time here and
proportional to the composite, against the ~22x of the number it replaces.

Unavailable rather than wrong: without TIMESTAMP_QUERY and
TIMESTAMP_QUERY_INSIDE_ENCODERS the timer is never built and `gpu_ms` is None —
on older drivers, the CPU compositor, wasm, and Apple silicon, whose GPUs
support counter sampling only at stage boundaries. `PHOTOCRAFT_NO_GPU_TIMING`
suppresses both the timer and the feature request.

Pass-boundary timestamps would cover Apple silicon and need only
TIMESTAMP_QUERY, but they capture 70-78 % of the encoder-level span here and
the shortfall grows with document size, so they were measured and rejected.

D3D12 is exercised: device creation, a 100 ns/tick period and the full
write/resolve/map round-trip succeed with no validation errors, including
across a flushed three-buffer refresh. Numbers throughout are from an
integrated Intel GPU on Mesa/Vulkan and a software D3D12 adapter; no vendor
Windows driver and no Apple hardware was tested.
2026-10-18 10:41:35 -06:00
aiongg 5c89f16b60 Merge pull request #7432 from aiongg/integration-2
Integration batch: 11 PRs on main
2026-10-18 11:41:49 -04:00
aiongg a07b87a1af Merge upstream main into integration-2
crates/engine/src/mode_cmds/tests.rs: keeps #7139's opaque-background test and main's full-precision angle test
2026-10-18 15:11:28 +00:00
aiongg 4df773e966 Merge pull request #7405 from aiongg/next
Integration batch: 19 PRs on main
2026-10-18 11:09:40 -04:00
Dylan M. Taylor 18f8d33577 docs: add SonarCloud and CodeRabbit badges to README 2026-10-18 11:00:42 -04:00
7066497886 Keep fractional translation-only Free Transform on .atn import (#7401)
Fixes #7298

Co-authored-by: Claude Haiku 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-18 10:51:21 -04:00
UnemarsandClaude Fable 5.1 491af6d42c Camera Raw histogram: spikes run off the top, as in Photoshop's Camera Raw (#7419)
The dialog's histogram fitted its tallest interior column to the plot, so one spike (a dark sky, a bright wall) flattened the midtones' hill to a few pixels. Camera Raw 16.1.1 scales linearly by the typical columns and lets a spike run off the top (measured on Photoshop 25.4: 49 px per 10 000 pixels in a 106 px plot, the 95th-percentile column at 14 546, the tallest at 21 600).

`rgb_histogram::Spikes`: `Fit` (the old behaviour, every other histogram keeps it) and `Clip` (the scale is at most 1.5 × the 95th-percentile column, taller columns clip at the top). The Camera Raw dialog's histogram uses `Clip`.

Test: `camera_raw_scale_lets_a_shadow_spike_run_off_the_top`.

Split out of #3816 at the reviewer's request.

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-18 10:50:47 -04:00
UnemarsandClaude Fable 5.1 65778c1f7e Raw tone curve: no NaN between a knot at black and the next one (#7416)
`develop::tone` interpolates the camera curve logarithmically between knots, and a curve whose first knot sits at black (0, 0) made the first segment divide by log₂ 0: every value in it came out NaN, and `retone` (the open-time Shadows/Highlights preview) propagated it into the pixels. From a knot at black the segment is now linear, where log₂ has no value; the other segments are unchanged.

Test: `retone_lifts_shadows_with_a_curve_from_black` (finite output, shadows lifted, highlights kept) fails without the fix.

Split out of #3816 at the reviewer's request.

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-18 10:50:41 -04:00
NetterSilverandClaude Haiku 5.5 713dbbed2f Save Actions ATN files atomically so a failed write keeps the old file (#7403)
Fixes #7290

Co-authored-by: Claude Haiku 5.5 <[email protected]>
2026-10-18 10:50:37 -04:00
NetterSilverandClaude Haiku 5.5 80553ecd93 Refuse strict ATN export of Close with an explicit document target (#7393)
Fixes #7299

Co-authored-by: Claude Haiku 5.5 <[email protected]>
2026-10-18 10:50:32 -04:00
David MannandClaude Opus 5.5 89fa322a3c Crop: Esc and Enter go to an open options bar popup first (#7386) (#7388)
The shortcut pass runs before the panels are drawn, and its crop/polygon
branch consumed Esc and Enter even while an egui popup of the options bar
was open (the Ratio list, the overlay and gear menus): Esc cancelled the
crop and left the list open, Enter committed the crop beneath it. Skip the
branch while `egui::Popup::is_any_open` (kept in memory from the last
frame; `ctx.any_popup_open` only sees the current pass).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 10:50:27 -04:00
Khawar Zamman WaniandClaude Opus 5.5 4b2bee675e Spot Healing Brush trail is Photoshop's dark half-opaque tint (#7342) (#7374)
The stroke preview was white at a quarter opacity, invisible on white
and faint elsewhere. Photoshop shows the area to heal in half-opaque
dark grey; the trail now does too. Other retouch trails are unchanged.

Test: drag_preview_canvas::the_spot_healing_trail_is_a_dark_half_opaque_tint
drags the real canvas offscreen on white and mid-grey and checks the
trail darkens the pixels to 40-60 %; it fails with the old colour.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 10:39:39 -04:00
David MannandClaude Opus 5.5 d459ca7939 Image Rotation: Arbitrary keeps Color Sampler points on their pixel (#7372) (#7373)
Sampler points are pixel indices, but rotate_arbitrary mapped them as the
pixel's top-left corner while the warp moves pixels by their centres, so
after a turn a point sat one pixel off its pixel, or fell off the canvas
(a corner point at 90° or 180°). Map the pixel centre, as #6720 does for
flips and right-angle turns; the turned Crop shares this map.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 10:39:34 -04:00
David MannandClaude Opus 5.5 a4c7b5ef89 Rotate Canvas Arbitrary: keep the angle at full precision, so rotated crops land on the pixel (#7369) (#7370)
rotate_arbitrary read `angle` as f32. rotated_crop sizes its canvas with
rotated_size at f64 and passes the angle through JSON; near a whole-pixel
ceil boundary the f32 angle grew the canvas 1 px more (1000x1000 at
0.11470651449337071: 1003 instead of 1002), shifting the crop by 1 px.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 10:39:29 -04:00
David MannandClaude Opus 5.5 fb6f8b4fa6 Canvas Size: growing the canvas re-renders PSD fill layers (#7362) (#7363)
A fill layer from a PSD keeps its stored pixels as a fill cache that covers
only the old canvas. Canvas Size, an extending Crop and Reveal All translated
that cache but kept it, so the added area of the fill layer stayed
transparent. Drop the fill caches whenever the canvas grows, so the fill
re-renders from its model over the whole canvas, as Photoshop does (Free
Transform and Perspective Crop already drop it).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 10:39:24 -04:00
David MannandClaude Opus 5.5 06d4d31dd4 Tool Presets: + saves only the current tool's options (#7356) (#7357)
The + button in Window › Tool Presets stored the whole ToolOptions struct, so choosing
the preset later wrote every other tool's options back (e.g. Crop's Delete Cropped
Pixels). Reset Tool's per-tool field lists become `option_keys`, which the + button now
uses to store only the tool's own options. Brush/Pencil Clear, the Eraser's mode, the
Remove tool's options and Content-Aware Move's Transform On Drop join their tools' lists,
and a test checks that every option belongs to a tool.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 10:39:19 -04:00
David MannandClaude Opus 5.5 5f648126c7 Crop and Straighten Photos keeps Duotone inks and is disabled for Indexed Color (#7354) (#7355)
The copies were made with Document::new and kept the mode but not the
duotone inks, so a Duotone scan came out as plain gray. Copy them, and
grey the command out for Indexed Color documents, as Photoshop does.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 10:39:15 -04:00
MarekSpodBiedry c8c651c413 Esc leaves full screen with the Crop tool showing its default frame (#7315) (#7350) 2026-10-18 10:39:09 -04:00
MarekSpodBiedry e04f522667 Centre stacked ruler digits and the Properties W/H/X/Y labels (#7314) (#7349) 2026-10-18 10:39:04 -04:00
MarekSpodBiedry 2c1c6f6693 Open Photoshop guides stored in a JPEG's APP13 resources (#7300) (#7348) 2026-10-18 10:38:59 -04:00
driftee 6e460aab8a fix(transform): isolate nested preview siblings (#7280) 2026-10-18 10:38:54 -04:00
Brandon ThomasandClaude Opus 5.5 5b087699be Tests: fix the flaky file-lock tests and the occasional portal_save hang (#7418)
The rotation and instance-log tests in logging.rs, and two app_dirs migration-lock tests, drop a
flock and check straight away that it is free. A child that another test spawns at that moment
holds a copy of every open descriptor until its exec, the lock included, so the check sometimes
saw it still held (about 6 failures in 15 runs of the app's test binary). Tests that spawn
children (the portal mock's dbus-daemon, the display-profile helper test) now hold CHILD_SPAWNS
for reading while they spawn, and the lock tests hold it for writing.

The portal mock made its message stream inside its thread; a stream only gets messages that
arrive after it exists, so a SaveFile sent before the thread got going was lost and the test
waited forever (about 1 run in 25). The stream is now made before the thread starts.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 10:29:07 -04:00
aiongg 5a01650652 Integration: cargo fmt 2026-10-18 14:19:01 +00:00
aiongg 0b167ca259 Integration: #6476's uppercase token test writes its file owner-only (#5459 on main) 2026-10-18 14:19:01 +00:00
aiongg fac74f83e8 Integration: #6414 an unconfirmed save releases the unsaved-changes prompt 2026-10-18 14:19:01 +00:00
aiongg 0c36fbc49d Integration: #6035 move the new tests above existing tests' doc comments 2026-10-18 14:19:01 +00:00
aiongg 432c973033 Merge PR #6030: Help › Search: typed letters go to the field, not to menu mnemonics (fixes #5996) 2026-10-18 14:19:01 +00:00
aiongg 8f1eedfc36 Merge PR #6039: Window › Arrange: tiles keep their documents, and every tile has rulers (fixes #6017) 2026-10-18 14:19:00 +00:00
aiongg e7d4ce5e18 Merge PR #6364: Path Selection: the canvas right-click opens the path menu, as the Pen's does (#1056) 2026-10-18 14:19:00 +00:00
aiongg ccb62fcd8f Merge PR #6038: Type: Korean paragraph text wraps between words, not syllables (fixes #5959) 2026-10-18 14:18:59 +00:00
aiongg 9692eea083 Merge PR #6346: File › Open: a file type filter per format (fixes #6337) 2026-10-18 14:18:59 +00:00
aiongg 17e3490142 Merge PR #6035: Image Size: Indexed Color resamples with Nearest Neighbor only (fixes #6019) 2026-10-18 14:18:58 +00:00
aiongg b9b4a73cc0 Merge PR #6338: Smart filters: an edited filter's dialog previews again (fixes #6324) 2026-10-18 14:18:58 +00:00
aiongg dad0881247 Merge PR #6366: Type: the text caret shows on a white background (part of #6085) 2026-10-18 14:18:57 +00:00
aiongg eb2b1517a8 Merge PR #6505: Character and Paragraph panels: size, leading, baseline shift and indents follow the Type units preference 2026-10-18 14:18:57 +00:00
aiongg b5fed83c79 Merge PR #6335: Replace Color: the sample eyedropper is armed when the dialog opens (fixes #6279) 2026-10-18 14:18:56 +00:00
aiongg c5c68d5de7 Merge PR #6344: Replace Color: a Result swatch whose Color Picker sets the sliders (fixes #6278) 2026-10-18 14:18:56 +00:00
aiongg fb678d7463 Merge PR #6476: Automation: a control token authenticates in either letter case (#6425) 2026-10-18 14:18:55 +00:00
Michael Higgins cd3ef1444f Merge branch 'main' into fix/5799-batch-same-format-raw 2026-10-18 10:16:37 -04:00
Michael Higgins 49e1ae898b Merge branch 'main' into fix/4845-autohide-taskbar 2026-10-18 10:15:00 -04:00
Michael Higgins d7760fd7c4 Merge branch 'main' into fix/6884-pen-tool 2026-10-18 10:14:26 -04:00
Michael Higgins e8e745b7c2 Merge branch 'main' into fix/txt2-edited-object-6968 2026-10-18 10:14:21 -04:00
Michael Higgins 62f94d3f9f Merge branch 'storytold:main' into fix/5713-smoothing-pressure-follows-brush 2026-10-18 10:10:31 -04:00
Michael Higgins 43ad6f2990 Merge branch 'storytold:main' into fix/5884-delay-load-windows-data-pdf 2026-10-18 09:44:31 -04:00
aiongg 19e3292c9f Merge PR #7141: perf(camera-raw): Shadows/Highlights 1.5× faster, bit for bit the same 2026-10-18 13:23:03 +00:00
aiongg 811abbc3e9 Merge PR #6333: Help › Search: ← / → and Space edit the field instead of driving the menu (#6332) 2026-10-18 13:23:02 +00:00
aiongg 94bb46f5cc Merge PR #6834: Object Selection: enclosed background gaps stay unselected, only object-like holes fill (#6556) 2026-10-18 13:23:02 +00:00
aiongg d5b9de755a Merge PR #6814: Re-saving a Photoshop 16-bit PSB no longer adds a second Transparency channel 2026-10-18 13:23:01 +00:00
aiongg 2437fc36c5 Merge PR #6046: fix(path): validate Make Selection options and guard selection state 2026-10-18 13:23:00 +00:00
aiongg 58d8d911a2 Merge PR #6003: Count: reject unknown groups instead of adding markers to the wrong group 2026-10-18 13:23:00 +00:00
aiongg eef436de45 Merge PR #5654: Count tool: deleting an earlier count group keeps the active group (#5653) 2026-10-18 13:22:59 +00:00
aiongg 785b9478d7 Merge PR #6771: Plug-ins: running a filter plug-in respects layer locks (#6770) 2026-10-18 13:22:59 +00:00
aiongg da96d71506 Merge PR #6863: Native format: refuse a .pcraft with out-of-range numbers when it is opened (#6862) 2026-10-18 13:22:58 +00:00
aiongg 45c5f41af0 Merge PR #7046: Render Video: GIF frame delay follows timelines under 1 fps (#7045) 2026-10-18 13:22:58 +00:00
aiongg c9ac35b3b1 Merge PR #7139: Perspective Crop and Rotate Arbitrary keep the Background opaque (#7138) 2026-10-18 13:22:57 +00:00
aiongg 71a5bc44be Merge PR #6499: Desktop: a control/automation flag without its value is a usage error (#6429)
apps/photocraft/src/main.rs: #6499's parse_launch_args is the one parser; it also takes main's --help/-h, -- and unknown-option errors (#6435); usage_exit_code is the one exit helper
2026-10-18 13:19:41 +00:00
aiongg e452f6ad80 Merge PR #6414: Web: a requested download no longer marks the document saved (#4962)
crates/ui-egui/src/lib.rs: Services keeps both main's file_stamp and #6414's write_unconfirmed
2026-10-18 13:16:00 +00:00
aiongg cab62b9516 Merge PR #6399: Brush: the Dual Brush tip keeps its proportion at tiny main sizes (#4809) 2026-10-18 13:15:52 +00:00
aiongg 2c8314165e Merge PR #6506: Refuse Affinity spot colours nested more than 4 levels deep 2026-10-18 13:15:51 +00:00
aiongg d072538dae Merge PR #5992: Retouch: bound every stroke command before its dense buffers (#4961, #6110) 2026-10-18 13:15:51 +00:00
aiongg 4127604122 Merge PR #6502: Engine: reject numeric overflow before document edits 2026-10-18 13:15:50 +00:00
aiongg 5676d7c5b0 Merge PR #5671: fix(svg): budget rasterised fallback layers across imports 2026-10-18 13:15:50 +00:00
Higgs32584andClaude Opus 5.5 98f4c9e1b8 test(psd): write the NUL in psd16_merged_transparency.rs as an escape
The test file held a literal NUL byte, so git treated it as binary.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 08:54:20 -04:00
PerchenkoAntonandBrandon Thomas 8d2e615ab9 i18n(uk): translate color picker titles and rasterization warnings (#6153)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-18 03:50:35 -04:00
MarekSpodBiedryandBrandon Thomas c39d752758 Translate the brush size and hardness shortcut rows in every language (#6742) (#6831)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-18 03:48:59 -04:00
Marco ArnulfoandBrandon Thomas d6ea04299f Document tabs: keep whole names, hide what doesn't fit, list every document in the » menu (#4525)
* Document tabs: keep whole names, hide what doesn't fit, list every document in the » menu (fixes #4487)

* Document tabs: check the active document in the » menu (#4487)

Like Photoshop's, the » menu marks the active document with a check mark,
drawn as the menu bar's menus draw theirs.

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-18 03:48:54 -04:00
dotCooCoo 99031545b2 Merge main into fix/abr-repeated-brush-names 2026-10-18 00:48:20 -07:00
cc772ad9dc Saving onto a dangling symlink writes through to its target (#6605) (#6649)
* fix(format): saving onto a dangling symlink writes through to its target (#6605)

resolve_symlink fell back to the link path when canonicalize failed, so an
atomic save renamed over the symlink and replaced it with a regular file.
Dangling chains now resolve lexically (relative targets included, with a
depth cap), so the bytes land at the target and the link survives; a
symlink loop is a clear error instead of a silent link replacement.

* Follow dangling links without folding .. by text

A folder on the way may itself be a symlink, so leave .. for the OS to resolve.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: highperfocused <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 03:35:35 -04:00
wwew 04967b12f9 docs: write product name as PhotoCraft in engine module doc, LUT export header and image sources (#5454) 2026-10-18 03:35:28 -04:00
JunHyun ParkandClaude Opus 5.5 386368956f Korean: consistent terms in the Gradient Editor, Object Selection messages and shortcut hints (#6882)
Eighteen labels in ko.tsv used a different word than the rest of the
Korean catalog for the same term, or named a menu item by a label the
menu does not use (Reset All to Defaults reads 모두 기본값으로 초기화).
Only translation values change; placeholders and ellipses are untouched.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 03:35:22 -04:00
Tom InglingandClaude Opus 5.5 b3c8a2a7f3 Assign Profile and Convert to Profile: Preview, as in Photoshop; Black Point Compensation spelled out (#6825)
- Both dialogs get the Preview checkbox (`PREVIEWED`).
- The canvas shows a filter preview through the preview's own colour management (its profile and
  mode), not the committed document's: Assign Profile changes only the profile, so its preview
  looked unchanged. The preview keeps the document's id, so Proof Colors still apply.
- Previews run with the app's Color Settings: `working` is the chosen working space, not the
  built-in default.
- The `bpc` checkbox (Convert to Profile, Proof Setup) reads "Black Point Compensation" instead of
  "Bpc"; every catalog already translates it.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 03:35:17 -04:00
Carter Smith e9036c72c6 Focus font search when opening the picker (#7222)
* Focus font search when reopening the picker

* Keep font search focused through the opening click release
2026-10-18 03:35:12 -04:00
Jason Xiang 37beb18454 fix(codecs): keep EXR color channels within one layer (#5866) 2026-10-18 03:35:05 -04:00
David MannandClaude Opus 5.5 8a5a680674 Menus: one checkmark column keeps every label aligned (#5705) (#5809)
Menu rows prefixed their label with "✔ ", three spaces or nothing, so
unchecked toggles (Lock Guides, Lock Slices) sat at a different x than
checked and plain rows, most visibly in CJK translations. Every row,
submenu headers included, now reserves one fixed-width checkmark column
(the width of "✔ " in the button font) and draws the mark in it.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-18 03:34:58 -04:00
Korg1ch 295a28a50b fix(ui): Ctrl/Cmd-drag in the Navigator zooms to the drawn rectangle, as in Photoshop (#6872) (#7140)
* fix(ui): Ctrl/Cmd-drag in the Navigator zooms to the drawn rectangle, as in Photoshop (#6872)

* test(ui): cover the Navigator zoom rectangle (#6872)
2026-10-18 03:34:52 -04:00
David MannandClaude Opus 5.5 44dc7c2c77 OpenRaster: layer locks no longer change silently on save (#7018) (#7019)
* OpenRaster: layer locks no longer change silently on save (#7018)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* ORA: the Background's position lock is not reported as lost (#7018)

Every new document's Background carries a position lock, so the warning made a
plain .ora unsavable in place.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 03:34:47 -04:00
Michael HigginsandClaude Opus 5.5 a914c58a63 fix(ui): wide ellipse for Elliptical Marquee, dashed C + brush for Quick Selection (#6875) (#6917)
The Elliptical Marquee used a dashed circle and Quick Selection a brush
inside a dashed circular arc, so the two read alike. Photoshop draws the
marquee as a horizontal ellipse and Quick Selection as a brush in an
upright dashed rounded C. New original ellipse-dashed.svg; quick-selection.svg
redrawn; tests check the ellipse is wider than tall and the C runs down
the left edge.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 03:34:42 -04:00
NetterSilverandClaude Haiku 5.5 866e7bfe4e Upload 16-bit layer masks as R16Float instead of R32Float (#6845)
Fixes #6534

Co-authored-by: Claude Haiku 5.5 <[email protected]>
2026-10-18 03:34:37 -04:00
NetterSilverandClaude Haiku 5.5 dfb4aa70ae Keep Color Sampler points on their pixel after flips and turns (#6720)
Found while testing crates/engine/src/commands.rs:117-120 (turn) with crates/engine/src/canvas_geom.rs:224 (Turn::affine); applied in crates/engine/src/lib.rs:807-814 (Session::edit)

Co-authored-by: Claude Haiku 5.5 <[email protected]>
2026-10-18 03:34:32 -04:00
David MannandClaude Opus 5.5 0296c8e0b5 Remove tool: every queued stroke is tinted in full (#6555) (#6642)
With Remove after each stroke off, every finished stroke was fed into the
one shared tint trail, whose point counter still held the previous
stroke's length, so later strokes were stamped only past that count.
Trail::feed_stroke starts each new stroke's count at zero.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-18 03:34:27 -04:00
denstu473 68dfaf1ae6 Check unclamped transform dimensions against the pixel budget (#6493) 2026-10-18 03:34:21 -04:00
David MannandClaude Opus 5.5 e6c31f4d4a Menus: rows scrolled out of view no longer open their submenus (#5896, #6225) (#6356)
A tall menu reopens where it was last scrolled to, so after reaching Edit ›
Preferences › Settings… or Window › Theme its top rows lie hidden above the
view, over the menu bar. egui opens a submenu whenever the pointer is over its
row, clipped or not: the hidden submenu row under the title opened on the
press, and the release on the title counted as a click outside that submenu,
which closed the whole menu. Hovering the top scroll arrow scrolled the menu
back up, which is why that "fixed" it.

Submenu rows are now drawn like `ui.menu_button` but skip egui's submenu
logic while the pointer is over a part of the row that is scrolled out of
view (an already open submenu, e.g. from the keyboard, is drawn as before).

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-18 03:34:16 -04:00
xsvm f2ae78ed93 perf(psd): 8-bit merged composite unmattes through a table, not a division per sample (#5694) (#6029)
composite_rgba8 removed the white matte from 8-bit RGB composites with
one integer division per colour sample (3 per pixel, ~108 million on a
36 MP file). The result depends only on the sample and its alpha, so the
same expression, now unmatte_u8, fills a 256 x 256 table once per call
and each sample is a lookup. Built per call, not as a static, so no
binary (wasm included) grows by the table.

The new test runs all 65 536 (sample, alpha) pairs through
composite_rgba8 against the replaced loop, kept verbatim as the
reference.
2026-10-18 03:34:11 -04:00
Jason Xiang c41a7957f9 fix(io): exclude transparent palette entries for opaque PNG pixels (#5869) 2026-10-18 03:34:05 -04:00
.CooCoo ea647146db Swatches: the .ase reader refuses files with more than 10,000 groups (#5811)
photocraft_psd::ase::parse counted colors toward MAX_SWATCHES but not
group starts, so the number of groups it read grew with the file's
size. An empty group start is six bytes, and parse held about 64 bytes
for each: in a test build, parsing a 60 MB file of 10,000,000 empty
group starts raised the process's peak memory from 63 MB to 705 MB.
swatches.import reads files of up to 512 MiB, and read_file built a
group for each group start before the import refused more than 10,000
groups.

parse now counts group starts and returns LimitExceeded past
MAX_GROUPS (10,000), the most groups the Swatches panel holds. Files
with at most 10,000 groups parse as before, so every file the import
accepted still loads. Files with more were refused before as well; now
parse refuses them before it holds more than 10,000 groups. write also
refuses more than MAX_GROUPS groups, as it refuses more than
MAX_SWATCHES colors.

ase_group_cap_is_enforced_on_hostile_input parses 10,001 empty group
starts and expects LimitExceeded, then parses the same file with one
block fewer and expects all 10,000 groups. too_many_groups checks that
write refuses 10,001 groups and that 10,000 groups are written and
read back.
2026-10-18 03:34:00 -04:00
David MannandClaude Opus 5.5 0f0f58c2ab PSD: layer_tree_max_depth hoists layers under unclosed dividers past the cap (#5758) (#5759)
Frames past the cap collected nothing, so the layers under dividers that
are never closed were lost once those dividers nested past max_depth,
and nothing was left to hoist at the end. A first pass now matches
dividers to folders and only closed dividers open a frame, so every
frame's depth is the final depth of its nodes and the capped tree equals
layer_tree cut at max_depth, still at most max_depth + 1 levels deep.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-18 03:33:55 -04:00
David MannandClaude Opus 5.5 b1de6e8ade Preset Manager: renaming or deleting a brush keeps the current preset and names consistent (#5681) (#5682)
Edit > Presets > Preset Manager renamed and deleted brush presets directly, so a rename
left the current preset pointing at the old name (brush.presets.update then failed), accepted
a case-insensitive duplicate name, and a delete left the current preset dangling. It now uses
the same rules as brush.presets.rename / brush.presets.delete through two small shared
helpers (rename_at, forget_deleted_preset). Patterns and custom shapes are unchanged.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-18 03:33:50 -04:00
068ca57210 Document tab says "Quick Mask" in Quick Mask mode, as in Photoshop (#6660)
* Document tab says "Quick Mask" in Quick Mask mode, as in Photoshop

In Quick Mask mode, painting and Free Transform target the mask, not the
layer. The tab still read "(Layer 1, RGB/8)", so a user who pressed Q by
accident and then rotated saw the red overlay turn with the box and the
layer stay put, with nothing on screen saying why. The tab now reads
"(Quick Mask/8)", the same condition `paint_target` routes on.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Credit arnav-hiwarkar in the About window

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Keep contributors/people.toml as on main

The branch removed another contributor's entry; the added entry only repeated the username.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-18 03:33:45 -04:00
f6f53f657d Reject notes.set moves that make the popup non-finite (#6174)
Found while testing crates/engine/src/notes_cmds.rs:105

Co-authored-by: Claude Haiku 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-18 03:33:40 -04:00
Nicolas FalesyandClaude Opus 5.5 2301ecf9e9 fix(linux): file dialog probe sees a portal with no FileChooser backend (#5739) (#6422)
The probe read the FileChooser `version` property and only counted a few D-Bus
error names as "absent". zbus wraps property errors in `Error::FDO`, and a portal
with no FileChooser backend doesn't export the interface at all, so the read fails
with `InvalidArgs` ("No such interface"). Neither matched, the probe said
"inconclusive", and Save As / Export did nothing without the "No file dialog can
open" notice.

Introspect the portal object and look for the interface instead, and treat
InvalidArgs, UnknownMethod and NameHasNoOwner (as `FDO` or `MethodError`) as absent.

Fixes #5739


Claude-Session: https://claude.ai/code/session_01AfRHB1wHUjrJx6agBYGnVu

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 03:33:35 -04:00
Michael HigginsandClaude Opus 5.5 0007cad01a Paste: an image pastes with Ctrl+V whichever key is let go first (#6329) (#6360)
With no text on the clipboard (an image copied from a browser, a file from Explorer) egui-winit
swallows the Ctrl+V press and sends only the V release; the paste then ran only when that release
still carried Ctrl. Letting go of Ctrl first, as many people do, pasted nothing, so Ctrl+V seemed
to need several tries. A V release whose press never arrived, after Ctrl was held and before any
other key, is now that swallowed Ctrl+V and pastes once. A plain V tap (the Move tool) and the
web build, which always gets the key-down, are unchanged.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 03:33:29 -04:00
Andrew Barnes 436ca3569c fix(gpu): clamp device limits to adapter support (#5986) (#6050)
Keep backend defaults within supported limits while preserving the
adapter's texture and buffer maxima. Add regressions for V3D shader
variables, weaker GL adapters, minimum alignments and capable devices.
2026-10-18 03:33:24 -04:00
David MannandClaude Opus 5.5 977cf5f6de Krita: uncompressed tiles are read as interleaved pixels (#7082) (#7083)
* Krita: uncompressed tiles are read as interleaved pixels (#7082)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* kra: the agent-surface test writes its raw tile interleaved, like Krita (#7082)

The krita.rs fixture wrote its flag-0 tile as byte planes, which only the old
decoder read back correctly.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 03:33:19 -04:00
David MannandClaude Opus 5.5 a002e66824 Snapping: layer targets ignore the selection, so a moved selection snaps to guides and the document (#6290) (#6355)
Snap To Layers built each layer's edges and centre from transform_bounds, which clips the
layer to the selection. With a selection, every layer's targets were the selection's own
bounds, so dragging the outline with a marquee snapped it back to where it started (shown
as layer alignments) and beat nearby guides and document edges. Layer targets now use the
layer's whole content bounds, as in Photoshop; the moving box (layer_rect) is unchanged.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-18 03:33:14 -04:00
NetterSilverandClaude Haiku 5.5 275d4cbcf7 Reject out-of-range leading, baseline shift and scales in type styles (#6196)
Found while testing crates/engine/src/type_cmds.rs:287

Co-authored-by: Claude Haiku 5.5 <[email protected]>
2026-10-18 03:33:09 -04:00
Michael HigginsandClaude Opus 5.5 16774eea6f Twirl, Pinch and Spherize fill the inscribed ellipse (#5643) (#5871)
They acted inside a circle fitted to the bounds' shorter side, so on a wide
or tall image only a central disc changed. Like Photoshop's, they now act
inside the ellipse inscribed in the bounds (selection or layer), reaching all
four edges; square bounds are unchanged.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 03:33:04 -04:00
Michael HigginsandClaude Opus 5.5 c408c83a9f Pencil: a 1 px line paints the same pixels whichever end it starts from, at any Smoothing (#6336) (#6341)
The pixel-grid walker stepped between the pixels holding each segment's ends. Smoothing splits
a Shift-click line into pieces along it (so does the pointer on a Shift-drag), and snapping every
piece to its own pixels bent the line into a jagged staircase; rounding the half-pixel ties away
from zero also made the line depend on which end it started from. The walker now samples the
segment's own line at each column (or row) centre and resolves ties on the pixel grid, so the
pieces of a straight line paint exactly the pixels of the whole line, both ways.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 03:32:58 -04:00
David MannandClaude Opus 5.5 175bdce659 GPU: a group's effect cache follows its vector mask and children's Blend If (#5706) (#5707)
* GPU: a group's effect cache follows its vector mask and children's Blend If (#5706)

group_key, which decides when a styled group's effect shape and distance
fields are rebuilt on the GPU, hashed the group's children and layer mask
but not the group's own vector mask, nor each child's Blend If and
Advanced Blending. A group's Stroke or Drop Shadow kept the old outline
after those changed. Hash them, as the CPU's layer_identity does.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* GPU: pass the pin callback in the group key test after main's signature change

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-18 03:32:53 -04:00
FinnNkandClaude Opus 5.5 9ae0f624fc test(type): PostScript names guessed apart from their installed family resolve exactly (#5471)
Covers #5044's two names: BMDoHyeon (family "BM DoHyeon", guessed "BM Do Hyeon") and Daum_SemiBold (guessed "Daum_Semi Bold"). Both resolve to their installed faces and aren't reported missing on main, through the exact PostScript lookup (#1844) that missing-font checks follow since #608.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 03:07:55 -04:00
MarekSpodBiedry 2e1cc763a7 Stamp Visible fills an empty active pixel layer instead of adding another (#6785) (#6828) 2026-10-18 03:07:50 -04:00
NetterSilverandClaude Haiku 5.5 fb5896f43f Fix generator spaced size keeping size text in asset file name (#6628)
Found while testing crates/engine/src/web_cmds.rs:1171

Co-authored-by: Claude Haiku 5.5 <[email protected]>
2026-10-18 03:07:45 -04:00
David MannandClaude Opus 5.5 e8ab6846c0 Place Scale Marker: lay out the label in points, not pixels (#5754) (#5755)
fontSize is in points and type.create renders it at resolution/72 px
per point, but the bar height, label height and baseline treated it as
pixels. At 300 ppi a 12 pt label (50 px) overlapped the bar. Lay out in
font_size * dpi / 72 px; type.create still gets points. 72 ppi is
unchanged.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-18 03:07:40 -04:00
David MannandClaude Opus 5.5 5a9fb94b9b Type: Paste Lorem Ipsum inserts into the type layer named by "layer" (#5659) (#5660)
paste_lorem decided whether to insert into existing text from the active
layer, while with_text edits the layer the "layer" param names. With a
pixel layer active, {"layer": T} made a new paragraph layer and left T
unchanged. An explicit "layer" now always goes through with_text, so a
type layer gets the text and any other layer is an error; without
"layer" the behaviour is unchanged.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-18 03:07:34 -04:00
David MannandClaude Opus 5.5 20c3e95a6b EXR: the ACEScg preference applies to every way of opening a file (#6782) (#6783)
The "Open color-less EXR as ACEScg" option was only applied by file.openAs; background
opens (File > Open, drag-drop, Open Recent via Session::start_open) and file.openExrParts
kept io's linear-sRGB default. Both now retag through tag_exr_colorspace, start_open before
the Ask When Opening prompt so it sees the ACEScg profile.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-18 03:07:29 -04:00
Korg1ch 84f326fa25 Actions: a Paint Bucket step keeps its explicit colour through .atn (#7199) (#7208)
* fix(atn): a Paint Bucket step keeps its explicit colour through .atn export and import (#7199)

* test(atn): an explicit Paint Bucket colour survives an .atn round trip (#7199)
2026-10-18 03:07:23 -04:00
Korg1ch f04fc80267 fix(engine): flip and rotate presets turn about the selection's bounds, as Free Transform does (#6928) (#6967)
* fix(engine): flip and rotate presets turn about the selection's bounds, as Free Transform does (#6928)

* test(engine): cover flips and turns about the selection (#6928)
2026-10-18 03:07:18 -04:00
NetterSilverandClaude Haiku 5.5 e66941bde1 Ignore a leading UTF-8 BOM in variable data set CSV import (#6485)
Found while testing crates/engine/src/variables_cmds.rs:285-292 (import_data_sets_text; the file is read in import_data_sets at line 279)

Co-authored-by: Claude Haiku 5.5 <[email protected]>
2026-10-18 03:07:13 -04:00
wwew 73cd277a03 fix(engine): check the define-brush tip size multiply before reserving (wasm32) (#6902) 2026-10-18 03:07:08 -04:00
NetterSilverandClaude Haiku 5.5 d722dd8b54 Clamp filter.cameraRaw exposure to its documented -5..5 range (#6876)
Found while testing crates/algo/src/camera_raw.rs:592 (value taken unchecked in crates/engine/src/lens_cmds.rs:137 raw_params)

Co-authored-by: Claude Haiku 5.5 <[email protected]>
2026-10-18 03:07:03 -04:00
David MannandClaude Opus 5.5 8ef5337c29 Measurement Scale: large pixel lengths print their real value (#7016) (#7017)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 03:06:59 -04:00
David MannandClaude Opus 5.5 13bbf0eefe Custom filter: in CMYK a positive offset brightens (#7076) (#7077)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 03:06:54 -04:00
David MannandClaude Opus 5.5 f3a9b81b1f Duotone: view and flat export clamp ink density like the Multichannel conversion (#6854) (#6855)
Duotone::render used the raw curve output, so ink curves above 100 % pushed the
simulated print out of gamut (red + blue at full density showed green instead of black).

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-18 03:06:49 -04:00
David MannandClaude Opus 5.5 7d166edb2a Lens Flare: don't darken 32-bit pixels above 1.0 (#5785) (#5786)
Screen as 1 - (1 - c)(1 - a) pulls a 32-bit value above white down
towards 1.0 (2.0 with a = 0.5 became 1.5, the core became 1.0). Add
a·max(1 - c, 0) instead: the same result for c <= 1, unchanged above.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-18 03:06:45 -04:00
David MannandClaude Opus 5.5 363d385114 Trap: refuse locked layers (#5623) (#5624)
Image > Trap wrote the active layer's pixels without checking locks, so a
layer under Lock All or the pixel lock (its own or its group's) was trapped
anyway. Check check_pixels_unlocked before writing, like the fill commands.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-18 03:06:40 -04:00
Korg1ch 36d6b669d4 test(engine): Content-Aware Fill with an empty sampling area is refused and changes nothing (#6532) 2026-10-18 09:46:18 +03:00
Korg1ch 0f12ac151f fix(engine): refuse a Content-Aware Fill with nothing to sample instead of filling black (#6532) 2026-10-18 09:19:41 +03:00
skylar taylor-barrick 3794e6fbbe Merge pull request #7118 from nicolasfalesy/perf/stack-mode-moments
perf(stack): one loop per statistic, up to 2.2× faster, bit for bit the same
2026-10-17 23:28:16 -06:00
Nicolas FalesyandClaude Opus 5.5 9ff27ec204 perf(stack): one loop per statistic, 1.3–2.2× faster on one thread
#6462 left Mean, Variance, Standard Deviation, Skewness and Kurtosis 5–15%
slower than the old serial loop on one thread. The cause: `stat` matched on
the mode three times per pixel inside one shared loop. `combine` now picks
the statistic once and hands `combine_with` a closure with a constant mode,
so each statistic gets its own loop with the match folded away. The sample
buffers are sized to the layer count and filled by index instead of `push`,
which re-checked the capacity per sample.

Every output is bit for bit the same (121 hashes match main).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01FUAqfMqVHdoeDa2y68KQQw
2026-10-18 06:54:53 +02:00
skylar taylor-barrick 2d0f3b61cf Merge pull request #2376 from Unemars/content-aware-workspace
Content-Aware Fill workspace, as Photoshop's
2026-10-17 22:50:54 -06:00
skylar taylor-barrick 8c30a41e06 Merge pull request #7126 from dotCooCoo/fix/stack-statistics-same-bits-on-windows
Stack Mode: Kurtosis and Skewness give the same bits on Windows and Linux
2026-10-17 21:08:12 -06:00
Michael HigginsandClaude Opus 5.5 4d9f163c61 Linux: Reveal never hands xdg-open a file (#7166) (#7168)
Reveal in Finder on Linux opens the linked file's parent with xdg-open,
which launches whatever it is given. A link stored as `tool.jar/a.png`
names a file as its parent, so Reveal launched that file. Reveal now
refuses a parent that is not a folder.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-17 20:53:59 -06:00
Michael Higgins 72b4a8c0bf Merge branch 'storytold:main' into fix/5884-delay-load-windows-data-pdf 2026-10-17 22:27:05 -04:00
skylar taylor-barrick ceb85009c7 perf(canvas): a Properties-panel adjustment drag refreshes only the view (#6839) (#7155)
`damage_since` had a case for the Image > Adjustments dialog but none for the
Properties panel's `live_adjust` preview, so every parameter change returned no
damage and recomposited the whole document — `gpu-full` at 24.0 MP.

That is what made #5778 a latency regression at 100 % zoom: it correctly stops
CPU-compositing a 1.5 MP proxy, but the full-size GPU composite replacing it is
per-pixel work and 24 MP of it costs more. The Properties path now gets what the
dialog had: a session-tagged key, the layer's area as damage, and of that only
what the view shows, catching up as the view moves.

Intel UHD P630, 24 MP document, 12 changes, medians:

  v0.9.0    70.3 ms   1.5 MP CPU proxy
  main     203.1 ms   gpu-full, 24.0 MP
  this      47.8 ms   gpu-rect, 1.10 MP

Byte-identical to the full-refresh base on all 12 changes and across a pan.
25 % zoom and PHOTOCRAFT_CPU_COMPOSE are unchanged.

`live_region` does not narrow by the layer's mask: a reveal-all mask keeps its
coverage in the default pixel and reports empty content bounds, which emptied
the damage and froze the preview. Every adjustment layer has one.
2026-10-17 20:21:51 -06:00
Brandon ThomasandClaude Opus 5.5 8407d9ccb1 Release 0.10.0 (#7192)
* Release 0.10.0

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Cargo.lock: photocraft-ipp at 0.10.0

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-17 21:28:00 -04:00
David MannandClaude Opus 5.5 b450baa6eb Image: Trim and merged Duplicate report an error on documents over 2^30 px (#7223)
`compose::flatten` returns an empty buffer for canvases over MAX_RENDER_PIXELS.
Trim (Top Left) indexed `px[0]` of it and Duplicate › Merged Layers Only fed it
to `write_region`'s length assert: both panicked; Trim's other modes reported
"nothing to trim to". Both now return a "too large to flatten" error first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 03:02:31 +02:00
dotCooCoo 0bdea678e7 Merge main into fix/abr-repeated-brush-names 2026-10-17 18:02:25 -07:00
carterwsmith 61db2b2bba Group all selected layers from the Layers panel footer 2026-10-17 18:00:15 -07:00
UnemarsandClaude Fable 5.1 66c0e4ce13 Content-Aware Fill workspace: review follow-ups (#2376)
Testing on a 6000 × 4000 document found the proxy's sampling state computed
per pixel against every stroke segment: each stroke got slower, and an undo
tested 1.7 M pixels × 3000 segments on the UI thread, blocking it for over a
minute. The proxy is now painted as the engine paints the full-resolution
mask: the option's area and the fill area are sampled once at the proxy's
pixel centres (set_base), and each stroke is painted over them in order,
visiting only its own pixels (paint, restate_all), so a dab costs its segment
and a rebuild costs the strokes' areas.

Also from the same review:
- `edit.contentAwareFill {"ui": …}` with the workspace closed is an error
  instead of a fill with the defaults (an agent polling the state after an
  Esc added a fill per poll).
- The view sends one Down per press: the press is the stroke's first point
  and the frames before egui's drag threshold are its next ones, instead of
  drag_started() beginning the stroke over (the lasso lost its first points
  the same way; the Zoom tool zoomed on every frame the button was held).
- The preview texture is box-averaged to the device's max_texture_side when
  the plan's rectangle exceeds it (a 17000 px wide plan gave a validation
  error every frame).
- A preview that fails counts as shown for that state (Preview::finish), so
  it is reported once instead of restarting on every repaint.
- The view shows the document's visible layers composited (an Apply to a new
  layer shows); the engine's limits on strokes, lasso edits and points hold
  at the brush with a message instead of failing Apply; the footer is
  Photoshop's reset, OK, Apply, Cancel on every platform; a leftover
  `let _ = i;` is gone.

Tests: strokes painted once and equal to the engine's sampling area; `ui`
with the workspace closed; the first points of a stroke and a lasso through
egui_kittest; the preview texture at a 2048 limit; a failing preview
reported once; the view after Apply to a new layer; the limits at the brush.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-18 02:20:20 +02:00
Higgs32584andClaude Opus 5.5 204c76ce36 fix(psd): an edited type layer keeps its Txt2 object instead of a bare stub (#6968)
When a type layer's text changed, build_txt2 replaced the layer's object in
Photoshop's own Txt2 block with a bare `<< /0 << /0 text /6 runs >> >>`:
no paragraph runs (/5), no view (/1, which names the object's frame) and none
of the style keys. Photoshop 27.10 quits while opening such a file
(layer_effects.psd after type.edit), as it refused stripped objects in #2374.

An edited object now starts from the file's object at its slot: the text is
replaced, the paragraph runs are remade one per paragraph of the new text,
and every other run array of the model (style runs, ...) is cut or extended
to the new length before the auto-kern modes are applied. The view and other
keys are kept; only the old text's glyph pen positions (/21) are dropped.
An object whose run arrays aren't shaped as Photoshop writes them still falls
back to a fresh one.

Test: text edited_text_keeps_the_files_txt2_object.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 20:03:18 -04:00
David MannandClaude Opus 5.5 e1ce85a5df PSD: Hue/Saturation export writes each triplet in its own slot (#7065)
The hue2 block holds a colorize triplet at byte 4 and a master triplet at
byte 10. The writer copied the active triplet into both, so a Colorize hue of
200 also landed in the master slot (outside its -180..180 range) and toggling
Colorize in Photoshop showed the wrong values.

Write the active triplet at its own offset and Photoshop's defaults in the
other (colorize 30/25/0, master 0/0/0), with the colorize hue stored signed as
Photoshop does (200 -> -160). The reader maps a negative colorize hue back to
the model's 0..360.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 01:55:28 +02:00
dotCooCoo c2cf0e0610 Merge main into fix/mixer-brush-clean-and-load 2026-10-17 16:28:35 -07:00
skylar taylor-barrick ead6d2197f Package: report its paths with / on Windows too (#7184)
`file.package` absolutised the caller's `dir` and then built `folder`, `document`
and every `links` entry on it with `join`, which always writes `/`. On Windows
that left one string spelt both ways:
`C:\Users\...\pkg/Print Me/Links/art.png`. The control channel takes `/` and
rejects alternate separators, so a reported path could not be handed back to it.

Spell the absolutised directory with `/` as well, keeping a leading `\\`
(verbatim and UNC prefixes, where Win32 does not accept `/`) intact.
2026-10-17 19:20:33 -04:00
skylar taylor-barrick f8cd3e7982 chore: clear the rustc 1.96 clippy lint that fails CI
Unrelated to this branch's change; main already fails clippy --all-targets -- -D warnings
without it, which is what failed the Windows integration job here. One
clippy::nonminimal_bool site in canvas.rs, taking clippy's own rewrite: the
negation stays outside, so the is_finite guards and their NaN behaviour are
unchanged. A sweep of all 30 workspace members found no other site.
2026-10-17 17:08:37 -06:00
dotCooCoo 3c9c93ed8c GPU startup: Auto on Windows leaves Vulkan out when an Intel adapter is present (#4)
With the `auto` backend on Windows, eframe created its wgpu instance with
Vulkan, and creating it, listing its adapters and asking each adapter
about the window's surface loaded and ran the Intel Vulkan driver
(igvk64.dll) before the adapter selector could pick Intel's DX12
adapter. When the driver crashed there, the first launch died, and only
the next one moved to DX12 through the startup marker.

`configure` now lists the DX12 adapters first, with a DX12-only
instance, when the plan is `auto` on Windows without WGPU_BACKEND. If
one of them is an Intel adapter, the instance is created with DX12 and
GL only (GL stays the last resort), so no Vulkan driver is loaded. On a
system with an Intel and a discrete GPU, the discrete GPU then runs on
DX12 instead of Vulkan. WGPU_BACKEND, --safe-gpu, a chosen backend and
the crash fallback are unchanged, and System Info still shows the Intel
DirectX 12 note.

The decision is `windows_auto_backends`, tested with vendor lists.
2026-10-17 15:28:20 -07:00
Michael Higgins 2e3aa8ded2 Merge branch 'main' into fix/5884-delay-load-windows-data-pdf 2026-10-17 18:17:21 -04:00
Diego Chagas 235e7578f3 Print through CUPS over IPP and the print portal, so File › Print works in the Flatpak (#2728) (#3017)
Main prints through craft-print, which runs `lp`; a Flatpak has no `lp`,
so File › Print fails there. This keeps craft-print as the route wherever
`lp` is on the path (and on Windows) and adds two more behind it:

- `photocraft-ipp`, a pure-Rust IPP client (CUPS-Get-Printers/Classes/
  Default, Print-Job over HTTP/1.1) that talks to CUPS over its socket or
  TCP; the Flatpak manifests grant --socket=cups for it. A `printServer`
  named in the request always takes this route. `file.print.printers`
  lists the queues.
- The XDG Print portal as the app's spooler (`Session::print_spooler`),
  for a sandbox without the CUPS socket or a system without a printer:
  the system print dialog, seeded with copies, paper and orientation.
  PHOTOCRAFT_PRINT_PORTAL=1 prefers it, =0 removes it.

A route that exists but fails (unknown printer, refused job) is an error;
only a missing route moves on. Results gain `backend` (lp, windows, ipp,
portal). docs/printing.md describes the routes.
2026-10-17 15:55:54 -06:00
Higgs32584andClaude Opus 5.5 86f6199b01 Print: load Windows.Data.Pdf.dll before using it; fix the error hint (#5884)
Review follow-up on #6395:
- Check that the DLL actually loads (winsafe `LoadLibrary`, no unsafe) rather than that the
  file exists: a DLL that is present but cannot load still aborts at the delay-load stub.
- The hint no longer says to clear Print Settings; that doesn't take a page off the PDF path.
  It now points to Save as PDF, or printing an RGB image without page text or labels.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 16:53:11 -04:00
Derek GliwaandClaude Opus 5.5 2cf274f991 Brushes: accept .abr files up to 1 GiB
Import Brushes refused .abr files over 512 MiB: the engine's preset file cap
and the .abr decoder's sample budget were both 512 MiB. Raise both to 1 GiB
for brush files only; gradient, swatch, action and image-tip imports keep the
512 MiB cap. A compile-time assert keeps the reader and decoder caps equal.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:38:11 -05:00
Michael HigginsandClaude Opus 5.5 1d65bd605b Web: Place Embedded and canvas drops place images (#5749) (#5817)
* Web: Place Embedded and canvas drops place images (#5749)

File > Place Embedded was disabled in the browser (the engine command reads a
path), though the menu path reads the picked file's bytes. Files dropped on
the canvas were always opened as new documents. The web shell now tracks the
drag position, and drops on the canvas are placed from their bytes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Format (cargo fmt)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Narrow to the web canvas-drop half: Place Embedded enablement landed in #5585

Drop the picker test that covered the part #5585 now owns.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-17 13:20:44 -07:00
skylar taylor-barrick 06ad0d5c95 Merge pull request #2863 from 1Git2Clone/ci-panic-hunt
CI: run panic_hunt in the Linux test job
2026-10-17 12:14:20 -06:00
skylar taylor-barrick 039b86f8f6 Merge pull request #7149 from skylartaylor/linked-path-checks
Follow linked paths only on this computer or the document's share
2026-10-17 12:13:47 -06:00
skylar taylor-barrick 482fd899cf Build: take craft-docpath from the merged craft-libs rev
craft-libs#169 is merged, so the fork pin is no longer needed. Pinned to
storytold/craft-libs 82a24b006eefa036b43c09a67fb9a3fbd17ffe63 (main after the merge) rather than the older 12281d07,
which predates resolve_absolute and the Windows colon refusal.
2026-10-17 12:02:56 -06:00
Gert Pellin - GPE ba40454b18 Logging: a second launch leaves the running instance's log alone (#6432) (#6478)
logging::rotate renamed photocraft.log up the chain on every start without
knowing whether another instance still wrote to it. The running instance
kept its handle, so after a second launch its live log was .1.log, after a
third .2.log, and a fourth launch deleted it.

The instance that writes photocraft.log now holds an exclusive lock on
photocraft.lock until it exits (a separate file, since Windows locks are
mandatory and would keep the log itself from being opened). A start that
finds that lock held leaves the chain alone and writes
photocraft-<pid>.log, marked by photocraft-<pid>.lock. Those side-by-side
logs are pruned to the newest KEEP once their instances have exited; a
held one is never touched. Rotation and pruning run under rotate.lock so
two starts at once can't interleave. On a file system without locks every
start rotates as before.
2026-10-17 11:57:39 -06:00
Khawar Zamman WaniandClaude Opus 5.5 378231214a Desktop app: photocraft --help prints usage; unknown options are usage errors (#6431) (#6435)
* fix(desktop): `photocraft --help` prints usage; unknown options are usage errors (#6431)

The desktop binary's argument loop took every argument it didn't know as
a file, so `photocraft --help`, `-h` or a typo such as `--safegpu`
opened the window and logged "Couldn't open --help".

- `--help` / `-h` print the usage (every option and environment
  variable) and exit 0.
- Any other argument starting with `-` (except macOS `-psn_`) is named
  on stderr and the launch exits 2 before the log file or window, along
  with any bad `--control` port (`control_args_exit_code` becomes
  `usage_exit_code`).
- `--` ends the options, so a file whose name starts with `-` opens.
- `--help` and `--version` write without `println!`, so a closed pipe
  (`photocraft --help | head -1`) can't panic.

Tests: apps/photocraft/tests/command_line_usage.rs runs the binary with
no display; 4 of its 6 tests fail on the old code.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(desktop): usage tests exit before the window opens; fmt smart_filter_authorization

`arguments_after_double_dash_are_files` and `macos_process_serial_number_is_still_ignored`
passed only valid arguments, so the app went on to open its window. Removing DISPLAY makes
that fail fast on Linux, but Windows runners have a desktop and both tests hung until
nextest's 600 s timeout. They now add a bad `--control` value so the launch always fails
with the usage errors, and assert stderr names only that error (no "unknown option").

Also `cargo fmt` crates/automation/tests/smart_filter_authorization.rs, left unformatted
on main by #5684, which failed the Format step of every PR's ubuntu test job.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-17 11:57:27 -06:00
ZuujisandClaude Opus 5.5 796ee3b103 Image Assets: two layers naming the same file no longer overwrite each other (#6614) (#6668)
* Image Assets: two layers naming the same file no longer overwrite each other (#6614) [WIP]

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Image Assets: two layers naming the same file no longer overwrite each other (#6614)

generate_assets wrote every requested asset, so two layers (or two
default variants) naming one file silently replaced each other and the
result listed the file twice. Each file is now written once per run:
layers go top first, as the Layers panel lists them, the first writer
keeps the name, and later ones are reported in errors. Names are
compared ignoring case, since Logo.png and logo.png are one file on
Windows and macOS.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-17 11:55:54 -06:00
David MannandClaude Opus 5.5 589941a536 Save: refuse to replace a read-only file (#6600) (#6643)
Saving writes a temporary file and renames it over the destination. A
rename only needs the folder to be writable, so on Linux and macOS a
read-only (chmod 444) file was silently replaced, while on Windows the
rename failed with a vague "locked or read-only" access error.

atomic_write now asks the AtomicFs whether the existing destination is
read-only (new `is_readonly`, false by default for other implementors)
and refuses before anything is written: "cannot save <file>: the file
is read-only; the original file was not changed". New files and
writable files save as before. Photoshop refuses the same way; the
user saves a copy with Save As.

Tests: a fake AtomicFs reporting read-only gets the error with nothing
written or renamed; a real file marked read-only keeps its bytes and
mode and gets the clear message on every platform.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 11:55:43 -06:00
David MannandClaude Opus 5.5 c9899e9c29 Duotone: ink curve points must be finite (#7049) (#7050)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-17 11:55:31 -06:00
Marco Arnulfo e196d8e47c Opening a file that is already open shows its tab instead of a second copy (fixes #6598) (#6630) 2026-10-17 11:53:21 -06:00
ZuujisandClaude Opus 5.5 1dcaa28b96 Quick Export: Same Folder asks before replacing the document's own file (#6599) (#6632)
With Export Preferences › Location = Same Folder, Quick Export wrote
<name>.<format> beside the document's file with no check, so a document
opened from photo.png was exported over photo.png itself. The app now
opens the save dialog for that case, and file.export.quickExport refuses
it without a `path`. Overwriting an earlier export beside a PSD is kept.

Adds file_cmds::same_file (text, dev+inode on Unix, case-insensitive
canonical path on Windows).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-17 11:53:10 -06:00
David MannandClaude Opus 5.5 193f495e5a Perspective Crop and Rotate Arbitrary keep the Background opaque (#7138)
Both filled the Background's uncovered area with the background colour
including its alpha (tools.setColors accepts one), leaving see-through
pixels on a Background. They now use alpha 1, as crop_doc does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 19:44:29 +02:00
David MannandClaude Opus 5.5 2f038c5eb2 Perspective Crop: W and H follow the Point/Pica Size preference (#7135)
size_params converted points at a hard-coded 72 per inch. It now uses
Preferences › Point/Pica Size, as the rulers, the type tools and the
Crop tool do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 19:37:40 +02:00
Michael Higgins 4146d61b78 Merge branch 'storytold:main' into fix/6884-pen-tool 2026-10-17 13:36:48 -04:00
David MannandClaude Opus 5.5 2a690f0f71 Crop: a refused crop keeps the frame for fixing (#7133)
commit_crop cleared the box Undo, took the frame, turned the view upright
and reset the angle before running image.crop, and restored nothing when
the engine refused it (e.g. a resolution past 10 000 ppi). It now keeps
the frame, its turn, its box Undo/Redo and the view and puts them back on
an error. Leaving the tool with a refused frame cancels it instead of
retrying every frame.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 19:30:02 +02:00
David MannandClaude Opus 5.5 22cacbf198 Perspective Crop: switching documents drops the pending box (#7130)
perspective_crop_ui::cancel_stale ran only on a pointer event or a
commit, so after a tab switch the other document's quad was drawn (and
reported) until the next press. sync_views now runs it next to the Crop
tool's cancel_stale.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 19:21:38 +02:00
Nicolas FalesyandClaude Opus 5.5 4b660fcffa perf(camera-raw): Shadows/Highlights 1.5× faster, bit for bit the same
The fast local Laplacian filter upsampled every remapped level in full for
every reference (up to 96), but each pixel reads that level for at most two
references. `llf` now samples it only where the weight is nonzero, with
`up_at` (same terms, same order as `up`; row taps found once per row,
`mirror` only near the edges). `quantile` selects its element instead of
sorting the whole work copy (5 sorts per render, serial).

36 MP dialog, all cores: Shadows 252 -> 168 ms, Highlights 275 -> 184 ms,
all 14 sliders 627 -> 411 ms. Output hashes match main.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01FUAqfMqVHdoeDa2y68KQQw
2026-10-17 13:18:21 -04:00
David MannandClaude Opus 5.5 b052fd591e Crop: Straighten is one box Undo step (#7127)
The Straighten branch of crop_ui::pointer returned before the press step
was recorded and pushed, although it turns the frame and makes it
pending. With a crop pending, Undo then undid the document (or skipped a
box step). It now records the step like every other frame gesture,
through a shared push_press_step.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 19:15:25 +02:00
David MannandClaude Opus 5.5 617b75597c Image Size: type keeps its pixel size when the resolution changes (#7124)
image.imageSize set the new resolution and then resampled, which scales
the type transform by the pixel factor and re-renders at the new ppi
(text px = pt x dpi / 72): doubling 72 -> 144 ppi made text 4x as tall.
With Resample off the pixels stayed but the next re-render doubled them.

canvas_geom::set_resolution now changes the resolution keeping type the
same size in pixels, as Photoshop does: text space grows by new / old, so
the box and path scale with it and the layer transform undoes that.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 19:08:58 +02:00
David MannandClaude Opus 5.5 fd99c1f464 Perspective Crop: a drag without area leaves no crop box (#7061)
A straight drag (no width or no height) made a zero-area quad that could neither be committed nor replaced by a new drag, only cleared with Esc. Such a drag now leaves no box.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 18:45:34 +02:00
skylar taylor-barrick 8c12449819 Save: keep the replaced file's metadata (#6603, #7073, #7094, #7095, #7096)
Saving writes a temporary file and renames it over the target, so everything
attached to the old file went with the file that was unlinked. Several of those
are security decisions, so a save quietly undid them: a download lost its Mark
of the Web, an explicit ACL fell back to whatever the folder hands down.

New crates/format/src/atomic/meta.rs, reached from atomic_write through two
AtomicFs methods (copy_permissions became copy_metadata; replace_existing is new
and defaults to rename, so the failure-injecting test file systems are
unchanged):

- Extended attributes (#6603): flistxattr/fgetxattr/fsetxattr through safe
  rustix, per attribute, best effort. macOS Finder tags, com.apple.quarantine
  (the Mark of the Web's macOS counterpart) and Linux user.* all ride along.
  Attributes the system owns refuse to be written and are skipped;
  com.apple.decmpfs is skipped deliberately, since it describes storage the new
  bytes do not have.
- macOS ACL (#7073): a new macOS-only exacl dependency (MIT).
  fcopyfile(COPYFILE_ACL) is an unsafe fn and fclonefileat(CLONE_ACL) is
  APFS-only and would mean writing every save as clone-then-truncate; the
  reasoning is in docs/decisions.md. A file with no ACL is left alone.
- Windows (#7094, #7096): ReplaceFileW through winsafe's kernel feature (MIT,
  no dependencies, a safe wrapper; the workspace forbids writing the FFI call
  here). One call keeps the destination's DACL, its named streams including
  Zone.Identifier, and its creation time.
- Automation write path (#7095): AuthorizedWorkspace::write copies the replaced
  file's attributes through the two cap-std descriptors, never a path, so a
  request path still cannot escape the write root. Permissions there remain
  #4963; ReplaceFileW is path-based and so is not used on that path.

The order inside copy_metadata matters: attributes and ACL first, permission
bits last. Writing an attribute needs the destination open for writing, so
copying a 0444 mode onto the temporary file first locks you out of your own
temporary file. Covered by a regression test.

Atomicity is not weakened. The metadata step runs on the temporary file before
the swap, never touches bytes, and never fails a save. Windows is the one place
the swap itself changed, and a backup file name is passed because it is a
correctness requirement, not an option: ReplaceFileW without one documents
ERROR_UNABLE_TO_MOVE_REPLACEMENT as leaving the replaced file deleted and the
replacement under its temporary name, which is a failed save that loses the
user's document. With a backup name the same failure leaves both files alone.
ERROR_UNABLE_TO_MOVE_REPLACEMENT_2, the one error that does move things, is
unwound by hand; every other error falls back to the plain rename, i.e. to the
previous behaviour.

THE WINDOWS PATHS ARE UNVERIFIED ON WINDOWS. They were written and reviewed on
a Mac. The #[cfg(windows)] tests -- Zone.Identifier, a second named stream,
icacls output, creation time -- have never executed. They do compile and pass
clippy -D warnings against x86_64-pc-windows-msvc, which is as far as a Mac
gets. CI's Windows job is the first real run, and #7094 in particular (Mark of
the Web surviving a replace) is the whole point of the change and is the part
no one has observed working.

Tested on macOS: a real chmod +a entry and a real Finder tag both survive a
save (checked with ls -le and xattr, and as unit tests), plus a 64 KiB
attribute value, a read-only file, the mode, and the automation path.

Still open: Linux POSIX ACLs are not carried over (system.posix_acl_access is
not reported by listxattr, so it needs its own step; not part of these issues),
and macOS creation time is still reset by a save (that half of #6603 needs
setattrlist, which has no safe wrapper here).
2026-10-17 10:43:09 -06:00
David MannandClaude Opus 5.5 638912f0a6 Merge main into fix/6575-psd-unknown-adjustment
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 18:36:20 +02:00
David MannandClaude Opus 5.5 4286ff86a9 Merge main into fix/6550-selected-path-outline
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 18:30:12 +02:00
David MannandClaude Opus 5.5 5d6e0331e3 Merge main into feat/tool-213-4-cam-preview-large
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 18:30:04 +02:00
David MannandClaude Opus 5.5 b6f18820c6 Merge main into fix/pcraft-load-nonfinite
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 18:26:31 +02:00
David MannandClaude Opus 5.5 e30cedbb2a Merge main into fix/transform-fill-cache
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 18:24:17 +02:00
David MannandClaude Opus 5.5 c5b1f7769a Merge main into fix/6539-lightness-track
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 18:21:07 +02:00
David MannandClaude Opus 5.5 a4190ad6a0 Merge main into fix/droplet-conditional-actions
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 18:20:49 +02:00
David MannandClaude Opus 5.5 0e98028f24 Merge main into fix/tiff-filter-effects-sections
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 18:18:51 +02:00
David MannandClaude Opus 5.5 cdf7f2c7fe Merge main into fix/gradient-offset-resize
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 18:13:04 +02:00
itsF4LCON 3bca0123b0 Type: the blink-phase test finds the caret by its strokes
arrow_keys_reset_caret_blink_phase (#5512) looked for a 1.5-wide line, the old caret. The caret is now CARET_STROKES (a 3 pt white halo and a 1 pt black line), so the test matches those.
2026-10-17 18:12:29 +02:00
David MannandClaude Opus 5.5 847dc9eb08 Merge main into fix/auto-align-mask
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 18:10:39 +02:00
David MannandClaude Opus 5.5 f0cd444a53 Merge main into fix/6535-clipped-adjust-damage
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 18:10:24 +02:00
David MannandClaude Opus 5.5 0aa456edc3 Merge main into fix/6267-psd-hue-sat-sf
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 18:06:09 +02:00
itsF4LCON 6d521c2d3e Merge remote-tracking branch 'upstream/main' into fix/6085-text-caret-contrast 2026-10-17 18:04:21 +02:00
David MannandClaude Opus 5.5 363462e8d2 Merge main into fix/6268-psd-perspective-warp-sf
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 18:03:47 +02:00
dotCooCoo 074d4b08b6 Stack Mode: Kurtosis and Skewness give the same bits on Windows and Linux
`stat` raised each deviation from the mean to a power with `f32::powi`,
with the exponent passed through the moment closure. On
x86_64-pc-windows-msvc that call goes to the C runtime's `powf`, whose
cubes and fourth powers can differ from repeated multiplication in the
last bit. On Linux the same call gives the bits of repeated squaring, as
compiler-builtins' `__powisf2` computes it. Kurtosis
came out one ULP higher on Windows in two values of the test fixture, and
`stack::tests::statistics_are_bit_identical_to_the_serial_implementation`
failed there.

The moments and Skewness's `sd³` now use `ipow`, which multiplies by
repeated squaring: `x·x²` for the cube and `x²·x²` for the fourth power.
Results on Linux do not change, and the recorded bits stay as they are.
2026-10-17 08:58:44 -07:00
IvanandClaude Opus 5.5 a4fe4ffbed Contributors: add 1Git2Clone
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 18:37:13 +03:00
IvanandClaude Opus 5.5 45afd2e9d7 CI: run panic_hunt in the Linux test job
The full-registry fuzz (AGENTS.md Rule 9) is #[ignore]d and no workflow ran it, so nothing
enforced "must stay green". The new step reruns the Test step's nextest selection with
--run-ignored only, filtered to the panic_hunt binary, so nothing rebuilds; the ci profile
terminates it if it hangs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 18:37:12 +03:00
David MannandClaude Opus 5.5 3096e456dd Merge main into fix/preset-group-rename-unique
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 17:24:08 +02:00
David MannandClaude Opus 5.5 340f282c8a Merge main into fix/extending-crop-keeps-hidden-background
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 17:22:59 +02:00
David MannandClaude Opus 5.5 50df815d99 Merge main into fix/crop-default-mode-box-undo
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 17:19:55 +02:00
David MannandClaude Opus 5.5 030818dcc2 Merge main into fix/image-size-resolution-physical
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 17:16:40 +02:00
David MannandClaude Opus 5.5 d4d5e9cf93 Merge main into fix/6988-ui-set-f32-range
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 17:11:07 +02:00
David MannandClaude Opus 5.5 bdeff4fd6c Merge main into fix/5559-bitmap-dialog
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 17:08:28 +02:00
David MannandClaude Opus 5.5 931e762899 Merge main into fix/5559-bitmap-dialog
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 17:08:26 +02:00
David MannandClaude Opus 5.5 af85bd7b0a Merge main into fix/stamp-down-keeps-clipping
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 17:08:18 +02:00
David Mann 4616bfb256 Merge origin/main into fix/atn-crop-units 2026-10-17 17:00:34 +02:00
David Mann f5ea68d776 Merge origin/main into fix/merged-background-locks 2026-10-17 17:00:26 +02:00
David Mann 3f45847cb3 Merge origin/main into fix/gradient-style-scale-range 2026-10-17 17:00:15 +02:00
David Mann dc71c6440c Merge origin/main into fix/gif-delay-below-1fps 2026-10-17 17:00:12 +02:00
David Mann 73e48771a7 Merge origin/main into fix/conditional-mode-change-gray 2026-10-17 17:00:10 +02:00
David Mann 378f2eb680 Merge origin/main into fix/lens-smart-filter-focal 2026-10-17 17:00:07 +02:00
David Mann 97059e99fd Merge origin/main into fix/transform-selection-reference 2026-10-17 17:00:05 +02:00
David Mann 671878c014 Merge origin/main into fix/crop-angle-readout 2026-10-17 17:00:02 +02:00
David Mann 68629b4cf8 Merge origin/main into fix/crop-size-point-pref 2026-10-17 16:59:59 +02:00
David Mann 3549195cf4 Merge origin/main into fix/match-zoom-clamp 2026-10-17 16:59:57 +02:00
David Mann 3d1f76a367 Merge origin/main into fix/headless-index-keeps-active-doc 2026-10-17 16:59:54 +02:00
David Mann b18b64c039 Merge origin/main into fix/headless-save-quality 2026-10-17 16:59:51 +02:00
David Mann 05c431317f Merge origin/main into fix/actions-delete-drops-fkey 2026-10-17 16:59:43 +02:00
David Mann 33d6b06693 Merge origin/main into fix/crop-target-size-samplers 2026-10-17 16:59:41 +02:00
David Mann f8c7955699 Merge origin/main into fix/artboards-to-files-marks 2026-10-17 16:59:38 +02:00
David Mann 45ad14fbac Merge origin/main into fix/timeline-setprops-work-area 2026-10-17 16:59:35 +02:00
David Mann 1193ebed08 Merge origin/main into fix/shortcut-set-keeps-action-fkeys 2026-10-17 16:59:33 +02:00
David Mann dd9e44706a Merge origin/main into fix/multichannel-channel-target 2026-10-17 16:59:30 +02:00
David MannandClaude Opus 5.5 a32b588ae6 Crop / Canvas Size: extending keeps the Background's hidden pixels (#7086)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 16:58:36 +02:00
dotCooCoo 274d7c0a36 Merge main into fix/mixer-brush-clean-and-load 2026-10-17 07:47:04 -07:00
David MannandClaude Opus 5.5 cb5b1f8e9e Crop: in default mode, box Undo/Redo keeps the box in place (#7088)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 16:45:16 +02:00
ebosido444-ai fcd337e1fc perf(text): rasterize a type layer's pixels on all cores, bit for bit the same
A type render spends most of its time after the glyphs are drawn: composing each
colour's coverage over the premultiplied accumulator, dividing it back to straight
colour, and converting the tile bytes. All three are per pixel - no pixel reads a
slot another wrote - so they ran one after another on one core for no reason.

Measured on a 3.75 MP paragraph (4000 characters, 14 pt at 300 dpi, 1851 glyphs),
release, Windows 11, i5-10300H: rasterize 98.3-103.9 ms before, 60.3-61.8 ms after
(1.6-1.7x). Whole-render stage profile before: ink rect 4.1, draw 18.6-22.9, prefix
sum 7.0-8.1, composite 22.6-33.4, unpremultiply 11.2-14.0, write_region 23.8, prune
3.1 ms.

The glyph drawing, the winding prefix sum and the ink-rect pass stay on one thread:
the first two carry a running accumulator across pixels, and the band split that
would keep them bit-identical costs a second glyph traversal per band.

Native only (rayon, as the other crates): the web build keeps the serial loops.
2026-10-17 17:42:50 +03:00
David MannandClaude Opus 5.5 4fc492d2ca Actions: an imported Crop step in a unit other than pixels is not applied as pixels (#7084)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 16:40:40 +02:00
David MannandClaude Opus 5.5 8574f8d809 Merge to HDR Pro and merged duplicates: the Background layer is a real Background (#7078)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 16:39:44 +02:00
Hamza Naveed efa1edd079 algo: gate the recorded-hash test's helpers with the test (#7053)
`blocks` and `fnv` exist only for `harris_and_describe_hashes_recorded_from_the
_serial_code`, which is `#[cfg(all(target_os = "linux", target_arch =
"x86_64"))]` because the hashes it compares against go through the platform's
hypot / ln / cos / atan2 / sin_cos. Everywhere else the test is compiled out and
the two helpers are left unused, so `-D warnings` fails on `dead_code`:

    error: function `blocks` is never used   crates/algo/src/features.rs:704
    error: function `fnv` is never used      crates/algo/src/features.rs:708

They now carry the same cfg as the only thing that calls them.

CI's matrix is ubuntu-latest and windows-latest and runs clippy on both, so this
fails the Windows leg as much as a local run on macOS: `target_os = "linux"` is
false for both.
2026-10-17 08:39:15 -06:00
Josephand00xJ0 351149f0df Fix the build: restore export_presets in load_presets_json (#5344 x #5616) (#6906)
#5344 added the export_presets field to Persisted; #5616 rewrote load_presets_json to read each set with take() and its Persisted initializer lacked the new field, so main stopped compiling (E0063 at presets/mod.rs:180). Read export_presets the same way as the other sets; the existing load step and round-trip test cover it.

Co-authored-by: 00xJ0 <[email protected]>
2026-10-17 08:32:54 -06:00
David MannandClaude Opus 5.5 0b1953c494 Crop: W x H x Resolution follows the Point/Pica Size preference (#7038)
The crop tool's W x H x Resolution mode converted points and picas at a
fixed 72 pt/in, while Image Size, Canvas Size (#5757), the rulers, Info
and the shape dialog use Preferences > Units & Rulers > Point/Pica Size.
With Traditional (72.27), W "72 pt" x H "1 in" at 300 ppi cropped to
300 x 300 instead of 299 x 300.

target() and ratio() now take the points per inch, and the callers
(commit_params, preset_ratio, fit_frame) pass the preference.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:45:51 +02:00
David MannandClaude Opus 5.5 b2f9c45c6a Match Zoom: matched views stay within each document's zoom range (#7036)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:45:34 +02:00
David MannandClaude Opus 5.5 758ad4db44 Crop: the turn readout never shows -0.0° or -180.0° (#7040)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:45:13 +02:00
David MannandClaude Opus 5.5 cb185320ed Conditional Mode Change: "gray" as a single source mode matches Grayscale (#7042)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:44:56 +02:00
David MannandClaude Opus 5.5 7ebaa94a82 Render Video: GIF frame delay follows timelines under 1 fps (#7045)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:44:43 +02:00
David MannandClaude Opus 5.5 01a58e8f6f Layer Style: gradient overlay and stroke scale stay within 10-150 % (#7047)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:44:42 +02:00
David MannandClaude Opus 5.5 1bb3542b99 Lens Correction: smart filters keep the exact focal length (#7029)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:42:14 +02:00
David MannandClaude Opus 5.5 276d830e7c Transform Selection: an unknown reference point is an error (#7027)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:40:09 +02:00
David MannandClaude Opus 5.5 e1c119200b Automation: place and layer/data-set export by index keep the active document (#7025)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:37:18 +02:00
David MannandClaude Opus 5.5 c6ea59461f Automation: doc.save rejects a bad quality instead of ignoring or clamping it (#7023)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:36:02 +02:00
David MannandClaude Opus 5.5 000fec6f59 Keyboard Shortcuts: switching sets keeps action function keys (#7004)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:12:43 +02:00
David MannandClaude Opus 5.5 30505e3b53 Crop: Color Samplers scale with a W x H crop (#7012)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:12:30 +02:00
David MannandClaude Opus 5.5 fd2c10bba8 Artboards to Files: paths, notes, slices and marks move with the board (#7010)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:12:17 +02:00
David MannandClaude Opus 5.5 361a1bf22e Channels: Multichannel conversion keeps the targeted channel (#7002)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:12:14 +02:00
David MannandClaude Opus 5.5 99c6fb4ce7 Timeline: a longer duration moves a work area that reached the old end (#7006)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:10:46 +02:00
David MannandClaude Opus 5.5 1c312c4ba6 Actions: deleting an action removes its function key (#7014)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:10:33 +02:00
David MannandClaude Opus 5.5 82707fd15d Stamp Down keeps the lower layer's clipping, label and link (#6999)
Stamp Down rebuilt the layer below from scratch and copied only its id,
name, blend mode, visibility and locks, so a clipped layer came back
unclipped (its pixels showing outside the clip base) and lost its colour
label and link. Photoshop writes the stamp into the existing layer; keep
the three properties as Merge Down already does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:04:16 +02:00
David MannandClaude Opus 5.5 8b61a8b186 Control: ui.set rejects zoom, rotation, center and dockWidth beyond f32 range (#6988)
A finite f64 like 1e39 became inf on `as f32`: zoom jumped to 100%,
rotation reset to 0, dockWidth fell to its minimum and center was stored
as [inf, 0], all with ok:true. A new f32_field (same filter as
rotate_view's finite_number) rejects these in the validate phase, so a
rejected call changes nothing. num_field stays as is: pointer events
must keep accepting huge coordinates.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 14:08:03 +02:00
David MannandClaude Opus 5.5 e4c41e147e Image Size: Resolution with Resample on keeps the print size in physical units (#6979)
With Resample on and the unit in inches/cm/mm/pt/picas, a new Resolution
only stored the resolution; width/height are kept in pixels, so the print
size shrank (720 x 576 px at 72 ppi, 10 x 8 in, set to 300 ppi stayed
720 x 576 px = 2.4 x 1.92 in). Photoshop keeps the print size and
resamples: 3000 x 2400 px. Scale the stored pixels by new/old resolution
in physical units; pixels and percent keep the pixel count as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:38:50 +02:00
NetterSilver 158dcc6731 Fix engine compile error: load export presets from presets JSON
Fixes #6911

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
(cherry picked from commit bb7a5f2309)
2026-10-17 13:34:00 +03:00
PerchAnton 120cfe19a2 Merge origin/main into fix/finite-command-parameters
Resolve Color to Alpha validation conflict while preserving the upstream array guards and finite color parsing.
2026-10-17 13:34:00 +03:00
Finn NewickandClaude Opus 5.5 dd57638d9e fix(psd): make packbits::decode_into crate-private and overflow-safe
decode_into computed out.len() + expected unchecked. With a non-empty
buffer and an expected length near usize::MAX it panicked in debug builds
and, in release, wrapped and returned Ok having decoded nothing. Compute
the target with checked_add and report LimitExceeded, and test each packet
as len > target - out.len() so the bound checks cannot overflow either.

The only callers are decode and decode_rle in this module, and the crate
README documents packbits::{encode, decode} as the public API, so
decode_into becomes pub(crate).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 10:11:57 +01:00
itsF4LCON 85aa10b16d Merge remote-tracking branch 'upstream/main' into fix/6085-text-caret-contrast 2026-10-17 11:01:10 +02:00
dotCooCoo 8f971cc79f Mixer Brush: Clean Brush empties the brush, and the automatic Load and Clean can be switched off
The Mixer Brush could not be emptied. MixerState::clean dropped only
the pickup well, paint.mixerBrush refilled the reservoir from the
foreground before every stroke unless loadAfterStroke was false, and
the app always sent the defaults. A stroke on a new layer over an image
always carried the foreground color, and the options bar had no Load
Brush or Clean Brush control.

Cleaning now empties both wells, so a clean brush lays down only the
color it picks up from the canvas. The new commands brush.mixer.load
and brush.mixer.clean load and empty the brush. paint.mixerBrush reads
loadAfterStroke and cleanAfterStroke from the brush and applies them
after the stroke. Both are on by default, and a stroke with both on
paints as before. A stroke whose color differs from the one recorded
by the last stroke, load or clean loads that color first. A load or
clean records the foreground, so choosing a new foreground loads the
reservoir.

The options bar has the Current Brush Load swatch, whose menu holds
Load Brush and Clean Brush, and two toggles for the automatic Load and
Clean. Brush Settings shows the same two options. The new strings have
rows in every catalog.
2026-10-16 22:49:47 -07:00
Higgs32584andClaude Opus 5.5 425e782ac2 Pen: a finished path keeps its anchor squares on screen (#6884)
Closing a path or pressing Enter with the Pen left only the 1 px outline, so the
path looked gone and the anchors to edit were hidden. The Pen, Freeform Pen and
Curvature Pen now draw the active paths' anchors as Path Selection does, as in
Photoshop.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 00:25:15 -04:00
Nicolas FalesyandClaude Opus 5.5 5f65579ecb perf(panorama): Photomerge 4× faster, bit for bit the same (#6489)
N is the whole algo-side Photomerge pipeline on 3 × 8 MP photos (register at the engine's
scale with the Auto layout, gain + vignette fit, seams, multi-band blend), 16 threads:
1042 ms before, 264 ms after, 3.9×, rounded to 4×. The engine's own glue (reading regions,
building weights, writing layers) is not in this number.

Registration and multi-band blending ran almost entirely on one thread per image (four
images on 16 threads), so most of the machine sat idle. Every output is bit for bit what
the serial code produced: a probe that hashes the public Photomerge entry points
(detect_many, match_pairs, align for every layout, photometric, seam_labels, multiband at
1 to 7 levels with 1 and 3 channels, with holes in the alpha, with images sticking out of
the canvas, plus direct harris / describe calls) matches on all 349 outputs before and
after, on 4 × 2 MP, 3 × 8 MP, 97×61, 211×157, 333×251, 320×240 two-image and 40×35 sets,
at 16, 3 and 1 threads.

- features `blur` (Harris window and BRIEF smoothing): the row pass runs rows in
  parallel; the column pass cuts the image into bands of 64 columns, each holding the
  `&mut` row segments of its own columns (no unsafe), run in parallel. Every column keeps
  its own f32 running sum, started from its first window summed in order and updated in
  the same order, so only the loop nest changes.
- `harris`: Sobel, the corner response and the non-maximum scan are parallel by rows (the
  candidates are fully ordered by score, y, x afterwards, so arrival order is irrelevant).
  `describe`: one descriptor per corner in parallel, kept in corner order.
- `reduce`, `expand`, `push_pull`: per-row maps in parallel. `reduce`'s column pass adds
  five whole rows in tap order (same per-element sum, now vectorised); `expand` works out
  its source columns once and truncates instead of calling libm `floor` (the clamped
  position is never negative); `push_pull` builds each coarse row from its two fine rows in
  raster order, which is the order the serial scatter-add used.
- `multiband`: grid fill per image, Laplacian difference, band accumulation (every row
  walks the bands in the original order), normalisation, collapse and the coverage union
  are parallel. Float additions are never reordered.

Left serial on purpose: the peak fold in `harris` (about 1 ms, an order-free max but not
worth the proof), the corner spacing grid, `seam_labels` (a dynamic-programming path whose
rows depend on the previous one), `photometric` sample gathering, and the pyramid levels
themselves (each level needs the one above).

Median of 5 (8 MP rows: 7), release, ms, interleaved before/after runs. The laptop was
busy with unrelated builds the whole time (load average 11 to 18 on 16 threads), so the
1-thread rows are noisy (a single thread is time-sliced against them) and rows can move
by 10 to 20 percent.

| Case (probe)                   | before (16 thr) | after (16 thr) | speedup | before (1 thr) | after (1 thr) |
|--------------------------------|----------------:|---------------:|--------:|---------------:|--------------:|
| register, 4 × 2 MP             |             154 |             60 |    2.6× |            336 |           167 |
| multiband, 4 × 2 MP, 3 ch      |             407 |            108 |    3.8× |            591 |           407 |
| multiband, 4 × 2 MP, 1 ch      |             307 |             73 |    4.2× |            813 |           566 |
| whole pipeline, 4 × 2 MP       |             437 |            160 |    2.7× |            716 |           504 |
| register, 3 × 8 MP (full size) |             327 |            124 |    2.6× |            978 |           391 |
| multiband, 3 × 8 MP, 3 ch      |            2348 |            916 |    2.6× |           1742 |          1150 |
| whole pipeline, 3 × 8 MP       |            1042 |            264 |    3.9× |           2264 |          1280 |

The 8 MP multiband row at 16 threads is the noisiest (minimum of 7: 959 to 224 ms, 4.3×);
the whole-pipeline row is steady. Max RSS for the 3 × 8 MP whole pipeline (the probe's
inputs are about 1.05 GB of it): 2.79 GB before, 2.79 GB after (three runs each).

Tests: the serial `blur`, `reduce`, `expand`, `push_pull` and `multiband` are kept in the
test modules as references and compared bit for bit over sizes that cross every band and
row boundary (1×1, 1-wide, 1-high, odd sizes, 63 / 64 / 65 / 129 columns, radius 0 to
wider than the image, holes, partial and zero weights, ROIs outside the canvas, 1 to 7
levels). Hard-coded hashes recorded from the old code cover multiband, push_pull,
reduce + expand everywhere (plain f32 arithmetic), and harris + describe on Linux x86-64
(they go through the platform's hypot / ln / cos / atan2 / sin_cos).


Claude-Session: https://claude.ai/code/session_01AfRHB1wHUjrJx6agBYGnVu

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-16 20:20:22 -06:00
wwew bd3b5d9192 fix(automation): refuse a group/world-readable control-token file (#4966) (#5459) 2026-10-16 20:20:03 -06:00
aiongg 7c44b2bc15 Build: use craft-docpath from craft-libs 2026-10-17 02:19:47 +00:00
ZuujisandClaude Opus 5.5 fbcb70b317 Save: ask before overwriting a file another app changed (#6596) (#6652)
* Save: ask before overwriting a file another app changed (#6596) [WIP]

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Save: ask before overwriting a file another app changed (#6596)

File › Save now notes the size and modification time of a document's
own file when it is opened or saved, and checks them again before
writing back. If another app changed the file in between, a prompt asks
whether to replace it (Cancel keeps both; Save As saves elsewhere)
instead of silently overwriting the other app's changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-16 20:19:32 -06:00
Korg1ch 4c881326ea fix(windows): keep crash-recovery autosaves out of the roaming profile (#5648) (#6412)
* fix(windows): keep crash-recovery autosaves out of the roaming profile (#5648)

* test(windows): cover the local recovery folder and moving old autosaves (#5648)
2026-10-16 20:19:06 -06:00
MarekSpodBiedry fbfc72ceb5 Close All, Close Others and Quit ask again about documents dirtied during the prompt (#4954) (#5079) 2026-10-16 20:18:47 -06:00
MarekSpodBiedry 20013ef9bd Linked smart objects: don't refresh a document while a background job runs on it (#4973) (#5074) 2026-10-16 20:18:17 -06:00
David MannandClaude Opus 5.5 3d2914368d PSD type: clamp warp bend and distortion before the f32 cast (#6856) (#6857)
A TySh warpValue, warpPerspective or warpPerspectiveOther beyond f32 became inf and the warp was silently dropped; they are now clamped to -100..100 like type.warpText.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 20:17:27 -06:00
Nicolas FalesyandClaude Opus 5.5 5bd428a239 perf(stack): Stack Mode 11× faster (#6462)
Layer › Smart Objects › Stack Mode (`algo::stack::combine`) was one serial
per-pixel loop (1.0x parallelism, identical at 1 and 16 threads), and Entropy
built a HashMap per channel per pixel.

- Output pixels are computed in parallel in 4096-pixel chunks (`par_rows`, so
  wasm keeps its serial path). Each chunk owns its scratch buffers.
- Each pixel uses exactly the same arithmetic in the same order as before, so
  every mode except Entropy is bit-identical: 110 output hashes (10 modes x 11
  cases: 3 x 24 MP, 7 layers of odd/mismatched sizes, transparency, HDR values,
  1 layer, no layers) match the old code.
- Entropy: a `[u32; 256]` histogram plus a short list of the bins touched
  replaces the HashMap; touched bins are summed in ascending order, so the result
  is now deterministic. The old HashMap summed in random iteration order and
  differed run to run (up to 1.2e-7 between two runs of the old code). New vs old
  output: max abs difference 1.19e-7 (one f32 ulp near 1.0), on up to ~10% of
  values in the 7-layer cases; the 3-layer 24 MP case is identical.
- Modes that never use the mean (Median, Min, Max, Range, Summation, Entropy) no
  longer compute it; Median uses an unstable sort (same result under total_cmp).

3 x 24 MP, release, median of 5 interleaved before/after runs (load average
~3.5-4.5 from an unrelated build running alongside), ms:

| Mode       | before | after (16 thr) | speedup | after (1 thr) |
|------------|-------:|---------------:|--------:|--------------:|
| Median     |   1100 |             99 |   11.1x |          1064 |
| Mean       |    308 |             36 |    8.6x |           338 |
| StdDev     |    342 |             41 |    8.3x |           380 |
| Entropy    |   4727 |            269 |   17.5x |          2730 |
| Kurtosis   |    458 |             55 |    8.3x |           488 |
| Maximum    |    368 |             36 |   10.1x |           346 |

The other modes land between 8x and 10x. Max RSS for Median is unchanged
(1.50 GB, of which 1.13 GB is the input layers).

Tests: hard-coded bit patterns recorded from the old code for every mode but
Entropy; Entropy against hand-computed values and run-to-run determinism; and a
chunk-boundary test comparing a multi-chunk image to per-pixel results.


Claude-Session: https://claude.ai/code/session_01AfRHB1wHUjrJx6agBYGnVu

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-16 20:16:45 -06:00
F4LCON 75978a72f0 Targa: RLE packets end with their scanline (#6382) (#6386)
The image crate's Targa encoder let run-length packets run on from the end of
one row into the next. TGA 2.0 says a packet must not cross a scanline, and
readers that decode a row at a time misread such files: Pillow fails on them
with "buffer overrun", and texture tools and game engines reject them.

Targa is now written by our own encoder (codecs::tga), as Photoshop writes
it: RLE true colour or grayscale, every packet within its row, rows stored
bottom-up (the origin readers that ignore the descriptor assume), the alpha
bits in the descriptor and the TGA 2.0 footer. Reading stays with the image
crate.
2026-10-16 20:15:32 -06:00
jonathnwxh e31905358d Restore export preset field omitted by upstream preset loader 2026-10-17 09:32:44 +08:00
jonathnwxh 1dd50ba859 Merge current upstream while preserving font metadata repair 2026-10-17 09:24:30 +08:00
nrq6993-a11yandCursor c61cf9f0bd fix(engine): load each persisted preset set independently (#5616)
One malformed set in the preferences file (gradients, swatches, styles...)
made load_presets_json ignore the whole presets blob, so saved New Document
presets vanished at every launch: the shell merges back only changed keys,
leaving the damaged set on disk. Each set now loads on its own; null and
malformed sets keep their defaults.

Refs storytold/photocraft#5602

Co-authored-by: Cursor <[email protected]>
2026-10-16 19:20:06 -06:00
nrq6993-a11yandCursor a1e19a0b87 fix(text): break paragraph lines at the horizontally scaled width (#5611)
Horizontal scale was applied after line breaking, so paragraph text below
100% wrapped early and text above 100% overflowed the box. Break box text
at the available width divided by the paragraph's horizontal scale (the
largest one when runs mix), scale the first-line indent and alignment
offset back to the box, and keep the scaled trailing space out of the
line extent.

Fixes storytold/photocraft#5565

Co-authored-by: Cursor <[email protected]>
2026-10-16 19:19:59 -06:00
Matthew SantiagoandClaude Opus 5.5 d213273c74 GPU composite: lower-stack cache chunks have no texture view (#5423)
The prefix cache's chunk textures are only copied to and from
(COPY_SRC | COPY_DST), but went through Tex::new, which always creates
a default view. Vulkan rejects a view on an image with no sampled,
storage or attachment usage (VUID-VkImageViewCreateInfo-image-04441);
wgpu doesn't check it, so it only shows with the validation layers on,
once per new chunk while painting. The view was never used. Chunks now
hold a bare wgpu::Texture. No rendering change.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-16 19:19:53 -06:00
Ahmed MagdyandClaude Opus 5.5 4e5c6a018a fix(io): SVG text placed by a transform keeps it on import (#4806) (#5334)
usvg builds the glyph outlines of a text element (Text::flattened) with
identity absolute transforms, and the SVG importer read each path's
abs_transform(). Text positioned by its own or an ancestor's transform,
as Illustrator writes every text element (matrix(1 0 0 1 x y)), lost it:
it landed off the canvas, so nothing showed, or at its untransformed
place and size.

The converter now composes each node's transform from its parent chain,
which equals abs_transform() outside text, and sizes raster fallbacks
from the same transform. A new test checks translate, matrix, scale and
transformed groups against resvg's own render; it fails on main.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-16 19:19:47 -06:00
F4LCON a05daee325 Filters: the live preview is never coarser than the screen (#6326) (#6351)
On a document over about 5 MP with Low Resolution Previews on, a filter
dialog's live preview ran on a reduced proxy at every zoom, so at 100 % the
canvas showed the image in k-pixel blocks and hid exactly what Unsharp Mask,
High Pass and Smart Blur do.

ensure_filter_preview now caps its factor with proxy::view_factor(k, zoom),
the same rule adjustment previews got in #5764, so from 100 % the preview is
full resolution and stays reduced when zoomed out. The preview already runs on
a cancellable background worker, so the full-resolution path does not block the
UI thread.

Also in this change:

- canvas.rs and filter_preview_ui.rs share one canvas::filter_preview_factor,
  so the in-dialog preview window cannot size textures from an uncapped factor
  and rebuild them when the first result arrives.
- The cap is gated on cfg!(target_arch = "wasm32"), so the web build keeps its
  proxy rather than running a full-resolution filter inline on the UI thread.
- The end-to-end test moved into mod tests and covers Low Resolution Previews
  off; a new factor test covers 25/50/75/100/400 %, NaN and zero zoom.
2026-10-16 19:14:50 -06:00
Thang Nguyen aaf86e2a9c fix(psd): read the split ("quilt") warp of placed layers
Photoshop writes a split custom warp as `quiltWarp` in the placed layer's
SoLd/SoLE descriptor: a grid of Bezier patches (deformNumCols x
deformNumRows control points, patch boundaries in quiltSliceX/Y). Only a
single 4x4 `warp` mesh was read, so such smart objects rendered unwarped
and in the wrong place. Read the quilt grid into a multi-patch
BezierMesh; anything inconsistent is still refused.

On two real mockups (a sneaker and a V-neck jersey), the mean difference
from another PSD renderer over the design area drops from 14.4 to
0.66/255 and from 13.0 to 0.64/255. Refs #4398.
2026-10-17 08:09:29 +07:00
Tanish Manku 132257912a README: put Community packages after the Get started status note (follow-up to #1149) (#6886)
The Community packages heading from #1149 landed before the maintainers' releasing note and the IMPORTANT status admonition, so those two paragraphs rendered under Community packages instead of Get started. Move the section to just above Documentation; the content is unchanged.
2026-10-16 17:52:24 -07:00
Ivan · VanTan 10fdcbbefc chore: fix workspace Clippy collapsible_match warnings (#5190)
Strict workspace Clippy stopped on collapsible_match in affinity, raw, plugins, engine, ui-egui and xtask. Pure conditions become guarded match arms (each falling through to an existing empty arm), and the ruler drag state and the measurement command result are bound to locals, preserving evaluation order, error messages and fallbacks.

The same lint fixes also appear as a side commit in #5157 (David2766), which can drop it on rebase.
2026-10-16 17:42:36 -07:00
Gustavo Ziger d900daa0ba fix(web): File › Place Embedded is enabled in the browser and places the picked file's bytes (#1209, #5749) (#5585)
With a document open in the web app, Place Embedded was disabled because its menu enablement inherited the engine's native-filesystem check, although the menu path reads the picker's bytes and never touches a path. Place Embedded is now enabled whenever a document and a file-dialog service exist; Place Linked keeps the engine's native guard, since the web could only link to a bare file name (caught in #5817 by Higgs32584). An enablement test runs with the native check forced off, and a file-dialog test covers the browser answer shape, cancel, invalid bytes and the Free Transform handoff.

Fixes #1209. Fixes #5749.
2026-10-16 17:38:56 -07:00
Michael Higginsandahmedhosnypro 3722dfbc63 fix(ui): Arabic and Hebrew layer names and the title read right to left (#5159) (#5385)
egui places font runs left to right with no bidi reordering, so a right-to-left layer name showed every word correctly but in reversed order. bidi_text::visual puts a label into display order with the Unicode Bidirectional Algorithm (unicode-bidi, already in the tree via usvg): right-to-left words stay in logical order for the shaper, everything else in a right-to-left run is reversed around them and brackets are mirrored. The Layers panel's rows and the app-drawn title bar use it; the title keeps a left-to-right paragraph so '— PhotoCraft' stays at the right, and a long right-to-left name is cut at its logical end so its start survives. Display only: stored names and the OS title keep the logical text.

Fixes #5159.

Co-authored-by: ahmedhosnypro <[email protected]>
2026-10-16 17:36:06 -07:00
Gert Pellin - GPE 5a33441af3 fix(engine): an unknown layer id or name lists the document's layers, and a name matches regardless of case as a fallback (#5058) (#5133)
A command that fails on a layer id or name the document doesn't have now says which layers there are, top-down as id and name (at most 20, names cut at 64 characters), so an agent can recover from the error alone: ids are not positions, and a freshly opened PNG's Background is layer 2. NoLayer becomes UnknownLayer { id, known } at both command entry points, keeping the 'no such layer' prefix. A layer name that matches no layer exactly matches regardless of case; an exact match always wins.

Fixes #5058.
2026-10-16 17:34:40 -07:00
Gert Pellin - GPE a6a1d2ed22 feat(algo): face detection with libfacedetection's CNN in pure Rust, and image.detectFaces (part of #3603) (#4513)
photocraft_algo::faces::detect finds faces in an RGBA image with a score, a box and five landmarks, through a pure-Rust port of libfacedetection's YuNet network (Shiqi Yu, BSD-3-Clause; licence, NOTICE and ATTRIBUTION rows included). The weights are the library's, converted to half precision (107 KB), parsed defensively. image.detectFaces reports the document's faces to scripts and agents; it is read-only with no history step and no menu entry. The network is compiled out of the browser build, where the command reports itself unavailable. Head outputs are pinned against the C++ reference.

Part of #3603.
2026-10-16 17:31:02 -07:00
Gert Pellin - GPE 1b3bc48267 docs(readme): Community packages section with the Gentoo ::snakebyte overlay (#1149)
Adds a Community packages heading to the README, noting these installers are maintained by community members, with the Gentoo recipe for media-gfx/photocraft-bin from the ::snakebyte overlay. The overlay's ebuild fetches the official Linux x86_64 release tarball with Manifest-pinned hashes that match the release's SHA256SUMS.
2026-10-16 17:28:51 -07:00
Harrison O'Connor-Hoover 8a4ed48354 fix(ui): numeric fields drop oversized text, paste and IME events before layout (part of #2150) (#2258)
A single oversized input event reached egui text layout in a numeric field even though it could never become a valid value. number_edit now drops Text, Paste and IME events longer than 128 scalar values while the field has focus, and the custom parser rejects accumulated text over the cap before normalizing. DragValue keeps focus, accessibility, drag and arithmetic behaviour. Six harness tests cover the event kinds, the Unicode boundary, accumulation, undo, and document text fields staying unrestricted.

Part of #2150.
2026-10-16 17:25:50 -07:00
wwew dd69f0797d fix(engine): reserve the define-brush tip and selection-mask buffers fallibly (#212) (#5469)
brush.defineFromSelection allocated the sampled tip and the selection mask infallibly over the whole selection, so a large selection could abort the process. Both now go through the engine's fallible allocator and return a normal error that leaves the brush, presets, history and document untouched. A failpoint test asserts all four.

Part of #212.
2026-10-16 17:23:08 -07:00
safvanedge aecac608b4 fix(ui): Type tool caret stays solid after typing, navigation or a click, then resumes blinking (#5511) (#5512)
The caret's blink phase was tied to the wall clock, so an arrow key or keystroke that landed in the off half-cycle moved an invisible caret. A transient caret_blink_time on UiState is reset on every handled key event, on a click that places the caret, and while dragging; draw_overlay computes the phase from it so the caret is solid for 530 ms after each interaction, and the repaint is scheduled to the next phase boundary.

Fixes #5511.
2026-10-16 17:20:53 -07:00
Vikram Bhaskaran c7d2c85faf fix(ui): Color Picker's Only Web Colors snaps every edit and is remembered across pickers (#4995) (#5442)
With Only Web Colors on, every picker edit (field, sliders, hex, Lab/CMYK, screen sampler, Current) snaps through the clamped 216-colour ramp. The checkbox is remembered on the Color panel state so the foreground, field, command, Layer Style and type-style pickers open with the same setting. Opening with it on snaps the colour and its original together, so OK without an edit leaves a fill layer unchanged. The hex field follows the colour when another path changes it while focused.

Fixes #4995.
2026-10-16 17:19:03 -07:00
Nigel Ihza Firdausy 9628290b36 fix(ui): Layer › New Fill Layer › Solid Color opens the Color Picker, creating the layer on OK (#5144) (#5166)
Creating a Solid Color fill from the Layer menu or the Layers panel footer now opens the shared Color Picker seeded with the foreground colour. The layer is created on OK in one history step; Cancel leaves the document and history untouched. Parameterised calls (CLI, MCP, actions) still create the layer directly.

Fixes #5144.
2026-10-16 17:13:51 -07:00
Higgs32584andClaude Opus 5.5 c9334c4661 Windows: a maximized custom-title-bar window lets an auto-hidden taskbar show (#4845)
An auto-hidden taskbar reserves no work area, so the maximize guard left the
caption-less window covering the whole monitor and the shell treated it as a
full-screen app, never revealing the taskbar. On each monitor edge with an
auto-hidden taskbar (found via Shell_TrayWnd / Shell_SecondaryTrayWnd through
safe winsafe calls) the window now stops 1 px short of the monitor.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 19:39:39 -04:00
David MannandClaude Opus 5.5 e1db181435 Guides: ruler guide snapping rebuilds its targets when snap options change (#6858)
The cached guide targets were keyed only by document and revision, so showing the
grid or turning Snap To Layers/Document Bounds off kept the stale targets; the key
now includes the Snap To options.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 01:35:33 +02:00
David MannandClaude Opus 5.5 22217e6ab2 CLI batch: skip shell-only Save and view steps inside played action sets (#6860)
Batch strips Save/Save As/zoom steps from every action of a loaded set, called actions
included, and remaps their unchecked/dialog step indices.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 01:32:48 +02:00
David MannandClaude Opus 5.5 921637322c Native format: refuse a .pcraft with out-of-range numbers when it is opened (#6862)
A number past f32's range (e.g. opacity 1e39) decoded as infinity, so the document opened but
could never be saved again; read_manifest now refuses it as corrupt, naming the field.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 01:30:16 +02:00
jonathnwxh b0484eb72c Remove extra blank line at merged test boundary 2026-10-17 07:24:29 +08:00
jonathnwxh 4d32089b4f Merge upstream main and retain both font metadata and paragraph-wrap regressions 2026-10-17 07:23:25 +08:00
David Mann 9d38b28da6 Merge remote-tracking branch 'origin/main' into feat/file-216-2-large-fixtures
# Conflicts:
#	docs/scorecard.md
2026-10-17 01:19:35 +02:00
David Mann 0739ff48c5 Merge remote-tracking branch 'origin/main' into fix/4017-huge-new-document
# Conflicts:
#	crates/ui-egui/src/gpu_canvas.rs
#	docs/scorecard.md
#	perf/budgets.toml
2026-10-17 01:07:37 +02:00
Marco Arnulfo db46713c2c Unsaved-changes prompt: the default answer takes keyboard focus, so arrows and Enter work (part of #6441) (#6466) 2026-10-16 17:03:15 -06:00
Jason Xiang 06ad07ab9b fix(io): color-manage grayscale WebP exports (#5875) 2026-10-16 17:02:54 -06:00
Jason Xiang f61ed26e14 fix(io): honor version-aware OpenRaster group isolation (#5872) 2026-10-16 17:02:23 -06:00
Jason Xiang 840edc311c fix(io): preserve linear grayscale profiles for selected EXR parts (#5867) 2026-10-16 17:01:52 -06:00
Jason Xiang 462adeadd2 test(io): cover mixed-region CMYK TIFF composites (#5588) 2026-10-16 17:01:10 -06:00
xsvm 8fe2c74f58 perf(testkit): sample RSS from /proc/self/statm, not smaps, on Linux (#5399)
memory-stats reads /proc/self/smaps by default on Linux: every sample formats and parses one
block per mapping while holding the mmap lock. The benches' RssSampler polls it every 2 ms, so
on a profile of bevel_bench --size 4000x3000 the rss-sampler thread was ~22% of all samples
(smaps_account 14.7%, smaps_pte_entry 6.3%), contending with the measured threads.

Enable the crate's always_use_statm feature: one short read of the kernel's RSS counters, the
same number VmRSS reports. The measured code is unchanged; only the harness overhead goes.
2026-10-16 17:00:43 -06:00
wwew 93f6376c5e docs: write product name as PhotoCraft in package descriptions and comments (#5615) 2026-10-16 16:59:41 -06:00
wwew e33384c9ba docs: write product name as PhotoCraft in crate module docs (#5455) 2026-10-16 16:58:50 -06:00
PerchenkoAnton f360cb0b4a fix(engine): give artboard exports unique indexed filenames (#6655) 2026-10-16 16:57:33 -06:00
wwew 651647fdf6 docs(cli): write product name as PhotoCraft in USAGE and description (#5449) 2026-10-16 16:56:48 -06:00
Jason Xiang f1c45b887e fix(codecs): respect PAM ENDHDR line boundaries (#5874) 2026-10-16 16:38:54 -06:00
Marco Arnulfo 6a7fc88c79 Crop tool: custom ratios keep three decimals, 1.414 : 1 no longer becomes 1.4 : 1 (fixes #6487) (#6503) 2026-10-16 16:38:35 -06:00
Marco Arnulfo 97f50b4714 Pen: the path in progress stays visible while panning with Space (fixes #6592) (#6625) 2026-10-16 16:38:11 -06:00
Diego Chagas ad6463b50f Paragraph text: a word wider than the box breaks at the box edge (#6694) (#6696)
parley's default overflow wrap only breaks at UAX #14 opportunities, so
a word longer than the paragraph box ran out past its edge and resizing
the box changed nothing. Paragraph text now sets OverflowWrap::BreakWord,
so such a word wraps at the box edge as Photoshop does; a space or hyphen
still wins over a mid-word break, a box that fits the word lays out as
before, and point text is untouched.
2026-10-16 16:37:45 -06:00
Korg1ch 3c75db7a80 fix(linux): avoid duplicate AppImage desktop launcher when managed by AM / AppMan (#6392) (#6394)
* fix(linux): avoid duplicate AppImage desktop launcher when managed by AM / AppMan (#6392)

* test(linux): cover AM / AppMan launcher detection in AppRun smoke test (#6392)
2026-10-16 16:37:17 -06:00
midasmatterhorn 19227022ca Histogram: scale bar heights linearly to the tallest bin, not sorted.get(250) (#6638) 2026-10-16 16:36:58 -06:00
Nicolas Falesy 54a4782849 perf(camera-raw): Shadows/Highlights 1.9× faster, bit for bit the same (#5964) (#6247)
The local Laplacian filter behind Shadows and Highlights builds a whole
pyramid per reference value (up to 96). Three costs dominated it:

- blur_step called mirror() (an integer remainder) for every tap of every
  pixel; the source rows are now found once per row and columns mirror only
  within two pixels of an edge.
- up() blurred a zero-stuffed plane, three samples in four zero; it now adds
  only the nonzero taps, in the same order.
- the small pyramid levels were split into one-row rayon tasks, costing more
  in scheduling than the rows; planes under 32K samples run on one thread.

The output is bit for bit the same: a new test checks blur_step and up
against the plain versions on odd, even, tiny and parallel-sized planes.
2026-10-16 16:26:19 -06:00
David MannandClaude Opus 5.5 c354a25a6b Type: paragraph box handles are picked at the unsnapped press (#6819)
With View > Snap on, the canvas snapped the Type tool's press (a point
gesture) before the tool's handle hit test, with the edited text's own
glyph bounds and centre lines among the targets. The snapped press could
leave the 6 pt handle grab area, so the press placed the caret or
committed the edit instead of resizing the box.

- The Type tools pick the box handle or glyph at the raw press, as Free
  Transform already does.
- While editing, only a press on a box handle starts snapping, and the
  resize drag snaps to guides and other layers, not to the text being
  resized; caret drags are no longer snapped.

Canvas-level test drags all eight handles (at the handle and inside its
grab area) at 25-200 % zoom, 1x and 2x HiDPI, plain/scaled/rotated layer.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 00:19:26 +02:00
MarekSpodBiedry 156b27d65b Object Selection: enclosed background gaps stay unselected, only object-like holes fill (#6556) 2026-10-17 00:06:54 +02:00
David MannandClaude Opus 5.5 89339396ff Droplets: carry the actions that Conditional steps call (#6804)
called_actions only followed actions.play steps, so a droplet whose action
used actions.conditional saved neither branch and failed with "no action
named ..." when run standalone. It now also queues the then/else targets.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 23:42:36 +02:00
David MannandClaude Opus 5.5 1338d78b45 Transform: drop a fill layer's cached render when its frame changes (#6808)
Free Transform and Warp moved a fill layer's linked masks (and so the frame its
gradient is laid out in) but kept the cached render, which the compositor draws
as-is while the Fill is unchanged. Drop the cache in apply_transform and
warp_layer, as transform_layer_geometry and Perspective Crop already do.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 23:42:34 +02:00
Higgs32584andClaude Opus 5.5 cd75fa78ef PSD/PSB: a 16/32-bit file's "Transparency" channel stays the merged transparency on re-save (#4654)
Photoshop's 16-bit PSBs can mark the merged transparency only by naming it
"Transparency" in the alpha channel names (positive Lr16 layer count, no Mt16).
The importer read it as a user alpha channel, and the next save wrote a second
one. merged_has_alpha now also accepts that name when the layers sit in a
global Lr16/Lr32 block and the names cover every extra channel.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 17:38:42 -04:00
David MannandClaude Opus 5.5 3afb0b1283 PSD: layered TIFF byte-order conversion swaps every smart filter section (#6802)
Tx::filter_effects converted only the first u64-length section of FEid/FXid and copied the rest as stored, so a second smart object's section kept the source byte order and FilterEffects::parse lost the whole block.
It now mirrors FilterEffects::parse: one section per smart object, each with its 4-byte padding, until only trailing zero padding remains.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 23:33:20 +02:00
nrq6993-a11yandCursor 2e8d20ca9c Fix rustfmt on snap_ui smart-target assignment.
CI Format step failed on a line break rustc-fmt does not want.

Co-authored-by: Cursor <[email protected]>
2026-10-17 00:24:53 +03:00
David MannandClaude Opus 5.5 8c6412bbb2 Plug-ins: running a filter plug-in respects layer locks (#6770)
plugin.run wrote into raster and smart object layers without checking
locks. It now refuses layers with locked pixels or Lock All like the
built-in filters, and with Lock Transparency it keeps the layer's alpha
(as built-in filters do, via filters::keep_alpha).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 23:20:35 +02:00
David MannandClaude Opus 5.5 ec83a1117f Presets: refuse renaming a group to a name another group has (#6768)
renameGroup in the shared preset group editor renamed without checking
that the new name was free, so two groups could share a name and a later
replace import of that name removed both. It now refuses like the
Swatches sibling; renaming a group to its own name stays fine.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 23:20:35 +02:00
David MannandClaude Opus 5.5 0fc5660f60 Image Size: a gradient fill's centre offset stays a fraction of the frame (#6778)
map_fill multiplied the offset by the full linear part of the canvas transform, so Image Size
(and Perspective Crop) scaled it: 200% moved a 25% offset to 50%. Each axis of the linear part
is now normalised, so scale drops out while flips and 90° turns still mirror and turn it.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 23:19:40 +02:00
David MannandClaude Opus 5.5 031ab7d5b2 Apply Image: write into the targeted layer mask (#6780)
With the layer mask targeted, apply_image took the layer branch and fetched the
layer's pixel surface with the target dropped, so the mask stayed unchanged and the
pixels were overwritten. A mask target now goes through the single-plane path and
is written back over the canvas, keeping the mask's default outside it.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 23:18:53 +02:00
Ian Swope 9b6a85824d Mesh warp: tiles encoded in parallel and in-bounds taps read directly, about twice as fast with identical pixels 2026-10-16 16:13:44 -05:00
David MannandClaude Opus 5.5 9ac2c08875 Actions: renaming an action updates the Conditional steps that call it (#6772)
actions.rename only rewrote the "action" param of actions.play steps, so an
actions.conditional step whose "then"/"else" named the renamed action failed
with "no action named ..." on the next play. The rename loop now also rewrites
matching then/else branches, in saved actions and the pending recording.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 23:02:33 +02:00
David MannandClaude Opus 5.5 af5e77b150 Auto-Align: warp layer masks as masks, keeping what lies outside revealed (#6776)
Auto-Align warped a moving layer's mask with the colour warp (warp_placed), which
returns a grey + alpha surface defaulting to 0, so a reveal-all mask hid everything
outside its painted area. Masks now go through warp_gray (planar placements) or
warp_mesh_gray (cylindrical/spherical), keeping one channel and the mask's default.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 23:01:14 +02:00
David MannandClaude Opus 5.5 bdf44ab18c PSD: read colours stored as HSB (#6774)
color_from_desc had no HSBC arm, so HSB descriptor colours fell through to the
RGB fallback and came back None: solid fills vanished, strokes and effects went
black. Convert HSBC (hue in degrees, saturation/brightness 0..100) with the
existing channel_map::hsb_to_rgb, now pub(crate).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 22:51:32 +02:00
David MannandClaude Opus 5.5 ab81052308 Fit Image: a width or height of 0 leaves that side unconstrained (#6766)
fit_image read width/height with f64_param and clamped to 1, so a 0 or
negative side (dialogs send 0 for an empty field) became a 1 px box and
shrank the document to 1x1. It now uses size_param like the rest of
file_cmds.rs, so such a side falls back to the document's own size.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 22:49:38 +02:00
David MannandClaude Opus 5.5 01dbccee18 Merge main into feat/tool-213-7-live-retouch
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 22:42:01 +02:00
David MannandClaude Opus 5.5 f5d9969803 Merge main into fix/history-brush-source
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 22:37:11 +02:00
David MannandClaude Opus 5.5 b01f6d33b3 PSD: layered TIFF byte-order conversion reads the class of Idnt/indx references (#6764)
The byte-order converter read Idnt/indx reference items as a bare u32, but
they are class + u32 (as descriptor.rs reads them since #4434), so a block
holding such a reference desynced and was dropped (or left unconverted).
Read the class first; regression test round-trips such an lfx2 block.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 22:33:54 +02:00
David MannandClaude Opus 5.5 43ade8be26 Merge main into perf/p33-cmyk-dab
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 22:32:33 +02:00
David MannandClaude Opus 5.5 8af5f2b880 Merge main into fix/history-panel-menu
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 22:28:43 +02:00
David MannandClaude Opus 5.5 e5f1ecf501 Merge main into fix/smart-filter-last-mask
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 22:24:50 +02:00
nanopink 4354113e73 fix: match Photoshop Gradient Fill scale limits 2026-10-16 16:23:53 -04:00
David MannandClaude Opus 5.5 221af61dea Merge main into fix/count-delete-group-active
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 22:22:55 +02:00
David MannandClaude Opus 5.5 48ed913526 Merge main into fix/video-fps-validate
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 22:19:12 +02:00
David MannandClaude Opus 5.5 824cbdb022 Merge main into docs/scorecard-stale-rows-2
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 22:19:08 +02:00
David MannandClaude Opus 5.5 dd6f66e3ae Merge main into fix/lens-filters-smart-locks
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 22:17:26 +02:00
David MannandClaude Opus 5.5 0ea2684679 Merge main into fix/adjust-smart-object-locks
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 22:15:01 +02:00
David MannandClaude Opus 5.5 73b0bef8a5 Merge main into fix/fill-layer-angle-finite
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 22:15:00 +02:00
David MannandClaude Opus 5.5 c7f5dfdbc5 Merge main into feat/file-215-18-export-resample
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 22:13:25 +02:00
David MannandClaude Opus 5.5 699a16e024 Merge main into fix/lock-transparency-atop
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 22:12:47 +02:00
David MannandClaude Opus 5.5 31df8a357b Merge main into fix/mask-from-transparency-bg-depth
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 22:12:45 +02:00
David MannandClaude Opus 5.5 b9d646baad Merge main into fix/multichannel-alpha
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 22:11:01 +02:00
David MannandClaude Opus 5.5 1bd9c7a3dc Merge main into fix/1954-overlay-opacity-stops
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 22:08:36 +02:00
David MannandClaude Opus 5.5 1b0e69d2f6 Merge main into feat/tool-214-12-shape-feather
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 22:07:55 +02:00
David MannandClaude Opus 5.5 c1f0a7522a Merge main into fix/5559-bitmap-dialog
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 22:06:11 +02:00
David MannandClaude Opus 5.5 83afd80a87 Merge main into feat/ui-217-4-save-in-background
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 22:01:22 +02:00
David MannandClaude Opus 5.5 1336022624 Merge main into perf/p25-blur-cancel
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 21:58:23 +02:00
David MannandClaude Opus 5.5 78ae69a456 Merge main into feat/file-215-2-noparam
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 21:55:46 +02:00
David MannandClaude Opus 5.5 7977bed5c4 Merge main into feat/ui-217-4-animated-zoom
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 21:55:27 +02:00
David MannandClaude Opus 5.5 362f7168ee Merge main into feat/file-215-2-blur
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 21:53:52 +02:00
David MannandClaude Opus 5.5 e361737258 Merge main into feat/file-215-2-stylize-pixelate
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 21:53:31 +02:00
David MannandClaude Opus 5.5 5990376d1c Merge main into feat/file-215-2-other
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 21:50:49 +02:00
David MannandClaude Opus 5.5 1af75eef72 Merge main into feat/file-215-2-sharpen-noise
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 21:50:26 +02:00
David MannandClaude Opus 5.5 6199c1e04d Merge main into feat/file-215-2-wind-extrude-tiles
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 21:50:14 +02:00
David MannandClaude Opus 5.5 b9abd1c0d9 Merge main into feat/ui-217-1-tool-options-persist
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 21:48:33 +02:00
David MannandClaude Opus 5.5 c751ef0ce8 Merge main into feat/file-215-2-mezzotint-halftone
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 21:47:48 +02:00
David MannandClaude Opus 5.5 2fef710e49 Merge main into feat/4946-ctrl-hover-distance
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 21:25:44 +02:00
David MannandClaude Opus 5.5 dc71a11d41 Merge main into fix/4809-dual-brush-size
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 21:25:44 +02:00
vangel76andClaude Opus 5.5 489feb2333 Tests: pcraft keeps text run baseline; tighten sub/superscript size check (#5645)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 20:57:15 +02:00
David MannandClaude Opus 5.5 5d3eb7f130 Paths: drag a path row onto the Trash to delete it (#6553)
In Photoshop a path is deleted by dragging its row in the Paths panel
onto the Delete (Trash) button at the panel's bottom: the row follows
the cursor and releasing over the Trash deletes that path, whichever
row is selected. PhotoCraft's rows only reacted to clicks, so the
drag gave no feedback and a drop on the Trash did nothing.

Saved path, work path and vector mask rows now start a drag. While it
runs the row's name follows the cursor (the ghost the Layers and
History panels use) and the Trash is outlined when under the pointer;
releasing there runs the row's own delete command (path.delete for a
saved or the work path, layer.vectorMask.delete for a vector mask),
reusing the History panel's dropped_on helper. A drag that never sees
its release is dropped next frame.

A shape layer's path row does not start a drag: what deleting it does
is #6552's change, kept separate.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 19:56:28 +02:00
David MannandClaude Opus 5.5 ed4e94a479 PSD: open an adjustment layer of an unknown type as an unsupported adjustment (#6575)
An adjustment layer whose block no mapper knows (Photoshop 4's `hue `, or a
type newer than PhotoCraft) opened as an empty pixel layer with no notice.
Its block did survive in psd_blocks, but the layer showed as a paintable
empty pixel layer and saved without the "pixel data irrelevant" flag.

Photoshop writes every adjustment layer without pixels and with "pixel data
irrelevant" set (every adjustment layer in the corpus; pixel, type and smart
layers never have it). A record like that which is not a group, matches no
known content kind, and carries exactly one block that is neither a known
layer-information key nor a known adjustment now imports as
Adjustment::Unsupported with that block, plus the usual "could not be read"
warning naming the key. With two or more candidates nothing is guessed and
the layer stays a pixel layer as before. The rule only fires for layers
without pixels, so no pixels can be lost, and the block is written back
verbatim either way.

Export keeps an unknown-key Unsupported block in place while it is
unchanged; before, it was written a second time beside the preserved copy.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 19:52:26 +02:00
David MannandClaude Opus 5.5 e88268d7dd Paths: Delete Path on a shape layer's path row deletes the layer (#6552)
The right-click menu of a shape layer's "<Layer> Shape Path" row in the
Paths panel had no Delete Path, while Photoshop offers it enabled.

Measured in Photoshop: with a rectangle shape layer's shape path targeted
in the Paths panel, Delete Path removes the whole shape layer as one
"Delete Layer" history step (another shape layer below is untouched);
it does not leave a full-canvas fill behind.

The row now offers Delete Path, dispatching the existing layer.delete
command for that row's layer (passed explicitly, so a multi-layer
selection is not deleted with it). Saved and work path rows keep
path.delete and a vector mask row keeps Delete Vector Mask.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 19:42:04 +02:00
David MannandClaude Opus 5.5 65dc117b4b Shortcuts: a command shortcut leaves the focused menu bar and runs (#6573)
After an Alt tap or F10 the in-window menu bar owns the keyboard, and
shortcuts::handle returned before dispatching anything, so Ctrl+D, F7
and the other command shortcuts did nothing until Esc left the bar.
Since the shortcuts run before the menu bar in a frame, the bar can't
let go of the key itself in time.

menu_keys::leave_for_shortcut takes the keyboard off the bar when a
Cmd/Ctrl combo or a function key other than F10 is pressed while the
bar is focused with no menu open; shortcuts::handle calls it before its
early return, so the shortcut runs in the same press. Arrows, Enter,
Esc, letters, Alt+letter and F10 keep driving the bar, and an open menu
still swallows shortcuts.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 19:03:03 +02:00
David MannandClaude Opus 5.5 86ee9c9c7c Paths: the path selected in the Paths panel is drawn on the canvas (#6550)
Selecting a saved path in the Paths panel drew nothing: the canvas overlay
only drew the work path, the active shape layer's path and a targeted vector
mask. The selected saved path is Photoshop's target path, so it is now drawn
with any tool (anchor squares with Path Selection, and with Direct Selection's
overlay), still hidden by View > Show > Target Path and Extras.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 19:03:01 +02:00
Davos d21683cae3 fix(ui): disable Batch without a recorded action 2026-10-16 20:01:28 +03:00
aiongg 0a328189aa Smart filters, data sets, video: re-read stored files only when local or on the document's share
Paths these features keep in the document go through the same resolver as
linked smart objects (relative to the document's folder; other computers,
devices and URLs refused):

- A Displace smart filter's `mapPath` and a Color Lookup smart filter's LUT
  `file`, re-read on every re-render: a path that is not followed leaves
  the pixels as they are, as for a missing map or LUT.
- Apply Data Set's Pixel Replacement images.
- Reload Frame of a video layer's footage.

Apply Data Set and Reload Frame say why a path is not followed.
2026-10-16 16:53:28 +00:00
aiongg 348499a5e0 PSD export: embed linked files only when they are local or on the document's share
Save As PSD/PSB, layered TIFF and Package as PSD read a linked smart
object's file to embed it. The path is resolved with the same resolver as
other linked-file reads, against the document's own file (the new
ExportOptions::document_path / PsdExportOptions::document_path, set by the
engine's saves and the app's Save As). A link that is not followed is
written as pixels with a warning that says why, as for a missing file.
Nested smart-object documents resolve against the same document.
2026-10-16 16:53:21 +00:00
aionggandDavid Mann 87918a6c85 Linked smart objects: follow stored paths only to local files or the document's share
A linked smart object's path comes from the document. Every place that
follows it now goes through one resolver (photocraft_io::doc_path, built on
craft-docpath): a relative path is taken from the document's folder, and
only local files or files on the share the document itself is on are
opened. Paths to other computers, device paths and URLs are treated like a
missing file: the layer keeps its last rendering.

- Link watch: such links are not stamped, and no background look starts
  when no link of the document is followed.
- Update Modified Content and other re-renders read only followed files;
  the error names why a link is not followed.
- Package lists such links as missing instead of copying them.
- Reveal in Finder for a linked layer shows only a followed file; the
  document's own Reveal is unchanged. The macOS invocation ends options
  with `--`, and Explorer is not asked to select a path containing a comma
  or a quotation mark.
- Place Linked stores a relative path as an absolute one.

The document path travels to smart-object renders in a thread-local scope
entered by Session::edit, command dispatch and background jobs.

Co-authored-by: David Mann <[email protected]>
2026-10-16 16:53:13 +00:00
AlastorIRL 2406e762b9 Merge remote-tracking branch 'origin/main' into feat/animated-gif-open-and-timeline-fixes
# Conflicts:
#	crates/ui-egui/src/i18n/de.tsv
#	crates/ui-egui/src/i18n/es.tsv
#	crates/ui-egui/src/i18n/ko.tsv
#	crates/ui-egui/src/i18n/zh-hans.tsv
#	crates/ui-egui/src/i18n/zh-hant.tsv
#	crates/ui-egui/src/view_cmds/tests.rs
2026-10-16 13:45:19 -03:00
David MannandClaude Opus 5.5 2f21722eb4 Content-Aware Move: live preview for large selections (TOOL-213-4, #6680)
The live preview only covered selections up to 256x256 px; larger ones (the
command takes up to 16 M) errored, the UI remembered the failure and showed
nothing until release.

Engine: a preview whose selection (or landed area) is over 256x256 is now
solved on a scaled-down copy of the area around the selection and where it
lands (area-average resize of the target, or of the composite with Sample All
Layers, and of the selection), on a one-layer document of its own, then
enlarged back (bilinear, float samples, so any depth) over the selection's
shape where it lands and, for a move, where it left. The landed content is
sampled through the scaled placement, so rounding the scaled offset does not
shift it. ContentAwarePreview::refine gives the full-resolution job, with the
command's own margin, so the refined preview is the command's result bit for
bit. Every step checks the JobCtx; the session is never touched; bad params
and selections over the command's limit still error. Selections within
256x256 take the unchanged path (same output as before, checked by hash
against main). The bilinear resampler used by Transform On Drop now runs its
rows in parallel (same results).

Measured (release, 24 MP 8-bit document, Ryzen 9 7950X with other builds
running): coarse preview 600x400 0.26 s, 1500x1000 0.38 s, 3000x2000 0.69 s
(256x256 full-resolution preview: 0.21 s); the exact refine takes as long as
the command (0.8 s, 4.1 s, 9.4 s).

UI: cam_preview shows the coarse result, then starts the refine as a second
cancellable job and swaps it in; a failed refine leaves the coarse result
shown, and a failed first job is still not retried.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 17:32:28 +02:00
David MannandClaude Opus 5.5 145acbda8b Layers: toggling a clipped adjustment damages only its base (#6535)
`photocraft_compose::change_bounds` returned "anywhere" for every
adjustment layer before looking at `clipped`, although a clipped
adjustment only changes its clip base's pixels. Hiding or showing one
(or undoing that) therefore recomposited the whole canvas, which on a
heavy document fails the GPU memory fit check and falls back to a full
CPU redraw taking seconds to minutes.

`change_bounds` sees one layer and cannot find the base, so a new
`change_bounds_in(layers, k, canvas)` looks at the sibling list: a
clipped adjustment's change bounds are its base's change bounds. It
stays `None` (anywhere) when no unclipped base lies beneath it (the
compositor then draws it as an ordinary adjustment), when the base is
an adjustment (layers clipped to it composite atop the adjusted
backdrop), and, through `change_bounds`, when the base is a
pass-through group holding an adjustment (visible clipped layers make
that group isolated, so they decide whether its adjustment reaches the
layers beneath) or draws outside its bounds. The engine's
`layers_damage`, `step_damage` and `one_sided_bounds` use it.

Tests: a compose unit test that the clipped adjustment's bounds equal
the base's, that every pixel the toggle changes lies inside them, and
the cases that stay unbounded; an engine test that hide, undo, redo,
show and `layer.setProps` visible report the base's rect (top level and
inside a group), and that recompositing only that rect over the
previous canvas equals a full recomposite.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 15:04:09 +02:00
David MannandClaude Opus 5.5 ac8d0af24c Hue/Saturation: the Lightness track runs black, grey, white (#6539)
The Lightness slider's track in the Hue/Saturation editor (Properties
panel and dialog, which share it) ran black -> the current hue -> white.
Photoshop's Lightness track is a fixed black -> mid grey -> white ramp
whatever the Hue. lightness_track() now returns that ramp and no longer
takes the hue; the Saturation track still follows the hue. The UI-217-16
scorecard row describes the new track.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 15:00:11 +02:00
UnemarsandClaude Opus 5.5 87fbb2018f Merge origin/main into content-aware-workspace
Conflicts:
- distort_ui.rs: main's `drop_foreign` (#5092, #5123) closes the Content-Aware Fill workspace
  too when another document becomes active or its own is closed, stopping its background
  preview as Cancel does; what Apply wrote stays. New test
  `the_workspace_is_dropped_when_another_document_becomes_active`.
- sr, tr, vi catalogs: both sides' rows. Dropped es's High, Low, Duplicate Layer and Contract,
  which main has now with the same translations.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 14:19:21 +02:00
David MannandClaude Opus 5.5 1e3971413f Docs: FILE-215-2 lists Hue/Saturation smart filters as mapped (#6267)
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 13:12:19 +02:00
David MannandClaude Opus 5.5 c467592211 Docs: FILE-215-2 lists Perspective Warp smart filters as mapped (#6268)
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 13:12:19 +02:00
David Mann 50aceedbd4 Merge commit '2a952f164c0b4cf936b1c6497db10dd095341c00' into fix/6267-psd-hue-sat-sf 2026-10-16 13:04:51 +02:00
David Mann a32e7cc328 Merge commit '2a952f164c0b4cf936b1c6497db10dd095341c00' into fix/6268-psd-perspective-warp-sf 2026-10-16 13:04:51 +02:00
itsF4LCON 1302e08791 Arrange: the shown tiles stay put when a visible document is activated (#6017)
With more documents than tiles, the run of shown documents was worked out from the active
index alone, so after activating document 2 in Two-up, a click into the visible document 1
moved the tiles back to [0, 1]. The run is now kept from frame to frame and slides only when
a hidden document becomes active.
2026-10-16 12:04:34 +02:00
itsF4LCON 5b5fc9a80e Merge remote-tracking branch 'upstream/main' into fix/6017-arranged-tiles 2026-10-16 12:02:40 +02:00
itsF4LCON a6211253f8 Merge remote-tracking branch 'upstream/main' into fix/6085-text-caret-contrast 2026-10-16 11:37:08 +02:00
itsF4LCON 73e6671893 Merge remote-tracking branch 'upstream/main' into feat/6278-replace-color-result 2026-10-16 11:37:07 +02:00
highperfocused 4d6f3f4d1e style: cargo fmt (review on #6499) 2026-10-16 11:31:52 +02:00
jonathanwxh-cellandClaude Opus 5.5 9c914a9fd2 Refuse Affinity spot colours nested more than 4 levels deep
paint::color followed a spot colour's RGB equivalent and base colour with
no depth limit. Colours are shared objects, so a spot colour that links to
itself overflowed the stack and aborted the import, and a chain whose levels
link to the next through both took exponential time. Stop after 4 levels of
spot colours defined through spot colours, with a warning.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 15:58:41 +08:00
Marco Arnulfo f59b75b058 Character and Paragraph panels: size, leading, baseline shift and indents follow the Type units preference (fixes #6491) 2026-10-16 09:35:06 +02:00
PerchAnton 3f216a0cd1 fix(engine): reject numeric overflow before document edits 2026-10-16 10:28:57 +03:00
highperfocused a08992c9d9 Desktop: a control/automation flag without its value is a usage error (#6429) 2026-10-16 09:14:24 +02:00
jonathnwxh 670ca40804 Merge upstream and retain both save-type and clipboard regressions 2026-10-16 13:58:18 +08:00
JGF 2a952f164c Windows/Linux: open files in the running PhotoCraft instead of a second instance (#3708) (#3755)
Fixes #3708; part of #1540 (Flatpak needs its own follow-up: each sandbox has its own loopback).

A second launch hands its files to the running instance over an authenticated loopback channel (random port, 32-byte token in a private instance.json, constant-time check, bounded clients, requests and paths). Files are acknowledged only once the UI has taken them; the instance shuts the hand-off down before a CPU-compatibility relaunch; it runs only in the per-user settings mode. --new-instance and PHOTOCRAFT_NEW_INSTANCE=1 opt out.

Thanks to @MrCloudy2 (#2358), whose Linux version first handled the relaunch and per-user endpoints, and @Desayuno64 (#5273), whose shutdown flag and read deadline informed the final design.
2026-10-15 22:48:30 -07:00
Tanish Manku d12be8ecd0 test(ui-egui): canvas_16f and rotation preview tests run on the app's wgpu device (#6454)
Both harnesses built their device with egui_kittest's .wgpu() (egui's default limits) instead of
gpu_canvas::wgpu_setup() (the adapter's own limits, what the app requests), as #5954 fixed for the
background_redraw and navigator tests. On that device:
- canvas_16f's sixteen_bit_gradient_on_screen and thirty_two_bit_preview_exposes_values_above_one
  panicked in egui-wgpu ("Failed to create staging buffer for index data", renderer.rs:984), every
  run, single-threaded too;
- arbitrary_rotation_preview's two tests failed "the fixture must use the GPU canvas".
Both binaries pass on the app's device.
2026-10-15 22:33:14 -07:00
Gert Pellin d8a23d3ec3 Automation: a control token authenticates in either letter case (#6425)
validate_token accepts uppercase hex and server_token keeps the token
lowercased, but authentication_reply passed the client's token to
token_matches unchanged, which compared bytes exactly. A client sending
the token exactly as configured (--control-token or a token file in
uppercase) was refused; only the bundled bridge, which lowercases, got in.

token_matches now folds ASCII letters on both sides, still without an
early exit. Only A-Z fold, so a non-hex byte never matches a hex digit.
2026-10-16 07:30:48 +02:00
58f855a40f Save As on Linux: the file type picked sets the extension (#6121, #4328, #4394) (#6151)
* Save As on Linux: the file type picked sets the extension (#6121, #4328, #4394)

The GTK file chooser behind the desktop portal keeps the typed name when
another file type is picked, and rfd 0.17.2 reads only the file name from
the portal's answer, so Untitled-1.psd with PNG picked was written as a
PSD. On Linux, Save As now asks the portal's FileChooser.SaveFile itself
(over the zbus already used for the portal), reads current_filter, and
gives the name that type's extension: an extension PhotoCraft saves is
replaced, none or another is appended. If that names an existing file, the
dialog opens again under the new name so the portal asks before replacing
it. rfd remains the fallback without a portal, and this can go once an rfd
release reads current_filter (PolyMeilex/rfd#341).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Save As on Linux: keep typed extensions, open on the stem, follow the request handle

Review follow-ups, checked on real GNOME 46 and KDE Plasma 6.7.5 sessions
(Wayland and X11):

- An extension the user typed is kept, as in PolyMeilex/rfd#341: only a
  name with none, or still with the extension the dialog opened with, takes
  the picked type's. GTK reports the type even when it is untouched, so
  art.png with the PSD type left alone was saved as art.psd.
- The dialog opens on the stem (test), as on Windows (#4407): with
  test.psd in the name and PNG picked, GTK asked to replace test.psd,
  which the save leaves alone. A name that then exists is asked about
  again under that name; the rule compares against the suggested name, so
  a test.psd GTK completes back still becomes test.png.
- Response 2 (GNOME's dialog closed with Esc) is a cancel: it fell back to
  rfd, which opened a second Save As.
- The answer is awaited on the handle SaveFile returns, which a portal
  older than 0.9 picks itself (the Request documentation).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* style(automation): format smart filter refresh closure

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: skylar taylor-barrick <[email protected]>
2026-10-15 22:47:17 -06:00
Tanish Manku 6368e6a851 GPU memory follow-ups (#5973 review): fit cleared on device loss and on the CPU canvas; measured when switching to a document whose canvas is current; notice names the 3/8 card cap; VRAM override documented; perf rows count memory like the app (#6458) 2026-10-15 21:37:05 -07:00
Tanish Manku 6d3443c098 GPU memory: discrete cards aren't charged for the document; GPU Memory preference; GPU Memory Full status (#5973)
* GPU memory: a graphics card's own memory isn't charged for the document; GPU Memory preference; GPU Memory Full in the status bar

Many layers hit a cliff: past what the GPU memory budget leaves for layer pages, every edit
that redraws the whole document (opacity, visibility, a nudge) goes to the CPU compositor.
Measured on 3000x2000 full-canvas layers (RTX 5090, 31 GB RAM, Memory Usage 8 GB): 150
layers took 2.6 s per such edit, 300 layers 6 s. The budget was Memory Usage minus the
document's pixels and History, which is right where the GPU shares system memory, but a
discrete card's memory doesn't hold the document, so 150 layers of 6 MP (3.6 GB) squeezed
the budget below what they needed.

- Automatic budget: discrete adapters are no longer charged for the document's pixels
  (integrated and software adapters still are). 150 layers: 2.6 s -> 25 ms.
- Preferences › Performance › GPU Memory (MB, 0 = Automatic; performance.gpuMemoryMb) sets
  the budget outright. 300 layers with 12 GB: 6 s -> 45-72 ms.
- The status bar shows "GPU Memory Full" while the active document's layers don't fit
  (both status bar styles); hovering gives the numbers, clicking opens Preferences ›
  Performance. Its Graphics rows show the budget and what the layers need. The slow CPU
  redraw notice gives the numbers and points at GPU Memory.
- Headless: the preference is a normal pref (prefs.set), and ui.state's perf.timings
  reports gpuLayerFit {doc, needed, budget} next to gpuBudgetMb.

Next (owner-approved plan): after #5370, read the card's real limit from the driver
(wgpu memory budget thresholds) and shrink on out-of-memory; then redraw the visible
area on the GPU first when layers still don't fit.

* GPU memory: typed discrete check; the status and budget follow a GPU Memory change at once (measure_fit); perf rows P60/P61 for whole-document edits among 150 full-canvas layers under the app's budget

* GPU memory on top of #5234: memory_budget takes the card's memory (vram) and the GPU Memory value; the document's pixels are charged unless the card's own memory is known; a set GPU Memory is capped at the card's share; benches use video_memory
2026-10-15 21:22:46 -07:00
FinnNkandClaude Opus 5.5 297520b9c8 perf(gpu): cap the GPU memory budgets by a discrete GPU's own memory (#5234)
* perf(gpu): cap the GPU memory budgets by a discrete GPU's own memory

The compositor and canvas budgets came from system RAM only, so a 16 GB card in a 96 GB machine planned for up to 8 GB of compositor pages plus 24 GB of display textures. Detect a discrete adapter's dedicated memory (DXGI on Windows through winsafe, amdgpu sysfs on Linux) and cap each budget at three eighths of it. PHOTOCRAFT_VRAM_MB simulates a smaller card. Fixes #4536.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* perf(gpu): keep a 16-bit canvas on the GPU in 8-bit when floats don't fit

With the canvas budget capped by a small card's memory, a 16-bit document under the float-canvas pixel limit could need more than the budget as Rgba16Float and fall back to the CPU canvas although its Rgba8Unorm set fits. Choose the float canvas only when it fits the budget, which is now shared atomically so format_for can read it while the renderer is locked.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* perf(gpu): detect a card's memory once per adapter, and test the Linux reader

video_memory built a DXGI factory each time it was asked, three times at
startup; it now remembers the answer per (vendor, device), so a GPU retry
that picks another adapter still gets its own. The amdgpu sysfs reader
takes the drm folder as a parameter and is tested against a temporary
folder: the matching card's VRAM, a card without the file, garbage ids, a
malformed size, no match and no folder.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-15 21:05:51 -07:00
Tanish Manku 57437ea1a9 fix(ui-egui): canvas textures fit the device's texture limit; GPU tests hold the GPU lock on the app's device (#5954)
- The CPU canvas texture and the crop's beyond-canvas bands were reduced to fit MAX_TEXTURE (4096)
  whatever the device allows. On a device with a smaller limit (2048 on some older GPUs and WebGL
  contexts) a 2600 px document was uploaded at full size: egui asserts and the backend can't
  create it. They now fit the smaller of the two. Test: cpu_canvas_texture_fits_the_device_limit.
- background_redraw and navigator GPU tests built their harness with egui's default wgpu limits
  instead of the app's wgpu_setup (the adapter's own limits), and the GPU canvas lost the device
  on it ("Device is lost", every run on an RTX 5090). They now use wgpu_setup.
- Eleven tests built a wgpu device without photocraft_testkit::gpu_lock, which testkit requires so
  devices aren't created concurrently; they now hold it.
2026-10-15 21:05:39 -07:00
Dylan M. Taylor cddb6fa21c ci: switch to shared nix workflow from craft-common-actions 2026-10-15 23:54:15 -04:00
skylar taylor-barrick 7053f703a5 Merge pull request #2933 from 1Git2Clone/ci-wallclock
CI: cut wall time, starting with the Windows test job
2026-10-15 21:38:18 -06:00
skylar taylor-barrick fd1faf4e13 Merge pull request #6381 from dylanmtaylor/ci/shared-freebsd-workflow
ci: delegate FreeBSD workflow to craft-common-actions and add nix check
2026-10-15 21:34:22 -06:00
skylartaylor 8f58ea4629 ci: integrate current main while retaining strict test gates 2026-10-16 05:25:24 +02:00
Higgs32584andClaude Opus 5.5 5c0e40784a rustfmt; merge main (CI Format step)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 22:29:42 -04:00
Higgs32584 36236b908a Merge remote-tracking branch 'origin/main' into fix/4962-web-save-dirty 2026-10-15 22:28:59 -04:00
Higgs32584andClaude Opus 5.5 5f8d92f9f0 rustfmt; merge main (CI Format step)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 22:27:09 -04:00
Higgs32584 ad620fba50 Merge remote-tracking branch 'origin/main' into fix/5884-delay-load-windows-data-pdf 2026-10-15 22:26:18 -04:00
Higgs32584andClaude Opus 5.5 c972acf98f rustfmt: format after merging main (CI Format step)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 22:16:12 -04:00
Higgs32584andClaude Opus 5.5 76f29f67d5 rustfmt: format after merging main (CI Format step)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 22:16:03 -04:00
Higgs32584 3eef8af459 Merge remote-tracking branch 'origin/main' into fix/help-search-arrows-space 2026-10-15 22:09:43 -04:00
Higgs32584 f46bd2a907 Merge remote-tracking branch 'origin/main' into fix/1056-path-selection-canvas-menu 2026-10-15 22:09:40 -04:00
Dylan M. Taylor 0cc161e2a8 ci: delegate FreeBSD workflow to craft-common-actions and add nix check 2026-10-15 22:04:09 -04:00
Higgs32584andClaude Opus 5.5 7bc556ec02 Web: a requested download no longer marks the document saved (#4962)
The web writer only triggers an anchor download, which the user can cancel
and which reports nothing back, yet Save treated it as a confirmed write:
the document was marked clean and the leave-page guard disarmed. Services
gains `write_unconfirmed` (set by the web shell); a save through such a
writer names the document but keeps its changes unsaved.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 21:54:09 -04:00
jonathnwxh f22d08da8c style: apply rustfmt to upstream smart-filter regression 2026-10-16 09:32:47 +08:00
David MannandClaude Opus 5.5 a9ba86898e PSD: Hue/Saturation smart filters open and save like Photoshop (#6267)
A Hue/Saturation smart filter (Fltr class HStr, filterID 'HStr') opened
as psd.unsupportedFilter, so re-rendering the smart object (replace
contents, relink) dropped it, and a Hue/Saturation smart filter made in
PhotoCraft was left out of PSD saves.

The new hue_sat_filter_map module maps Photoshop's layout (measured on a
Photoshop-saved file: presetKind, Clrz, Adjs = master Hst2 {H, Strt,
Lght} plus one Hst2 per edited range with LclR, BgnR, BgnS, EndS, EndR,
all longs) to image.adjustments.hueSaturation with the command's params,
and writes it back in that layout. Anything outside it (unknown keys,
duplicate or unknown ranges, values the dialog can't hold) stays an
opaque unsupported filter written back verbatim. smart_map.rs only gets
the dispatch hooks.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 03:18:42 +02:00
David MannandClaude Opus 5.5 b59b0cd1d5 PSD: Perspective Warp smart filters open and save like Photoshop (#6268)
A Perspective Warp smart filter (Fltr class perspectiveWarpTransform) fell
through to psd.unsupportedFilter, so a re-rendered smart object (e.g. after
Replace Contents) lost its perspective. Each quad now becomes a plane of
layer.smartObjects.perspectiveWarp (src = vertices, dst = warpedVertices),
and PSD save writes Photoshop's descriptor back: the original item with
only the geometry rewritten, or a new item in the layout Photoshop writes.
Inconsistent descriptors stay opaque, as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 03:18:18 +02:00
PerchenkoAnton 3cb3580a46 fix(psd): recover layered imports with damaged merged data (#6170) 2026-10-15 19:14:05 -06:00
jonathnwxh 7bff38c176 Merge remote-tracking branch 'origin/main' into repair/5413-upstream-schema 2026-10-16 09:13:54 +08:00
David MannandClaude Opus 5.5 0ce7e00fee Brush: the Dual Brush tip keeps its proportion at tiny main sizes (#4809)
Photoshop scales the Dual Brush diameter by the main size ratio, unrounded,
with a 5000 px cap and no lower floor: main 100 / dual 20 resized to 1 px
gives a 0.2 px dual tip, and back at 100 px it is 20 px again. PhotoCraft
already scaled the dual tip (#4856) but floored it at 0.5 px, so a trip to
1 px and back left a 50 px dual tip. Drop the floor (rendering bounds the
diameter itself), keep the cap, and leave a non-positive or NaN result alone.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 03:06:07 +02:00
Higgs32584andClaude Opus 5.5 cf3d26881d Windows: start without Windows.Data.Pdf.dll; delay-load it for printing (#5884)
winprint's PdfCreateRenderer made photocraft.exe and photocraft-cli.exe
import Windows.Data.Pdf.dll at load time, so Windows images without the
PDF component (NTLite-stripped, some N/LTSC builds) refused to start:
"windows.data.pdf.dll was not found". Both apps now delay-load it, and
PDF printing checks the DLL is installed first and reports an error
instead of aborting.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 20:41:08 -04:00
skylar taylor-barrick b630d7cb9a Security: enforce the automation read boundary on stored smart-filter params (#5684)
* Security: enforce the automation read boundary on stored smart-filter params

Smart filters store a command id and params inside the document, so a
crafted .pcraft could resolve stored ambient paths (displace mapPath,
color-lookup LUT files) at render time without the authorize gate:
synchronous smart-object rendering has no Session argument, spawned job
threads drop the requesting session's authority, and a trusted UI
render could warm the process-wide stack cache with a stage the gate
never saw.

Thread the requesting session's gate through a thread-local in jobs.rs
(entered around command execution, reinstalled on spawned workers and on
job apply, restored on drop), re-check stored filter commands in
render_in before source resolution and any cache lookup and in
apply_filter_to_surface, and re-authorize layer.smartFilter.setParam
with the complete merged params after merging stored document params —
including edits whose request contains no path and actions.play steps.
Denial is conservative: the document is unchanged and no worker starts.

Regression tests in the automation crate cover rerender, cached-stage
reuse, merged-param edits, action playback, background jobs, and filter
transfer, for both the displace map and color-lookup LUT vectors.

* Review: carry render authority through Session::edit and surface denials

Session::edit is the engine's universal mutation entry (linked-file
refresh, transforms, place); renders reached through it ran with no
authorization scope installed, so stored smart-filter params read
ambiently there even after the execute-path fix. Install the scope in
Session::edit as well, keyed on the session's authorize gate.

apply_filter_to_surface also conflated a denied filter with an unknown
one: authorize_render_command's Result was discarded with .ok()?, so a
denial silently rendered unfiltered pixels and the stage entered the
process-wide stack cache. The signature becomes
Result<Option<Surface>>: Err for denials (callers propagate), None for
ids that aren't pixel filters (pass-through semantics unchanged), and an
unreadable map keeps its deliberate pass-through. apply_smart_filter and
apply_smart_filters propagate instead of cur.clone().

Two tests: a refresh driven outside command execution (Session::edit)
is denied and leaves the document unchanged; a denied render caches no
stage. Nine authorization tests pass, engine suite and strict Clippy
clean, panic hunt green.

* ui_set description: name the layerFilter field main added

The Layers-panel filter dropdown (#5048) added layerFilter to the
control method's UI_SET_FIELDS without extending the ui_set tool's
description, so the drift guard (mcp.rs, ui_set_description_names_every_
accepted_field) fails on Windows CI for any branch built on main. Name
the field with its patch shape: by (kind, name or mode), kinds (a list
of layer kinds), name (matches part of the layer name), mode (a blend
mode name). The 40 MCP tests pass; strict Clippy clean.

* wasm size: compose and algo join cms at opt-level "z" to clear the 24 MiB gate

Main's browser build is 171 KB over the 24 MiB wasm gate in CI
(25,337,343 bytes), so every branch built on current main fails the
web size check. Demote three pixel crates in the wasm-release profile:

- cms: transforms are built once and cached (transform::cached),
  so "z" costs little runtime (measured -42 KB).
- compose: the browser app composites per frame on the GPU (crates/gpu,
  WebGPU/WebGL2), so the CPU compositor is not the browser's hot path
  there (measured -122 KB).
- algo: filters run per pixel on every interactive filter application.
  "z" there is the lever of last resort (measured -331 KB), taken
  because the gate is enforced in CI; if browser filters feel too slow,
  restore 3 here and reclaim the bytes from a feature-gated decoder.

Final size 24,811,949 bytes locally, 354 KB under the gate; the native
profiles are untouched. If a maintainer prefers keeping algo at 3, the
alternative is raising the self-imposed 24 MiB early-warning limit
(Cloudflare's hard cap is 25 MiB) - a policy call in packaging/web/
package.sh, not a code fix.
2026-10-15 18:19:26 -06:00
itsF4LCON dedf89feae Type: move the caret test next to its helper, away from the file's end 2026-10-16 01:33:24 +02:00
Higgs32584andClaude Opus 5.5 8237d76170 Help search test: take the first Levels result and move the cursor to the end
"levels" now finds two results (Image › Adjustments › Levels… and the
Levels adjustment layer), and one → only reaches level|s, so the test
needs a second → before typing the space.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 19:28:58 -04:00
itsF4LCON 67602017f8 Merge remote-tracking branch 'upstream/main' into feat/6278-replace-color-result
# Conflicts:
#	crates/ui-egui/src/i18n/ko.tsv
#	crates/ui-egui/src/i18n/zh-hans.tsv
#	crates/ui-egui/src/i18n/zh-hant.tsv
2026-10-16 01:21:27 +02:00
David Mann ee0da9fbac Merge main into docs/scorecard-stale-rows-2
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

# Conflicts:
#	docs/scorecard.md
2026-10-16 01:13:28 +02:00
David Mann e6d636ce75 Merge main into feat/ui-217-4-animated-zoom
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

# Conflicts:
#	docs/scorecard.md
2026-10-16 01:02:02 +02:00
David Mann 9aa9064ce9 Merge main into feat/ui-217-4-save-in-background
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

# Conflicts:
#	docs/scorecard.md
2026-10-16 00:54:47 +02:00
David Mann d94381edf0 Merge remote-tracking branch 'origin/main' into feat/tool-214-12-shape-feather
# Conflicts:
#	docs/scorecard.md
2026-10-16 00:46:45 +02:00
David MannandClaude Opus 5.5 f4f1065785 Move tool: Ctrl-hover shows the distance to another layer (#4946)
With Smart Guides on and the Move tool, holding Ctrl (Cmd on the Mac) with no
button down and hovering another layer now draws magenta measurement lines
with labels between the selected layers' bounds and that layer's: the gap
between facing edges, or the distances between corresponding edges on an axis
where the two overlap. Over empty canvas, the Background or the selection
itself the distances run to the canvas edges. While an Alt-drag duplicates the
layers, the distance from the original to the copy shows.

Overlay only: no command, no history state. The hovered layer is the
Auto-Select hit test, cached per document revision, selection and pointer
pixel. Values use the ruler unit.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 00:46:44 +02:00
itsF4LCON a76d3295ba Type: the text caret shows on a white background (part of #6085)
In the Photoshop themes the caret was a 1.5 pt white line with a 0.75 pt
black hairline inside, so on a white background only a faint grey line
showed. It is now a solid 1 pt black line inside a 3 pt white halo, readable
on any background, in every theme.
2026-10-16 00:38:48 +02:00
Tanish Manku 2b3d05636f Follow-ups to #5801/#5859: precheck docs name the group nesting limit; precheck test covers valid ids and null; Free Transform refusals translated; stranded-backup error tested (#6365) 2026-10-15 15:32:08 -07:00
Higgs32584andClaude Opus 5.5 944d0cf722 Path Selection: the canvas right-click opens the path menu, as the Pen's does (#1056)
The Path Selection and Direct Selection tools had no canvas menu, so Make Selection, Fill and
Stroke Path, Free Transform Path, Delete Path and the combine and style rows needed the Pen tool
or the Paths panel. Like Photoshop, their right-click now opens the path menu for the selected
path, with the Pen menu's rows and the same enablement.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 18:27:56 -04:00
itsF4LCON 0752cd8365 Merge remote-tracking branch 'upstream/main' into feat/6278-replace-color-result
# Conflicts:
#	crates/ui-egui/src/i18n/ko.tsv
#	crates/ui-egui/src/i18n/zh-hans.tsv
#	crates/ui-egui/src/i18n/zh-hant.tsv
2026-10-16 00:27:03 +02:00
Tanish Manku fdaa9b8316 Free Transform: New Layer applies the transform first; tests for switching tool, layer or New Layer (#5665, #5667) (#5859)
* Tests: Free Transform applies when another tool, layer or New Layer is picked (#5665, #5667)

Whole-app regression tests for the paths #5665 and #5667 report (a moved box, and a placed image moved or untouched; toolbar tool click, Layers panel row click, New Layer). They pass on main, so these issues don't reproduce here; the tests pin Photoshop's behaviour (apply, never silently cancel) while the reporters narrow down their environment.

* New Layer while Free Transform is open applies the transform first (History: the transform, then New Layer; a placed layer's step no longer swallows New Layer); tests: a real placed fixture, the New Layer button, History order

* Free Transform + New Layer: check the command can run before applying the transform; a refused transform keeps the box and the placed layer and returns the refusal

* Free Transform + New Layer: Session::precheck validates the call (params shape, New Layer's values, precondition) before the box is applied

* Free Transform + New Layer: commit-first only for PRECHECKED commands; precheck shares dispatch's admission checks (named-layer precondition, jobs, hidden target) and checks a named layer exists; Group from Layers documents its layer param; test: precheck agrees with execute

* Free Transform + New Layer: precheck resolves a layer given by name as the command does; Layer via Copy/Cut leave PRECHECKED (their failures show only when they run); rebased (lib.rs mod lines)
2026-10-15 15:26:13 -07:00
Tanish Manku 8376363d8c Shortcuts without the UI: shell commands and tools in the engine, reserved keys, set files (#5801)
* Shortcuts without the UI: shell commands and tools in the engine, reserved keys, set files

A retro against "everything is drivable headless; the UI is a layer" found the shortcut
work leaning on the UI. Fixed:

- The shell's command table (UI_COMMANDS: View › Zoom In, the panels…) moves into the
  engine as data (shell_commands::SHELL_COMMANDS; menus::UI_COMMANDS is now an alias), with
  the tools (shell_commands::TOOLS: id, Photoshop name, default key; a test keeps it equal
  to the toolbar). edit.keyboardShortcuts binds, lists, imports, exports and checks them for
  conflicts with no UI: a headless .kys import takes tool keys, and an export is the whole set.
  Tools may share a key (Shift cycling); a command given a tool's key takes it from them.
- Reserved system keys are refused by the engine (the dialog uses the same check) and left
  out of imported sets.
- importKys / exportKys / summarize take {"path": …} to read or write files, like
  actions.save; the automation gate refuses those paths for MCP.

* Keyboard shortcuts: bindings commit only after their exports are written (a bad target changes nothing); docs: the command knows the tools

* Keyboard shortcuts: both export targets are resolved and checked before either file is written; a failed later write removes the earlier one

* docs: reserved keys per platform

* Keyboard shortcuts export: files are staged next to their targets and moved into place only when all are written; a failure leaves the user's files untouched

* Keyboard shortcut exports all or nothing: unique create_new staging and backups beside each target, restored on any failure; the same target twice refused; docs: Ctrl+Shift+Esc

* Shortcut exports: a failed backup move removes its placeholder; undo restores backups without deleting first and names any it couldn't; read-only targets refused up front
2026-10-15 15:26:09 -07:00
Tanish Manku b09aeae2e2 Low-Latency Drawing preference (vsync off, opt-in), plus present-mode overrides for testers (#1343) (#5274)
* PHOTOCRAFT_PRESENT_MODE / PHOTOCRAFT_FRAME_LATENCY launch overrides for latency A/B tests (#1343)

Linux traces put the remaining stroke latency in presentation queueing (the app's frame fits
in one refresh; the rest waits on the swap chain), and an NVIDIA user cured it by forcing
vsync off. Testers can now compare without rebuilding: PHOTOCRAFT_PRESENT_MODE (fifo,
mailbox, immediate, autovsync, autonovsync) and PHOTOCRAFT_FRAME_LATENCY (1-3), applied over
the platform default. Bad values are logged and ignored. The active mode is logged at start,
kept in GpuInfo.present and shown in every stroke trace line.

RTX 5090, Vulkan, 120 Hz: default autovsync/latency 1 waits 6 ms per 8.3 ms frame; mailbox
renders every 2.3 ms with 0.2 ms wait, so each vsync shows the newest frame.

* docs: present mode and frame latency overrides

* Present mode overrides never request a mode the driver may lack (Mailbox crashed on X11/NVIDIA)

* Preferences › Performance › Low-Latency Drawing, on by default: no vsync wait while drawing (#1343)

Testers' traces put the ~50 ms stroke lag in the swap chain's vsync wait. With vsync off it
disappears (X11/NVIDIA; Windows/NVIDIA via the driver), and a shorter queue doesn't help. The
surface now presents with AutoNoVsync by default: wgpu picks Immediate, else Mailbox, else
Fifo by what the driver offers, so no unsupported mode is ever requested. Turning the
preference off brings vsync back (smooth, but late). It applies at the next launch, like the
other graphics settings. Where only Immediate exists the canvas may tear while it changes;
frames render as fast as input arrives during a stroke and stop when idle.

* Runtime and launch controls: ui.set strokeTrace, --stroke-trace, --present-mode, --frame-latency

The retro's remaining gap: the stroke trace and the presentation overrides were env-only. The trace now switches at runtime over the control channel (ui.set {"strokeTrace": true|false|"stderr"|path}) and with --stroke-trace; --present-mode / --frame-latency sit beside their env vars and win over them. Bad values are reported and ignored; none fails a launch.

* docs: strokeTrace beside the other ui.set fields

* docs: regenerate the scorecard after rebasing onto main

* Low-Latency Drawing: frames capped at 240 fps without vsync (after #4472); ui.set strokeTrace takes on/off only (a CSV path is launch-only); tearing documented as usual (Immediate); pref hidden on web; doc comment order

* stroke_trace: from_value doc says ui.set passes only on or off

* Low-Latency Drawing ships off (opt-in), per the owner's decision

* docs: present mode default is vsync (Low-Latency Drawing ships off)
2026-10-15 15:23:47 -07:00
itsF4LCON c04da11bb3 Smart filters: rustfmt 2026-10-16 00:16:06 +02:00
itsF4LCON 8176b613ca Smart filters: the dialog's preview window shows the edited filter too (#6324)
The filter dialog's own preview window matched the canvas preview by the
filter's command, but an edited smart filter previews as
layer.smartFilter.setParams, so the window kept the unfiltered image.
smart_ui::preview_command names the command the preview runs.
2026-10-16 00:15:50 +02:00
itsF4LCON 8369b4b55c File › Open: a file type filter per format (#6337)
After All Formats the Open dialog listed only PhotoCraft, OpenRaster, Krita,
SVG and Paint.NET. Every format it reads now has its own filter (Photoshop,
Affinity, HEIF, PNG, JPEG, TIFF, Portable Bitmap, OpenEXR, Radiance HDR,
camera raw, …), and together they cover All Formats exactly.
2026-10-16 00:04:50 +02:00
itsF4LCON f71eb28f84 Replace Color: a Result swatch whose Color Picker sets the sliders (#6278)
Photoshop's Replace Color shows the replacement colour under the Hue,
Saturation and Lightness sliders; picking one in its Color Picker sets the
sliders so the sampled colour becomes it. tone::replace_color_result gives
the swatch (the sampled colour after the shift) and tone::replace_color_shift
the slider values for a picked colour.
2026-10-15 23:55:58 +02:00
David Mann bbd5ed2205 Merge remote-tracking branch 'origin/main' into fix/5559-bitmap-dialog
# Conflicts:
#	crates/ui-egui/src/i18n/cs.tsv
#	crates/ui-egui/src/i18n/de.tsv
#	crates/ui-egui/src/i18n/el.tsv
#	crates/ui-egui/src/i18n/es.tsv
#	crates/ui-egui/src/i18n/fr.tsv
#	crates/ui-egui/src/i18n/hu.tsv
#	crates/ui-egui/src/i18n/id.tsv
#	crates/ui-egui/src/i18n/it.tsv
#	crates/ui-egui/src/i18n/ja.tsv
#	crates/ui-egui/src/i18n/ko.tsv
#	crates/ui-egui/src/i18n/nl.tsv
#	crates/ui-egui/src/i18n/pl.tsv
#	crates/ui-egui/src/i18n/pt-br.tsv
#	crates/ui-egui/src/i18n/ru.tsv
#	crates/ui-egui/src/i18n/sr.tsv
#	crates/ui-egui/src/i18n/tr.tsv
#	crates/ui-egui/src/i18n/uk.tsv
#	crates/ui-egui/src/i18n/vi.tsv
#	crates/ui-egui/src/i18n/zh-hans.tsv
#	crates/ui-egui/src/i18n/zh-hant.tsv
2026-10-15 23:52:42 +02:00
David Mann ce19e018b9 Merge remote-tracking branch 'origin/main' into feat/tool-214-12-shape-feather
# Conflicts:
#	docs/ui-parity.md
2026-10-15 23:52:29 +02:00
itsF4LCON 0504331c51 Smart filters: an edited filter's dialog previews again (#6324)
Re-opening a smart filter dropped the dialog's Preview, since the live
preview ran the filter command and would have stacked a second copy. The
preview now runs layer.smartFilter.setParams on the proxy, as OK does, at
full size and reduced afterwards (the smart object renders again from its
source, which the proxy doesn't scale), so it shows the filter replaced in
its stack.
2026-10-15 23:46:54 +02:00
jonathnwxh f4842a04e8 Merge upstream multi-layer blending and preserve viewport refresh limits 2026-10-16 05:44:29 +08:00
itsF4LCON 4258089716 Replace Color: the sample eyedropper is armed when the dialog opens (#6279)
Photoshop's first click on the image after opening Replace Color samples the
colour. The dialog now opens (and resets) with its canvas eyedropper active;
the pipette button still turns it off and on.
2026-10-15 23:38:01 +02:00
Higgs32584andClaude Opus 5.5 a008842a85 Help › Search: ← / → and Space edit the field instead of driving the menu
While a text field in an open menu has the keyboard, Nav::keys leaves
← / → (cursor) and Space (a typed space) to it: ← / → no longer switch
Help to Window / File, and Space no longer runs the lit result. ↑ / ↓,
↩ and Esc are unchanged. Follow-up to #5996 / #6030 (letters).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 17:32:01 -04:00
David MannandClaude Opus 5.5 b43be75264 PSD: Wind, Extrude and Tiles smart filters open and save like Photoshop (#6314)
Wind, Extrude and Tiles applied as smart filters were dropped from PSD
saves with a warning, and the same filters in a Photoshop PSD opened only
as verbatim, uneditable entries. smart_map.rs now maps them both ways in
filterFX:

- Wind (`Wnd `, filterID 1466852384): `WndM` enum (`Wnd `/`Blst`/`Stgr`)
  <-> method; `Drct` enum: `Left` <-> fromRight (streaks extend to the
  left), `Rght` <-> fromLeft.
- Extrude (`Extr`, 1165522034): `ExtT` enum (`Blks`/`Pyrm`) <-> type,
  `ExtS`/`ExtD` long <-> size/depth, `ExtR` enum (`Rndm`/`LvlB`) <->
  depthMode, `ExtF`/`ExtM` bool <-> solidFrontFaces/maskIncompleteBlocks.
- Tiles (`Tls `, 1416393504): `TlNm`/`TlOf` long <-> count/maxOffset,
  `FlCl` enum (`FlBc`/`FlFr`/`FlIn`/`FlSm`) <-> fill; the entry's
  `FrgC`/`BckC` <-> foreground/background.

Our random seed has no PSD key and is not written. Missing keys, wrong
value types, out-of-range numbers and unknown enum values keep the entry
verbatim; params with no PSD equivalent are reported, never guessed.

Measured in Photoshop: applied through Action Manager to a smart object,
saved, filterFX read back; each code also checked on a test image (pixels
compared). Scorecard FILE-215-2 note and file-format parity updated.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 23:30:11 +02:00
David MannandClaude Opus 5.5 badfd6df70 PSD: Mezzotint and Color Halftone smart filters open and save like Photoshop (#6316)
Mezzotint and Color Halftone applied as smart filters were dropped from PSD
saves with a warning, and the same filters in a Photoshop PSD opened as
unsupported (verbatim) smart filters. smart_map.rs now maps them both ways:

- Mezzotint: filterID/class `Mztn`, `MztT` enum (FnDt, MdmD, GrnD, CrsD,
  ShrL, MdmL, LngL, ShSt, MdmS, LngS) <-> `type` (fineDots .. longStrokes).
  PhotoCraft's `seed` has no Photoshop equivalent and is not saved.
- Color Halftone: filterID/class `ClrH`, `Rds ` and `Ang1`-`Ang4` longs <->
  `maxRadius`, `channel1`-`channel4`, written within the dialog ranges
  (4..127, -360..360).

Unknown enum codes, wrong value types and out-of-range values are kept
verbatim as unsupported filters, never a panic.

Measured in Photoshop: applied through Action Manager to a smart object,
saved, filterFX read back; codes confirmed by rendering each option.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 23:27:41 +02:00
David MannandClaude Opus 5.5 96bff5d7b6 PSD: Smart Sharpen and Dust & Scratches smart filters open and save like Photoshop (#6300)
Smart Sharpen (filter.sharpen.smartSharpen) and Dust & Scratches
(filter.noise.dustAndScratches) smart filters are now mapped both ways in
the PSD filterFX list instead of being dropped on save and opening as
uneditable entries.

- Smart Sharpen: filterID 1131 (numeric), stringID class smartSharpen;
  useLegacy bool, Amnt UntF #Prc, Rds UntF #Pxl, noiseReduction UntF #Prc,
  blur blurType enum. PhotoCraft removes a Gaussian blur, so it writes
  blur GsnB with useLegacy false. Entries with Lens Blur (Photoshop's
  default), Motion Blur (with Angl), legacy mode, any other key, wrong
  types/units or out-of-range values stay verbatim. presetKind
  (presetKindType) is accepted on open but not kept or written: the
  engine takes no such parameter, and Photoshop leaves it out of entries
  with explicit values.
- Dust & Scratches: filterID / class DstS; Rds and Thsh long (radius
  1..500, threshold 0..255; PhotoCraft params are rounded on write).

Measured in Photoshop: applied through Action Manager to a smart object,
saved, filterFX read back (Smart Sharpen default / Gaussian / Motion,
Dust & Scratches 1,0 and 4,20). Tests cover the measured shapes, option
round trips, malformed descriptors kept verbatim and hostile params.
Scorecard FILE-215-2 note and file-format parity doc updated.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 22:54:43 +02:00
David MannandClaude Opus 5.5 a99ad58591 PSD: Diffuse, Trace Contour, Crystallize and Pointillize smart filters open and save like Photoshop (#6302)
smart_map.rs maps four more filters both ways in filterFX:
- Diffuse: filterID `Dfs `, Fltr `Dfs ` { Md = DfsM enum Nrml / DrkO / LghO /
  anisotropic (long id) } <-> filter.stylize.diffuse `mode`.
- Trace Contour: `TrcC` { Lvl long 0-255, Edg = CntE enum Upr / Lwr }
  <-> filter.stylize.traceContour `level`, `edge`.
- Crystallize: `Crst` { ClSz long 3-300 } <-> filter.pixelate.crystallize.
- Pointillize: `Pntl` { ClSz long 3-300 } plus the entry's BckC as the
  `background` colour <-> filter.pixelate.pointillize.

Measured in Photoshop: applied through Action Manager to a smart object,
saved, filterFX read back. Our random `seed` has no PSD key and is not
written. Missing keys, wrong value types, out-of-range numbers and unknown
enum values keep the entry verbatim; values with no PSD equivalent on save
are reported instead of guessed.

Scorecard FILE-215-2 note and docs/file-format-parity.md updated (stays
partial).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 22:48:16 +02:00
David MannandClaude Opus 5.5 7d36b417e5 PSD: parameterless smart filters (Blur, Sharpen, Find Edges, Solarize…) open and save like Photoshop (#6296)
Blur, Blur More, Average, Despeckle, Find Edges, Facet, Fragment, Sharpen,
Sharpen More, Sharpen Edges and Solarize now map both ways in a smart
object's filterFX. Photoshop stores these filters with hasoptions false, no
Fltr descriptor and only the four-char filterID as a long (Blr , BlrM, Avrg,
Dspc, FndE, Fct , Frgm, Shrp, ShrM, ShrE, Slrz). PSD saves write exactly that
item (Nm, blendOptions, enab, hasoptions, FrgC, BckC, filterID) instead of
dropping the filter with a warning, and Photoshop's entries open as the
editable PhotoCraft filter instead of a verbatim pass-through. An entry that
carries a Fltr with settings, or an unknown or mistyped filterID, is still
kept verbatim.

Measured in Photoshop: applied through Action Manager to a smart object,
saved, filterFX read back.

Scorecard FILE-215-2 note and docs/file-format-parity.md updated (25 filters
mapped).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 22:46:47 +02:00
David MannandClaude Opus 5.5 ef4e921408 PSD: Surface Blur, Smart Blur and Radial Blur smart filters open and save like Photoshop (#6298)
Map three more smart filters both ways in filterFX (smart_map.rs):

- Surface Blur: filterID 1135 (numeric), Fltr class `surfaceBlur`,
  `Rds ` UnitFloat #Pxl -> radius, `Thsh` long -> threshold.
- Smart Blur: filterID `SmrB`, `Rds ` / `Thsh` doubles, `SmBQ` enum
  (SBQL/SBQM/SBQH -> low/medium/high), `SmBM` enum
  (SBMN/SBME/SBMO -> normal/edgeOnly/overlayEdge).
- Radial Blur: filterID `RdlB`, `Amnt` long, `BlrM` enum (Spn/Zm ->
  spin/zoom), `BlrQ` enum (Drft/Gd/Bst -> draft/good/best).

Measured in Photoshop: applied through Action Manager to a smart object,
saved, filterFX read back. Every measured Fltr descriptor is written back
byte for byte (checked against the measured files locally; they are not
committed). Entries with missing keys, wrong types, out-of-range or NaN
values, unknown enum values or keys we don't model stay verbatim, as does
Radial Blur at its defaults (no Fltr stored). An off-centre Radial Blur
centre and unknown enum names are reported on save, not guessed.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 22:46:42 +02:00
David MannandClaude Opus 5.5 d089908158 PSD: Offset and Custom smart filters open and save like Photoshop (#6304)
Offset (filter.other.offset) and Custom (filter.other.custom) applied as
smart filters were left out of PSD saves with a warning, and the same
filters in a Photoshop PSD opened only as verbatim, non-editable entries.
smart_map.rs now maps both ways in filterFX:

- Offset: filterID/class Ofst; Hrzn and Vrtc as long (signed), Fl  as the
  FlMd enum: Wrp  = wrap, Rpt  = repeat, Bckg = transparent ("Set to
  Transparent" on a layer). Unknown enum values, wrong types or missing
  keys keep the entry verbatim.
- Custom: filterID/class Cstm; Scl  and Ofst as long, Mtrx as 25 longs,
  row-major 5 x 5. Written as the engine renders it (no kernel = identity,
  missing cells 0, values clamped to -999..999, 1..9999, -9999..9999);
  fractional values have no PSD equivalent and are reported. On open,
  values outside Photoshop's ranges or a matrix that is not 25 longs keep
  the entry verbatim.

Measured in Photoshop: applied through Action Manager to a smart object,
saved, filterFX read back (Offset Wrap 0/0, Set to Background 12/-7,
Repeat 5/3; Custom default sharpen and a blur at Scale 2, Offset 10).
The measured files open as the editable filters and survive export and
re-import unchanged (checked locally, not committed).

Scorecard FILE-215-2 note and the file-format parity doc list the two
filters (still partial).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 22:45:46 +02:00
vangel76andClaude Opus 5.5 49438e71d2 Merge upstream/main into fix/5645-type-edit-keeps-subscript
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 22:36:12 +02:00
David Mann c84886adc2 WIP: PSD: Offset and Custom smart filters open and save like Photoshop (#6304) 2026-10-15 22:22:00 +02:00
David Mann 2058f2e180 Merge remote-tracking branch 'origin/main' into feat/ui-217-4-animated-zoom 2026-10-15 22:12:00 +02:00
David MannandClaude Opus 5.5 7f0e7cbee0 Zoom tool: holding the button zooms continuously with Animated Zoom on (#6210)
With Preferences > General > Animated Zoom on (the default), a Zoom tool
press held still past 0.3 s zooms continuously about the press point
(2 doublings a second, frame-rate independent, within the zoom_levels
range) until release; Alt zooms out. The release is then no click step.
A quick click still steps, Scrubby Zoom and the zoom rectangle are
unchanged, and with the preference off a hold is one click step.

The hold logic lives in the new zoom_hold.rs; canvas.rs only calls it.
general.animatedZoom leaves HIDDEN_UNTIL_IMPLEMENTED; prefs that do
nothing 25 of 166.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 22:11:45 +02:00
Muhammad Saad a4cdc63d88 Retouch: admit the rendered stroke extent, not an estimate (#4961, #6110)
Review of f116c32 was right that the guard did not establish the bound it
advertised. reject_bounds ran on the value stroke_coverage() returned, and that
function calls dense_coverage() internally, so the dense Vec was already
allocated by the time it was checked. reject_huge_stroke, the only check that
ran first, estimated from the raw points and half the tip, which brush dynamics
exceed: Scattering offsets a dab centre by its amount times the tip size.

Split rendering from dense extraction. stroke_coverage_within renders the stroke
into the renderer's tiles, which hold only what the dabs touch, reads the extent
once every dab is placed and refuses before allocating the width x height
buffer. retouch_cmds::coverage wraps it at MAX_STROKE_PIXELS and every stroke
command goes through it, so the bound now holds whatever the dynamics do.
reject_bounds is removed; it could not do the job it was named for.

spot_heal_surface returned a bare Rect and so had nowhere to refuse; it returns
Result<Rect> now. reject_huge_stroke stays as the cheap early-out and pads by
the scatter reach and the dual-brush radius so it no longer falls short.

The allowance is unchanged, so no stroke that worked before starts failing.

Tests: coverage_within takes the budget as a parameter, and a scattering brush
proves the rendered extent exceeds what the points alone suggest, is refused one
pixel under it and fits at it. Every command's refusal now asserts the pixels
and the history step count are untouched, and the ordinary stroke uses a valid
fixture and must succeed rather than merely avoid the size error.
2026-10-15 23:50:55 +04:00
David Mann 22f1d694c9 Merge remote-tracking branch 'origin/main' into feat/ui-217-4-animated-zoom 2026-10-15 21:35:40 +02:00
David MannandClaude Opus 5.5 e8f50f69c3 scorecard: TOOL-214-4 note and the pasteboard menu row catch up with main
Dragging layers onto another document's tab landed in #851 (#589), and the
pasteboard colour menu in #2633 (#2462); the TOOL-214-4 note and the
context-menu parity row still listed both as open.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 21:17:33 +02:00
David Mann 0aed96c7fd Merge remote-tracking branch 'origin/main' into docs/scorecard-stale-rows-2
# Conflicts:
#	docs/ui-parity.md
2026-10-15 21:17:33 +02:00
artman67andClaude Opus 5.5 c4de02978b Menu bar: releasing Alt after Alt+scroll no longer focuses the bar, so Space can't run File > New
Releasing Alt counted as a tap (keyboard focus to the menu bar, File lit) unless a key, text or
pointer-button press came in between. A mouse-wheel event was not on that list, so zooming with
Alt+wheel and letting go of Alt focused the bar; Space then opened File with New... lit, and a held
Space's key repeat ran it. A wheel event, or the window losing focus while Alt is down (Alt went
to a desktop shortcut), now cancels the tap.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:14:15 -04:00
jonathnwxh 0caa7ed54a Merge upstream CI ordering while retaining clipboard regression jobs 2026-10-16 01:53:19 +08:00
David MannandClaude Opus 5.5 a1837706ee Performance: a cancelled Gaussian Blur job stops within budget (#6205)
Cancelling a 24 MP Gaussian Blur r 50 job took up to ~400 ms (P25, budget
200 ms max). Stage timings showed two stretches without a cancel check:

- The transparency-lock pass (keep_alpha) that puts a Background layer's
  alpha back after the filter ran serially over every changed tile, about
  400-450 ms on 24 MP, with no cancel check. It now fixes the tiles in
  parallel, each in its own scratch surface, and checks for cancellation
  before each tile. Results are bit-identical to the serial pass (tested
  against it at 8, 16 and 32-bit).
- Box-blur column strips checked only before each strip; with ~31 strips
  per band and 32 threads one strip is tens of ms. line_passes now checks
  between passes.

P25 max: 337-399 ms -> 13-44 ms over five runs. The uncancelled job is
also faster: p50 about 690 -> 400 ms.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 19:17:07 +02:00
Nigel Ihza Firdausy 9a25a8a390 style(svg): format expanded conversion state initializer 2026-10-16 00:14:38 +07:00
Nigel Ihza Firdausy 159cd42ae9 fix(svg): honor U8 storage budget and avoid charging rejected layers
Preflight oversized pixmaps and conversion peak before allocating.
Only charge the U8 retained-surface allowance after render succeeds.
Clarify the warning and cover later small raster groups with regressions.
Merge upstream main to refresh the pull request branch.
2026-10-16 00:13:40 +07:00
David MannandClaude Opus 5.5 6f1984d126 Tools: each painting tool's brush persists across launches; restored tool options are clamped (#6212)
Tool options and the selected tool were already remembered (tool_memory, #2814), but
each painting tool's own brush (UiState.tool_brushes) was lost at quit, and the tool that
owned the session brush restarted as the Brush, so after quitting with the Eraser its
brush became the Brush's.

- Preferences.toolBrushes {owner, brushes}: saved change-driven when the pointer is up and
  on exit, restored at launch with stored tips as references (loaded on swap).
- brush_cmds::remembered_settings / restored_settings: shared with lastBrush; restored
  brushes pass the tools.setBrush checks and are clamped (size, hardness, opacity, flow,
  smoothing).
- tool_memory::sanitize clamps restored tool options to their options-bar ranges.
- Reset Tool / Reset All Tools clear the persisted state through the same path.
- Scorecard UI-217-1 note updated (stays partial: font recents).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 18:59:22 +02:00
David MannandClaude Opus 5.5 a3cbc880e6 Preferences: Save in Background off saves in the foreground (#6207)
Preferences > File Handling > Save in Background was never read. Saves
already run as a background job; the setting now picks how the app
waits for it, as in Photoshop:

- On (default): you keep working while the file is written; progress
  shows in the document tab and the status bar. The modal progress
  dialog no longer appears for these saves.
- Off: the same job, worker and atomic write, but the modal progress
  dialog shows at once and no shortcut reaches the app until the file
  is complete; a close or quit asked for meanwhile waits for it.

The setting now shows in Edit > Preferences. Prefs audit: settings that
do nothing 26 -> 25 of 166.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 18:48:58 +02:00
David Mann 2a31c28c80 WIP: Zoom tool: hold to zoom continuously with Animated Zoom (#6210) 2026-10-15 18:12:32 +02:00
jonathnwxh ce0ba03572 Use the actual pixel grid Show preference in regression 2026-10-15 23:56:30 +08:00
jonathnwxh 173e7fd6bd Test successful Object Selection restoring hidden Extras 2026-10-15 23:51:18 +08:00
jonathnwxh 2cea742a00 Cover remaining interactive selection routes and preserve no-op hiding 2026-10-15 23:50:21 +08:00
jonathnwxh 6fc1004b50 Cover Extras restoration through actual layer thumbnail clicks 2026-10-15 23:47:34 +08:00
jonathnwxh 6bef23a715 Reveal Extras when interactive selection commands succeed 2026-10-15 23:45:34 +08:00
ersoynarin-code 7d42b5c838 fix(perf): validate regression minimum threshold 2026-10-15 18:38:34 +03:00
jonathnwxh 67a2938cab Guard intentional hidden selections against failures and unrelated edits 2026-10-15 23:32:18 +08:00
jonathnwxh 0a608e63db Cover marquee pointer gesture and subsequent explicit hide 2026-10-15 23:31:23 +08:00
jonathnwxh 14ceeb927e Reproduce new selections remaining hidden after Extras is disabled 2026-10-15 23:27:15 +08:00
David MannandClaude Opus 5.5 24a77a18c0 Shapes: a soft shape's transform frame follows its path (#5846)
A shape below 100 % Density renders over the whole canvas, and a Feather
grows its pixels, so Free Transform, the Move tool's transform controls,
snapping and Properties > Transform framed the canvas or the blur. As in
Photoshop the frame now follows the path and stroke whatever the mask
Density and Feather: photocraft_compose::hard_shape_bounds (shape_bounds
without the soft reach) feeds whole_layer_bounds and the Properties
Transform fields. Rendering keeps the soft bounds; other layers and hard
shapes are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 17:08:05 +02:00
David MannandClaude Opus 5.5 442987528f Merge main into fix/4017-huge-new-document
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 16:52:49 +02:00
David MannandClaude Opus 5.5 eeb955649b Merge main into perf/p33-cmyk-dab
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 16:52:46 +02:00
David MannandClaude Opus 5.5 275576543f Merge main into feat/tool-213-7-live-retouch
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 16:38:01 +02:00
David MannandClaude Opus 5.5 8c184c78d5 Merge main into fix/smart-filter-last-mask
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 16:30:58 +02:00
David MannandClaude Opus 5.5 031e0de625 Merge main into feat/file-215-18-export-resample
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 16:29:28 +02:00
David MannandClaude Opus 5.5 c7bd182e91 Merge main into fix/lens-filters-smart-locks
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 16:29:14 +02:00
David MannandClaude Opus 5.5 5b0d7263e6 Merge main into fix/count-delete-group-active
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 16:25:43 +02:00
David MannandClaude Opus 5.5 9203a6857a Merge main into fix/adjust-smart-object-locks
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 16:23:49 +02:00
David MannandClaude Opus 5.5 01184b7083 Merge main into fix/video-fps-validate
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 16:20:48 +02:00
David MannandClaude Opus 5.5 674340230c Merge main into feat/file-216-2-large-fixtures
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 16:16:28 +02:00
jonathnwxh e0ddefe7e0 test(text): retain raw TySh as the document importer does 2026-10-15 22:07:31 +08:00
jonathnwxh 2656f8db3d fix(text): preserve imported font records during text and style edits 2026-10-15 22:04:27 +08:00
jonathnwxh 70ffad5a56 test(text): retain imported font metadata after text edits 2026-10-15 22:01:09 +08:00
David MannandClaude Opus 5.5 daaeeda7c7 Merge main into docs/scorecard-stale-rows-2
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 16:00:11 +02:00
David Mann ecaa81b42a Merge branch 'main' into perf/p33-cmyk-dab 2026-10-15 15:57:57 +02:00
David Mann e240016fe4 Merge branch 'main' into fix/smart-filter-last-mask 2026-10-15 15:56:58 +02:00
David Mann 8b5ddbba91 Merge branch 'main' into fix/lens-filters-smart-locks 2026-10-15 15:56:52 +02:00
David Mann 3182414717 Merge branch 'main' into fix/count-delete-group-active 2026-10-15 15:55:59 +02:00
David Mann 6717d228a1 Merge branch 'main' into fix/adjust-smart-object-locks 2026-10-15 15:55:47 +02:00
David Mann ff26781bab Merge branch 'main' into fix/video-fps-validate 2026-10-15 15:55:38 +02:00
David Mann b693494237 Merge branch 'main' into fix/mask-from-transparency-bg-depth 2026-10-15 15:55:29 +02:00
David Mann 9fa9f86c48 Merge branch 'main' into fix/fill-layer-angle-finite 2026-10-15 15:55:12 +02:00
David Mann 8c524c9295 Merge branch 'main' into fix/lock-transparency-atop 2026-10-15 15:54:57 +02:00
David Mann 830676cc7d Merge branch 'main' into fix/multichannel-alpha 2026-10-15 15:54:46 +02:00
jonathanwxh-cell 6b3a6ffd8b Document conservative blend preview coverage
Explain why clip_damage replaces the covered intersection, including an
empty one, while retaining the affected region for uncovered to recompute.
This is a comment-only follow-up to review on storytold/photocraft#5413.

Existing controlled 24 MP release measurement: median refresh/upload-
submission time was 734.677 ms for the full 6000x4000 region and 59.641 ms
for a 1280x720 viewport, five measured samples per region after warmup.
This compares regions in the same candidate binary on CPU fallback with
Vulkan llvmpipe, not two revisions or physical-GPU completion latency.
Evidence: https://github.com/jonathanwxh-cell/photocraft/actions/runs/38512473874
2026-10-15 21:52:28 +08:00
Michael Higgins 310c489996 Merge branch 'storytold:main' into fix/5799-batch-same-format-raw 2026-10-15 09:39:09 -04:00
Michael Higgins 0c959f10d4 Merge branch 'storytold:main' into fix/5713-smoothing-pressure-follows-brush 2026-10-15 09:37:28 -04:00
David MannandClaude Opus 5.5 2ab37e7e55 Scorecard: regenerate after merging main (TOOL-214-12 done, #5846)
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 14:06:00 +02:00
David MannandClaude Opus 5.5 8435a4586f History Brush source: snapshot sources, size check, purge and budget fixes
- Edit > Purge > Histories resets a state source to the default instead of
  leaving every document without one (History::clear).
- The pinned source state counts against the history byte budget first; the
  newest states fill what remains, so trimming stays within max_bytes.
- The History Brush and Fill > History refuse a source of another canvas size
  (after Crop, Canvas Size or Image Size) instead of painting shifted pixels.
- Snapshots can be the source: history.setSource {snapshot}, a source column on
  snapshot rows, HistorySource::State|Snapshot in the engine; deleting the
  snapshot leaves no source, Purge > Histories keeps it.
- paint.historyBrush and Fill > History take History panel rows for `state`
  (redo rows included), as history.setSource does, and accept `snapshot`.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 13:58:10 +02:00
David MannandClaude Opus 5.5 e23840f516 History panel menu: review fixes (floating steps, cleared-state memory, clean undo, New Snapshot)
- Step Backward in the History panel puts a floating selection back like Edit > Undo; Step
  Forward and Edit > Redo put it back and step instead of dropping it first (which recorded a
  Move and wiped the redo states); with nothing to step to the piece keeps floating.
- Memory figures and the byte budget count the states Clear History keeps for its undo.
- Undoing Clear History keeps a saved document clean.
- New Snapshot... in the panel menu runs history.newSnapshot.
- Tests: Clear on one document leaves the others alone, and each fix above.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 13:57:54 +02:00
Muhammad Saad f116c3230a Retouch: guard every stroke command, not only Pattern Stamp (#6110)
Pattern Stamp was the only retouch command that refused a stroke it could not
render. Clone Stamp, Healing Brush, Spot Healing and Remove all reach the same
dense conversion: coverage is accumulated in tiles, but dense_coverage()
materialises the whole bounding box, and the retouch paths read a Region over
that box for every channel. The box comes from the stroke's own points, which
are validated against MAX_COORD rather than the document, so it is not bounded
by the canvas. Both allocations go through the infallible vec!, which aborts
instead of returning an error.

With the guards now shared in retouch_cmds, apply them to the rest:
reject_huge_stroke before anything is rendered, and reject_bounds before the
dense region read where the command can return one. spot_heal_surface returns a
Rect, so Spot Healing charges the stroke in the command; Remove builds a list,
so each stroke is charged as it is parsed.

The allowance is unchanged, so no stroke that worked before starts failing.

Test: every retouch stroke refuses the oversized box, leaves the pixels alone,
and still accepts a stroke across the whole canvas.
2026-10-15 15:47:38 +04:00
David Mann ce5af000f2 Merge remote-tracking branch 'origin/main' into fix/history-panel-menu 2026-10-15 13:03:07 +02:00
David Mann 2bc49845a7 Merge remote-tracking branch 'origin/main' into fix/history-brush-source 2026-10-15 13:03:04 +02:00
itsF4LCON d065c874e5 Merge remote-tracking branch 'upstream/main' into fix/5996-help-search-mnemonics 2026-10-15 12:58:26 +02:00
David Mann 43d8572c9f Merge remote-tracking branch 'origin/main' into feat/tool-214-12-shape-feather
# Conflicts:
#	docs/scorecard.md
#	docs/ui-parity.md
2026-10-15 12:12:20 +02:00
David Mann 9e532faa3a Merge remote-tracking branch 'origin/main' into fix/5559-bitmap-dialog
# Conflicts:
#	crates/ui-egui/src/i18n/cs.tsv
#	crates/ui-egui/src/i18n/de.tsv
#	crates/ui-egui/src/i18n/el.tsv
#	crates/ui-egui/src/i18n/es.tsv
#	crates/ui-egui/src/i18n/fr.tsv
#	crates/ui-egui/src/i18n/hu.tsv
#	crates/ui-egui/src/i18n/id.tsv
#	crates/ui-egui/src/i18n/it.tsv
#	crates/ui-egui/src/i18n/ja.tsv
#	crates/ui-egui/src/i18n/ko.tsv
#	crates/ui-egui/src/i18n/nl.tsv
#	crates/ui-egui/src/i18n/pl.tsv
#	crates/ui-egui/src/i18n/pt-br.tsv
#	crates/ui-egui/src/i18n/ru.tsv
#	crates/ui-egui/src/i18n/sr.tsv
#	crates/ui-egui/src/i18n/tr.tsv
#	crates/ui-egui/src/i18n/uk.tsv
#	crates/ui-egui/src/i18n/vi.tsv
#	crates/ui-egui/src/i18n/zh-hans.tsv
#	crates/ui-egui/src/i18n/zh-hant.tsv
2026-10-15 11:55:20 +02:00
David MannandClaude Opus 5.5 24a86a3f6f Image: Bitmap dialog with Output resolution and per-method settings (#5559)
Image > Mode > Bitmap now opens a dialog laid out like Photoshop's:
Resolution (Input, read-only; Output in pixels/inch or pixels/centimeter),
Method (50% Threshold, Pattern Dither, Diffusion Dither, Halftone Screen,
Custom Pattern) with a pattern picker enabled for Custom Pattern, and the
Halftone Screen settings (Frequency, Angle, Shape) shown only for that method.

The image.mode.bitmap command gains `resolution` (resamples to the Output
resolution keeping the print size, then sets it) and the `customPattern`
method (threshold map ranked from the pattern's lightness, tiled from the
top-left). The halftone screen is laid out at the output resolution. The
live preview keeps the canvas size.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 11:54:35 +02:00
David MannandClaude Opus 5.5 869b2556d3 io: drop the Photoshop version from the gradient opacity comments (#1954)
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 11:49:50 +02:00
David Mann 09f782b1ff Merge remote-tracking branch 'origin/main' into fix/1954-overlay-opacity-stops 2026-10-15 11:37:40 +02:00
Finn Newick 093c6836c8 Merge remote-tracking branch 'origin/main' into refactor/engine-drop-note-insert 2026-10-15 10:37:03 +01:00
jonathnwxh e338761de9 Merge upstream CI test and web packaging fixes 2026-10-15 17:04:54 +08:00
Persi 5ad0b92aac Merge branch 'main' into psd-background-opaque-check 2026-10-15 10:09:43 +02:00
Brian TorresandClaude Sonnet 5.5 827e4e7094 Codecs: cap what tiff_exif::read copies from overlapping EXIF values (#5982)
An entry's value only has to lie inside the file, so many Exif entries can
point at the same bytes: a 62 KB TIFF with 1,000 such entries made the reader
copy 50 MB (and the block writer copy it again), on every TIFF import and on
export of a document carrying an EXIF block.

read() now has a copy budget shared by IFD0's descriptive tags and the GPS and
Exif directories: at most 4 MiB, and never more than the stream's own length,
since the values of a well-formed stream are distinct bytes of it. An entry
that does not fit is left out and later, smaller ones are still kept; the
small directories are read first so a huge maker note cannot crowd out the
GPS position. Maker notes of 200 KB and 1 MB round-trip as before.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Rj8vs4QyUES3uwgX6SuGGh
2026-10-15 01:01:37 -07:00
Nigel Ihza Firdausy 35b4b30f2d style(path): keep regression cases consistently spaced 2026-10-15 15:00:44 +07:00
Nigel Ihza Firdausy caceb01dda test(path): cover invalid Make Selection params and open-path modes 2026-10-15 14:59:18 +07:00
Nigel Ihza Firdausy ec2627dbbb fix(path): validate Make Selection parameters before rasterization 2026-10-15 14:57:59 +07:00
itsF4LCON 116a607697 Window › Arrange: tiles keep their documents, and every tile has rulers (#6017)
With documents tiled side by side, a click into the other one moved it
to the first tile, swapping the two, and only the active tile showed
rulers. Each document now keeps its tile (with more documents than
tiles, the shown run slides just enough to include the active one), and
inactive tiles and floating windows draw their own document's rulers.
Guides and the zero point are still dragged in the active view.
2026-10-15 09:41:16 +02:00
itsF4LCON 510636b19c Type: Korean paragraph text wraps between words, not syllables (#5959)
Unicode's line-breaking rules allow a break between any two Hangul
syllables, so Korean box text split words (설 / 정이나). Korean is set
with breaks at the spaces between words, as Photoshop does: runs of
Hangul now lay out with WordBreak::KeepAll. Chinese and Japanese keep
breaking between characters.
2026-10-15 09:34:09 +02:00
xsvm 9009683671 refactor(gpu): bevel fields are asked for, windowed and checked as one pair
- the bevel program asks for both fields with B::bevel_fields, so they always come with the
  same reach;
- field and bevel_fields share one windowing (fields_in_windows: one window, or bands on wasm),
  so wasm pairs them per band instead of taking a separate path;
- compose's bevel_height and bevel_distance_fields share bevel_dists;
- sync_fx recomputes the pair together and returns Unsupported when the two are not over the
  same rectangle at the same reach, instead of falling back to one at a time.

Test: paired_bevel_fields_equal_the_fields_one_at_a_time also checks the banded path.
2026-10-15 15:34:03 +08:00
xsvm 5a93e93a44 perf(gpu): a chisel bevel's two fields find their sharp edges once
On the GPU canvas a bevel size change recomputes BevelInside and BevelOutside on the CPU, one
distance_field call each, and each call ran sharp_edges (edge pixels and refined normals) on the
same window again; the CPU compositor already shares them (bevel_height). sharp_edges was ~17% of
the CPU time of those size changes.

When both fields are recomputed over the same rectangle at the same reach (a size change, a new
shape), sync_fx now computes them together: compose::effects::bevel_distance_fields finds the
sharp edges once and returns both. Same window, same functions, so both fields are bit for bit
the ones computed one at a time; the chamfer fields, wasm and a single bevel field keep the old
path.

Test: paired_bevel_fields_equal_the_fields_one_at_a_time (reach 3, 9, 40; whole region and two
parts; bit-equal to field() of each kind).
2026-10-15 15:34:03 +08:00
xsvm d743c2532d perf(compose): bevel maps shade, chisel and mix rows on threads, results unchanged
A chisel bevel size change rebuilds the bevel maps of the whole layer region. The distance
fields already run on threads (#2119), but the passes after them still ran on one thread over
every pixel: the chisel height from the two distances, the texture term, the highlight/shadow
shading (once, or twice for emboss styles) and the emboss inside/outside mix.

Each of these computes a pixel from its own inputs only (the height map, the shape, the
distances), so they now go row by row through par_units / par_fill like the fields: the same
arithmetic in the same order per pixel, so the maps are bit for bit the old ones whatever the
thread count. Maps under 65536 pixels and wasm stay on the calling thread.

Test: bevel_maps_do_not_depend_on_the_threads (4 shapes x 5 styles x 3 techniques, with soften
and a texture, 1 vs 2, 5 and 16 threads, every map bit-equal).
2026-10-15 15:34:02 +08:00
itsF4LCON 494308a038 Image Size: Indexed Color resamples with Nearest Neighbor only (#6019)
An Indexed Color document holds only its colour table's colours, and a
save quantises to them. Interpolating methods showed blended colours on
screen that the saved file then lost. image.imageSize now resamples an
Indexed document with Nearest Neighbor whatever method it is given, and
the dialog offers only Automatic and Nearest Neighbor for one.
2026-10-15 09:28:55 +02:00
itsF4LCON 5b39b2b3af Help › Search: typed letters go to the field, not to menu mnemonics (#5996)
In an open menu a plain letter runs the row with that mnemonic. Typing
into Help › Search did too: "S" ran System Info and "A" opened the
ArtCraft website, so only "De" of "Deselect" could be typed. Mnemonics
now wait while a text field in the menu has the keyboard.
2026-10-15 09:18:19 +02:00
Christian Gleissner 181bd3be39 fix(count): reject unknown target groups before adding markers 2026-10-15 07:14:15 +01:00
Persi 11e4b40735 PSD export: check the Background's opacity from its tiles 2026-10-15 07:50:38 +02:00
Muhammad Saad 205c9c1737 History Brush: bound the stroke before its dense buffers (#4961)
The History Brush renders its coverage and reads its source region densely
over the stroke's bounds. Coverage is accumulated in tiles, so a thin stroke
holds only the tiles it touches, but dense_coverage() materialises the whole
bounding box and Region::read allocates that box again for every channel.
Neither is admitted first, and both go through the infallible vec!, which
aborts the process instead of returning an error.

Pattern Stamp already refuses a stroke it cannot render: reject_huge_stroke
bounds it from the points and the brush size before anything is rendered,
reject_bounds checks the rendered box, and MAX_STROKE_PIXELS (8192 squared)
leaves room for a stroke that runs corner to corner across a 24-36 MP canvas.
The three were private to that module.

Move them to the shared retouch_cmds module and apply them in the History
Brush: reject_huge_stroke before the source surface is cloned or any coverage
is rendered, reject_bounds before the dense source read. The budget is
unchanged, so every stroke that worked before still works.

Clone Stamp, Healing Brush, Spot Healing and Remove reach the same conversion
and are left for a follow-up.

Test: a stroke whose box dwarfs the canvas is refused with the size error and
leaves the pixels as they were, while one that runs corner to corner across
the canvas still paints, at 8, 16 and 32 bits.
2026-10-15 09:39:25 +04:00
Bram HeerinkandClaude Opus 5.5 f49564d281 PSD: keep guide resources 1092/1097 only while they match the guides (#5966)
Photoshop writes image resource 1092 (guides v2: 1032's guides plus a
colour per guide) and 1097 (one GUID per guide) next to 1032. Export
regenerated 1032 from the document but wrote 1092 and 1097 back verbatim,
so after Clear Guides, New Guide, a move or a recolour the file carried
two guide lists that disagree.

Both are now written back only while 1092 still describes the document's
guides (positions, directions and colours) and 1097 has as many entries;
otherwise both are left out and the file carries 1032 alone, as files
from before guides v2 do. Import takes the guides' own colours from 1092
(Photoshop's default Cyan stays with the preference colour), the read
half of #5967.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 12:05:48 +07:00
dotCooCoo f8cc7fca50 Brushes: importing an .abr file numbers repeated brush names in linear time
brush.presets.importAbr named each imported preset with unique, which
compared a candidate name with the names of the presets one by one and
tried "Name", "Name 2", "Name 3" and so on until one was free. A
version 1 or 2 brush with a computed tip and no name is named after
its tip ("9 px Round"), so a file of n such brushes of one diameter
cost about n^3/6 string comparisons. In a test build, importing 10,000
of them took 25 minutes (22 minutes of CPU time).

The import now builds the set of preset names once and keeps the next
number to try for each repeated name, as swatches.import does for
swatch and group names. Names still compare case-insensitively, and
every preset gets the same name as before. brush.presets.importImage
names its one preset with the same code.

repeated_brush_names_are_numbered_in_linear_time imports 10,000 such
brushes on a thread of its own and fails when the import takes more
than 20 seconds. repeated_brush_names_take_the_first_free_number
checks the names when the file repeats a name in other letter cases,
when a preset already holds one of the numbers, when the file has a
name that the import already gave to an earlier brush, and when a
name clashes with a built-in preset.
2026-10-14 20:25:51 -07:00
jonathanwxh-cell cc5971ff5b ci: run clipboard regressions independently 2026-10-15 10:07:54 +08:00
David MannandClaude Opus 5.5 6bb45c8d5b Shapes: Density and Feather on shape layers (#5846)
A shape layer's path is its vector mask in Photoshop (Properties > Masks),
with its own vector-mask density and feather. PhotoCraft's ShapeLayer had
neither, so soft shapes could not be made, and stroked soft shapes from PSDs
lost both on import.

- doc: ShapeLayer gains `density` (0..1, default 1) and `feather` (px,
  default 0).
- vector: the shape renderer applies them to the fill as a soft vector mask
  (fill shows beyond the path at 1 - density; the feather is the vector
  mask's Gaussian), so the shape's own pixels, the GPU (which samples them)
  and the stroke/fill split all follow. The stroke stays crisp. The feather
  blur moves from compose::masks to vector::feather and both share it and the
  feathered area, so a shape and an equal vector mask render the same pixels.
- compose: soft shapes' bounds reach their fill, their layer effects follow
  the soft alpha, and the split cache key includes density and feather.
- engine: `layer.vectorMask.edit` on a shape layer sets density/feather in one
  history step (path/enabled/linked/invert are refused there); density is now
  validated (finite 0..100) instead of clamped. `layer.vectorMask.info`
  reports a shape's values.
- ui: Properties shows the Vector Mask Density and Feather sliders for a
  selected shape layer in both themes (live drag, one undo per gesture).
- .pcraft: manifest and convert carry both with serde defaults (older files
  load at 100 % and 0 px).
- PSD: export writes them as the vector density/feather mask parameters;
  import reads them into the shape (re-rendered, as the stored pixels lack the
  fill beyond the path). Soft unstroked solid/gradient shapes now import as
  shapes instead of fill layers with a vector mask; pattern-filled ones stay
  fill layers.

Photoshop's 10 px feather on a 100 px black square, row 100, x = 30..72:
ours is within 1 level of every measured value (8, 16 and 32 bits);
density 50 % shows 127/128 beyond the path and 0 inside.

Scorecard: TOOL-214-12 done.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 03:07:46 +02:00
David MannandClaude Opus 5.5 732b977dc4 Merge main into fix/history-panel-menu
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 02:41:07 +02:00
David MannandClaude Opus 5.5 599a2304ed History panel menu: Delete removes the selected snapshot, as the trash does
Main's History panel can select a snapshot, and its trash then deletes that
snapshot (#5324). The panel menu's Delete still deleted the current state.
It now follows the same selection.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 02:41:06 +02:00
David MannandClaude Opus 5.5 a0c2c1992f docs: ui-parity marks the modifiers-at-press row done, as BUG-208-4 is
Main has the marquee Shift+Alt intersect test (#3624), so the row no longer
says it is missing.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 02:36:30 +02:00
David MannandClaude Opus 5.5 00959b3549 Merge main into docs/scorecard-stale-rows-2
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 02:35:04 +02:00
David MannandClaude Opus 5.5 1ce9a87b6b Effects: Gradient Overlay transparency follows Smoothness, midpoints and method (#1954)
Gradient layer effects baked only their colour stops; the opacity stops came
through raw, so Smoothness, opacity midpoints and the interpolation method
never reached the transparency ramp. Gradient fill layers applied Smoothness
and midpoints but always with Classic.

Both now share one bake (gradient_bake::bake_opacity). Measured on scripted
Photoshop 25.1 (overlays and fill layers render identical pixels): Linear and
Perceptual also bend the opacity curve (stops enter the method's space as
sRGB grey levels, the result leaves as y^0.65), fitted within 0.015 of every
8-bit sample. Smooth keeps Classic's opacity curve (not measurable on 25.1).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 02:18:12 +02:00
jonathanwxh-cell 3de03f48bf Keep unexported clipboard copies usable when native reads fail 2026-10-15 08:16:22 +08:00
David MannandClaude Opus 5.5 8601a7b170 docs: resolve the conflict markers left in the main merge
The merge of main (5a4df1a3) committed unresolved conflict markers in the
status lines of gaps.md, target-app-parity.md and ui-parity.md and in the
Type/Vector rows of target-app-parity.md. Keep main's text and add this
PR's own items back: G8 Warp Text preview done (TYPE-218-7), the Type
row's evidence, and the selection-modifier and Alt-wheel rows.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 02:05:26 +02:00
Michael Higgins 16a7ad1a02 Merge branch 'storytold:main' into fix/5713-smoothing-pressure-follows-brush 2026-10-14 20:04:26 -04:00
David MannandClaude Opus 5.5 17881fc2ad Merge main into fix/mask-from-transparency-bg-depth
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 01:53:57 +02:00
David MannandClaude Opus 5.5 a8db6dd100 Export As: Resample menu, incl. Nearest Neighbor (#5838)
Before this change, Export As always resampled the exported copy with Bicubic, and the preview
used Bilinear. Pixel art and screenshots could not be enlarged with hard edges. Photoshop's
Export As has a Resample menu next to Width/Height/Scale for this.

- engine export_preset_cmds: RESAMPLE lists Photoshop's six choices (Bicubic Automatic, the
  default, then Bicubic, Bicubic Smoother, Bicubic Sharper, Bilinear and Nearest Neighbor)
  and export_resample() checks a value. An unknown value is now an error. Before, Image Size
  quietly treated it as Bicubic.
- Export presets save and restore resample. Older presets load with the default.
- layer.exportAs takes a `resample` param, checked even at 100 %.
- Export As dialog: a Resample dropdown under Image Size. It is remembered with the other
  settings, and a bad saved value falls back to the default. export_document passes the
  choice to image.imageSize for the exported copy only.
- Preview: the proxy keeps its fast Bilinear path, except for Nearest Neighbor, which is
  just as cheap and is what the user wants to see. The preview texture also uses NEAREST
  magnification then, so small exports look blocky instead of blurred.
- Translations for the three new labels in all 20 catalogues.
- Scorecard FILE-215-18 is now done. Also fixes a stale doc comment on VectorMask::feather,
  because the compositor does feather vector masks.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 01:53:42 +02:00
Michael Higgins 0f75495246 Merge branch 'storytold:main' into fix/5799-batch-same-format-raw 2026-10-14 19:50:22 -04:00
David MannandClaude Opus 5.5 f471db33a7 Merge main into fix/video-fps-validate
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 01:22:42 +02:00
David MannandClaude Opus 5.5 8003f3b20a Merge main into fix/adjust-smart-object-locks
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 01:19:31 +02:00
David MannandClaude Opus 5.5 22c0351ffc Merge main into fix/count-delete-group-active
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 01:10:54 +02:00
David MannandClaude Opus 5.5 e333c9c4be Merge main into perf/p33-cmyk-dab
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 01:03:29 +02:00
David MannandClaude Opus 5.5 91bffdec0b Merge main into feat/tool-213-7-live-retouch
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 00:57:30 +02:00
Higgs32584andClaude Opus 5.5 2750809257 Batch: Format "Same" saves camera raws as PSD instead of failing (#5799)
With Format "Same" every output took its input's extension, so a folder of
ARW/CR2/NEF/DNG files tried to write raws, which PhotoCraft can't, and every
file failed. Raws and other import-only inputs (PDN, Krita, Affinity) are now
saved as PSD; other inputs keep their format.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 18:56:28 -04:00
David MannandClaude Opus 5.5 78692cfb28 Merge main into feat/file-216-2-large-fixtures
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 00:46:51 +02:00
David MannandClaude Opus 5.5 b2e98a7d20 Merge main into fix/history-panel-menu
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 00:42:55 +02:00
David MannandClaude Opus 5.5 683e06aafa Merge main into fix/smart-filter-last-mask
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 00:40:03 +02:00
David MannandClaude Opus 5.5 0d32619ed8 Merge main into fix/history-brush-source
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 00:39:45 +02:00
David MannandClaude Opus 5.5 d201fbe056 Merge main into fix/lens-filters-smart-locks
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 00:33:46 +02:00
David MannandClaude Opus 5.5 27c4c56326 Merge main into fix/4017-huge-new-document
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 00:32:33 +02:00
David MannandClaude Opus 5.5 5a4df1a363 Merge main; BUG-208-4 done, rows re-checked against main
Re-checked every row this PR flips against current main: the cited
tests still exist and none of the rows was flipped meanwhile.
BUG-208-4 is done too: shift_alt_before_the_drag_intersects_the_selection
(marquee_tests.rs) covers the marquee Shift+Alt intersect.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 00:29:48 +02:00
Finn NewickandClaude Opus 5.5 fbaf6795cf refactor(engine): drop the second damage pass after single-edit duplicates and pastes
Session::edit has reported a new layer's bounds as damage since #3847, so
Duplicate Layer, Duplicate Layers and Paste no longer need to keep the
document from before the edit and work the damage out again with
note_insert. Paste Into still does: it is three edits.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:50:34 +01:00
David MannandClaude Opus 5.5 667a14d945 PSD Multichannel: alpha channels are stored as-is, not as ink (#5790)
A Multichannel PSD stores ink planes as tone (0 = solid ink), but the
planes DisplayInfo (1077) marks as alpha-type (kind 0/1) are stored as-is,
like alpha channels in every other mode. Import inverted every plane and
export wrote every channel inverted, so a white (selected) alpha imported
as 0 and RGB -> Multichannel -> save wrote alpha channels black.

Measured with a file Photoshop saved itself: a new 8x4 8-bit white RGB
document plus a "selected areas" alpha channel, white left half / black
right half, converted to Multichannel and saved as PSD. Merged planes:
Cyan, Magenta, Yellow = 255 everywhere (ink-inverted: no ink on white
paper); alpha = 255 255 255 255 0 0 0 0 per row (as-is, 255 = selected).
DisplayInfo kinds: 2, 2, 2, 0.

- io: after DisplayInfo, flip alpha-type channels back; on export invert
  only spot channels.
- compose: an alpha-type channel prints as black ink of 1 - value, so the
  composite of a Multichannel file is unchanged.

Tests: a kind-0 alpha imports as stored (white = 1.0) with the spot plane
still read as ink, the composite and the byte-for-byte round trip hold
(8 and 16 bit); a full alpha exports as a max plane next to a solid ink
stored 0. The #4031 composite check now compares against a black ink of
the inverted alpha, within f32 rounding; the compose unit test uses the
as-is polarity for its alpha channel.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 23:20:06 +02:00
David MannandClaude Opus 5.5 5781847da3 Paint: Lock Transparency blends source-atop on semi-transparent pixels (#5783)
With alpha kept (Lock Transparent Pixels, Fill's Preserve Transparency),
the brush, mixer, Fill, Gradient and Paint Bucket composited source-over
and then restored the old alpha, so a half-transparent pixel took the new
colour by as / ao instead of as. They now blend source-atop, as Photoshop
does: Co = (1-as)*Cb + as*B(Cb,Cs), alpha unchanged. New shared helper
blend::composite_atop; over_native's lock branch lerps by the source alpha.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 23:15:43 +02:00
vangel76andClaude Opus 5.5 5f83b53a32 Type: keep superscript and subscript (FontBaseline) when a text layer is re-rendered (#5645)
CharStyle had no superscript/subscript attribute, so PSD import ignored FontBaseline and the first re-render (entering text editing) drew the run as plain text. Model it as CharStyle::baseline, read and write FontBaseline in EngineData, draw such runs at 58.3% of their size shifted by 33.3% of it (Photoshop defaults), and accept "baseline" in the character params.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:13:41 +02:00
nrq6993-a11yandCursor c71f951b90 Fix View › Snap off still snapping via smart guides (storytold/photocraft#5782).
Smart-guide targets were built even when the master snap switch was off; gate them on snap_on like other targets.

Co-authored-by: Cursor <[email protected]>
2026-10-15 00:06:16 +03:00
David MannandClaude Opus 5.5 bb7c84f997 Fill layers: reject non-finite gradient angles and pattern angle/phase (#5752)
layer.newFillLayer.gradient and gradient.presets.apply cast `angle` to f32
unchecked, so 1e39 stored an infinite angle (NaN render, .pcraft save
refused) and 450 stayed 450. They now share gradient.fill.set's parsing:
non-finite is BadParams, the angle is normalised to (-180, 180].
Pattern placement (Pattern Fill layer, Edit > Fill, Paint Bucket) does the
same for its angle and rejects a phase outside +/-1e7 like pattern.fill.set.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 22:34:51 +02:00
Higgs32584andClaude Opus 5.5 b768970d4d Brush smoothing: the lagging brush keeps the pressure of where it is (#5713)
Smoother::push moved the brush part of the way to the pointer but gave
every point the pointer's newest pose (`..p`). On a fast stroke the
brush trails far behind, so when the pen lifted, the trailing brush took
the lift's near-zero pressure while still short of the end, and the
end catch-up drew a straight hairline from there to the pointer: a thick
stroke stepping down to a thin tail.

The brush's pressure, tilt, rotation (the short way round) and wheel now
move with its position, for exponential smoothing and the pulled string.
The end catch-up then ramps from the brush's pressure to the pen's last,
a taper instead of a step.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 15:43:48 -04:00
David MannandClaude Opus 5.5 08abbf7d90 Layer masks: From Transparency and Mask All Objects convert the Background and match the document depth (#5679)
From Transparency and Mask All Objects attached a mask to the Background
without turning it into "Layer 0" first, unlike every other add-mask path.
They and Hide Selection also built their masks as 8-bit, so a 16/32-bit
layer's alpha was quantised to 256 levels by From Transparency. All three
now build the mask with mask_at_depth at the document depth; 8-bit
documents are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 20:37:46 +02:00
David MannandClaude Opus 5.5 afd735ba96 Image > Adjustments: locked smart objects refuse new smart filters (#5655)
adjust_as_smart_filter recorded image.adjustments.* (and Shadows/Highlights)
as a smart filter without checking locks, so a Lock All or Lock Pixels smart
object still took the filter. It now refuses with check_pixels_unlocked, the
same rule filter.* smart filters use. Regression test covers both locks.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 19:57:05 +02:00
David MannandClaude Opus 5.5 f31fb26fe8 Video: Interpret Footage and New Video Layer from File reject an invalid frame rate (#5657)
Both commands stored the `fps` param unchecked: 0 or a negative rate was
kept, and 1e39 became an infinite rate the next .pcraft save refused. They
now use timeline_cmds::fps_param (finite, above 0, at most MAX_FPS), as
timeline.create and timeline.setProps do; a missing fps keeps the old
fallback. Regression test covers 0, -1, 1e39 and "x" for both commands.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 19:55:35 +02:00
David MannandClaude Opus 5.5 a0e5e6eb14 Count tool: deleting an earlier count group keeps the active group (#5653)
count.deleteGroup only clamped the active index to the new length, so
removing a group before the active one silently made the next group
active and later count.add calls landed there. Shift the index down
when the removed group precedes it; deleting the active last group
still selects the new last group.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 19:50:24 +02:00
IvanandClaude Opus 5.5 691a022b12 CI: skip the test (windows-latest) gate in cancelled runs
With always() the gate also ran when a newer push cancelled the run,
taking a runner slot only to report a red check on the superseded
commit. !cancelled() still runs it when a test leg fails (a skipped
required check would count as passing), but not in a cancelled run.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 20:29:16 +03:00
David MannandClaude Opus 5.5 e29ec76d4c Smart Filters: deleting the last filter removes its filter mask (#5619)
Deleting or dragging away the only smart filter now clears the filter mask, as
Clear Smart Filters already did, so the next filter takes the new selection.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 18:57:40 +02:00
David MannandClaude Opus 5.5 baae6f506f Filters: Lens Correction, Adaptive Wide Angle and Camera Raw Filter respect locks on smart objects (#5621)
Root cause: run_filter in lens_cmds.rs added the smart filter and returned
for a smart object before it checked the pixel and full locks, so a smart
object with Lock All or Lock Pixels (its own or a parent group's) took the
filter and a history step.

Fix: check the locks before the smart-object branch, as other filters do.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 18:57:08 +02:00
IvanandClaude Opus 5.5 f0f0d6f58a CI: the gpu test group covers the other ui-egui wgpu tests
The canvas|gpu|mip|type_tool name filter missed ui-egui unit tests that
build a Harness with a real wgpu device: background_redraw's GPU tests,
panel_docking's floating_group_titles_are_opaque_in_studio_themes and
swap_arrow's rendered_glyph_has_a_left_head_and_a_lower_right_head. Name
them in both overrides, so they share the 4-thread cap and get the CI
retries. The other .wgpu() harnesses only render when a fixture env var
is set, or are #[ignore]d.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:22:15 +03:00
IvanandClaude Opus 5.5 3bd9bc72a7 Tests: monitor_macos answers nextest's --list
monitor_macos is harness = false, so nothing answered cargo-nextest's
discovery: `--list --format terse` ran the whole test and nextest failed
to parse its output on macOS. List the one test, and nothing for
`--ignored`, as mac_menu_appkit already does; `monitor_macos --exact`
still runs the body.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:22:14 +03:00
Nigel Ihza Firdausy 92647dd6ff fix(svg): cast channel count for pixel budget arithmetic 2026-10-14 23:18:25 +07:00
Nigel Ihza Firdausy 6f46fcfc4a fix(svg): budget rasterised fallback layers across imports 2026-10-14 23:17:59 +07:00
jonathnwxh ff92cb3ffd Preserve editable clipboard contents when native reads fail 2026-10-14 22:57:13 +08:00
David MannandClaude Opus 5.5 32d965cf50 Regenerate docs/scorecard.md after merging main
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 15:29:17 +02:00
David MannandClaude Opus 5.5 aa26badf20 Merge main into feat/file-216-2-large-fixtures
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 15:12:01 +02:00
David MannandClaude Opus 5.5 e795a09d31 Merge main into docs/scorecard-stale-rows-2
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 15:10:52 +02:00
David MannandClaude Opus 5.5 afc09b4a05 Merge main into fix/4017-huge-new-document
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 15:09:12 +02:00
David MannandClaude Opus 5.5 0543f25682 Merge main into feat/tool-213-7-live-retouch
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 15:07:24 +02:00
IvanandClaude Opus 5.5 b23a01aa8f Tests: postscript_only_style waits for the font scan
selected_style reads the font engine through try_shared, which returns None while the system
font scan is still running. Under cargo test the test passed only because an earlier test in the
same process had already finished the scan; run on its own (nextest runs every test that way, and
`cargo test -- --exact` does too) it saw no engine and got "Regular" instead of "SemiBold". It now
waits for the scan with photocraft_text::shared(), as every_theme_shows_the_type_options does.
On main, alone, it failed before this and passes after, under both cargo test and nextest.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 16:03:09 +03:00
David MannandClaude Opus 5.5 afdc75faf9 Merge main into fix/history-panel-menu
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 14:54:24 +02:00
David MannandClaude Opus 5.5 4c160c5685 Merge main into perf/p33-cmyk-dab
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 14:52:46 +02:00
IvanandClaude Opus 5.5 4a93095939 CI: keep a test (windows-latest) check that reports the test result
Splitting the Windows tests renamed their check from `test (windows-latest)` to one per leg. No
status check is required on main today, but if one is added under the old name it would wait
forever. A small job under that name now fails unless the platform pick and the whole test matrix
passed (`needs` gives one result for all legs, so a failing Linux leg fails it too). It checks the
result instead of exiting 0 under `if: always()`, which would pass with every leg red. Suggested
by Zach in review.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 14:29:13 +03:00
IvanandClaude Opus 5.5 e99f09b306 Tests: mac_menu_appkit answers nextest's --list
The test runs without a harness (AppKit needs the main thread), so nothing answered the `--list`
that cargo-nextest sends each test binary: on macOS the binary ran the whole test as its listing
and nextest failed on the output. It now lists itself in libtest's terse format, and nothing for
`--ignored`; a run passes `--exact <name>`, which falls through to the test as before. Found by
Zach running `cargo nextest run --workspace` on a Mac.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 14:28:41 +03:00
Juan Antonio RedondoandClaude Opus 5.5 78d06781a0 perf(retouch): row-parallel Blur/Sharpen dabs, 2.6-3.3x faster strokes (#5426)
The Blur and Sharpen tools ran each dab's separable Gaussian
(local_blur) and the mix back onto the working copy on one thread. A
large brush is a lot of work per dab: a 2500 px dab is about 10^9
multiply-adds, so strokes on big images stalled the app for seconds.

Both passes of local_blur and the per-dab mix now run their rows in
parallel with rayon, as blur.rs already does. Every row does exactly
the same arithmetic in the same order, so the output is bit-identical:
the layer pixels after the strokes below hash the same as on main.

paint.blur, 200-point stroke on a 6000x4000 noise layer, release,
10-core M-series:

  brush    commit (main -> this)   live stroke (main -> this)
  300 px   323 ms -> 124 ms        497 ms -> 314 ms
  1000 px  1.11 s -> 312 ms        1.70 s -> 996 ms
  2500 px  2.77 s -> 843 ms        4.26 s -> 2.44 s

The live stroke keeps a larger serial share (region copies and
mix_back per push); that is left for a follow-up.

Refs #5426

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 12:12:54 +02:00
jonathnwxh e2ed5cdcb7 Limit blend mode hover refresh to the visible canvas 2026-10-14 12:41:22 +08:00
IvanandClaude Opus 5.5 e5bf9c22c9 CI: run the Windows tests on two runners instead of three
The integration tests run as one leg again. On the Windows runner (EPYC 7763, 3 runs) that leg
took 14.3-15.3 min, against 10.5-10.8 for each half, so the slowest leg is now the integration
one at ~15 min instead of the unit one at ~12.8. In exchange each PR that runs Windows takes two
Windows runners instead of three and ~7 fewer runner-minutes: every leg pays its own setup
(~1 min) and builds the workspace libraries its tests link against (~4 min even from sccache).
That matters more while PRs queue for the account's concurrent-job limit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 01:48:31 +03:00
Gert Pellin f805b12a17 PSD: a flat Bitmap file saves in Bitmap mode and opens as a Background again
#3560's test expected the flat Bitmap file to be saved with a layer section.
With #4814 Bitmap is written in its own mode, which has no layers in
Photoshop: the test now checks the Bitmap header, the missing layer section
and that the saved file reopens with its Background.
2026-10-13 21:47:41 +02:00
Gert Pellin bdbf1a2a71 Merge remote-tracking branch 'origin/main' into feat/4814-psd-palette-modes 2026-10-13 20:45:36 +02:00
UnemarsandClaude Opus 5.5 0c928a1bec Merge origin/main into content-aware-workspace
Conflicts:
- edit_menu_cmds.rs: main's #4133 (Sample All Layers off unless the active layer has no pixels
  in the window) now decides `sampleAllLayers` inside the workspace's fill plan; the job keeps
  the plan-based fill, the spec keeps the workspace's parameters.
- lib.rs: both module lines (caf_ui, cam_preview).
- hu, tr, vi catalogs: both sides' rows. Dropped de's High/Low (main now has them) and hu's
  older "Vanishing Point" row main reworded. Added the 15 workspace strings to sr.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 20:22:22 +02:00
David MannandClaude Opus 5.5 f28d3978ea Sort the engine module list after the merge (cargo fmt)
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 20:20:02 +02:00
David MannandClaude Opus 5.5 577c4eea67 Merge origin/main into feat/tool-213-7-live-retouch
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 20:10:40 +02:00
David MannandClaude Opus 5.5 9c608e63b7 Merge origin/main into docs/scorecard-stale-rows-2
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 19:55:08 +02:00
David MannandClaude Opus 5.5 3120afdbb2 Merge origin/main into fix/history-brush-source
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 19:52:41 +02:00
David MannandClaude Opus 5.5 d2816df448 Merge origin/main into docs/scorecard-stale-rows-2
None of the five rows was flipped on main; every test the notes cite
exists on main. Main made TYPE-218-1 (type on a path) partial, so the
Type recount in gaps.md G8 and the target-app-parity Type row now say
5 done, 3 partial, 1 missing and name it partial. Header lines of
gaps.md and ui-parity.md keep main's entries plus this PR's.
docs/scorecard.md regenerated.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 18:44:44 +02:00
David MannandClaude Opus 5.5 8b9b3a048b Merge origin/main into feat/tool-213-7-live-retouch
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 18:40:32 +02:00
David MannandClaude Opus 5.5 7c5505baa2 Merge origin/main into fix/history-brush-source
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 18:40:28 +02:00
David MannandClaude Opus 5.5 ebd3c12270 Large documents: a gigapixel New Document draws without freezing (#4017)
The reduced CPU canvas composites one-colour tiles once per colour instead
of every document pixel, and GPU canvas textures the device refuses fall
back to the CPU canvas instead of disabling the GPU for the session.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 18:21:45 +02:00
David MannandClaude Opus 5.5 afb7de2d3a Performance: brush dab on a CMYK document within budget (#5108)
A brush dab on an A4 300 ppi CMYK page spent ~5 ms per touched tile in the
GPU canvas's tile upload: every pixel of the 256x256 tile went through the
CMYK -> sRGB transform on one thread. CMYK and Lab tiles now convert in
16-row bands in parallel, and a run of identical pixels (the transparent or
flat area around a dab) is converted once. Texels are bit-identical to the
per-pixel conversion.

The band workers enter the document's CMYK profile scope; before, the rayon
workers converting tiles did not, so a CMYK document tagged with its own
profile uploaded tiles through the built-in coated CMYK instead.

P33 (Windows 11, 32 threads, RTX 5070 Ti, 15 reps): p95 13.7-16.8 ms -> 2.6-3.3 ms.
enforce stays false: the committed baseline machine measures every row
over budget, so it is left for the next baseline.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 18:10:21 +02:00
David MannandClaude Opus 5.5 8eef90bf7e Tests: generated large-file fixtures and a P20 number (#5107)
photocraft_psd::fixtures (feature testgen) generates the FILE-216-2
fixtures at test time: a 20 MP layered PSD and a 150-layer A4 layout
(groups, masks, hidden layers, drop shadows, known merged colours), a
2.02 GB PSD just under Photoshop's cap and the 4-5 GB PSBs, streamed row
by row. io tests/large_files.rs opens, checks, saves and re-opens each
(the 2 GB PSD and 4.38 GB PSB are opt-in). The ui-egui large_file_bench
times opening the big two, wired to P20 and a new P60.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 17:58:38 +02:00
David MannandClaude Opus 5.5 511b55bec3 History Brush source: Serbian labels
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 13:10:26 +02:00
David MannandClaude Opus 5.5 716e398ede Regenerate docs/scorecard.md after merging main
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 12:47:44 +02:00
David MannandClaude Opus 5.5 09af99c6f3 History panel menu: Serbian labels
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 12:41:44 +02:00
David MannandClaude Opus 5.5 c951480e99 Merge origin/main into feat/tool-213-7-live-retouch
Background Eraser: main now samples and matches against the pre-stroke pixels (#4552); the
shared dab effect takes the pre-stroke surface so the live preview, whose working areas are
per dab, reads the same pixels as the commit.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 12:33:25 +02:00
David MannandClaude Opus 5.5 03f929bffb Merge main into fix/history-brush-source
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 12:08:08 +02:00
David MannandClaude Opus 5.5 402d005e05 Merge origin/main into docs/scorecard-stale-rows-2
Conflicts in docs/gaps.md, docs/target-app-parity.md and docs/ui-parity.md:
kept main's status lines and rows and this branch's Type, selection-modifier
and Alt-wheel evidence. docs/scorecard.md regenerated.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 12:04:36 +02:00
David MannandClaude Opus 5.5 71ea169a84 Merge main into fix/history-panel-menu
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 12:04:26 +02:00
Gert Pellin 5f59f0671a Layered TIFF: Indexed, Bitmap and Duotone layers are modelled in their pixel format
The layered TIFF export builds its layer data through the PSD exporter,
which now writes these modes in their own PSD mode (palette, 1 bit,
duotone). TIFF has no such layout and interleaves the merged image in
the pixel format, so the PSD model for it is made in that format (RGB or
Grayscale), as before.

Refs #4814
2026-10-13 11:51:34 +02:00
Gert Pellin 2a10524d53 PSD: save Indexed Color, Bitmap and Duotone documents in their own mode
Indexed Color (mode 2): the flattened document as palette indices, the
Color Table as Color Mode Data (256 reds, greens, blues), its length in
resource 1046 and the transparent entry in 1047; the importer now reads
both back. Bitmap (mode 0): 1 bit per pixel, 1 = black. Neither mode has
layers in Photoshop, so both are written flat, like Multichannel; the
layerless file building is shared with multichannel_map.

A Duotone keeps the ink data it was imported with (undocumented, kept
verbatim in Duotone::psd_raw) over its grey plate. Duotones whose inks
were set in PhotoCraft have no such data and stay Grayscale with the
warning, as before.

Fixes #4814
2026-10-13 09:00:20 +02:00
David Mann 601d92ab8b Merge remote-tracking branch 'origin/main' into feat/tool-213-7-live-retouch 2026-10-13 01:45:11 +02:00
David MannandClaude Opus 5.5 d553a1b29f Regenerate docs/scorecard.md after merging main
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 01:45:06 +02:00
David Mann ca65995579 Merge remote-tracking branch 'origin/main' into docs/scorecard-stale-rows-2 2026-10-13 01:43:37 +02:00
David MannandClaude Opus 5.5 dbb2062118 Merge main into feat/tool-213-7-live-retouch
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 01:43:25 +02:00
David MannandClaude Opus 5.5 9b70086a2d Merge main into docs/scorecard-stale-rows-2
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 01:43:25 +02:00
David MannandClaude Opus 5.5 fdef9180a7 History panel menu: Hungarian and Turkish labels
Hungarian and Turkish now claim complete menus, so the History panel
menu's New Snapshot… and History Options… need entries there too.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 01:36:49 +02:00
David MannandClaude Opus 5.5 2fff5845b9 Merge main into fix/history-brush-source
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 01:23:39 +02:00
David MannandClaude Opus 5.5 507588aab3 Merge main into fix/history-panel-menu
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 01:21:56 +02:00
David MannandClaude Opus 5.5 ed26278c8b Merge main into fix/history-panel-menu
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 01:18:48 +02:00
David Mann 001d16cd9f Merge remote-tracking branch 'origin/main' into feat/tool-213-7-live-retouch 2026-10-13 01:15:02 +02:00
David MannandClaude Opus 5.5 9e22da62f6 Merge main into feat/tool-213-7-live-retouch
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 01:06:25 +02:00
David Mann 132935eb2e Merge main into docs/scorecard-stale-rows-2
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

# Conflicts:
#	docs/gaps.md
#	docs/scorecard.md
#	docs/target-app-parity.md
2026-10-13 01:01:57 +02:00
David Mann cbe11954c6 Merge main into fix/history-panel-menu
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

# Conflicts:
#	crates/ui-egui/src/dock.rs
#	crates/ui-egui/src/i18n/vi.tsv
#	crates/ui-egui/src/panel_docking.rs
2026-10-13 00:59:37 +02:00
David Mann 4c0e1f3770 Merge main into fix/history-brush-source
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

# Conflicts:
#	crates/ui-egui/src/i18n/cs.tsv
#	crates/ui-egui/src/i18n/de.tsv
#	crates/ui-egui/src/i18n/el.tsv
#	crates/ui-egui/src/i18n/es.tsv
#	crates/ui-egui/src/i18n/fr.tsv
#	crates/ui-egui/src/i18n/id.tsv
#	crates/ui-egui/src/i18n/it.tsv
#	crates/ui-egui/src/i18n/ja.tsv
#	crates/ui-egui/src/i18n/ko.tsv
#	crates/ui-egui/src/i18n/nl.tsv
#	crates/ui-egui/src/i18n/pl.tsv
#	crates/ui-egui/src/i18n/pt-br.tsv
#	crates/ui-egui/src/i18n/ru.tsv
#	crates/ui-egui/src/i18n/uk.tsv
#	crates/ui-egui/src/i18n/vi.tsv
#	crates/ui-egui/src/i18n/zh-hans.tsv
#	crates/ui-egui/src/i18n/zh-hant.tsv
2026-10-13 00:52:27 +02:00
David Mann 018213b8ea Merge main into docs/scorecard-stale-rows-2
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

# Conflicts:
#	docs/scorecard.md
#	docs/ui-parity.md
2026-10-13 00:29:04 +02:00
David MannandClaude Opus 5.5 7c501ed90d Merge fixups: translate the History Brush source strings into Hungarian and Turkish
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 00:16:07 +02:00
David Mann 23cf5ec69a Merge main into fix/history-brush-source
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

# Conflicts:
#	crates/ui-egui/src/i18n/cs.tsv
#	crates/ui-egui/src/i18n/de.tsv
#	crates/ui-egui/src/i18n/el.tsv
#	crates/ui-egui/src/i18n/es.tsv
#	crates/ui-egui/src/i18n/fr.tsv
#	crates/ui-egui/src/i18n/id.tsv
#	crates/ui-egui/src/i18n/it.tsv
#	crates/ui-egui/src/i18n/ja.tsv
#	crates/ui-egui/src/i18n/ko.tsv
#	crates/ui-egui/src/i18n/nl.tsv
#	crates/ui-egui/src/i18n/pl.tsv
#	crates/ui-egui/src/i18n/pt-br.tsv
#	crates/ui-egui/src/i18n/ru.tsv
#	crates/ui-egui/src/i18n/uk.tsv
#	crates/ui-egui/src/i18n/vi.tsv
#	crates/ui-egui/src/i18n/zh-hans.tsv
#	crates/ui-egui/src/i18n/zh-hant.tsv
2026-10-13 00:07:53 +02:00
David MannandClaude Opus 5.5 8c7a2de4a7 Merge main into feat/tool-213-7-live-retouch
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 00:04:59 +02:00
David Mann 2ed5fd38c2 Merge main into fix/history-panel-menu
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

# Conflicts:
#	crates/ui-egui/src/i18n/vi.tsv
2026-10-13 00:01:56 +02:00
UnemarsandClaude Opus 5.5 52399524fe i18n(it): drop the workspace's rows main's Italian catalog now has (High, Low, Duplicate Layer, Contract)
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 23:00:48 +02:00
UnemarsandClaude Opus 5.5 100cedbdb7 i18n(hu): the Content-Aware Fill workspace in the Hungarian catalog from main
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 21:23:26 +02:00
UnemarsandClaude Opus 5.5 ea3c9506f5 i18n(tr): the Content-Aware Fill workspace in the Turkish catalog from main
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 21:11:34 +02:00
UnemarsandClaude Opus 5.5 8cd6f16b88 Merge origin/main d43db3e1 into content-aware-workspace
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 21:11:34 +02:00
David MannandClaude Opus 5.5 684f211a72 Merge fixups: translate the History Brush source strings
i18n::tests::every_tl_literal_is_translated needs every tl! string in the complete catalogs:
the options bar hint and the History panel source column tooltip get rows in all 17.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 14:10:23 +02:00
David Mann 27f001c3fa Merge remote-tracking branch 'origin/main' into fix/history-brush-source 2026-10-12 14:10:23 +02:00
David MannandClaude Opus 5.5 5241773022 Merge fixups: Vietnamese History panel menu labels
main added a complete Vietnamese catalog; the panel menu's new labels need rows there too.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 13:27:24 +02:00
David Mann 4a0617ce83 Merge remote-tracking branch 'origin/main' into fix/history-panel-menu 2026-10-12 13:27:24 +02:00
AlastorIRL 5cd134f353 fix(ui): wire video menu pickers/dialogs and fix Timeline Stop playback 2026-10-12 08:16:25 -03:00
David Mann 9c2d6ac892 Merge remote-tracking branch 'origin/main' into docs/scorecard-stale-rows-2
# Conflicts:
#	docs/gaps.md
#	docs/scorecard.md
#	docs/ui-parity.md
2026-10-12 12:27:32 +02:00
David Mann 89f4bb51dd Merge remote-tracking branch 'origin/main' into feat/tool-213-7-live-retouch
# Conflicts:
#	docs/brush-parity.md
#	docs/gaps.md
#	docs/scorecard.md
2026-10-12 12:07:03 +02:00
UnemarsandClaude Opus 5.5 1705492c02 Merge main into content-aware-workspace: #3395's cancellable fill, bounded workspace params
- The fill area and sampling area (`content_aware_plan_with`) are worked out
  inside the background job, cancellable row by row as #3395 made the rest of
  the fill: the window and selection are read in parallel bands, Auto sampling
  is `auto_sampling_with`. The params are still checked before the job starts.
  The workspace's preview builds its plan with its own cancel flag.
- The apply step is #3395's: only the fill area's bounds keep the original
  pixels and coverage. `CafPlan::layer_px` is kept only when Sample All Layers
  samples something else than the layer.
- `fillEdits` and `samplingStrokes` take at most 1000 entries and 200 000
  points in all (MAX_CAF_EDITS, MAX_CAF_POINTS).
- `paint_strokes` leaves a mask that does not cover its area alone, writes
  through `get_mut`, and visits each segment's own bounds instead of the whole
  stroke's for every segment (the same pixels). The workspace's
  `sampled_at`, `in_hole` and outline read their buffers with `get`.
- Content-Aware Scale's transform box (#3096) and the Vanishing Point
  workspace keep their menu entries next to the Content-Aware Fill workspace.
- Vietnamese labels for the workspace.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 09:50:41 +02:00
Brandon ThomasandClaude Opus 5.5 c824a61ea1 Merge main; recount Type evidence and regenerate the scorecard
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 18:21:38 -04:00
David Mann 46e833e9c6 Merge origin/main
# Conflicts:
#	docs/gaps.md
2026-10-12 00:14:07 +02:00
David Mann d701538974 Merge origin/main
# Conflicts:
#	docs/gaps.md
2026-10-11 23:51:37 +02:00
David MannandClaude Opus 5.5 086a82d547 Pattern Stamp and Background Eraser draw live; Spot Healing shows its heal area (TOOL-213-7, #3754)
Pattern Stamp (aligned through LiveRetouch, Impressionist dab by dab) and
Background Eraser now paint during the drag; the release commits exactly the
pixels the live stroke showed (per-pixel tests at 8 and 16 bits). Spot
Healing keeps healing on release and shows a dark overlay while dragging.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-11 23:50:35 +02:00
David MannandClaude Opus 5.5 1c8199d417 Merge remote-tracking branch 'origin/main' into docs/scorecard-stale-rows-2
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-11 20:52:18 +02:00
David Mann cf79e20685 Merge remote-tracking branch 'origin/main' into fix/history-panel-menu
# Conflicts:
#	crates/ui-egui/src/i18n/pt-br.tsv
2026-10-11 20:50:35 +02:00
David MannandClaude Opus 5.5 2b738e0c7c History: panel menu, Step Forward/Backward and Clear History (#2966)
The History panel's menu (☰) now has Photoshop's items: Step Forward, Step
Backward, New Snapshot… (greyed, snapshots aren't built yet), Delete, Clear
History, New Document, History Options… (greyed, built in parallel) and Close,
above the dock's own group items. Each runs an engine command.

- history.stepForward / history.stepBackward: one state along the list
  (Err when there is none).
- history.clear: removes every state but the current one without changing
  the image. It is undoable: History keeps the cleared states until the next
  step, and Edit > Undo puts them back. Fade is dropped and can no longer
  underflow on an empty history.
- history.deleteState is enabled only when there is an earlier state.
- Translations for the new labels in all complete-menu languages.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-11 17:56:26 +02:00
David MannandClaude Opus 5.5 b5e3e80a3b History: set the History Brush source from the panel (history.setSource) (#2966)
The History panel gets Photoshop's source column at the left of every state:
the History Brush icon marks the source, and a click there makes that state
the source through the new `history.setSource {state}` command (not a step,
and the row is not stepped to). `paint.historyBrush`, the interactive History
Brush and Fill > History use the source when no `state` is passed; by
default it is the oldest state held, as before.

The source is kept by state, not index: photocraft-ops gives every history
state a stable id, so the source stays on its state while older states fall
off. As in Photoshop (checked by script), the source state itself does not
fall off the history-state limit or the memory budget, and once it is
deleted or discarded by a new step after stepping back there is no source:
the History Brush then refuses to paint without an explicit `state` until a
new source is set. Rows count the greyed redo states too, so the common
"filter, step back, paint the filtered state in" workflow works.

`document.inspect` / `ui_inspect` report `historySource` {state, label}
(null when its state was deleted). Snapshots are not implemented yet:
`snapshot` returns an "unsupported" error.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-11 17:37:42 +02:00
David MannandClaude Opus 5.5 46f046ca4e scorecard: five rows catch up with work already on main
AGENTS.md sends contributors to the scorecard's missing and partial rows
when picking work; these five described code that has since landed.

- TYPE-218-7 missing -> done: Warp Text opens with the layer's warp
  (filter_dialog.rs warp_defaults) and previews live; covered by
  warp_text_dialog_tests.rs and
  warp_text_preview_matches_the_full_size_result_on_a_reduced_proxy.
- TOOL-214-13 partial -> done: Properties shows Density and Feather for
  both pixel and vector masks (mask_props_ui.rs, mask_props_ui_tests.rs).
- BUG-207-5 missing -> done: all twelve retouch tools are tested against
  mask, alpha channel, Channels-panel and Quick Mask targets; Sample All
  Layers and the smudge fringe have their own tests (retouch_cmds/tests.rs).
  Pencil and Mixer Brush targets are still untested, noted in the row.
- UI-217-18 missing -> partial: Alt+wheel zooms around the pointer at
  x1.1 a notch, measured on Photoshop in #1718, not the ~5 % the row asks
  for; the status bar zoom controls fit (#2823); Navigator not checked.
- BUG-208-4 stays partial with a corrected note: marquee add/subtract,
  Lasso add/subtract/intersect and Polygonal Lasso are tested from
  modifiers held at the press; a marquee Shift+Alt intersect test is
  still missing.

docs/gaps.md (G8), docs/target-app-parity.md (Type row) and
docs/ui-parity.md updated to match, with revision-history rows.
docs/scorecard.md regenerated: Tools 26/15/2, Workspace and UI 8/9/6,
Type 2/4/3, Open bugs 30/2/0.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-11 16:27:16 +02:00
IvanandClaude Opus 5.5 39904a27ce Docs: CI runs nextest, the craft-fonts tests have their own job
Comments and docs still said CI runs cargo test, that the craft-fonts tests run a second time in
the Linux job, and that CI's debuginfo matches the dev profile everywhere. development.md also
notes that test builds compile ui-egui unoptimized.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 11:32:35 +03:00
IvanandClaude Opus 5.5 110d58bea9 CI: split the Windows integration tests over two runners; retry flaky GPU tests
nextest --partition splits the integration tests in two, so Windows runs three legs: on the
Windows runner (EPYC 7763, 2 runs) the integration halves took 9.8 and 10.8 min against 13.8 for
one, leaving the unit leg (~12.8) as the longest.

A ci nextest profile retries the GPU tests: on WARP, under load, one hit wgpu's poll timeout once
in ~40 runs while measuring. A test that passes on retry is reported as FLAKY.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 10:06:39 +03:00
IvanandClaude Opus 5.5 60d682d65d CI: cache the Windows workspace libraries with sccache
rust-cache keeps only the dependencies, so every run recompiled the workspace crates. sccache
caches their libraries (test executables can't be cached and still link). Warm, on the Windows
runner (EPYC 7763, 2-3 runs), the unit leg built in 7.1 min instead of 11.5 and the integration
leg in 4.3 instead of 8.8. Only main writes (PRs use SCCACHE_GHA_RW_MODE=READ_ONLY); a full set
of entries was ~90 MB.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 08:58:53 +03:00
IvanandClaude Opus 5.5 6382148eb8 CI: run the Windows tests as two parallel legs
Unit tests (--lib --bins, plus doctests) and integration tests (--test '*', plus Clippy and the
layering check) now run on two Windows runners at once. The feature selection is the same, so
both legs restore one dependency cache; the integration leg saves it, so Clippy's metadata stays
in it. On the Windows runner (EPYC 7763) the job took 28.3 min; the legs take 18.2 and 18.3,
running the same 6095 tests between them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 07:48:54 +03:00
IvanandClaude Opus 5.5 2ff756b51a Tests: build ui-egui without optimization in the test profile
Its library and unit tests were the biggest test-build units after engine's (12.6 of ~63 compile
minutes on the Windows CI runner), and its tests run no heavy pixel code of their own. On the
Windows runner (EPYC 7763, 3 runs vs 7): build 18.9 -> 15.4 min, test run 10.0 -> 10.5. Only test
builds change; cargo run and cargo build use the dev profile.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 06:36:32 +03:00
IvanandClaude Opus 5.5 fa17b6f4b1 CI: run the Linux without-heif and craft-fonts builds in parallel
They ran after the main tests in the Linux test job and added ~10 min to its critical path (and
were why that job ran out of disk). Now a two-leg job restores the test job's cache read-only
(shared-key: test) and saves nothing, so the cache budget (#2688) doesn't grow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 05:08:46 +03:00
IvanandClaude Opus 5.5 7b1dd35b7b CI: report every failing test, not just the first
With the binaries running in parallel, running the rest after a failure costs little, and a red
main no longer hides other failures behind the first one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 05:07:17 +03:00
IvanandClaude Opus 5.5 bf93459200 CI: no debuginfo for the Windows test build
Writing PDBs for ~200 test binaries took 2.2 of ~21 build minutes on the Windows runner (3 runs
each on the same CPU model). A panic still reports its file and line; Linux keeps
line-tables-only for backtraces with lines.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 05:06:06 +03:00
UnemarsandClaude Opus 5.5 994c313706 Merge origin/main into content-aware-workspace
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-11 01:35:58 +02:00
IvanandClaude Opus 5.5 b9aa935d19 CI: run tests with nextest and skip building examples (#2204)
cargo test runs the ~200 test binaries one after another; on Windows that took 14 of the job's
37 minutes, 5 of them in gpu's parity tests on the software adapter. nextest runs them in parallel
with the GPU group from .config/nextest.toml. The examples were built and linked but never run;
Clippy's --all-targets still checks them. Doctests get their own step.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 01:41:45 +03:00
UnemarsandClaude Opus 5.5 2507f07ec2 Format: one rustfmt change main needs
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-10 10:11:15 +02:00
Unemars 7a5795bfcb Merge remote-tracking branch 'origin/main' into content-aware-workspace
# Conflicts:
#	crates/ui-egui/src/i18n/cs.tsv
#	crates/ui-egui/src/i18n/de.tsv
#	crates/ui-egui/src/i18n/el.tsv
#	crates/ui-egui/src/i18n/es.tsv
#	crates/ui-egui/src/i18n/fr.tsv
#	crates/ui-egui/src/i18n/id.tsv
#	crates/ui-egui/src/i18n/it.tsv
#	crates/ui-egui/src/i18n/ja.tsv
#	crates/ui-egui/src/i18n/ko.tsv
#	crates/ui-egui/src/i18n/nl.tsv
#	crates/ui-egui/src/i18n/pl.tsv
#	crates/ui-egui/src/i18n/pt-br.tsv
#	crates/ui-egui/src/i18n/ru.tsv
#	crates/ui-egui/src/i18n/uk.tsv
#	crates/ui-egui/src/i18n/zh-hans.tsv
#	crates/ui-egui/src/i18n/zh-hant.tsv
2026-10-10 10:06:08 +02:00
UnemarsandClaude Opus 5.5 4d7db6dabe Content-Aware Fill workspace, as Photoshop's
Edit › Content-Aware Fill… opens Photoshop's workspace (measured on 25.4)
instead of the generic dialog: a full-window dialog like Liquify's.

- Tools: Sampling Brush (B), Lasso (L), Hand (H), Zoom (Z), each with its
  options bar.
- Document view: the sampling area tinted green at 50 % and the fill area's
  marching ants.
- Preview panel: the fill computed in the background after each change,
  opening framed on the sampling window.
- Content-Aware Fill panel: Sampling Area Overlay (Show, Opacity, Color,
  Indicates), Sampling Area Options (Auto, Rectangular, Custom, Sample All
  Layers), Fill Settings, Output Settings, and the reset buttons.
- OK, Apply, Cancel.

As Photoshop's:
- The Sampling Brush subtracts in Auto and Rectangular and adds in Custom,
  which starts empty; Alt reverses. Its first size is a tenth of the
  window's side. Painting keeps the option, and added strokes may lie past
  the window.
- The lasso edits the fill area (New, Add, Subtract, Intersect, Expand,
  Contract); the window and the Auto area follow.
- Apply fills and stays open; Cancel keeps what was applied. Ctrl+Z undoes
  a stroke or an edit. Output opens on New Layer; settings are remembered.

The workspace holds plain data. `edit.contentAwareFill` takes it as
`samplingStrokes`, `fillEdits`, `sampleAllLayers` and `layer`, and both
the preview and OK run one plan (`content_aware_plan`), so the preview is
what gets written. New strings are translated in every catalog.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-10 10:05:40 +02:00
UnemarsandClaude Opus 5.5 2e1f3b0a41 Content-Aware tools: Photoshop's behaviour, measured
Photoshop 25.4 was measured as a black box (synthetic and public-domain
images, results read back exactly) and the content-aware family follows it:

- Fill (inpaint): coarse to fine with the coarser level's matches scaled up
  and voted with the finer level's own pixels, the coarsest level filled
  from the hole's edge inwards, a shallower pyramid. The fill is pieces of
  the image copied pixel for pixel with narrow seams, as Photoshop's;
  before, it was visibly blurred. Pixels have roles (hole, context,
  source, void), so excluded areas stay context instead of being filled.
- Colour adaptation: Image Melding's per-patch gain and bias, limited per
  level, instead of a low-frequency membrane pull.
- Sampling window: Photoshop's square of side 4·√(max(w,50)·max(h,50))
  centred on the selection, slid inside the canvas; Edit › Fill,
  Rectangular, Auto, Delete and Fill and Spot Healing use it.
- Auto sampling area: what looks like the selection's surroundings (local
  colour and contrast, one min cut), fitted to Photoshop's overlay.
- Patch Tool: a Content-Aware mode (Structure, Color, Sample All Layers).
- Content-Aware Move: Structure 3..7 alike, a band about a patch wide;
  Color as a gain/bias limited to about 0.04 per step.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-10 02:25:24 +02:00
649 changed files with 59842 additions and 4772 deletions

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
reviews:
auto_review:
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
ignore_usernames:
- "dependabot[bot]"
+1 -1
View File
@@ -12,7 +12,7 @@
gpu = { max-threads = 4 }
[[profile.default.overrides]]
filter = 'package(photocraft-gpu) | (package(photocraft-ui-egui) & (kind(test) | test(/canvas|gpu|mip|type_tool/)))'
filter = 'package(photocraft-gpu) | (package(photocraft-ui-egui) & (kind(test) | test(/canvas|gpu|mip|type_tool|background_redraw::|floating_group_titles|swap_arrow::tests::rendered_glyph/)))'
test-group = 'gpu'
# CI (`--profile ci`, .github/workflows/ci.yml). Stops at the first failure so a red PR frees its
+30
View File
@@ -0,0 +1,30 @@
version: 2
updates:
# Maintain Rust / Cargo dependencies
- package-ecosystem: "cargo"
directory: "/"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
# Consolidate updates into a single PR rather than one PR per crate
groups:
cargo-dependencies:
patterns:
- "*"
commit-message:
prefix: "chore(deps)"
# Maintain GitHub Actions workflows
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
groups:
actions:
patterns:
- "*"
commit-message:
prefix: "ci(deps)"
+145 -30
View File
@@ -4,6 +4,7 @@ on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
workflow_dispatch:
inputs:
fuzz:
@@ -22,8 +23,8 @@ env:
PHOTOCRAFT_NO_PRINT: "1"
LPDEST: photocraft-no-such-printer
# Debug and test builds keep line tables (file:line in panic backtraces) but drop the rest of the
# debug info: faster codegen and links (Windows PDBs most of all), and a target dir small enough
# for the Linux job's second, craft-fonts build (it ran out of disk with full debug info).
# debug info: faster codegen and links, and a smaller target dir (the Linux test job ran out of
# disk with full debug info). The Windows test legs drop debuginfo entirely (see the test job).
CARGO_PROFILE_DEV_DEBUG: line-tables-only
# A newer push to a PR branch cancels its older run. Runs on main are never cancelled: a merge
@@ -43,7 +44,7 @@ jobs:
name: select platforms
runs-on: ubuntu-latest
outputs:
os: ${{ steps.pick.outputs.os }}
legs: ${{ steps.pick.outputs.legs }}
web: ${{ steps.pick.outputs.web }}
steps:
- uses: actions/checkout@v4
@@ -57,9 +58,15 @@ jobs:
run: |
# No macOS on PRs or main: the org's 5 macOS slots are kept for release builds, and
# release.yml builds and tests macOS (a failing release is fixed and rebuilt).
all='["ubuntu-latest","windows-latest"]'
# Windows tests run as two legs in parallel: the unit tests and the integration tests. As
# one job they took ~28 min on the Windows runner; the legs take ~13 and ~15. Splitting
# the integration tests over a third runner (nextest --partition) saved ~2 min of wall
# time but cost ~7 runner-minutes and one more Windows slot per PR.
linux='{"os":"ubuntu-latest","part":"all"}'
win() { printf '{"os":"windows-latest","part":"%s"}' "$1"; }
all="[$linux,$(win unit),$(win integration)]"
if [ "$EVENT" != pull_request ] || [ "$FULL" = true ]; then
echo "os=$all" >> "$GITHUB_OUTPUT"
echo "legs=$all" >> "$GITHUB_OUTPUT"
echo "web=true" >> "$GITHUB_OUTPUT"
exit 0
fi
@@ -72,20 +79,25 @@ jobs:
echo "web=false" >> "$GITHUB_OUTPUT"
fi
if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/|crates/(ui-egui|text|gpu|tablet|heif)/|Cargo\.lock$|(.*/)?build\.rs$|\.github/workflows/ci\.yml$)'; then
echo "os=$all" >> "$GITHUB_OUTPUT"
echo "legs=$all" >> "$GITHUB_OUTPUT"
else
echo 'os=["ubuntu-latest"]' >> "$GITHUB_OUTPUT"
echo "legs=[$linux]" >> "$GITHUB_OUTPUT"
fi
test:
timeout-minutes: 100
name: test (${{ matrix.os }})
name: test (${{ matrix.os }}${{ matrix.part != 'all' && format(', {0}', matrix.part) || '' }})
needs: platforms
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: ${{ fromJSON(needs.platforms.outputs.os) }}
include: ${{ fromJSON(needs.platforms.outputs.legs) }}
env:
# No debuginfo on Windows: writing PDBs for ~200 test binaries cost 2.2 of ~21 build minutes.
# A panic still names its file and line; only backtrace frames lose theirs (Linux keeps
# line-tables-only, so a backtrace with lines is one OS away).
CARGO_PROFILE_DEV_DEBUG: ${{ startsWith(matrix.os, 'windows') && '0' || 'line-tables-only' }}
steps:
- uses: actions/checkout@v4
@@ -108,21 +120,44 @@ jobs:
# Only main saves caches; PRs restore main's. Every PR used to save its own set (up to
# 3.8 GB), usable by that PR only, so the repo's 10 GB cache budget kept evicting main's.
# A failing test still saves: the cache holds compiled dependencies, not test results.
# The Windows legs share one key (same dependencies); the integration leg saves it,
# because it also runs Clippy and so leaves Clippy's dependency metadata in it.
- uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
save-if: ${{ github.ref == 'refs/heads/main' && (matrix.part == 'all' || matrix.part == 'integration') }}
cache-on-failure: true
# The cheap checks run first: a format, lint, layering or stale-scorecard failure is red
# after a few minutes instead of after the test passes (#6119). Clippy restores main's cache.
# sccache caches the workspace crates' compiled libraries, which rust-cache leaves out:
# warm, each Windows leg built 4.5 min faster. Like rust-cache, only main writes (PRs read
# main's entries); a full set is ~90 MB, so it barely touches the 10 GB budget (#2688).
# Test executables can't be cached, so a PR still links its tests.
- name: Install sccache (Windows)
if: runner.os == 'Windows'
uses: Mozilla-Actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.18.0
- name: Use sccache (Windows)
if: runner.os == 'Windows'
env:
MAIN: ${{ github.ref == 'refs/heads/main' }}
run: |
echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV"
echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV"
if [ "$MAIN" != true ]; then echo "SCCACHE_GHA_RW_MODE=READ_ONLY" >> "$GITHUB_ENV"; fi
shell: bash
- name: Format
if: runner.os == 'Linux'
run: cargo fmt --all -- --check
# Keep formatting, strict Clippy, layering and scorecard checks ahead of tests.
- name: Clippy
if: matrix.part == 'all' || matrix.part == 'integration'
run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings
- name: Dependency layering
if: matrix.part == 'all' || matrix.part == 'integration'
run: cargo xtask layers
# docs/scorecard.md must match its sources (checklists, budgets, baseline, corpus floors,
@@ -131,36 +166,114 @@ jobs:
if: runner.os == 'Linux'
run: cargo xtask scorecard --check
# cargo-nextest runs the test binaries in parallel (`cargo test` runs them one at a time) and
# its `ci` profile (.config/nextest.toml) stops at the first failure and fails hung tests.
# nextest runs the test binaries in parallel (cargo test runs them one at a time), with the
# GPU tests capped by the `gpu` group in .config/nextest.toml (#2204).
- uses: taiki-e/install-action@83ac0ad63c0167e6f06796fab0fce28db1bf3db0 # v2.87.22
with:
tool: [email protected]
# `--features heif`: official builds open HEIC photos via the optional photocraft-heif crate.
# nextest doesn't run doctests, so `cargo test --doc` does, with the same selection.
# Separate native commands into steps so Windows reports either command's failure.
# `--lib --bins --tests` leaves out the examples, which cargo test would build and link but
# never run (Clippy's `--all-targets` still checks they compile). nextest skips doctests, so
# they get their own step.
#
# On Windows the unit leg runs `--lib --bins` and the integration leg `--test '*'` (every
# integration-test target); the feature selection stays the same, so both legs share the
# dependency cache. The `ci` profile fails fast and terminates hung tests
# (.config/nextest.toml); assertion failures are not retried.
- name: Test
run: cargo nextest run --workspace --locked --features heif --profile ci
env:
PART: ${{ matrix.part }}
run: |
case "$PART" in
unit) targets=(--lib --bins) ;;
integration) targets=(--test '*') ;;
*) targets=(--lib --bins --tests) ;;
esac
cargo nextest run --profile ci --workspace --locked --features heif "${targets[@]}"
shell: bash
# Every command against adversarial params must return Err, never panic or hang (AGENTS.md
# Rule 9). It is `#[ignore]`d (slow), so Test skips it. Same packages, features and targets as
# Test, so nothing rebuilds: `--test panic_hunt` would drop the other crates' dev-dependency
# features and recompile the whole graph. The `ci` profile terminates it if it hangs.
- name: Panic hunt
if: runner.os == 'Linux'
run: >-
cargo nextest run --profile ci --workspace --locked --features heif --lib --bins --tests
--run-ignored only -E 'binary_id(photocraft-engine::panic_hunt)'
- name: Doctests
run: cargo test --doc --workspace --locked --features heif
if: matrix.part == 'all' || matrix.part == 'unit'
run: cargo test --workspace --locked --features heif --doc
# The Windows tests used to be one job named `test (windows-latest)`; they're now legs named
# `test (windows-latest, unit)` and `test (windows-latest, integration)`. This job keeps the old
# name reporting, so a required status check on it (none today) would keep working. It fails
# unless the whole test matrix and both Linux variants passed. `needs` reports one result
# for all matrix legs; when a PR runs no Windows legs it still checks every Linux result.
windows-gate:
timeout-minutes: 5
name: test (windows-latest)
needs: [platforms, test, test-variants]
if: ${{ !cancelled() }}
runs-on: ubuntu-latest
steps:
- env:
PLATFORMS: ${{ needs.platforms.result }}
TEST: ${{ needs.test.result }}
VARIANTS: ${{ needs.test-variants.result }}
run: |
echo "platforms: $PLATFORMS, test: $TEST, variants: $VARIANTS"
[ "$PLATFORMS" = success ] && [ "$TEST" = success ] && [ "$VARIANTS" = success ]
# The two other builds of the Linux tests, in parallel with the test job instead of after it (they
# added ~10 min to its critical path). Each restores the test job's cache (`shared-key: test` is
# that job's key) and saves none: the dependencies are the same, and the 10 GB cache budget is
# tight (#2688).
test-variants:
timeout-minutes: 100
name: test (${{ matrix.variant }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
variant: [without heif, with craft-fonts]
steps:
- uses: actions/checkout@v4
- name: Install Linux system dependencies (egui/winit/rfd)
run: |
nohup sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc /usr/local/.ghcup \
/opt/hostedtoolcache/CodeQL </dev/null >/dev/null 2>&1 &
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \
libgl1-mesa-dev libgtk-3-dev
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
shared-key: test
save-if: false
- uses: taiki-e/install-action@83ac0ad63c0167e6f06796fab0fce28db1bf3db0 # v2.87.22
with:
tool: [email protected]
# The same codecs without the optional decoder: HEIC must stay a clear "not in this build"
# error for distributors who leave it out.
- name: Test without heif
if: runner.os == 'Linux'
if: matrix.variant == 'without heif'
run: cargo nextest run -p photocraft-heif -p photocraft-codecs -p photocraft-io --locked --profile ci
# The same tests built with the optional craft-fonts input (crates/text/build.rs), so the
# Japanese font tests that skip without it run here. Linux only. Keep the exact `--workspace
# --features heif` of the Test step: any other package or feature selection resolves a
# different feature set, recompiles the whole dependency graph into the same target dir and
# runs the runner out of disk (as `-p photocraft-text -p photocraft-ui-egui` did after #800).
# Only the tests of the crates that read `CRAFT_FONTS` run (the `-E` filter changes what runs,
# not what is built); engine and the apps only pass text on to them (#6120).
# The tests built with the optional craft-fonts input (crates/text/build.rs), so the Japanese
# font tests that skip without it run. Keep the test job's exact `--workspace --features heif
# --lib --bins --tests`, so the restored dependencies match and only the workspace rebuilds.
# Filter execution to the three crates that read the optional font input (#6127).
- name: Check out craft-fonts
if: runner.os == 'Linux'
if: matrix.variant == 'with craft-fonts'
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
@@ -168,12 +281,12 @@ jobs:
path: craft-fonts
- name: Test with craft-fonts
if: runner.os == 'Linux'
if: matrix.variant == 'with craft-fonts'
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: >-
cargo nextest run --workspace --locked --features heif --profile ci
cargo nextest run --profile ci --workspace --locked --features heif --lib --bins --tests
-E 'package(photocraft-text) | package(photocraft-io) | package(photocraft-ui-egui)'
corpus:
@@ -314,7 +427,9 @@ jobs:
run: cargo install cargo-fuzz --locked
- name: Fuzz
working-directory: crates/${{ matrix.target.crate }}
run: cargo fuzz run ${{ matrix.target.name }} ${{ matrix.target.args }} -- -max_total_time=${{ inputs.fuzz_seconds }}
env:
FUZZ_SECONDS: ${{ inputs.fuzz_seconds }}
run: cargo fuzz run ${{ matrix.target.name }} ${{ matrix.target.args }} -- -max_total_time="$FUZZ_SECONDS"
- name: Upload crash artifacts
if: failure()
uses: actions/upload-artifact@v4
+7 -35
View File
@@ -27,38 +27,10 @@ permissions:
jobs:
freebsd:
name: FreeBSD 14 (build + test + package)
runs-on: ubuntu-latest
timeout-minutes: 300
steps:
- uses: actions/checkout@v4
- name: Build, test and package in a FreeBSD VM
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
copyback: false
prepare: |
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
run: |
set -e
. "$HOME/.cargo/env"
# The checkout is synced in under another owner; let tests that call git read it.
git config --global --add safe.directory "*"
rustc --version
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked --features heif
CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked --features heif --exclude photocraft-ui-egui
CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --lib
CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --doc
# The VM's software Vulkan driver crashes (SIGSEGV) when several tests set up a GPU
# device at once, so the GPU integration tests run one at a time here.
CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --test '*' -- --test-threads=1
# The release package, as release.yml builds it. Free the debug build's disk first.
rm -rf target/debug
CARGO_BUILD_JOBS=4 bash packaging/freebsd/package.sh
tar -tzf dist/release/photocraft-*-freebsd-x86_64.tar.gz \
| grep -E '/bin/photocraft$|/bin/photocraft-cli$|/share/applications/ai\.storyteller\.photocraft\.desktop$|/share/doc/photocraft/LICENSE-MIT$'
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
with:
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
cargo-build-extra-args: "--features heif"
custom-test-command: "CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked --features heif --exclude photocraft-ui-egui && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --lib && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --doc && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --test '*' -- --test-threads=1"
run-package-check: true
+23
View File
@@ -0,0 +1,23 @@
# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation
# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice.
name: Nix
on:
push:
branches: [main]
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
pull_request:
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: nix-${{ github.ref }}
cancel-in-progress: true
jobs:
nix:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
+20
View File
@@ -0,0 +1,20 @@
name: SonarCloud
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
concurrency:
group: sonarcloud-${{ github.ref }}
cancel-in-progress: true
jobs:
sonarcloud:
# Tracked at @main so org-wide CI improvements propagate without PR churn.
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
secrets: inherit
+4 -4
View File
@@ -19,7 +19,7 @@ PhotoCraft is an open-source, native, Photoshop-comparable image editor written
| `docs/parity-checklist.md` | Generated list of every Photoshop menu item, live or missing |
| `docs/scorecard.md` | Generated scorecard: performance budgets and numbers, corpus floors, per-area checklists (tools, files, UI, type, automation, reliability, distribution), settings that do nothing |
| `crates/<name>/README.md` (where present) | Public API of that crate |
| [photocraft-corpus](https://github.com/storytold/photocraft-corpus) + `docs/development.md` › Test corpora | Real-file test oracles (our Photoshop-authored PSDs); with psd-tools, ag-psd and PngSuite fetched into `corpus/` by `cargo xtask corpus --all` at the pins in `xtask/src/corpus_pins.rs` |
| [photocraft-corpus](https://github.com/storytold/photocraft-corpus) + `docs/development.md` › Test corpora | Real-file test oracles (our Photoshop-authored PSDs and GIMP-authored XCFs); with psd-tools, ag-psd and PngSuite fetched into `corpus/` by `cargo xtask corpus --all` at the pins in `xtask/src/corpus_pins.rs` |
## 2. Workspace map
@@ -27,7 +27,7 @@ PhotoCraft is an open-source, native, Photoshop-comparable image editor written
crates/
geom cms color raster L0 foundation (geometry, ICC colour management, pixel formats + blend math, COW tiles)
psd codecs L0 standalone format crates (no workspace deps; publishable)
tablet L0 standalone pen tablet input (macOS AppKit, X11 XInput2); the one isolated unsafe crate
tablet L0 standalone pen tablet input (macOS AppKit, X11 XInput2, Windows barrel button); the one isolated unsafe crate
doc L1 document model (layers, masks, adjustments, effects, smart objects: pure data)
ops paint algo text vector L2 history, brush engine, imaging algorithms, type engine, paths/shapes
compose gpu format L3 CPU compositor (the oracle), wgpu compositor, .pcraft native format
@@ -52,7 +52,7 @@ xtask/ cargo xtask layers | wasm | ci | stats | corpus | t
People trust PhotoCraft with their work, and a crash loses it. A malformed file, a bad command or MCP param, a corrupt settings file, an odd keystroke or a full disk must produce an error the user or agent can act on, never a panic. Don't ship a feature by adding a panic path; fix a crash before building on top of it. The shared standard is `../craftrules/standards/never-crash.md`.
- **Non-test code never panics.** No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!` or `unimplemented!`. Return the crate's error type and propagate with `?`; use `ok_or(..)?`, `let .. else { return Err(..) }`, `if let`, or `unwrap_or*` where a fallback is truly correct (never one that silently corrupts a document). Unfinished features return an "unsupported" error. The only exception is a provably infallible literal: `#[allow(clippy::expect_used)]` plus `.expect("why it can't fail")`.
- **No `unsafe`.** The workspace sets `unsafe_code = "forbid"`. The one exception is the isolated helper crate `photocraft-tablet` (`crates/tablet`): winit drops pen tablet data, and reading it on macOS needs an AppKit event monitor (Objective-C interop). Only its `src/macos.rs` allows `unsafe` (`unsafe_code = "deny"` crate-wide, every block has a `SAFETY:` comment, tested against real `NSEvent`s); its X11 path and all mapping code are safe. Don't add `unsafe` anywhere else.
- **No `unsafe`.** The workspace sets `unsafe_code = "forbid"`. The one exception is the isolated helper crate `photocraft-tablet` (`crates/tablet`): winit drops pen tablet data, and reading it on macOS needs an AppKit event monitor (Objective-C interop) and on Windows a pointer message hook (`GetPointerPenInfo`). Only its `src/macos.rs` and `src/windows.rs` allow `unsafe` (`unsafe_code = "deny"` crate-wide, every block has a `SAFETY:` comment, tested against real `NSEvent`s and pointer messages); its X11 path and all mapping code are safe. Don't add `unsafe` anywhere else.
- **Input-derived numbers are hostile.** Use `get()` rather than `[i]`/`[a..b]` for indices from files, params, selections or arithmetic on them; slice strings only at char boundaries; use `checked_*`/`saturating_*` for lengths, offsets and counts; guard division by zero and NaN/inf casts; cap allocations sized by input.
- **Bound recursion** with depth limits or seen-sets (documents can be deep or cyclic).
- **Don't cascade.** Handle lock poisoning (`lock().unwrap_or_else(PoisonError::into_inner)`) and treat thread joins as `Result`s.
@@ -107,7 +107,7 @@ cargo xtask test-corpus # if you touched psd, io, codecs, compose, gpu, text
While iterating, run less and keep the full commands above as the final gate: `cargo test -p <crate> --lib --tests` skips the examples and doctests plain `cargo test` builds every time (ui-egui has 11 examples), and `cargo test -p <crate> --test <file>` runs one integration-test file. `cargo nextest run` runs tests in parallel across binaries; see `docs/development.md` › Faster test loops.
**Test corpora.** Real-file corpora live in `corpus/` (gitignored, never committed), fetched at pinned commits and sha256-verified by `cargo xtask corpus --all`: our Photoshop-authored oracles from https://github.com/storytold/photocraft-corpus plus psd-tools, ag-psd and PngSuite from their upstreams. Pins: `xtask/src/corpus_pins.rs`. The corpus tests are opt-in (cargo feature `corpus`): plain `cargo test` skips them, and with the feature on a missing corpus fails ("run `cargo xtask corpus --all`"). `cargo xtask test-corpus` fetches and runs them all. CI always runs them (the `corpus` job, cached by pin). Never commit corpus files; new oracles go to photocraft-corpus (its `AGENTS.md`), then a pin bump here. Details: `docs/development.md` › Test corpora.
**Test corpora.** Real-file corpora live in `corpus/` (gitignored, never committed), fetched at pinned commits and sha256-verified by `cargo xtask corpus --all`: our Photoshop- and GIMP-authored oracles from https://github.com/storytold/photocraft-corpus plus psd-tools, ag-psd and PngSuite from their upstreams. Pins: `xtask/src/corpus_pins.rs`. The corpus tests are opt-in (cargo feature `corpus`): plain `cargo test` skips them, and with the feature on a missing corpus fails ("run `cargo xtask corpus --all`"). `cargo xtask test-corpus` fetches and runs them all. CI always runs them (the `corpus` job, cached by pin). Never commit corpus files; new oracles go to photocraft-corpus (its `AGENTS.md`), then a pin bump here. Details: `docs/development.md` › Test corpora.
Commands must **never panic** on bad input (Rule 9): every `run` closure and the code it calls returns `Err`, not a panic, for any params or document state. New commands come with a graceful-failure test (empty/out-of-range/wrong-type params → `Err`, not a crash).
+5 -2
View File
@@ -27,7 +27,7 @@ next to them.
| `crates/ui-egui/src/i18n/sr.tsv` | Serbian (Latin, Ekavian) UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/{check,chevron-down,chevron-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/sampler.svg` | Color Sampler tool glyph: a target reticle marking a sampled pixel | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
@@ -38,11 +38,12 @@ next to them.
| `assets/icons/marquee-row.svg`, `assets/icons/marquee-column.svg` | Single Row and Single Column Marquee tool glyphs: a dashed row (column) between two edge bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/clip-below.svg`, `assets/icons/clip-release.svg` | Clipping-mask cursors (⌥ over the line between two layers): a hooked arrow onto the layer below; the same with a small "no" badge to release | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/layer-mask.svg`, `assets/icons/adjustment-layer.svg` | Layers panel footer glyphs: Add a mask (a frame around a solid disc) and New Fill or Adjustment Layer (a circle, one half solid), drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/ellipse-dashed.svg` | Elliptical Marquee tool glyph: a dashed horizontal ellipse, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/frame-placeholder.svg` | Frame tool glyph: an empty placeholder frame, crossed corner to corner | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/gradient.svg` | Gradient tool glyph: a rounded rectangle filled with a left-to-right linear ramp, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/eraser-background.svg`, `assets/icons/eraser-magic.svg` | Background Eraser and Magic Eraser tool glyphs: Lucide `eraser` plus added marks | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/eraser.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/brush-replace.svg` | Color Replacement tool glyph: Lucide `brush` plus two swap arrows | Lucide Icons and Contributors; arrows by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/quick-selection.svg` | Quick Selection tool glyph: Lucide `brush` plus a dashed selection arc around the bristles | Lucide Icons and Contributors; arc by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/quick-selection.svg` | Quick Selection tool glyph: Lucide `brush`, scaled to 70%, inside a dashed upright rounded "C" selection | Lucide Icons and Contributors; selection by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/bandage-sparkle.svg` | Remove tool glyph: Lucide `bandage` moved down, plus a sparkle mark | Lucide Icons and Contributors; mark by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/bandage.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/direct-select.svg` | Direct Selection tool glyph: Lucide `mouse-pointer-2` plus an anchor square | Lucide Icons and Contributors; square by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/mouse-pointer-2.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/pen-freeform.svg`, `assets/icons/pen-curvature.svg` | Freeform Pen and Curvature Pen tool glyphs: Lucide `pen-tool` scaled down, plus a freehand squiggle or a curve through two points | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/pen-tool.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
@@ -57,6 +58,7 @@ next to them.
| `crates/ui-egui/src/theme.rs` (Solarized Dark palette) | Solarized colour scheme used by the Solarized Dark theme | Ethan Schoonover | <https://ethanschoonover.com/solarized/> | MIT, [`LICENSE-solarized.txt`](crates/ui-egui/src/LICENSE-solarized.txt) |
| `crates/ui-egui/src/theme.rs` (Breeze Light and Breeze Dark themes) | The Breeze Light and Breeze Dark themes are original colour values on PhotoCraft's Studio layout, chosen to look close to KDE Plasma's Breeze (cool blue-greys, blue accent, 5 px frame radius) so the app blends into a Plasma desktop. None of Breeze's colour values, files or code are copied; the values are a few levels away from its schemes and tuned for text contrast | PhotoCraft contributors | Inspired by the look of KDE Breeze, <https://invent.kde.org/plasma/breeze> (its colour schemes are LGPL-2.0-or-later) | Original work |
| `crates/ui-egui/src/theme.rs` (Soft Dark and Soft Light themes) | Original colour values and radii for two low-contrast themes on PhotoCraft's Studio layout; no third-party palette, file or code is used | PhotoCraft contributors | Original | Original work |
| `crates/algo/data/libfacedetection.bin` (face detection weights, read by `crates/algo/src/faces.rs`, whose network code is a port of the library's) | libfacedetection CNN (YuNet design) weights and inference code, weights converted to half precision | Shiqi Yu | [ShiqiYu/libfacedetection](https://github.com/ShiqiYu/libfacedetection) (`src/facedetectcnn-data.cpp`, `facedetectcnn.cpp`, `facedetectcnn-model.cpp`) | BSD-3-Clause, [`LICENSE-libfacedetection.txt`](crates/algo/data/LICENSE-libfacedetection.txt) |
### Optional build input: craft-fonts (not files in this repo)
@@ -108,6 +110,7 @@ next to the files:
| Path (fetched) | Title | Author | Source | License |
|---|---|---|---|---|
| `corpus/photoshop/` (256 PSDs) | Photoshop oracle corpus: smart filters, layer-style effects, type, adjustments in every mode and depth | PhotoCraft contributors (authored with Adobe Photoshop 2026 by a script) | [https://github.com/storytold/photocraft-corpus](https://github.com/storytold/photocraft-corpus) (`photoshop/`, with its generator and README) | MIT OR Apache-2.0 |
| `corpus/gimp/` (28 files, 251 KB) | GIMP XCF oracles: 40×30 procedural layers written by GIMP 3.2.6 through the corpus generator `tools/gimp-xcf/xcf_fixtures.py`, each with GIMP's merged rendering as a PNG | PhotoCraft contributors (authored with GIMP 3.2.6 by a script) | [https://github.com/storytold/photocraft-corpus](https://github.com/storytold/photocraft-corpus) (`gimp/`, with its generator and README) | MIT OR Apache-2.0 |
| `corpus/psd-tools/` (309 files) | psd-tools test set | Kota Yamaguchi and contributors | [psd-tools `tests/psd_files`](https://github.com/psd-tools/psd-tools/tree/main/tests/psd_files) | MIT, Copyright (c) 2019 Kota Yamaguchi |
| `corpus/psd/` (170 files) | Small selection of the psd-tools and ag-psd test files | Kota Yamaguchi; Agamnentzar | psd-tools (above) and [ag-psd `test/`](https://github.com/Agamnentzar/ag-psd/tree/master/test) | MIT (both) |
| `corpus/heif/` (9 files, 0.1 MB) | HEIC/HEIF test files: `heic-rs/` checkerboards, RGB strips and a grid-tiled photo with EXIF and XMP (synthetic pixels encoded by macOS `sips`, each `.ref.png` Apple's decode); `pillow-heif/` the 10-bit RGBA `RGBA_10__29x100.heif` and its source `RGBA_16__29x100.png` | Thomas Braun (heic-rs); Pillow-Heif contributors | [heic-rs `tests/fixtures`](https://github.com/tbraun96/heic-rs/tree/main/tests/fixtures), [pillow-heif `tests/images`](https://github.com/bigcat88/pillow_heif/tree/master/tests/images) | MIT OR Apache-2.0 (heic-rs); BSD-3-Clause (pillow-heif) |
Generated
+109 -48
View File
@@ -849,7 +849,7 @@ dependencies = [
"maybe-owned",
"rustix 1.1.5",
"rustix-linux-procfs",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
"winx",
]
@@ -1222,6 +1222,11 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
[[package]]
name = "craft-print"
version = "0.1.0"
@@ -1229,7 +1234,7 @@ source = "git+https://github.com/storytold/craft-libs?rev=3d37da389a3618ab9b7610
dependencies = [
"craft-winprint-gdi",
"miniz_oxide 0.8.9",
"roxmltree",
"roxmltree 0.20.0",
"tempfile",
"winprint",
"winsafe",
@@ -1891,7 +1896,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -1929,6 +1934,18 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "exacl"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de9e551ced9d700a31dacb8b168129640240fcd7a279a3fa4d59608b84ebcae"
dependencies = [
"bitflags 2.13.2",
"log",
"scopeguard",
"uuid",
]
[[package]]
name = "exr"
version = "1.74.2"
@@ -2099,7 +2116,7 @@ version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646"
dependencies = [
"roxmltree",
"roxmltree 0.20.0",
]
[[package]]
@@ -2124,9 +2141,17 @@ dependencies = [
"hashbrown 0.17.1",
"linebender_resource_handle",
"memmap2",
"objc2 0.6.4",
"objc2-core-foundation",
"objc2-core-text",
"objc2-foundation 0.3.2",
"parlance",
"read-fonts",
"roxmltree 0.21.1",
"smallvec",
"windows",
"windows-core",
"yeslogic-fontconfig-sys",
]
[[package]]
@@ -3028,7 +3053,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3690,7 +3715,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"png 0.18.1",
"thiserror 2.0.21",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -4436,7 +4461,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4613,7 +4638,7 @@ dependencies = [
[[package]]
name = "photocraft"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"arboard",
"async-io",
@@ -4646,6 +4671,7 @@ dependencies = [
"rfd",
"ron",
"serde_json",
"tempfile",
"winit",
"winresource",
"winsafe",
@@ -4657,7 +4683,7 @@ dependencies = [
[[package]]
name = "photocraft-affinity"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"crc32fast",
"flate2",
@@ -4667,7 +4693,7 @@ dependencies = [
[[package]]
name = "photocraft-algo"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4680,7 +4706,7 @@ dependencies = [
[[package]]
name = "photocraft-automation"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"base64 0.22.1",
"cap-std",
@@ -4693,6 +4719,7 @@ dependencies = [
"photocraft-geom",
"photocraft-io",
"rmcp",
"rustix 1.1.5",
"schemars",
"serde",
"serde_json",
@@ -4702,7 +4729,7 @@ dependencies = [
[[package]]
name = "photocraft-cli"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"photocraft-automation",
"photocraft-codecs",
@@ -4716,7 +4743,7 @@ dependencies = [
[[package]]
name = "photocraft-cms"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"moxcms",
"rayon",
@@ -4724,7 +4751,7 @@ dependencies = [
[[package]]
name = "photocraft-codecs"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"exr",
"flate2",
@@ -4745,7 +4772,7 @@ dependencies = [
[[package]]
name = "photocraft-color"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"photocraft-cms",
"serde",
@@ -4753,7 +4780,7 @@ dependencies = [
[[package]]
name = "photocraft-compose"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"criterion",
"photocraft-color",
@@ -4768,7 +4795,7 @@ dependencies = [
[[package]]
name = "photocraft-doc"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4779,7 +4806,7 @@ dependencies = [
[[package]]
name = "photocraft-engine"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"blake3",
"craft-print",
@@ -4795,6 +4822,7 @@ dependencies = [
"photocraft-format",
"photocraft-geom",
"photocraft-io",
"photocraft-ipp",
"photocraft-ops",
"photocraft-paint",
"photocraft-plugins",
@@ -4804,20 +4832,23 @@ dependencies = [
"photocraft-text",
"photocraft-vector",
"rayon",
"roxmltree",
"roxmltree 0.20.0",
"same-file",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.21",
"wat",
"winsafe",
]
[[package]]
name = "photocraft-format"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"blake3",
"crc32fast",
"exacl",
"flate2",
"libc",
"photocraft-codecs",
@@ -4831,21 +4862,23 @@ dependencies = [
"serde",
"serde_json",
"thiserror 2.0.21",
"winsafe",
]
[[package]]
name = "photocraft-geom"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"serde",
]
[[package]]
name = "photocraft-gpu"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"half",
"log",
"photocraft-cms",
"photocraft-color",
"photocraft-compose",
"photocraft-doc",
@@ -4859,16 +4892,17 @@ dependencies = [
[[package]]
name = "photocraft-heif"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"heic-rs",
]
[[package]]
name = "photocraft-io"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"blake3",
"craft-docpath",
"crc32fast",
"flate2",
"photocraft-affinity",
@@ -4888,21 +4922,25 @@ dependencies = [
"photocraft-vector",
"proptest",
"resvg",
"roxmltree",
"roxmltree 0.20.0",
"serde_json",
"thiserror 2.0.21",
]
[[package]]
name = "photocraft-ipp"
version = "0.12.0"
[[package]]
name = "photocraft-ops"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"photocraft-doc",
]
[[package]]
name = "photocraft-paint"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4913,7 +4951,7 @@ dependencies = [
[[package]]
name = "photocraft-plugins"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4928,7 +4966,7 @@ dependencies = [
[[package]]
name = "photocraft-psd"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"flate2",
"photocraft-psd",
@@ -4939,17 +4977,18 @@ dependencies = [
[[package]]
name = "photocraft-raster"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
"proptest",
"rayon",
"serde",
]
[[package]]
name = "photocraft-raw"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"flate2",
"image",
@@ -4964,7 +5003,7 @@ dependencies = [
[[package]]
name = "photocraft-tablet"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"block2 0.6.2",
"objc2 0.6.4",
@@ -4976,7 +5015,7 @@ dependencies = [
[[package]]
name = "photocraft-testkit"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"memory-stats",
"photocraft-color",
@@ -4987,7 +5026,7 @@ dependencies = [
[[package]]
name = "photocraft-text"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"flate2",
"parley",
@@ -4996,12 +5035,13 @@ dependencies = [
"photocraft-geom",
"photocraft-psd",
"photocraft-raster",
"rayon",
"skrifa",
]
[[package]]
name = "photocraft-ui-egui"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"base64 0.22.1",
"craft-print",
@@ -5037,12 +5077,13 @@ dependencies = [
"serde_json",
"sys-locale",
"toml",
"unicode-bidi",
"winsafe",
]
[[package]]
name = "photocraft-vector"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"photocraft-color",
"photocraft-doc",
@@ -5053,7 +5094,7 @@ dependencies = [
[[package]]
name = "photocraft-web"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"eframe",
"egui",
@@ -5075,7 +5116,7 @@ dependencies = [
[[package]]
name = "photocraft-web-boot"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"flate2",
"js-sys",
@@ -5468,7 +5509,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5929,6 +5970,15 @@ version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
[[package]]
name = "roxmltree"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
dependencies = [
"memchr",
]
[[package]]
name = "rustc-hash"
version = "1.1.0"
@@ -5987,7 +6037,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6054,7 +6104,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6692,7 +6742,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -6919,10 +6969,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.5",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -7322,7 +7372,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -7429,7 +7479,7 @@ dependencies = [
"kurbo 0.11.3",
"log",
"pico-args",
"roxmltree",
"roxmltree 0.20.0",
"rustybuzz",
"simplecss",
"siphasher",
@@ -8082,7 +8132,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -8662,7 +8712,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
[[package]]
name = "xtask"
version = "0.9.0"
version = "0.12.0"
dependencies = [
"serde",
"serde_json",
@@ -8675,6 +8725,17 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
[[package]]
name = "yeslogic-fontconfig-sys"
version = "6.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76"
dependencies = [
"dlib",
"once_cell",
"pkg-config",
]
[[package]]
name = "yoke"
version = "0.8.3"
+32 -6
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"]
[workspace.package]
version = "0.9.0"
version = "0.12.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -36,9 +36,11 @@ photocraft-engine = { path = "crates/engine" }
photocraft-ui-egui = { path = "crates/ui-egui" }
photocraft-testkit = { path = "crates/testkit" }
photocraft-tablet = { path = "crates/tablet" }
photocraft-ipp = { path = "crates/ipp" }
# external
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "3d37da389a3618ab9b76109da11416e9ca4ea719" }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
thiserror = "2"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = "1"
@@ -75,9 +77,18 @@ opt-level = 2
opt-level = 1
# Line tables keep file:line in panic backtraces but drop variable info, which roughly halves link
# time and PDB size for the ~260 test binaries (#2204). CI sets the same through
# CARGO_PROFILE_DEV_DEBUG. To inspect locals in a debugger, build with CARGO_PROFILE_DEV_DEBUG=true.
# CARGO_PROFILE_DEV_DEBUG (and no debuginfo at all for the Windows tests). To inspect locals in a
# debugger, build with CARGO_PROFILE_DEV_DEBUG=true.
debug = "line-tables-only"
# Test builds compile ui-egui without optimization: its library and unit tests were the biggest
# test-build units after engine's (12.6 of ~63 compile minutes on the Windows CI runner), and its
# tests run no heavier pixel code of their own (that lives in the crates that stay optimized).
# Measured on CI: Windows build 18.9 -> 15.4 min, test run 10.0 -> 10.5. `cargo run` and
# `cargo build` use the dev profile and are unaffected.
[profile.test.package.photocraft-ui-egui]
opt-level = 0
[profile.release]
lto = "thin"
codegen-units = 1
@@ -127,17 +138,32 @@ opt-level = "z"
[profile.wasm-release.package.naga]
opt-level = "z"
# Pixel processing keeps its speed-oriented optimization level.
# The browser UI toolkit favors size to stay within the WebAssembly upload budget.
[profile.wasm-release.package.egui]
opt-level = "z"
[profile.wasm-release.package.epaint]
opt-level = "z"
[profile.wasm-release.package.eframe]
opt-level = "z"
# Pixel processing keeps its speed-oriented optimization level, with two exceptions.
# Colour-management transforms are built once and cached (`transform::cached`), so "z" costs
# little runtime. The browser app composites per frame on the GPU (`crates/gpu`, WebGPU/WebGL2),
# so the CPU compositor is not the browser's hot path there; "z" for compose keeps the wasm
# under the 24 MiB gate (measured −122 KB vs opt-level 3). algo's filters run per pixel on
# every interactive filter application; "z" there is the size lever of last resort (−331 KB),
# taken because the gate is enforced in CI: if browser filters feel too slow, restore 3 here
# and reclaim the bytes from a feature-gated decoder instead.
[profile.wasm-release.package.photocraft-raster]
opt-level = 3
[profile.wasm-release.package.photocraft-color]
opt-level = 3
[profile.wasm-release.package.photocraft-cms]
opt-level = 3
opt-level = "z"
[profile.wasm-release.package.photocraft-compose]
opt-level = 3
opt-level = "z"
[profile.wasm-release.package.photocraft-algo]
opt-level = 3
opt-level = "z"
[profile.wasm-release.package.photocraft-paint]
opt-level = 3
+1
View File
@@ -16,6 +16,7 @@ Inter font (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/
JetBrains Mono font (assets/fonts/JetBrainsMono-Regular.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-JetBrainsMono.txt.
Lucide icons (assets/icons/*.svg, except slice-knife.svg) — ISC; icons derived from Feather also MIT. See assets/icons/LICENSE-lucide.txt.
SCOWL English word list (assets/dict/en_US-scowl-50.txt.gz) — SCOWL license. See assets/dict/LICENSE-SCOWL.txt.
libfacedetection face detection weights and network (crates/algo/data/libfacedetection.bin, crates/algo/src/faces/cnn.rs) — BSD-3-Clause, Copyright (c) 2015-2019, Shiqi Yu, all rights reserved. See crates/algo/data/LICENSE-libfacedetection.txt.
Builds made with craft-fonts (CRAFT_FONTS_DIR; all official releases) also embed the fonts listed in
https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (today BIZ UDPGothic, Shippori Mincho and
+19 -1
View File
@@ -20,6 +20,9 @@
<img alt="macOS · Windows · Linux · FreeBSD · Web" src="https://img.shields.io/badge/macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20FreeBSD%20%C2%B7%20Web-native-2f7bf5?style=flat-square">
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%2F%20Apache--2.0-3a3a3a?style=flat-square">
<img alt="Status: early alpha" src="https://img.shields.io/badge/status-early%20alpha-d69e2e?style=flat-square">
<br>
<a href="https://sonarcloud.io/project/overview?id=storytold_photocraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/photocraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
</p>
<p align="center">
@@ -199,7 +202,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
</td>
<td width="33%" valign="top">
<h4>🗂️ Formats</h4>
PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), <a href="docs/pdn.md">Paint.NET PDN3 (read, with editable layers and all 14 blend modes)</a>, <a href="docs/ora.md">OpenRaster (read and write, with layers, groups and blend modes, for Krita, MyPaint and GIMP)</a>, <a href="docs/kra.md">Krita .kra (read, with paint layers, groups, blend modes and transparency masks)</a>, SVG (opens as shape layers, places as a vector Smart Object), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and FITS (astronomy images: 8/16-bit and 32-bit float, gray or RGB, header kept), and the native <code>.pcraft</code> format. Flat formats retain the depths they support: PNG keeps 8/16-bit samples, TIFF keeps 8/16-bit and 32-bit float samples, and OpenEXR keeps half/float samples; JPEG, WebP and the other 8-bit formats reduce higher-depth samples with an export warning. See the <a href="crates/codecs/README.md#capability-matrix-default-build">codec capability matrix</a> for each format. HEIC photos from iPhone and Mac are read-only (included in official builds; optional <code>--features heif</code> for source builds). AVIF is write-only with the optional <code>avif</code> build feature; AVIF decoding and JPEG XL are unavailable. Affinity documents (<code>.af</code>, <code>.afdesign</code>, <code>.afphoto</code>, <code>.afpub</code>) open natively, read only, with artboards, layers, vectors, text, images and masks; effects, adjustments and other unsupported parts are listed in a warning, and Affinity export is unavailable.
PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), <a href="docs/pdn.md">Paint.NET PDN3 (read, with editable layers and all 14 blend modes)</a>, <a href="docs/ora.md">OpenRaster (read and write, with layers, groups and blend modes, for Krita, MyPaint and GIMP)</a>, <a href="docs/xcf.md">GIMP XCF (read, with layers, groups, masks, blend modes, channels and the selection)</a>, <a href="docs/kra.md">Krita .kra (read, with paint layers, groups, blend modes and transparency masks)</a>, SVG (opens as shape layers, places as a vector Smart Object), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and FITS (astronomy images: 8/16-bit and 32-bit float, gray or RGB, header kept), and the native <code>.pcraft</code> format. Flat formats retain the depths they support: PNG keeps 8/16-bit samples, TIFF keeps 8/16-bit and 32-bit float samples, and OpenEXR keeps half/float samples; JPEG, WebP and the other 8-bit formats reduce higher-depth samples with an export warning. See the <a href="crates/codecs/README.md#capability-matrix-default-build">codec capability matrix</a> for each format. HEIC photos from iPhone and Mac are read-only (included in official builds; optional <code>--features heif</code> for source builds). AVIF is write-only with the optional <code>avif</code> build feature; AVIF decoding and JPEG XL are unavailable. Affinity documents (<code>.af</code>, <code>.afdesign</code>, <code>.afphoto</code>, <code>.afpub</code>) open natively, read only, with artboards, layers, vectors, text, images and masks; effects, adjustments and other unsupported parts are listed in a warning, and Affinity export is unavailable.
</td>
<td width="33%" valign="top">
<h4>🪄 The everyday essentials</h4>
@@ -275,6 +278,8 @@ flatpak install --user photocraft-<version>-linux-x86_64.flatpak # or -linux-a
flatpak run ai.storyteller.photocraft
```
The Flatpak prints through the host's CUPS socket (File › Print talks IPP to it directly, no `lp` needed); without CUPS, or without a printer, File › Print opens the system print dialog through the print portal. See [printing](docs/printing.md).
The AppImage needs no install: the first run registers its launcher icon and menu entry in `~/.local/share` so the dock shows PhotoCraft's icon on Wayland. Set `PHOTOCRAFT_NO_DESKTOP_INTEGRATION=1` to skip that, and see [`docs/releasing.md`](docs/releasing.md) › Linux to undo it.
On a Wayland session, files dropped on the window don't open yet: winit 0.30, the windowing library under egui, has no Wayland drag and drop (#386). Use File › Open, or copy the image in your file manager and paste it with Ctrl+V. To drag and drop, start PhotoCraft under XWayland: `WAYLAND_DISPLAY= photocraft`, `WAYLAND_DISPLAY= ./photocraft-<version>-linux-x86_64.AppImage`, or `flatpak run --nosocket=wayland --socket=x11 ai.storyteller.photocraft`. On Wayland, PhotoCraft shows the command for your install in a notice until you dismiss it. Preferences › Performance › Linux display server = X11 does the same on every launch. With a fractional display scale (125%, 150%, 175%) an XWayland window is drawn at a lower resolution and enlarged by the compositor, so the interface is blurred there; native Wayland stays sharp (#1072).
@@ -300,6 +305,19 @@ Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are
> [!IMPORTANT]
> **Status:** PhotoCraft is in early alpha, and we want to be straight about where it stands: much of Photoshop's feature surface exists in some form, but **it is not yet a Photoshop replacement for daily professional work**. The biggest gaps are AI/generative features, 15 missing tools, depth in typography and pro workflows, and plug-in compatibility. Every Photoshop menu item is wired to a command ([`docs/parity-checklist.md`](docs/parity-checklist.md)), but that measures wiring, not behaviour. The honest, dimension-by-dimension picture and where we're going next are in the [roadmap](ROADMAP.md) and [parity assessment](docs/target-app-parity.md). Expect rough edges, and please file issues (include your OS, document size, layer count and a screenshot). You can also tell us what broke on [Discord](https://discord.gg/artcraft).
## Community packages
These installers are maintained by community members, not by the PhotoCraft team.
On Gentoo, the [::snakebyte overlay](https://github.com/switch87/snakebyte-overlay) packages the Linux release as `media-gfx/photocraft-bin`:
```sh
eselect repository add snakebyte git https://github.com/switch87/snakebyte-overlay.git
emaint sync -r snakebyte
echo 'media-gfx/photocraft-bin ~amd64' >> /etc/portage/package.accept_keywords/photocraft
emerge --ask media-gfx/photocraft-bin
```
## Documentation
Developer, architecture, automation, format, and security documentation is maintained in the [PhotoCraft documentation book](book/).
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "photocraft-cli"
description = "Headless Photocraft: convert, inspect, run commands, batch, MCP server"
description = "Headless PhotoCraft: convert, inspect, run commands, batch, MCP server"
version.workspace = true
edition.workspace = true
license.workspace = true
+8
View File
@@ -12,6 +12,14 @@ fn main() -> std::io::Result<()> {
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
return Ok(());
}
// Printing to PDF (winprint's `PdfCreateRenderer`) imports Windows.Data.Pdf.dll, which
// stripped Windows images (NTLite, some N/LTSC builds) lack. A load-time import stops the exe
// from starting at all ("windows.data.pdf.dll was not found", #5884), so load it on first use;
// the print command checks for it first and reports an error instead.
if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") {
println!("cargo:rustc-link-arg-bins=/DELAYLOAD:windows.data.pdf.dll");
println!("cargo:rustc-link-arg-bins=delayimp.lib");
}
let mut res = winresource::WindowsResource::new();
// FileVersion / ProductVersion strings and the numeric FILEVERSION / PRODUCTVERSION are left
// to `WindowsResource::new`, which reads the Cargo package version. A pre-release such as
+77 -11
View File
@@ -1,4 +1,4 @@
//! Headless Photocraft command line. `run` is the whole program, so it can be
//! Headless PhotoCraft command line. `run` is the whole program, so it can be
//! tested in-process as well as through the binary.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
@@ -12,22 +12,24 @@ use photocraft_io::ExportOptions;
use serde_json::{Value, json};
pub const USAGE: &str = "\
photocraft-cli: headless Photocraft
photocraft-cli: headless PhotoCraft
USAGE:
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>]
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps]
Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …).
TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data).
--quality sets the JPEG or WebP quality; a WebP written with a quality is lossy, without one lossless.
--tga-bits sets a TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as
its alpha, 24 none. Without it: 32 with an alpha channel or transparency, else 24.
--dpx-bits sets a DPX's bits per sample. Without it: 8 for an 8-bit document, else 16.
--dds-compression sets a DDS texture's compression: none (uncompressed, the default), bc1
(DXT1: alpha on or off), bc2 (DXT3) or bc3 (DXT5). --dds-mipmaps also writes the full mip chain.
photocraft-cli info <file> [--compact]
Print the document as JSON (size, mode, depth, layer tree).
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>]
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps]
Open a file, run engine commands in order, save the result. Each --params
applies to the preceding --cmd. Prints each command's JSON result.
photocraft-cli batch --actions <actions.json|set.atn> [--action <name>] --in <dir> --out <dir> [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>]
photocraft-cli batch --actions <actions.json|set.atn> [--action <name>] --in <dir> --out <dir> [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps]
Apply an action list to every image in a directory. A Photoshop Actions file (.atn) is a
set: its action --action names plays, as below. JSON steps are [id, params] pairs,
{\"command\": id, \"params\": {…}} objects or bare ids, as a recorded action or droplet stores them
@@ -69,18 +71,23 @@ struct Subcommand {
}
const SUBCOMMANDS: &[Subcommand] = &[
Subcommand { name: "convert", values: &["--format", "--quality", "--tga-bits", "--dpx-bits"], bare: &["--tiff-layers"], run: convert },
Subcommand {
name: "convert",
values: &["--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression"],
bare: &["--tiff-layers", "--dds-mipmaps"],
run: convert,
},
Subcommand { name: "info", values: &[], bare: &["--compact"], run: |a, out, _| info(a, out) },
Subcommand {
name: "run",
values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits"],
bare: &["--tiff-layers"],
values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression"],
bare: &["--tiff-layers", "--dds-mipmaps"],
run: run_cmds,
},
Subcommand {
name: "batch",
values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits"],
bare: &["--in-place", "--tiff-layers"],
values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression"],
bare: &["--in-place", "--tiff-layers", "--dds-mipmaps"],
run: batch,
},
Subcommand { name: "droplet", values: &["--out"], bare: &[], run: droplet },
@@ -219,6 +226,11 @@ fn export_opts(a: &Args) -> Result<ExportOptions, String> {
o.encode.dpx_bits =
Some(b.parse().ok().and_then(photocraft_io::DpxBits::from_bits).ok_or_else(|| format!("bad --dpx-bits `{b}`: expected 8, 10, 12 or 16"))?);
}
if let Some(c) = a.get("--dds-compression") {
o.encode.dds.compression =
photocraft_io::DdsCompression::from_name(c).ok_or_else(|| format!("bad --dds-compression `{c}`: expected none, bc1, bc2 or bc3"))?;
}
o.encode.dds.mipmaps = a.has("--dds-mipmaps");
Ok(o)
}
@@ -444,11 +456,32 @@ fn is_input(p: &Path) -> bool {
p.is_file() && p.extension().is_some_and(|e| known(&e.to_string_lossy().to_ascii_lowercase()))
}
/// Drops the Save and view steps the desktop records from `action` (Batch saves each result
/// itself, #3143), keeping its unchecked and dialog flags on the steps that remain (#6860).
fn strip_shell_steps(action: &mut Action) {
use photocraft_engine::actions_cmds::{shell_save_command, shell_view_command};
let (mut steps, mut disabled, mut dialogs) = (Vec::new(), std::collections::BTreeSet::new(), std::collections::BTreeSet::new());
for (i, (id, p)) in std::mem::take(&mut action.steps).into_iter().enumerate() {
if shell_save_command(&id) || shell_view_command(&id) {
continue;
}
if action.disabled.contains(&i) {
disabled.insert(steps.len());
}
if action.dialogs.contains(&i) {
dialogs.insert(steps.len());
}
steps.push((id, p));
}
(action.steps, action.disabled, action.dialogs) = (steps, disabled, dialogs);
}
fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
let actions_path = a.get("--actions").ok_or("batch needs --actions <file>")?;
let in_dir = PathBuf::from(a.get("--in").ok_or("batch needs --in <dir>")?);
let out_dir = PathBuf::from(a.get("--out").ok_or("batch needs --out <dir>")?);
let (actions, set, warnings) = load_actions_file(actions_path, a.get("--action"))?;
let (actions, mut set, warnings) = load_actions_file(actions_path, a.get("--action"))?;
set.iter_mut().for_each(strip_shell_steps);
for w in &warnings {
let _ = writeln!(err, "warning: {w}");
}
@@ -751,4 +784,37 @@ mod batch_action_set_tests {
}
let _ = std::fs::remove_dir_all(&dir);
}
/// Save and view steps recorded inside a batched action set are skipped, also in a called
/// action, and unchecked steps stay unchecked once they are gone (#6860).
#[test]
fn batch_skips_save_and_view_steps_inside_an_action_set() {
let dir = std::env::temp_dir().join(format!("photocraft-cli-action-set-save-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = |name: &str| dir.join(name).to_string_lossy().to_string();
std::fs::create_dir_all(dir.join("in")).unwrap();
let (code, _, stderr) = invoke(&["run", "--new", r#"{"width":8,"height":6}"#, "--out", &path("in/a.png")]);
assert_eq!(code, 0, "{stderr}");
let set = json!({"version": 1, "actions": [
{"name": "Outer", "steps": [["actions.play", {"action": "Rotate"}], ["file.saveAs", {}]]},
{"name": "Rotate", "steps": [
["file.save", {}],
["image.imageRotation.90cw", {}],
["view.fitOnScreen", {}],
["image.imageRotation.90cw", {}],
["file.saveAs", {}],
], "disabled": [3], "dialogs": [1]},
]});
std::fs::write(dir.join("actions.json"), set.to_string()).unwrap();
for action in ["Outer", "Rotate"] {
let (code, stdout, stderr) =
invoke(&["batch", "--actions", &path("actions.json"), "--action", action, "--in", &path("in"), "--out", &path(action)]);
assert_eq!(code, 0, "{stdout}{stderr}");
let (code, info, stderr) = invoke(&["info", &path(&format!("{action}/a.png")), "--compact"]);
assert_eq!(code, 0, "{stderr}");
let info: Value = serde_json::from_str(&info).unwrap();
assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(6), Some(8)), "{action}: rotated once, the unchecked step skipped");
}
let _ = std::fs::remove_dir_all(&dir);
}
}
+48 -5
View File
@@ -163,19 +163,29 @@ fn info_and_convert_report_decode_warnings() {
gif.extend_from_slice(b"\x2C\0\0\0\0\x01\0\x01\0\0\x02\x02\x44\x01\0");
}
gif.push(0x3B);
// A GIF opens in Indexed Color (#7886), so `convert` to PNG also notes the palette PNG it
// writes, after the decode warning.
let cases = [
("whole.jpg", &jpeg[..], None),
("half.jpg", &jpeg[..jpeg.len() / 2], Some("JPEG data ends early (the file is truncated or damaged); part of the image is missing")),
("anim.gif", &gif[..], Some("only the first of 3 frames was imported")),
("whole.jpg", &jpeg[..], None, None),
("half.jpg", &jpeg[..jpeg.len() / 2], Some("JPEG data ends early (the file is truncated or damaged); part of the image is missing"), None),
("anim.gif", &gif[..], Some("only the first of 3 frames was imported"), Some("written as an 8-bit palette PNG (2 colours)")),
];
for (name, bytes, warning) in cases {
for (name, bytes, warning, export) in cases {
let file = d.join(name);
std::fs::write(&file, bytes).unwrap();
let (out, _) = ok(bin().arg("info").arg(&file).arg("--compact"));
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["warnings"], json!(warning.into_iter().collect::<Vec<_>>()), "{name}");
let (_, err) = ok(bin().arg("convert").arg(&file).arg(d.join(format!("{name}.png"))));
assert_eq!(err.trim(), warning.map(|w| format!("warning: {w}")).unwrap_or_default(), "{name}");
let expected: Vec<String> = warning.into_iter().chain(export).map(|w| format!("warning: {w}")).collect();
assert_eq!(
err.trim(),
expected.join(
"
"
),
"{name}"
);
}
std::fs::remove_dir_all(d).unwrap();
}
@@ -494,6 +504,39 @@ fn dpx_converts_both_ways_with_the_bits_asked_for() {
}
}
/// DDS converts both ways; `--dds-compression` picks the FourCC (bytes 84–87; none writes
/// uncompressed pixels) and `--dds-mipmaps` the mip count (bytes 28–31). A compression that
/// isn't none, bc1, bc2 or bc3 is refused and nothing is written (#1327).
#[test]
fn dds_converts_both_ways_with_the_compression_asked_for() {
let d = tmp("dds");
let a = d.join("a.png");
write_png(&a, 8, 4, 3);
for (args, fourcc, mips) in [
(&[][..], [0; 4], 1),
(&["--dds-compression", "bc1"][..], *b"DXT1", 1),
(&["--dds-compression", "DXT3", "--dds-mipmaps"][..], *b"DXT3", 4),
(&["--dds-compression", "bc3"][..], *b"DXT5", 1),
(&["--dds-mipmaps"][..], [0; 4], 4),
] {
let out = d.join("a.dds");
ok(bin().arg("convert").arg(&a).arg(&out).args(args));
let bytes = std::fs::read(&out).unwrap();
let mip_count = u32::from_le_bytes(bytes[28..32].try_into().unwrap());
assert_eq!((&bytes[..4], &bytes[84..88], mip_count), (&b"DDS "[..], &fourcc[..], mips), "{args:?}");
let back = d.join("back.png");
ok(bin().arg("convert").arg(&out).arg(&back));
assert!(back.exists(), "{args:?}");
}
for bad in ["bc7", "dxt2", ""] {
let out = d.join("bad.dds");
let o = bin().arg("convert").arg(&a).arg(&out).args(["--dds-compression", bad]).output().unwrap();
assert_eq!(o.status.code(), Some(1), "--dds-compression {bad}");
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --dds-compression `{bad}`")), "--dds-compression {bad}");
assert!(!out.exists(), "--dds-compression {bad} wrote a file");
}
}
/// `--tga-bits` picks a TGA's bits per pixel (byte 16 of its header); anything but 24 or 32 is
/// refused and nothing is written.
#[test]
+1 -1
View File
@@ -21,4 +21,4 @@ flate2 = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Node", "Request", "RequestInit", "RequestCache", "Response", "ResponseInit", "Headers", "ReadableStream", "WritableStream", "TransformStream", "TransformStreamDefaultController", "Transformer", "StreamPipeOptions", "ReadableWritablePair", "console"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Node", "Request", "RequestInit", "RequestCache", "Response", "ResponseInit", "Headers", "ReadableStream", "ReadableStreamDefaultReader", "WritableStream", "WritableStreamDefaultWriter", "TransformStream", "TransformStreamDefaultController", "Transformer", "StreamPipeOptions", "ReadableWritablePair", "console"] }
+117 -6
View File
@@ -8,7 +8,10 @@
//! one part, however large the app grows.
//!
//! The browser inflates the parts (`DecompressionStream`: Safari 16.4, Firefox 113, Chrome 80).
//! In one that can't, this module inflates them itself, a little slower.
//! In one that can't, this module inflates them itself, a little slower. Some servers recognise
//! the gzip in a part and send it with `Content-Encoding: gzip` (Apache's `mod_mime_magic`
//! labels it `x-gzip`), so the browser has inflated it already: the joined download is then
//! taken as it is, by its first bytes.
//!
//! A download that fails is tried again, the later tries past the browser's cache (a bad copy
//! there would otherwise fail forever). When every try fails the page says so and offers a reload.
@@ -36,6 +39,34 @@ fn parts(list: &str) -> Option<Vec<String>> {
(!parts.is_empty() && parts.iter().all(plain)).then_some(parts)
}
/// What the joined parts hold, told by their first bytes.
#[cfg(any(target_arch = "wasm32", test))]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Payload {
/// The gzip packaging made, still to be inflated.
Gzip,
/// The app's WebAssembly: the server sent the gzip with a `Content-Encoding` the browser
/// already undid.
Wasm,
}
/// Enough leading bytes to tell the payloads apart.
#[cfg(any(target_arch = "wasm32", test))]
const HEAD: usize = 4;
/// What a download starting with `head` (at least `HEAD` bytes, or all there were) holds, or
/// `None` when it is neither, such as a web page or nothing at all.
#[cfg(any(target_arch = "wasm32", test))]
fn payload(head: &[u8]) -> Option<Payload> {
if head.starts_with(&[0x1f, 0x8b]) {
Some(Payload::Gzip)
} else if head.starts_with(b"\0asm") {
Some(Payload::Wasm)
} else {
None
}
}
/// Gzip inflated a chunk at a time, for browsers that can't do it themselves.
#[cfg(any(target_arch = "wasm32", test))]
struct Inflater(flate2::write::GzDecoder<Vec<u8>>);
@@ -69,11 +100,11 @@ mod boot {
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::{JsFuture, spawn_local};
use web_sys::{
Headers, ReadableStream, ReadableWritablePair, RequestCache, RequestInit, Response, ResponseInit, StreamPipeOptions, TransformStream,
TransformStreamDefaultController, Transformer, WritableStream,
Headers, ReadableStream, ReadableStreamDefaultReader, ReadableWritablePair, RequestCache, RequestInit, Response, ResponseInit, StreamPipeOptions,
TransformStream, TransformStreamDefaultController, Transformer, WritableStream, WritableStreamDefaultWriter,
};
use super::Inflater;
use super::{HEAD, Inflater, Payload};
/// Where packaging puts the app, next to this module's own files.
const PARTS: &str = "./app/parts.txt";
@@ -172,11 +203,71 @@ mod boot {
Ok(compressed.pipe_through(&pair))
}
/// The next chunk `reader` gives, or `None` at the end of the stream.
async fn read(reader: &ReadableStreamDefaultReader) -> Result<Option<js_sys::Uint8Array>, JsValue> {
let result = JsFuture::from(reader.read()).await?;
if js_sys::Reflect::get(&result, &JsValue::from_str("done"))?.is_truthy() {
return Ok(None);
}
Ok(Some(js_sys::Reflect::get(&result, &JsValue::from_str("value"))?.dyn_into()?))
}
/// Writes `chunk` into `writer`. When nothing will read it any more (the app's loader gave up
/// on its stream), the download from `reader` is cancelled too, so it doesn't stay pending.
async fn write(chunk: &js_sys::Uint8Array, reader: &ReadableStreamDefaultReader, writer: &WritableStreamDefaultWriter) -> Result<(), JsValue> {
if let Err(e) = JsFuture::from(writer.write_with_chunk(chunk)).await {
let _ = reader.cancel_with_reason(&e);
return Err(e);
}
Ok(())
}
/// Writes the `held` chunks, then the rest of `reader`'s stream, into `writer`, then closes it.
async fn pass_on(held: Vec<js_sys::Uint8Array>, reader: &ReadableStreamDefaultReader, writer: &WritableStreamDefaultWriter) -> Result<(), JsValue> {
for chunk in &held {
write(chunk, reader, writer).await?;
}
while let Some(chunk) = read(reader).await? {
write(&chunk, reader, writer).await?;
}
JsFuture::from(writer.close()).await?;
Ok(())
}
/// The app's WebAssembly from the `joined` parts: inflated, unless its first bytes show the
/// browser has already done that.
async fn unpacked(joined: &ReadableStream) -> Result<ReadableStream, JsValue> {
let reader: ReadableStreamDefaultReader = joined.get_reader().unchecked_into();
let (mut head, mut held) = (Vec::with_capacity(HEAD), Vec::new());
while head.len() < HEAD {
let Some(chunk) = read(&reader).await? else { break };
let wanted = u32::try_from(HEAD - head.len()).unwrap_or(u32::MAX).min(chunk.length());
head.extend(chunk.subarray(0, wanted).to_vec());
held.push(chunk);
}
let Some(payload) = super::payload(&head) else {
let e = JsValue::from_str("the download is not PhotoCraft's WebAssembly");
// Stops the rest of the download, which nothing will read.
let _ = reader.cancel_with_reason(&e);
return Err(e);
};
let rest = TransformStream::new()?;
let writer = rest.writable().get_writer()?;
spawn_local(async move {
if let Err(e) = pass_on(held, &reader, &writer).await {
let _ = writer.abort_with_reason(&e);
}
});
match payload {
Payload::Gzip => inflated(&rest.readable()),
Payload::Wasm => Ok(rest.readable()),
}
}
async fn run(fresh: bool) -> Result<(), JsValue> {
let list = JsFuture::from(fetch(PARTS, fresh).await?.text()?).await?.as_string().unwrap_or_default();
let parts = super::parts(&list).ok_or_else(|| JsValue::from_str("app/parts.txt is not a parts list"))?;
let joined = TransformStream::new()?;
let wasm = inflated(&joined.readable())?;
// Why the download stopped: the app's loader only reports that its stream broke.
let cause = Rc::new(RefCell::new(None));
let failed = cause.clone();
@@ -187,6 +278,10 @@ mod boot {
failed.replace(Some(e));
}
});
let wasm = match unpacked(&joined.readable()).await {
Ok(wasm) => wasm,
Err(e) => return Err(cause.take().unwrap_or(e)),
};
let headers = Headers::new()?;
headers.set("Content-Type", "application/wasm")?;
let init = ResponseInit::new();
@@ -258,7 +353,7 @@ fn main() {
#[cfg(test)]
mod tests {
use super::{Inflater, MARKER, parts};
use super::{HEAD, Inflater, MARKER, Payload, parts, payload};
fn gzip(bytes: &[u8]) -> Vec<u8> {
let mut z = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
@@ -317,6 +412,22 @@ mod tests {
assert!(run(b"").is_err(), "nothing at all");
}
/// A server that recognises the gzip (Apache's `mod_mime_magic`) sends it with
/// `Content-Encoding: x-gzip`, and the browser inflates it on the way (#7639). The joined parts
/// are then the app's WebAssembly, which must start as it is, not be inflated a second time.
#[test]
fn the_first_bytes_tell_gzip_from_wasm_the_browser_already_inflated() {
let mut wasm = b"\0asm\x01\0\0\0".to_vec();
wasm.extend(sample());
let packed = gzip(&wasm);
assert_eq!(payload(&packed[..HEAD]), Some(Payload::Gzip), "as packaged");
assert_eq!(payload(&wasm[..HEAD]), Some(Payload::Wasm), "inflated by the browser");
assert_eq!(payload(&packed[..2]), Some(Payload::Gzip), "a download shorter than HEAD");
assert_eq!(payload(b"<!DOCTYPE html>"), None, "a web page");
assert_eq!(payload(b"\0as"), None, "a cut-off wasm header");
assert_eq!(payload(b""), None, "nothing at all");
}
#[test]
fn a_parts_list_gives_its_names_in_order() {
let list = format!("{MARKER}\nphotocraft-web-0123abcd.part-aa\r\n\n photocraft-web-0123abcd.part-ab \n");
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "photocraft-web"
description = "Photocraft in the browser (eframe web runner, WebGPU with WebGL2 fallback)"
description = "PhotoCraft in the browser (eframe web runner, WebGPU with WebGL2 fallback)"
version.workspace = true
edition.workspace = true
license.workspace = true
@@ -34,7 +34,7 @@ rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent", "BeforeUnloadEvent", "AbortController", "AbortSignal", "DomException", "Response", "IdbFactory", "IdbOpenDbRequest", "IdbDatabase", "IdbRequest", "IdbTransaction", "IdbTransactionMode", "IdbObjectStore", "IdbCursor", "IdbCursorWithValue", "RequestInit", "Navigator"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "DomRect", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent", "BeforeUnloadEvent", "AbortController", "AbortSignal", "DomException", "Response", "IdbFactory", "IdbOpenDbRequest", "IdbDatabase", "IdbRequest", "IdbTransaction", "IdbTransactionMode", "IdbObjectStore", "IdbCursor", "IdbCursorWithValue", "RequestInit", "Navigator"] }
[dev-dependencies]
serde_json = { workspace = true }
+4 -4
View File
@@ -1,4 +1,4 @@
//! Photocraft in the browser.
//! PhotoCraft in the browser.
//!
//! Runs the same [`photocraft_ui_egui::PhotocraftApp`] as the desktop app through eframe's web
//! runner (wgpu: WebGPU where available, WebGL2 otherwise). Build with `trunk build --release`
@@ -6,10 +6,10 @@
//!
//! Differences from the desktop app:
//! - no TCP control server (browsers can't listen on sockets);
//! - File → Open uses the browser file picker; bytes arrive asynchronously through
//! `Services::inbox`;
//! - file pickers return browser file contents asynchronously through `Services::file_dialog`;
//! this includes File → Open and commands such as Place Embedded;
//! - saving/exporting triggers a browser download;
//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox.
//! - dropped files are read asynchronously by `web::WebShell` and delivered through `Services::inbox`.
//!
//! URL query flags: `?cpu` forces the CPU canvas path (same as `PHOTOCRAFT_CPU_CANVAS=1`);
//! `?webgl` forces the WebGL2 backend instead of WebGPU.
+67 -15
View File
@@ -1,6 +1,8 @@
//! The browser shell: web `Services`, drag-and-drop, and the eframe web runner.
use std::cell::Cell;
use std::collections::{HashMap, HashSet};
use std::rc::Rc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
@@ -17,9 +19,9 @@ type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster, Krita and Affinity documents, flat images, and
/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries.
const OPEN_EXTS: &[&str] = &[
"pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr",
"pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "cin", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef",
"orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub",
"pcraft", "pdn", "ora", "xcf", "xcfgz", "gz", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico",
"qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "cin", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef",
"nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub",
];
const SVG_EXTS: &[&str] = &["svg", "svgz"];
const CANVAS_ID: &str = "photocraft_canvas";
@@ -79,11 +81,13 @@ pub fn start() {
Box::new(move |cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, ThemeKind::Pro);
let inbox: Inbox = Arc::default();
let place_inbox: Inbox = Arc::default();
let served: Served = Arc::default();
load_font_manifest(served.clone(), cc.egui_ctx.clone());
let (session, warnings) = crate::preset_bridge::session(presets.clone());
preset_warnings.extend(warnings);
let mut app = PhotocraftApp::new(session, services(inbox.clone()));
let drag = listen_drag(&pen_target);
let mut app = PhotocraftApp::new(session, services(inbox.clone(), place_inbox.clone(), drag_pos_service(pen_target.clone(), drag)));
if !preset_warnings.is_empty() {
photocraft_ui_egui::notices::post(&mut app, i18n::t("Some brush presets could not be loaded"), preset_warnings, true, None);
}
@@ -97,7 +101,7 @@ pub fn start() {
}
let unsaved = Arc::new(AtomicBool::new(false));
guard_unload(unsaved.clone());
Ok(Box::new(WebShell { app, inbox, unsaved, served, database, presets }))
Ok(Box::new(WebShell { app, inbox, place_inbox, unsaved, served, database, presets }))
}),
)
.await;
@@ -140,6 +144,38 @@ fn listen_pen(target: &web_sys::HtmlCanvasElement, feed: photocraft_ui_egui::sty
}
}
/// The pointer in a drag over the page, in CSS pixels relative to the viewport (`clientX`,
/// `clientY`): the last `dragover` or `drop` event's.
type DragPos = Rc<Cell<Option<(f32, f32)>>>;
/// Follows files dragged over the canvas. eframe's drop events carry no pointer position and the
/// page gets no pointer events during a drag, so this is where a drop lands: on the document's
/// canvas it places the files, elsewhere it opens them (#5749).
fn listen_drag(target: &web_sys::HtmlCanvasElement) -> DragPos {
use wasm_bindgen::closure::Closure;
let pos = DragPos::default();
for kind in ["dragover", "drop"] {
let pos = pos.clone();
// A DragEvent is a MouseEvent: only its client position is read.
let cb = Closure::<dyn FnMut(web_sys::MouseEvent)>::new(move |e: web_sys::MouseEvent| pos.set(Some((e.client_x() as f32, e.client_y() as f32))));
if target.add_event_listener_with_callback(kind, cb.as_ref().unchecked_ref()).is_ok() {
cb.forget();
}
}
pos
}
/// `Services::cursor_pos` for the web: the last drag position in egui points, mapped as eframe
/// maps pointer events (relative to the canvas, divided by the zoom factor).
fn drag_pos_service(canvas: web_sys::HtmlCanvasElement, pos: DragPos) -> photocraft_ui_egui::CursorPosFn {
Box::new(move |ctx: &egui::Context| {
let (x, y) = pos.get()?;
let rect = canvas.get_bounding_client_rect();
let zoom = ctx.zoom_factor();
(zoom.is_finite() && zoom > 0.0).then(|| egui::pos2((x - rect.left() as f32) / zoom, (y - rect.top() as f32) / zoom))
})
}
/// Closing or reloading the tab while a document has unsaved changes asks first, as closing the
/// desktop window does: the browser shows its own "Leave site?" prompt (#1380). `unsaved` is
/// refreshed every frame by [`WebShell`].
@@ -219,10 +255,13 @@ async fn fetch_bytes(url: &str, integrity: Option<&str>) -> Result<Vec<u8>, Stri
}
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously,
/// so the app's own drop path can't handle them) and feed them through the inbox.
/// so the app's own drop path can't handle them) and feed them through the inboxes.
struct WebShell {
app: PhotocraftApp,
/// Dropped files to open as documents.
inbox: Inbox,
/// Dropped files to place on the active document's canvas.
place_inbox: Inbox,
/// Read by the `beforeunload` listener ([`guard_unload`]).
unsaved: Arc<AtomicBool>,
served: Served,
@@ -285,17 +324,26 @@ impl WebShell {
impl eframe::App for WebShell {
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
for f in dropped {
let inbox = self.inbox.clone();
if !dropped.is_empty() {
let names: Vec<String> =
dropped.iter().map(|f| f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into())).collect();
// Decided now, where the drop landed: on the document's canvas the files are placed
// as layers (as on the desktop), elsewhere opened.
let places = self.app.dropped_on_canvas(ctx, &names);
let files: Vec<_> = dropped.into_iter().zip(names).zip(places).collect();
let (inbox, place_inbox) = (self.inbox.clone(), self.place_inbox.clone());
let ctx = ctx.clone();
// One after another, so they open or place in the order they were dropped.
wasm_bindgen_futures::spawn_local(async move {
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
match f.bytes_async().await {
Ok(bytes) => {
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
ctx.request_repaint();
for ((f, name), place) in files {
match f.bytes_async().await {
Ok(bytes) => {
let queue = if place { &place_inbox } else { &inbox };
queue.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
ctx.request_repaint();
}
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
}
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
}
});
}
@@ -333,7 +381,7 @@ impl eframe::App for WebShell {
}
}
fn services(inbox: Inbox) -> Services {
fn services(inbox: Inbox, place_inbox: Inbox, cursor_pos: photocraft_ui_egui::CursorPosFn) -> Services {
Services {
screen_pick: screen_color_service(),
import: Some(Box::new(|name: &str, bytes: &[u8], max_svg_group_depth: usize| {
@@ -379,11 +427,15 @@ fn services(inbox: Inbox) -> Services {
}
})),
write: Some(Box::new(|path: &str, bytes: &[u8]| download(path, bytes))),
// A download may be cancelled and never reports back: it doesn't save the document.
write_unconfirmed: true,
encode_png: Some(Box::new(|w, h, rgba| {
let img = Image::from_u8(w, h, ChannelLayout::Rgba, rgba.to_vec()).map_err(|e| e.to_string())?;
photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &EncodeOptions::default()).map_err(|e| e.to_string())
})),
inbox: Some(inbox),
place_inbox: Some(place_inbox),
cursor_pos: Some(cursor_pos),
// Preferences live in the browser's localStorage.
load_prefs: Some(Box::new(|| local_storage()?.get_item(PREFS_KEY).ok().flatten())),
save_prefs: Some(Box::new(|text: &str| local_storage().ok_or("no localStorage")?.set_item(PREFS_KEY, text).map_err(|e| format!("{e:?}")))),
+3 -1
View File
@@ -4,7 +4,7 @@ version.workspace = true
edition.workspace = true
license.workspace = true
default-run = "photocraft"
description = "Photocraft: an open-source, native image editor"
description = "PhotoCraft: an open-source, native image editor"
build = "build.rs"
[features]
@@ -65,6 +65,8 @@ dispatch2 = { version = "=0.3.1", default-features = false, features = ["std"] }
# Drop position on X11 (`cursor.rs`): pure-Rust X client, already in Cargo.lock; pinned exactly.
[target.'cfg(target_os = "linux")'.dependencies]
photocraft-cms = { workspace = true }
# The print portal's PDF: a private (0600) temporary file, removed when the dialog is done.
tempfile = "3"
x11rb = { version = "=0.13.2", default-features = false, features = ["image", "randr", "xkb"] }
# Clipboard images in types other than PNG on Wayland (`services.rs`, #2546): arboard asks for
# image/png only. Already in Cargo.lock (arboard's wayland-data-control); pinned exactly.
+8
View File
@@ -11,6 +11,14 @@ fn main() {
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
return;
}
// Printing to PDF (winprint's `PdfCreateRenderer`) imports Windows.Data.Pdf.dll, which
// stripped Windows images (NTLite, some N/LTSC builds) lack. A load-time import stops the exe
// from starting at all ("windows.data.pdf.dll was not found", #5884), so load it on first use;
// the print command checks for it first and reports an error instead.
if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") {
println!("cargo:rustc-link-arg-bins=/DELAYLOAD:windows.data.pdf.dll");
println!("cargo:rustc-link-arg-bins=delayimp.lib");
}
let mut res = winresource::WindowsResource::new();
res.set_icon("../../assets/app-icon/photocraft.ico")
.set("ProductName", "PhotoCraft")
+717
View File
@@ -9,6 +9,9 @@
//! folder can't be written (read-only medium, Program Files), the app warns and falls back.
//! 3. The platform convention: macOS `~/Library/Application Support/Photocraft`, Windows
//! `%APPDATA%\Photocraft`, Linux `$XDG_CONFIG_HOME/photocraft` or `~/.config/photocraft`.
//!
//! One exception: the platform convention keeps crash-recovery autosaves out of what is synced
//! with the settings: the Windows roaming profile and the Linux config folder ([`recovery_dir`]).
use std::ffi::OsString;
use std::path::{Path, PathBuf};
@@ -60,6 +63,396 @@ pub fn config_dir() -> Option<PathBuf> {
current().dir.clone()
}
/// The crash-recovery folder's name.
const RECOVERY: &str = "Recovery";
/// Where crash-recovery autosaves live, with any left in the settings directory by an earlier
/// version moved there first ([`adopt_legacy_recovery`]). Call it once, at startup.
pub fn recovery_dir() -> Option<PathBuf> {
let data = current();
let dir = resolve_recovery(data, |k| std::env::var_os(k))?;
Some(match data.dir.as_ref().map(|d| d.join(RECOVERY)) {
Some(legacy) => adopt_legacy_recovery(&legacy, &dir),
None => dir,
})
}
/// The recovery folder for `data`. Autosaves hold whole documents and are rewritten every few
/// minutes, so with the platform convention they stay out of what is synced with the settings:
/// - Windows (#5648): the machine-local `%LOCALAPPDATA%\Photocraft\Recovery`; the roaming
/// `%APPDATA%` may be synced at sign-in and sign-out or capped by policy.
/// - Linux and other XDG systems (#6612): `$XDG_STATE_HOME/photocraft/Recovery`, by default
/// `~/.local/state/photocraft/Recovery`, where the XDG Base Directory spec puts state that
/// should outlive a restart but isn't configuration; dotfile managers and config sync often copy
/// `~/.config` whole. As the spec asks, a relative `XDG_STATE_HOME` is ignored.
/// - macOS: `~/Library/Application Support/Photocraft/Recovery` as before. Apple's guidelines put
/// app-created data that can't be regenerated there; `~/Library/Caches` may be purged by the
/// system.
///
/// They are never in a temp or cache folder, which cleanup tools may empty before a crashed
/// document is recovered. The override and portable folders keep everything in one place.
pub fn resolve_recovery(data: &DataDir, env: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
if data.mode == Mode::Platform {
if cfg!(windows) {
if let Some(local) = env("LOCALAPPDATA").filter(|l| !l.is_empty()) {
return Some(PathBuf::from(local).join("Photocraft").join(RECOVERY));
}
} else if !cfg!(target_os = "macos") {
let state = env("XDG_STATE_HOME")
.map(PathBuf::from)
.filter(|s| s.is_absolute())
.or_else(|| env("HOME").filter(|h| !h.is_empty()).map(|h| PathBuf::from(h).join(".local").join("state")));
if let Some(state) = state {
return Some(state.join("photocraft").join(RECOVERY));
}
}
}
data.dir.as_ref().map(|d| d.join(RECOVERY))
}
/// The lock file, beside the recovery folder, that keeps two PhotoCraft processes starting at
/// once (`--new-instance`, control launches) from moving the same autosaves.
const MIGRATION_LOCK: &str = "recovery-migration.lock";
/// How long a launch waits for another one's move before going ahead without moving anything.
const MIGRATION_WAIT: std::time::Duration = std::time::Duration::from_secs(5);
/// Suffix of a copy staged for publishing ([`stage_copy`]): disposable, the original still exists.
const STAGED: &str = ".moving";
/// Suffix of an original set aside while it is copied to another volume ([`move_across`]): it
/// may be the only copy, so it is put back, never thrown away, if the move doesn't finish.
const HELD: &str = ".held";
/// Move autosaves from `legacy` (the recovery folder of versions before #5648) into `dir`, so a
/// crash just before an update is still offered for recovery. Returns the folder this process
/// uses for its recovery store. That is `legacy` only when `dir` doesn't exist and no process can
/// be moving autosaves (the move failed, or its lock can't be made), so nothing deletes what is
/// written there. A launch that finds another one moving uses `dir`, which the move never
/// replaces or removes. See [`adopt_locked`] for how they move.
pub fn adopt_legacy_recovery(legacy: &Path, dir: &Path) -> PathBuf {
adopt_legacy_recovery_waiting(legacy, dir, MIGRATION_WAIT)
}
/// [`adopt_legacy_recovery`], waiting up to `wait` for another process's move.
fn adopt_legacy_recovery_waiting(legacy: &Path, dir: &Path, wait: std::time::Duration) -> PathBuf {
let held_aside = || legacy.parent().is_some_and(|p| !side_entries(p, legacy, HELD).is_empty());
if legacy == dir || (!legacy.is_dir() && !held_aside()) {
return dir.to_path_buf();
}
// An empty old folder has nothing to move (`remove_dir` only removes an empty one).
if !held_aside() && std::fs::remove_dir(legacy).is_ok() {
return dir.to_path_buf();
}
let unmoved = || if dir.exists() || !legacy.is_dir() { dir.to_path_buf() } else { legacy.to_path_buf() };
let Some(parent) = dir.parent() else { return unmoved() };
let lock =
std::fs::create_dir_all(parent).and_then(|()| std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(parent.join(MIGRATION_LOCK)));
let Ok(lock) = lock else {
// No lock file means no other process can be moving: the old folder is safe to use.
log::warn!("couldn't open the recovery move lock in {}; autosaves stay where they are", parent.display());
return unmoved();
};
let deadline = std::time::Instant::now() + wait;
loop {
match lock.try_lock() {
Ok(()) => break,
Err(std::fs::TryLockError::WouldBlock) if std::time::Instant::now() < deadline => std::thread::sleep(std::time::Duration::from_millis(50)),
Err(std::fs::TryLockError::WouldBlock) => {
// Another process is moving and may yet remove the old folder: never write there.
log::info!("another PhotoCraft is moving crash-recovery autosaves; using {}", dir.display());
return dir.to_path_buf();
}
Err(std::fs::TryLockError::Error(e)) => {
// Locking fails for every process here alike, so none can be moving.
log::warn!("couldn't lock {}: {e}; autosaves stay where they are", parent.join(MIGRATION_LOCK).display());
return unmoved();
}
}
}
// The lock file stays (removing it would race a process about to lock it); the OS releases
// the lock when `lock` is dropped or the process ends.
let used = adopt_locked(legacy, dir);
if used == legacy {
// This process writes its autosaves to the old folder: it keeps the lock for as long as it
// runs, so no other launch moves (and removes) the folder under it.
KEPT_LOCKS.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(lock);
}
used
}
/// Migration locks of the old folders this process uses (see [`adopt_legacy_recovery`]), kept
/// until it ends.
static KEPT_LOCKS: std::sync::Mutex<Vec<std::fs::File>> = std::sync::Mutex::new(Vec::new());
/// The move itself, under the migration lock. First, what a process that ended mid-move left is
/// tidied: originals it set aside are put back, copies it staged are removed. An empty `legacy`
/// is then just removed. Otherwise the whole folder moves if `dir` doesn't exist yet; when both
/// exist, each autosave (`<key>.pcraft` bundle and `<key>.json` sidecar, see
/// `photocraft_format::autosave`) moves as a whole, bundle first, unless `dir` already has either
/// part, and one that stays is left in `legacy` untouched. Every move is a [`move_path`].
fn adopt_locked(legacy: &Path, dir: &Path) -> PathBuf {
if let Some(parent) = legacy.parent() {
for held in side_entries(parent, legacy, HELD) {
put_back(&held, legacy);
}
}
// Autosave parts held aside inside the old folder by a per-autosave move.
let held_parts: Vec<(PathBuf, String)> = std::fs::read_dir(legacy)
.into_iter()
.flatten()
.flatten()
.filter_map(|e| e.file_name().to_str().and_then(|n| side_of(n, HELD)).map(|orig| (e.path(), orig.to_owned())))
.collect();
for (held, original) in held_parts {
put_back(&held, &legacy.join(original));
}
for folder in [dir.parent(), Some(dir)].into_iter().flatten() {
remove_stale_staging(folder);
}
// Another process may have finished the move while this one waited.
if !legacy.is_dir() {
return dir.to_path_buf();
}
if std::fs::remove_dir(legacy).is_ok() {
return dir.to_path_buf();
}
if !dir.exists() {
// The common case: the whole folder moves at once.
return match move_path(legacy, dir) {
Ok(()) => {
log::info!("moved crash-recovery autosaves from {} to {}", legacy.display(), dir.display());
dir.to_path_buf()
}
Err(e) => {
log::warn!("couldn't move crash-recovery autosaves from {} to {} ({e}); using the old folder", legacy.display(), dir.display());
if legacy.is_dir() { legacy.to_path_buf() } else { dir.to_path_buf() }
}
};
}
move_entries(legacy, dir);
// Only an emptied folder goes; anything that couldn't move stays for a later launch.
let _ = std::fs::remove_dir(legacy);
dir.to_path_buf()
}
/// Move each autosave of `from` into `to` (both folders): bundle first, then sidecar, skipped if
/// `to` has either part. Temp files of an interrupted write (`photocraft_format::atomic`) go
/// along too, for the recovery store's startup sweep there.
fn move_entries(from: &Path, to: &Path) {
let Ok(entries) = std::fs::read_dir(from) else { return };
let names: Vec<String> = entries.flatten().filter_map(|e| e.file_name().to_str().map(str::to_owned)).collect();
let keys: std::collections::BTreeSet<&str> = names.iter().filter_map(|n| n.strip_suffix(".pcraft").or_else(|| n.strip_suffix(".json"))).collect();
for key in keys {
let parts = [format!("{key}.pcraft"), format!("{key}.json")];
if parts.iter().any(|p| std::fs::symlink_metadata(to.join(p)).is_ok()) {
continue;
}
// A bundle that moves without its sidecar is still listed (`list_recovery` reads a
// committed bundle's manifest); a sidecar must never move without its bundle.
for part in &parts {
let src = from.join(part);
if std::fs::symlink_metadata(&src).is_ok()
&& let Err(e) = move_path(&src, &to.join(part))
{
log::warn!("couldn't move {} to {}: {e}", src.display(), to.display());
break;
}
}
}
for name in names.iter().filter(|n| n.ends_with(".pcsave.tmp")) {
let _ = move_path(&from.join(name), &to.join(name));
}
}
/// How deep [`copy_tree`] follows folders: an autosave bundle is a few levels deep.
const MAX_COPY_DEPTH: u32 = 16;
fn already_exists(path: &Path) -> std::io::Error {
std::io::Error::new(std::io::ErrorKind::AlreadyExists, format!("{} already exists", path.display()))
}
/// A path beside `of`, named `.<name>.<pid>-<n><suffix>`, that no other call uses.
fn side_path(of: &Path, suffix: &str) -> std::io::Result<PathBuf> {
static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
let parent = of.parent().ok_or_else(|| std::io::Error::other(format!("{} has no parent folder", of.display())))?;
let name = of.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
Ok(parent.join(format!(".{name}.{}-{}{suffix}", std::process::id(), NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed))))
}
/// The name `side` was made from, if it is a [`side_path`] name with `suffix`.
fn side_of<'a>(side: &'a str, suffix: &str) -> Option<&'a str> {
let rest = side.strip_prefix('.')?.strip_suffix(suffix)?;
let (name, ids) = rest.rsplit_once('.')?;
let (pid, n) = ids.split_once('-')?;
let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit());
(!name.is_empty() && digits(pid) && digits(n)).then_some(name)
}
/// The side paths with `suffix` in `folder` made from `of`.
fn side_entries(folder: &Path, of: &Path, suffix: &str) -> Vec<PathBuf> {
let Some(name) = of.file_name().and_then(|n| n.to_str()) else { return Vec::new() };
std::fs::read_dir(folder)
.into_iter()
.flatten()
.flatten()
.filter(|e| e.file_name().to_str().and_then(|n| side_of(n, suffix)) == Some(name))
.map(|e| e.path())
.collect()
}
/// Move the file or folder `from` to `to` without ever replacing anything at `to`. On one volume
/// a folder is renamed (Windows refuses an existing target; elsewhere only an empty folder could
/// be replaced) and a file is hard-linked, which fails on an existing target, and then unlinked
/// at `from`. Otherwise (another volume, e.g. a roaming profile redirected to a network share
/// while `%LOCALAPPDATA%` stays local) it is copied ([`move_across`]).
fn move_path(from: &Path, to: &Path) -> std::io::Result<()> {
if std::fs::symlink_metadata(to).is_ok() {
return Err(already_exists(to));
}
let meta = std::fs::symlink_metadata(from)?;
let moved = if meta.is_dir() { std::fs::rename(from, to) } else { std::fs::hard_link(from, to).and_then(|()| std::fs::remove_file(from)) };
match moved {
Ok(()) => Ok(()),
Err(e) if matches!(e.kind(), std::io::ErrorKind::AlreadyExists | std::io::ErrorKind::DirectoryNotEmpty) => Err(e),
Err(_) => move_across(from, to),
}
}
/// Move `from` to `to` by copying. `from` is first renamed aside on its own volume
/// (`.<name>.<pid>-<n>.held`, [`hold`]), so a process still writing to the old path (another
/// launch that couldn't take the lock, an older PhotoCraft) starts a new `from` there instead of
/// adding to what is copied, and nothing it writes is removed. The held original is copied to a
/// staged path ([`stage_copy`]), published without replacing anything ([`publish`]), and only
/// then removed. If the copy or publish fails, the held original is put back ([`put_back`]).
fn move_across(from: &Path, to: &Path) -> std::io::Result<()> {
let held = hold(from)?;
match stage_copy(&held, to).and_then(|staged| publish(&staged, to)) {
Ok(()) => {
let removed = if std::fs::symlink_metadata(&held)?.is_dir() { std::fs::remove_dir_all(&held) } else { std::fs::remove_file(&held) };
if let Err(e) = removed {
log::warn!("copied {} to {} but couldn't remove {}: {e}", from.display(), to.display(), held.display());
}
Ok(())
}
Err(e) => {
put_back(&held, from);
Err(e)
}
}
}
/// Rename `from` aside on its own volume (see [`move_across`]); returns where it is now.
fn hold(from: &Path) -> std::io::Result<PathBuf> {
let held = side_path(from, HELD)?;
std::fs::rename(from, &held)?;
Ok(held)
}
/// Return the held original `held` to `original`. If `original` has been made again meanwhile (a
/// writer that kept using the old path), the held folder's entries move into it without replacing
/// anything ([`move_entries`]); whatever can't move stays held, logged, and is tried again under
/// the next move's lock ([`adopt_locked`] puts back what a process that ended mid-move held).
/// Nothing held is ever removed with data in it.
fn put_back(held: &Path, original: &Path) {
if std::fs::symlink_metadata(original).is_err() && std::fs::rename(held, original).is_ok() {
return;
}
if held.is_dir() && original.is_dir() {
move_entries(held, original);
if std::fs::remove_dir(held).is_ok() {
return;
}
}
log::warn!("{} is kept aside: {} exists", held.display(), original.display());
}
/// Copy `from` to a staging path beside `to` that this call creates exclusively, so nothing
/// another process made is ever written to or cleaned up. On failure the staged copy is removed.
fn stage_copy(from: &Path, to: &Path) -> std::io::Result<PathBuf> {
let staged = side_path(to, STAGED)?;
let is_dir = std::fs::symlink_metadata(from)?.is_dir();
let copied = if is_dir { copy_tree(from, &staged, 0) } else { copy_file(from, &staged) };
if let Err(e) = copied {
// `copy_tree` and `copy_file` create `staged` exclusively, and everything under a
// fresh folder is new: `AlreadyExists` means `staged` was someone else's to keep.
if e.kind() != std::io::ErrorKind::AlreadyExists {
let _ = if is_dir { std::fs::remove_dir_all(&staged) } else { std::fs::remove_file(&staged) };
}
return Err(e);
}
Ok(staged)
}
/// Remove copies a process left staged in `folder` when it ended mid-move: disposable, since the
/// original is still in place or held aside. Called only under the migration lock, which every
/// staging happens under. Links are never followed.
fn remove_stale_staging(folder: &Path) {
let Ok(entries) = std::fs::read_dir(folder) else { return };
for entry in entries.flatten() {
if !entry.file_name().to_str().is_some_and(|n| side_of(n, STAGED).is_some()) {
continue;
}
let removed = match entry.file_type() {
Ok(t) if t.is_dir() => std::fs::remove_dir_all(entry.path()),
Ok(t) if t.is_file() => std::fs::remove_file(entry.path()),
_ => continue,
};
if let Err(e) = removed {
log::warn!("couldn't remove the stale staged copy {}: {e}", entry.path().display());
}
}
}
/// Give the staged copy its final name `to` without replacing anything there (see [`move_path`]
/// for the folder and file cases). On failure the staged copy is removed and `to` is untouched.
fn publish(staged: &Path, to: &Path) -> std::io::Result<()> {
let is_dir = std::fs::symlink_metadata(staged)?.is_dir();
let published = if std::fs::symlink_metadata(to).is_ok() {
Err(already_exists(to))
} else if is_dir {
std::fs::rename(staged, to)
} else {
std::fs::hard_link(staged, to).and_then(|()| std::fs::remove_file(staged))
};
if published.is_err() {
let _ = if is_dir { std::fs::remove_dir_all(staged) } else { std::fs::remove_file(staged) };
}
published
}
/// Copy the folder `from` to the new folder `to` (created here; an existing `to` is an error):
/// plain files and folders only (a link or other special file is refused), at most
/// [`MAX_COPY_DEPTH`] levels.
fn copy_tree(from: &Path, to: &Path, depth: u32) -> std::io::Result<()> {
if depth > MAX_COPY_DEPTH {
return Err(std::io::Error::other(format!("{} is nested too deep", from.display())));
}
std::fs::create_dir(to)?;
for entry in std::fs::read_dir(from)? {
let entry = entry?;
let target = to.join(entry.file_name());
if entry.file_type()?.is_dir() {
copy_tree(&entry.path(), &target, depth.saturating_add(1))?;
} else {
copy_file(&entry.path(), &target)?;
}
}
Ok(())
}
/// Copy one plain file (a link or other special file is refused) to the new file `to` (an
/// existing `to` is an error), flushed to disk.
fn copy_file(from: &Path, to: &Path) -> std::io::Result<()> {
if !std::fs::symlink_metadata(from)?.file_type().is_file() {
return Err(std::io::Error::other(format!("{} is not a plain file", from.display())));
}
let mut src = std::fs::File::open(from)?;
let mut dst = std::fs::OpenOptions::new().write(true).create_new(true).open(to)?;
std::io::copy(&mut src, &mut dst)?;
dst.sync_all()
}
/// Resolve the data directory from the environment (`env`) and the executable's folder.
pub fn resolve(env: impl Fn(&str) -> Option<OsString>, exe_dir: Option<&Path>) -> DataDir {
if let Some(d) = env("PHOTOCRAFT_CONFIG_DIR").filter(|d| !d.is_empty()) {
@@ -223,6 +616,330 @@ mod tests {
let _ = std::fs::remove_dir_all(&root);
}
/// #5648: on Windows, autosaves go to the machine-local `%LOCALAPPDATA%`, not the roaming
/// profile; an override or portable folder keeps them inside itself.
#[test]
fn autosaves_stay_out_of_the_roaming_profile() {
let root = temp("recovery-local");
let settings = root.join("appdata").join("Photocraft");
let local = root.join("local");
let env = env_of(&[("LOCALAPPDATA", &local)]);
let platform = DataDir { dir: Some(settings.clone()), mode: Mode::Platform, warning: None };
let expected = if cfg!(windows) { local.join("Photocraft").join("Recovery") } else { settings.join("Recovery") };
assert_eq!(resolve_recovery(&platform, &env), Some(expected));
for mode in [Mode::Override, Mode::Portable] {
let d = DataDir { dir: Some(settings.clone()), mode, warning: None };
assert_eq!(resolve_recovery(&d, &env), Some(settings.join("Recovery")), "{mode:?}");
}
// No (or an empty) LOCALAPPDATA: beside the settings, as before.
let empty = PathBuf::new();
assert_eq!(resolve_recovery(&platform, env_of(&[("LOCALAPPDATA", &empty)])), Some(settings.join("Recovery")));
assert_eq!(resolve_recovery(&platform, |_| None), Some(settings.join("Recovery")));
let nowhere = DataDir { dir: None, mode: Mode::Platform, warning: None };
assert_eq!(resolve_recovery(&nowhere, |_| None), None);
let _ = std::fs::remove_dir_all(&root);
}
/// #6612: on Linux (and other XDG systems) autosaves go to `$XDG_STATE_HOME`, by default
/// `~/.local/state`, not the config folder; a relative `XDG_STATE_HOME` is ignored. Windows
/// and macOS don't read it, and an override or portable folder keeps them inside itself.
#[test]
fn autosaves_go_to_the_xdg_state_folder() {
let root = temp("recovery-xdg");
let settings = root.join("home").join(".config").join("photocraft");
let home = root.join("home");
let state = root.join("state");
let platform = DataDir { dir: Some(settings.clone()), mode: Mode::Platform, warning: None };
let xdg = !cfg!(windows) && !cfg!(target_os = "macos");
let expect = |xdg_path: PathBuf| Some(if xdg { xdg_path } else { settings.join("Recovery") });
assert_eq!(resolve_recovery(&platform, env_of(&[("XDG_STATE_HOME", &state), ("HOME", &home)])), expect(state.join("photocraft").join("Recovery")));
let default = home.join(".local").join("state").join("photocraft").join("Recovery");
assert_eq!(resolve_recovery(&platform, env_of(&[("HOME", &home)])), expect(default.clone()));
let relative = PathBuf::from("relative/state");
assert_eq!(resolve_recovery(&platform, env_of(&[("XDG_STATE_HOME", &relative), ("HOME", &home)])), expect(default.clone()));
let empty = PathBuf::new();
assert_eq!(resolve_recovery(&platform, env_of(&[("XDG_STATE_HOME", &empty), ("HOME", &home)])), expect(default));
// No home at all: beside the settings, as before.
assert_eq!(resolve_recovery(&platform, |_| None), Some(settings.join("Recovery")));
for mode in [Mode::Override, Mode::Portable] {
let d = DataDir { dir: Some(settings.clone()), mode, warning: None };
assert_eq!(resolve_recovery(&d, env_of(&[("XDG_STATE_HOME", &state), ("HOME", &home)])), Some(settings.join("Recovery")), "{mode:?}");
}
let _ = std::fs::remove_dir_all(&root);
}
/// An autosave bundle and its sidecar, as `format::Autosaver` writes them.
fn autosave(dir: &Path, key: &str) {
std::fs::create_dir_all(dir.join(format!("{key}.pcraft"))).unwrap();
std::fs::write(dir.join(format!("{key}.pcraft")).join("manifest.json"), key).unwrap();
std::fs::write(dir.join(format!("{key}.json")), key).unwrap();
}
#[test]
fn autosaves_from_the_old_folder_move_to_the_new_one() {
let root = temp("recovery-move");
let legacy = root.join("roaming").join("Recovery");
let dir = root.join("local").join("Photocraft").join("Recovery");
autosave(&legacy, "a");
assert_eq!(adopt_legacy_recovery(&legacy, &dir), dir);
assert!(!legacy.exists(), "the old folder is gone");
assert_eq!(std::fs::read_to_string(dir.join("a.pcraft").join("manifest.json")).unwrap(), "a");
assert!(dir.join("a.json").is_file());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn autosaves_merge_into_an_existing_folder_without_overwriting() {
let root = temp("recovery-merge");
let legacy = root.join("roaming").join("Recovery");
let dir = root.join("local").join("Recovery");
autosave(&legacy, "old");
autosave(&legacy, "both");
// A temp file of an interrupted write moves along, for the recovery store's sweep.
let temp_file = ".old.json.4294967294-0.pcsave.tmp";
std::fs::write(legacy.join(temp_file), b"partial").unwrap();
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("both.json"), "newer").unwrap();
assert_eq!(adopt_legacy_recovery(&legacy, &dir), dir);
assert!(dir.join(temp_file).is_file() && !legacy.join(temp_file).exists());
assert!(dir.join("old.pcraft").is_dir() && dir.join("old.json").is_file(), "a whole autosave moves");
assert_eq!(std::fs::read_to_string(dir.join("both.json")).unwrap(), "newer", "never overwritten");
assert!(!dir.join("both.pcraft").exists(), "a clashing autosave isn't split");
assert!(legacy.join("both.pcraft").is_dir() && legacy.join("both.json").is_file(), "it stays behind whole");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn autosaves_that_cannot_move_keep_being_used_where_they_are() {
let root = temp("recovery-stuck");
let legacy = root.join("roaming").join("Recovery");
autosave(&legacy, "a");
// A file where the new folder's parent should be: nothing can be created there.
std::fs::write(root.join("blocked"), b"in the way").unwrap();
let dir = root.join("blocked").join("Recovery");
assert_eq!(adopt_legacy_recovery(&legacy, &dir), legacy);
assert!(legacy.join("a.json").is_file(), "nothing lost");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn an_empty_old_folder_is_removed_even_where_it_cannot_move() {
let root = temp("recovery-empty");
let legacy = root.join("roaming").join("Recovery");
std::fs::create_dir_all(&legacy).unwrap();
std::fs::write(root.join("blocked"), b"in the way").unwrap();
let dir = root.join("blocked").join("Recovery");
assert_eq!(adopt_legacy_recovery(&legacy, &dir), dir, "nothing to keep listed");
assert!(!legacy.exists(), "the empty old folder is gone");
let _ = std::fs::remove_dir_all(&root);
}
/// Where a rename can't cross volumes (a roaming profile on a network share), an autosave
/// is copied whole, nested folders included, and only then removed.
#[test]
fn a_move_across_volumes_copies_then_removes() {
let root = temp("recovery-copy");
let legacy = root.join("roaming").join("Recovery");
autosave(&legacy, "a");
std::fs::create_dir_all(legacy.join("a.pcraft").join("tiles")).unwrap();
std::fs::write(legacy.join("a.pcraft").join("tiles").join("t0.zst"), b"tile").unwrap();
let dir = root.join("local").join("Recovery");
std::fs::create_dir_all(root.join("local")).unwrap();
move_across(&legacy, &dir).unwrap();
assert!(!legacy.exists(), "the original is removed after the copy");
assert_eq!(side_leftovers(&root.join("roaming")), 0, "nothing is left held aside");
assert_eq!(std::fs::read_to_string(dir.join("a.pcraft").join("manifest.json")).unwrap(), "a");
assert_eq!(std::fs::read(dir.join("a.pcraft").join("tiles").join("t0.zst")).unwrap(), b"tile");
assert_eq!(std::fs::read_to_string(dir.join("a.json")).unwrap(), "a");
// One file on its own, as the per-autosave moves do.
move_across(&dir.join("a.json"), &root.join("a.json")).unwrap();
assert!(!dir.join("a.json").exists() && root.join("a.json").is_file());
assert_eq!(side_leftovers(&dir) + side_leftovers(&root), 0);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_failed_copy_puts_the_original_back_and_leaves_no_partial_copy() {
let root = temp("recovery-copy-fail");
let legacy = root.join("Recovery");
autosave(&legacy, "a");
// Deeper than any bundle: the copy gives up part way.
let mut deep = legacy.join("a.pcraft");
for _ in 0..=MAX_COPY_DEPTH {
deep = deep.join("d");
}
std::fs::create_dir_all(&deep).unwrap();
let dir = root.join("Local");
assert!(move_across(&legacy, &dir).is_err());
assert!(!dir.exists(), "nothing is published");
assert_eq!(side_leftovers(&root), 0, "no staged copy, nothing left held aside");
assert!(legacy.join("a.json").is_file() && deep.is_dir(), "the original is back in place");
// A target that already exists is never copied into or removed.
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("keep"), b"mine").unwrap();
assert!(move_across(&legacy.join("a.json"), &dir).is_err());
assert_eq!(std::fs::read(dir.join("keep")).unwrap(), b"mine");
assert!(legacy.join("a.json").is_file());
assert_eq!(side_leftovers(&root) + side_leftovers(&legacy), 0);
let _ = std::fs::remove_dir_all(&root);
}
/// Staged (`.moving`) and held (`.held`) side paths left in `dir`.
fn side_leftovers(dir: &Path) -> usize {
std::fs::read_dir(dir)
.unwrap()
.flatten()
.filter(|e| e.file_name().to_str().is_some_and(|n| side_of(n, STAGED).is_some() || side_of(n, HELD).is_some()))
.count()
}
/// The target appears after the copy is staged (another process got there first): publishing
/// fails, that process's target keeps its contents, the staged copy is removed and the
/// original is left as it was. Folder and file alike.
#[test]
fn a_target_that_appears_while_copying_is_never_replaced_or_removed() {
let root = temp("recovery-race");
let legacy = root.join("roaming").join("Recovery");
autosave(&legacy, "a");
let local = root.join("local");
std::fs::create_dir_all(&local).unwrap();
// A whole folder.
let dir = local.join("Recovery");
let staged = stage_copy(&legacy, &dir).unwrap();
autosave(&dir, "theirs");
let e = publish(&staged, &dir).unwrap_err();
assert_eq!(e.kind(), std::io::ErrorKind::AlreadyExists);
assert_eq!(std::fs::read_to_string(dir.join("theirs.json")).unwrap(), "theirs", "their folder is intact");
assert!(!dir.join("a.json").exists(), "nothing was copied into it");
assert!(!staged.exists() && side_leftovers(&local) == 0, "only the staged copy is removed");
assert!(legacy.join("a.json").is_file(), "the original is untouched");
// One file.
let to = dir.join("a.json");
let staged = stage_copy(&legacy.join("a.json"), &to).unwrap();
std::fs::write(&to, "theirs").unwrap();
assert_eq!(publish(&staged, &to).unwrap_err().kind(), std::io::ErrorKind::AlreadyExists);
assert_eq!(std::fs::read_to_string(&to).unwrap(), "theirs");
assert!(!staged.exists() && legacy.join("a.json").is_file());
let _ = std::fs::remove_dir_all(&root);
}
/// A process holds the lock and is copying the old folder (held aside, copy staged) when a
/// second launch gives up waiting. The second launch must get the new folder, never the old
/// one, and what it writes there survives the first one finishing: the publish refuses the
/// now-existing folder and the original is put back, so both autosaves stay (#6412 review).
#[test]
fn a_launch_that_times_out_never_writes_where_a_move_removes() {
let _spawns = crate::no_child_spawns();
let root = temp("recovery-timeout");
let legacy = root.join("roaming").join("Recovery");
autosave(&legacy, "old");
let local = root.join("local");
std::fs::create_dir_all(&local).unwrap();
let dir = local.join("Recovery");
// First process: takes the lock, sets the original aside and stages its copy, then pauses.
let first = std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(local.join(MIGRATION_LOCK)).unwrap();
first.lock().unwrap();
let held = hold(&legacy).unwrap();
let staged = stage_copy(&held, &dir).unwrap();
// Second launch: times out and writes a newer autosave where it was told to.
let chosen = adopt_legacy_recovery_waiting(&legacy, &dir, std::time::Duration::ZERO);
assert_eq!(chosen, dir, "a launch that couldn't get the lock never uses the old folder");
autosave(&chosen, "new");
// First process resumes: publish, and on failure put the original back (as `move_across`).
assert_eq!(publish(&staged, &dir).unwrap_err().kind(), std::io::ErrorKind::AlreadyExists);
put_back(&held, &legacy);
drop(first);
assert_eq!(std::fs::read_to_string(dir.join("new.json")).unwrap(), "new", "the newer autosave survives");
assert_eq!(std::fs::read_to_string(legacy.join("old.json")).unwrap(), "old", "the old one is back in place");
assert_eq!(side_leftovers(&local) + side_leftovers(&root.join("roaming")), 0);
// The next launch merges them.
assert_eq!(adopt_legacy_recovery_waiting(&legacy, &dir, std::time::Duration::ZERO), dir);
assert!(dir.join("old.json").is_file() && dir.join("new.json").is_file() && !legacy.exists());
let _ = std::fs::remove_dir_all(&root);
}
/// A writer that still uses the old path while it is being copied (an older PhotoCraft)
/// starts a new folder there; the move removes only the original it held aside.
#[test]
fn a_write_to_the_old_path_during_a_copy_is_not_removed() {
let root = temp("recovery-old-writer");
let legacy = root.join("roaming").join("Recovery");
autosave(&legacy, "old");
let local = root.join("local");
std::fs::create_dir_all(&local).unwrap();
let dir = local.join("Recovery");
let held = hold(&legacy).unwrap();
let staged = stage_copy(&held, &dir).unwrap();
autosave(&legacy, "new");
publish(&staged, &dir).unwrap();
std::fs::remove_dir_all(&held).unwrap();
assert_eq!(std::fs::read_to_string(dir.join("old.json")).unwrap(), "old");
assert_eq!(std::fs::read_to_string(legacy.join("new.json")).unwrap(), "new", "the new write is untouched");
let _ = std::fs::remove_dir_all(&root);
}
/// What a process killed mid-move left is tidied at the next move: a held original is put
/// back (it may be the only copy) and moved, a staged copy is removed; other dot-files and
/// look-alike names are not touched.
#[test]
fn a_killed_move_is_tidied_next_time() {
let root = temp("recovery-stale");
let roaming = root.join("roaming");
let legacy = roaming.join("Recovery");
autosave(&legacy, "a");
let held = hold(&legacy).unwrap();
assert!(!legacy.exists() && held.is_dir());
let local = root.join("local");
let dir = local.join("Recovery");
let stale = local.join(".Recovery.4294967294-3.moving");
autosave(&stale, "half");
let kept = [local.join(".Recovery.moving"), local.join(".keep.1-x.moving"), local.join("notes.txt")];
for k in &kept {
std::fs::write(k, b"x").unwrap();
}
assert_eq!(adopt_legacy_recovery_waiting(&legacy, &dir, std::time::Duration::ZERO), dir);
assert_eq!(std::fs::read_to_string(dir.join("a.json")).unwrap(), "a", "the held original was put back and moved");
assert!(!stale.exists(), "the stale staged copy is gone");
assert_eq!(side_leftovers(&roaming), 0);
assert!(kept.iter().all(|k| k.is_file()), "only side names are touched");
assert_eq!(side_of(".a.json.12-0.moving", STAGED), Some("a.json"));
assert!(side_of("a.json.12-0.moving", STAGED).is_none() && side_of(".a.json.-0.moving", STAGED).is_none());
assert!(side_of(".a.json.12-0.held", STAGED).is_none() && side_of(".a.json.12-0.held", HELD) == Some("a.json"));
let _ = std::fs::remove_dir_all(&root);
}
/// While another process holds the move lock, a launch moves nothing and uses the new folder;
/// once the lock is released, the move happens.
#[test]
fn a_move_waits_for_another_process_holding_the_lock() {
let _spawns = crate::no_child_spawns();
let root = temp("recovery-lock");
let legacy = root.join("roaming").join("Recovery");
autosave(&legacy, "a");
let dir = root.join("local").join("Recovery");
std::fs::create_dir_all(root.join("local")).unwrap();
let held = std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(root.join("local").join(MIGRATION_LOCK)).unwrap();
held.lock().unwrap();
assert_eq!(adopt_legacy_recovery_waiting(&legacy, &dir, std::time::Duration::ZERO), dir);
assert!(legacy.join("a.json").is_file() && !dir.exists(), "nothing moved under another's lock");
drop(held);
assert_eq!(adopt_legacy_recovery_waiting(&legacy, &dir, std::time::Duration::ZERO), dir);
assert!(dir.join("a.json").is_file() && !legacy.exists());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn no_old_folder_or_the_same_folder_changes_nothing() {
let root = temp("recovery-noop");
let dir = root.join("Recovery");
assert_eq!(adopt_legacy_recovery(&root.join("missing"), &dir), dir);
assert!(!dir.exists(), "nothing is created up front");
autosave(&dir, "a");
assert_eq!(adopt_legacy_recovery(&dir, &dir), dir);
assert!(dir.join("a.json").is_file());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn missing_exe_dir_or_env_never_panics() {
let d = resolve(|_| None, None);
+115 -14
View File
@@ -70,25 +70,61 @@ pub fn start() {
/// Whether the portal's `FileChooser` interface is on the session bus: `Some(false)` only when the
/// bus or the interface is definitely absent, `None` for anything inconclusive (a slow reply).
///
/// Asks the portal object to introspect itself rather than reading the interface's `version`
/// property: xdg-desktop-portal exports `FileChooser` only when a backend offers it, and without
/// one a property read fails with `InvalidArgs` ("No such interface"), an error the old check
/// didn't count as absent (#5739). The interface list answers the question directly.
#[cfg(target_os = "linux")]
fn file_chooser_portal() -> Option<bool> {
let Ok(conn) = zbus::blocking::connection::Builder::session().and_then(|b| b.method_timeout(std::time::Duration::from_secs(5)).build()) else {
return Some(false);
};
let proxy = zbus::blocking::proxy::Builder::<zbus::blocking::Proxy<'_>>::new(&conn)
.destination("org.freedesktop.portal.Desktop")
.and_then(|b| b.path("/org/freedesktop/portal/desktop"))
.and_then(|b| b.interface("org.freedesktop.portal.FileChooser"))
.and_then(|b| b.cache_properties(zbus::proxy::CacheProperties::No).build())
.ok()?;
match proxy.get_property::<u32>("version") {
Ok(_) => Some(true),
Err(zbus::Error::MethodError(name, _, _)) => {
let name = name.as_str();
let absent = ["ServiceUnknown", "UnknownObject", "UnknownInterface", "UnknownProperty"].iter().any(|n| name.ends_with(n));
absent.then_some(false)
}
Err(_) => None,
let reply = conn.call_method(
Some("org.freedesktop.portal.Desktop"),
"/org/freedesktop/portal/desktop",
Some("org.freedesktop.DBus.Introspectable"),
"Introspect",
&(),
);
match reply {
Ok(message) => match message.body().deserialize::<String>() {
Ok(xml) => Some(interface_listed(&xml)),
Err(_) => None,
},
Err(e) => classify_error(&e),
}
}
/// Whether introspection XML lists the portal's `FileChooser` interface (not a longer name such as
/// `FileChooser2`).
#[cfg(target_os = "linux")]
fn interface_listed(xml: &str) -> bool {
["\"", "'"].iter().any(|quote| xml.contains(&format!("<interface name={quote}org.freedesktop.portal.FileChooser{quote}")))
}
/// What a failed call says about the interface: `Some(false)` when the D-Bus error means the
/// service, object or interface isn't there, `None` for anything else (a timeout, a dropped
/// connection, an unknown error), which counts as present.
#[cfg(target_os = "linux")]
fn classify_error(e: &zbus::Error) -> Option<bool> {
use zbus::fdo::Error as Fdo;
// Names a bus daemon or portal answers with when the thing asked about is absent.
const ABSENT: [&str; 7] = ["ServiceUnknown", "NameHasNoOwner", "UnknownObject", "UnknownInterface", "UnknownMethod", "UnknownProperty", "InvalidArgs"];
match e {
zbus::Error::FDO(fdo) => matches!(
**fdo,
Fdo::ServiceUnknown(_)
| Fdo::NameHasNoOwner(_)
| Fdo::UnknownObject(_)
| Fdo::UnknownInterface(_)
| Fdo::UnknownMethod(_)
| Fdo::UnknownProperty(_)
| Fdo::InvalidArgs(_)
)
.then_some(false),
zbus::Error::MethodError(name, _, _) => ABSENT.iter().any(|n| name.as_str().ends_with(n)).then_some(false),
_ => None,
}
}
@@ -126,4 +162,69 @@ mod tests {
assert!(!on_path("kdialog", &path));
std::fs::remove_dir_all(&dir).unwrap();
}
#[cfg(target_os = "linux")]
mod portal {
use super::super::*;
const WITH_FILE_CHOOSER: &str = r#"<!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN" "http://www.freedesktop.org/standards/dbus/introspect.dtd">
<node>
<interface name="org.freedesktop.DBus.Peer"/>
<interface name="org.freedesktop.portal.Settings"><property name="version" type="u" access="read"/></interface>
<interface name="org.freedesktop.portal.FileChooser">
<method name="OpenFile"/>
</interface>
</node>"#;
const WITHOUT_FILE_CHOOSER: &str = r#"<node>
<interface name="org.freedesktop.DBus.Peer"/>
<interface name="org.freedesktop.portal.Settings"/>
</node>"#;
fn method_error(name: &'static str) -> zbus::Error {
let call = zbus::message::Message::method_call("/a", "M").unwrap().build(&()).unwrap();
let reply = zbus::message::Message::error(&call.header(), name).unwrap().build(&"detail").unwrap();
zbus::Error::MethodError(name.try_into().unwrap(), Some("detail".into()), reply)
}
#[test]
fn the_interface_list_says_whether_a_file_chooser_is_offered() {
assert!(interface_listed(WITH_FILE_CHOOSER));
assert!(interface_listed("<interface name='org.freedesktop.portal.FileChooser'>"), "single quotes");
assert!(!interface_listed(WITHOUT_FILE_CHOOSER), "portal without a backend");
assert!(!interface_listed(r#"<interface name="org.freedesktop.portal.FileChooser2">"#), "longer name");
assert!(!interface_listed(""));
}
#[test]
fn dbus_errors_for_a_missing_interface_mean_absent() {
// zbus's fdo proxies hand D-Bus errors back as `Error::FDO`, a plain method call as
// `MethodError`; both forms count. The portal answers `InvalidArgs` ("No such interface")
// when no backend offers the interface (#5739).
for e in [
zbus::fdo::Error::InvalidArgs("No such interface".into()),
zbus::fdo::Error::UnknownInterface("x".into()),
zbus::fdo::Error::ServiceUnknown("x".into()),
zbus::fdo::Error::NameHasNoOwner("x".into()),
zbus::fdo::Error::UnknownObject("x".into()),
] {
let desc = format!("{e:?}");
assert_eq!(classify_error(&zbus::Error::FDO(Box::new(e))), Some(false), "{desc}");
}
}
#[test]
fn method_error_names_still_count_as_absent() {
for name in ["org.freedesktop.DBus.Error.ServiceUnknown", "org.freedesktop.DBus.Error.InvalidArgs", "org.freedesktop.DBus.Error.UnknownMethod"] {
assert_eq!(classify_error(&method_error(name)), Some(false), "{name}");
}
assert_eq!(classify_error(&method_error("org.freedesktop.DBus.Error.AccessDenied")), None);
}
#[test]
fn inconclusive_errors_are_not_absent() {
assert_eq!(classify_error(&zbus::Error::FDO(Box::new(zbus::fdo::Error::NoReply("slow".into())))), None);
assert_eq!(classify_error(&zbus::Error::FDO(Box::new(zbus::fdo::Error::Failed("x".into())))), None);
assert_eq!(classify_error(&zbus::Error::InputOutput(std::sync::Arc::new(std::io::Error::other("gone")))), None);
}
}
}
+239 -16
View File
@@ -12,8 +12,10 @@
//! - macOS: Metal → CPU
//!
//! The choice that worked is remembered in `performance.gpuBackend` (Preferences › Performance,
//! where it can be reset). With `auto`, Intel adapters on Windows use DX12. `WGPU_BACKEND` still
//! overrides everything, and `--safe-gpu` forces the CPU path for one launch.
//! where it can be reset). With `auto`, Intel adapters on Windows use DX12, and when DXGI lists an
//! Intel adapter the instance leaves Vulkan out, so the first start doesn't load the Intel Vulkan
//! driver either. `WGPU_BACKEND` still overrides everything, and `--safe-gpu` forces the CPU path
//! for one launch.
//!
//! "CPU" composites on the CPU and draws the window with a software adapter where the platform
//! has one (WARP on Windows, llvmpipe over GL elsewhere). On Linux a GL adapter that can't present
@@ -204,6 +206,28 @@ pub fn backends(plan: &Plan, os: Os) -> Option<wgpu::Backends> {
})
}
/// Instance backends for `auto` on Windows (no `WGPU_BACKEND`) when `dx12_vendors`, the PCI vendor
/// ids of the DX12 adapters, includes Intel: DX12, with GL as the last resort (see [`rank`]). An
/// instance with Vulkan loads the Intel Vulkan driver before the adapter selector runs, and #4's
/// crash is in that driver. `dx12_vendors` is called only for that plan; `None` keeps [`backends`]'
/// choice.
pub fn windows_auto_backends(plan: &Plan, os: Os, dx12_vendors: impl FnOnce() -> Vec<u32>) -> Option<wgpu::Backends> {
let auto = plan.env.is_none() && plan.backend == GpuBackend::Auto && os == Os::Windows;
(auto && dx12_vendors().contains(&INTEL)).then_some(wgpu::Backends::DX12 | wgpu::Backends::GL)
}
/// The PCI vendor ids of the DX12 adapters, from a DX12-only instance (DXGI, no Vulkan driver)
/// with `desc`'s flags and backend options (the shader compiler from [`dx12_compiler`]).
fn dx12_vendors(desc: &wgpu::InstanceDescriptor) -> Vec<u32> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
backends: wgpu::Backends::DX12,
flags: desc.flags,
backend_options: desc.backend_options.clone(),
..wgpu::InstanceDescriptor::new_without_display_handle()
});
crate::services::block_on(instance.enumerate_adapters(wgpu::Backends::DX12)).iter().map(|a| a.get_info().vendor).collect()
}
/// The DX12 shader compiler (#712). wgpu's default loads `dxcompiler.dll` by name, and the
/// Windows DLL search falls through to the current directory and `PATH`, so another program's DXC
/// build (a browser's, an SDK's) got loaded and failed device creation or crashed the first shader
@@ -228,6 +252,8 @@ pub struct Candidate {
pub backend: wgpu::Backend,
/// Can present to the window.
pub surface_ok: bool,
/// Its name matches the adapter the user asked for ([`adapter_matches`]).
pub wanted: bool,
}
/// Rank of an adapter (lower is better): high-performance device types first (egui's power
@@ -259,19 +285,59 @@ fn rank(a: &Candidate, backend: GpuBackend, os: Os) -> (u8, u8, u8) {
(fallback, ty, be)
}
/// The adapter to use among `adapters` (`None` when there is none).
/// The adapter to use among `adapters` (`None` when there is none). Adapters the user asked for
/// win over the rest when any of them can present; otherwise the ranking decides (see
/// [`wanted_missed`]).
pub fn pick(adapters: &[Candidate], backend: GpuBackend, os: Os) -> Option<usize> {
adapters.iter().enumerate().filter(|(_, a)| a.surface_ok).min_by_key(|(i, a)| (rank(a, backend, os), *i)).map(|(i, _)| i)
let usable = |a: &&Candidate| a.surface_ok;
let any_wanted = adapters.iter().filter(usable).any(|a| a.wanted);
adapters.iter().enumerate().filter(|(_, a)| a.surface_ok && (a.wanted || !any_wanted)).min_by_key(|(i, a)| (rank(a, backend, os), *i)).map(|(i, _)| i)
}
/// Whether an adapter called `name` is the one the user asked for: `wanted` is a case-insensitive
/// piece of its name ("nvidia"), or several words that each appear in it ("arc a370m" finds
/// "Intel(R) Arc(tm) A370M Graphics"). An empty `wanted` matches nothing.
pub fn adapter_matches(name: &str, wanted: &str) -> bool {
photocraft_engine::prefs::gpu_adapter_matches(name, wanted)
}
/// The user asked for an adapter and none of `adapters` that can present is it.
pub fn wanted_missed(adapters: &[Candidate], wanted: Option<&str>) -> bool {
wanted.is_some() && !adapters.iter().any(|a| a.surface_ok && a.wanted)
}
/// Choosing an adapter is Linux-only for now: Windows has its own Intel/DX12 selection and macOS a
/// single Metal path, and neither is tested with a requested adapter.
pub const ADAPTER_CHOICE: bool = cfg!(target_os = "linux");
/// The adapter request that applies to this launch: none off Linux (`linux`), and none in CPU mode,
/// which wants a software adapter whatever the preference says.
pub fn applicable_adapter(wanted: Option<String>, backend: GpuBackend, linux: bool) -> Option<String> {
wanted.filter(|_| linux && backend != GpuBackend::Cpu)
}
/// Whether `PHOTOCRAFT_GPU_ADAPTER` makes this launch its own process: only when startup will act
/// on it (a non-empty value, on Linux, outside CPU mode). Otherwise it changes nothing, and the
/// launch may still be forwarded to the running window.
pub fn adapter_override_is_process_specific(env: Option<&str>, mode: RenderingMode, linux: bool) -> bool {
env.is_some_and(|v| !v.trim().is_empty()) && linux && mode != RenderingMode::Cpu
}
/// The adapter to ask for: `PHOTOCRAFT_GPU_ADAPTER` when set (for one launch), else
/// `performance.gpuAdapter`; `None` is automatic.
pub fn wanted_adapter(env: Option<&str>, pref: &str) -> Option<String> {
[env.unwrap_or(""), pref].iter().map(|s| s.trim()).find(|s| !s.is_empty()).map(|s| s.chars().take(256).collect())
}
/// Whether picking `chosen` applied the Intel-on-Windows DX12 default (a Vulkan adapter of the
/// same vendor was available).
pub fn intel_dx12_applied(adapters: &[Candidate], chosen: usize, os: Os) -> bool {
/// same vendor was available, or `vulkan_left_out`: [`windows_auto_backends`] left Vulkan out of
/// the instance).
pub fn intel_dx12_applied(adapters: &[Candidate], chosen: usize, os: Os, vulkan_left_out: bool) -> bool {
let Some(c) = adapters.get(chosen) else { return false };
os == Os::Windows
&& c.vendor == INTEL
&& c.backend == wgpu::Backend::Dx12
&& adapters.iter().any(|a| a.vendor == INTEL && a.backend == wgpu::Backend::Vulkan)
&& (vulkan_left_out || adapters.iter().any(|a| a.vendor == INTEL && a.backend == wgpu::Backend::Vulkan))
}
/// wgpu compiles indirect-call validation shaders inside `request_device`; Metal's compiler
@@ -285,7 +351,7 @@ fn skips_indirect_validation(os: Os, intel: bool) -> bool {
/// Configure eframe's wgpu setup for `plan`: instance backends, the DX12 shader compiler, adapter
/// selection (Intel on Windows, software adapters for `cpu`) and the marker update once the
/// adapter is chosen. `note` receives a remark for System Info (e.g. the Intel default).
pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, sentinel: SharedSentinel, note: Arc<Mutex<Option<String>>>) {
pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, wanted: Option<String>, sentinel: SharedSentinel, note: Arc<Mutex<Option<String>>>) {
if let egui_wgpu::WgpuSetup::CreateNew(create) = setup {
if let Some(b) = backends(plan, os) {
create.instance_descriptor.backends = b;
@@ -298,7 +364,13 @@ pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, sentinel
let env = std::env::var("WGPU_DX12_COMPILER").ok();
create.instance_descriptor.backend_options.dx12.shader_compiler = dx12_compiler(env.as_deref(), exe.as_deref().and_then(Path::parent));
}
let select = plan.env.is_none() && (plan.backend == GpuBackend::Cpu || (plan.backend == GpuBackend::Auto && os == Os::Windows));
let without_vulkan = windows_auto_backends(plan, os, || dx12_vendors(&create.instance_descriptor));
if let Some(b) = without_vulkan {
create.instance_descriptor.backends = b;
}
let vulkan_left_out = without_vulkan.is_some();
let wanted = applicable_adapter(wanted, plan.backend, ADAPTER_CHOICE);
let select = wanted.is_some() || (plan.env.is_none() && (plan.backend == GpuBackend::Cpu || (plan.backend == GpuBackend::Auto && os == Os::Windows)));
if select {
let backend = plan.backend;
create.native_adapter_selector = Some(Arc::new(move |adapters: &[wgpu::Adapter], surface: Option<&wgpu::Surface<'_>>| {
@@ -311,15 +383,20 @@ pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, sentinel
device_type: i.device_type,
backend: i.backend,
surface_ok: surface.is_none_or(|s| a.is_surface_supported(s)),
wanted: wanted.as_deref().is_some_and(|w| adapter_matches(&i.name, w)),
}
})
.collect();
let i = pick(&cands, backend, os).ok_or_else(|| "no graphics adapter found".to_string())?;
if let Some(w) = wanted.as_deref().filter(|_| wanted_missed(&cands, wanted.as_deref())) {
*note.lock().unwrap_or_else(PoisonError::into_inner) =
Some(format!("No graphics adapter named \"{w}\" can draw the window; using the automatic choice"));
}
if backend == GpuBackend::Cpu && cands.get(i).is_some_and(|c| c.device_type != wgpu::DeviceType::Cpu) {
*note.lock().unwrap_or_else(PoisonError::into_inner) =
Some("CPU image rendering; no software graphics adapter is available, so the window uses hardware graphics".into());
}
if backend == GpuBackend::Auto && intel_dx12_applied(&cands, i, os) {
if backend == GpuBackend::Auto && intel_dx12_applied(&cands, i, os, vulkan_left_out) {
*note.lock().unwrap_or_else(PoisonError::into_inner) =
Some("Intel graphics on Windows: using DirectX 12 (the Intel Vulkan driver is known to crash)".into());
}
@@ -443,6 +520,21 @@ pub fn read_display_server(path: Option<&Path>) -> photocraft_engine::prefs::Lin
.unwrap_or_default()
}
/// Preferences › Performance › Graphics Adapter (`performance.gpuAdapter`), read before the window
/// opens; empty (automatic) when the file or value is missing.
pub fn read_gpu_adapter(path: Option<&Path>) -> String {
let v: Value = path.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);
v.get("performance").and_then(|p| p.get("gpuAdapter")).and_then(Value::as_str).unwrap_or("").chars().take(256).collect()
}
/// Preferences › Performance › Low-Latency Drawing (`performance.lowLatencyDrawing`, off unless
/// turned on), read before the window opens: it picks the surface's present mode.
pub fn read_low_latency(path: Option<&Path>) -> bool {
let v: Value = path.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);
let default = photocraft_engine::prefs::Preferences::default().performance.low_latency_drawing;
v.get("performance").and_then(|p| p.get("lowLatencyDrawing")).and_then(Value::as_bool).unwrap_or(default)
}
/// Read the policy leniently at startup, including old settings without renderingMode.
pub fn read_rendering_prefs(path: Option<&Path>) -> (GpuBackend, RenderingMode) {
let v: Value = path.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);
@@ -463,6 +555,23 @@ mod tests {
Marker { backend: backend.into(), adapter: "Intel(R) UHD Graphics".into(), adapter_backend: adapter_backend.into(), ..Default::default() }
}
#[test]
fn low_latency_drawing_is_read_before_the_window_opens_and_defaults_off() {
let dir = std::env::temp_dir().join(format!("photocraft-low-latency-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("preferences.json");
let read = |json: &str| {
std::fs::write(&file, json).unwrap();
read_low_latency(Some(&file))
};
assert!(read(r#"{"performance":{"lowLatencyDrawing":true}}"#));
assert!(!read(r#"{"performance":{"lowLatencyDrawing":false}}"#));
assert!(!read(r#"{"performance":{"lowLatencyDrawing":"nope"}}"#), "garbage keeps the default (off)");
assert!(!read("not json"));
assert!(!read_low_latency(None), "no settings file: the default (off)");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn display_server_preference_is_read_before_the_window_opens() {
use photocraft_engine::prefs::LinuxDisplayServer;
@@ -579,7 +688,82 @@ mod tests {
}
fn cand(vendor: u32, device_type: wgpu::DeviceType, backend: wgpu::Backend) -> Candidate {
Candidate { vendor, device_type, backend, surface_ok: true }
Candidate { vendor, device_type, backend, surface_ok: true, wanted: false }
}
#[test]
fn a_requested_adapter_beats_the_automatic_ranking() {
use wgpu::{Backend as B, DeviceType as T};
// The order Vulkan lists this machine's GPUs in: iGPU, dGPU, eGPU.
let mut a = [cand(INTEL, T::IntegratedGpu, B::Vulkan), cand(INTEL, T::DiscreteGpu, B::Vulkan), cand(0x10de, T::DiscreteGpu, B::Vulkan)];
assert_eq!(pick(&a, Auto, Os::Other), Some(1), "automatic takes the first discrete adapter, the Arc");
a[2].wanted = true;
assert_eq!(pick(&a, Auto, Os::Other), Some(2));
a[2].wanted = false;
a[0].wanted = true;
assert_eq!(pick(&a, Auto, Os::Other), Some(0), "even the iGPU, when asked for");
// A wanted adapter that can't present is skipped, not an error.
a[0].surface_ok = false;
assert_eq!(pick(&a, Auto, Os::Other), Some(1));
assert!(wanted_missed(&a, Some("iris")));
assert!(!wanted_missed(&a, None));
a[0].surface_ok = true;
assert!(!wanted_missed(&a, Some("iris")));
// Among several wanted ones (same GPU on Vulkan and GL) the usual ranking decides.
let mut gl = [cand(0x10de, T::DiscreteGpu, B::Gl), cand(0x10de, T::DiscreteGpu, B::Vulkan)];
gl.iter_mut().for_each(|c| c.wanted = true);
assert_eq!(pick(&gl, Auto, Os::Other), Some(1));
}
#[test]
fn an_adapter_request_applies_on_linux_outside_cpu_mode() {
let w = || Some("nvidia".to_string());
assert_eq!(applicable_adapter(w(), Auto, true), w());
assert_eq!(applicable_adapter(w(), Vulkan, true), w());
assert_eq!(applicable_adapter(w(), Cpu, true), None);
assert_eq!(applicable_adapter(w(), Auto, false), None, "Windows and macOS keep their own selection");
assert_eq!(applicable_adapter(None, Auto, true), None);
}
#[test]
fn an_adapter_override_keeps_its_own_process_only_when_it_applies() {
let on = |env, mode, linux| adapter_override_is_process_specific(env, mode, linux);
assert!(on(Some("nvidia"), RenderingMode::Auto, true));
assert!(on(Some("nvidia"), RenderingMode::Gpu, true));
assert!(!on(Some("nvidia"), RenderingMode::Cpu, true), "ignored in CPU mode");
assert!(!on(Some("nvidia"), RenderingMode::Auto, false), "ignored off Linux");
assert!(!on(Some(" "), RenderingMode::Auto, true));
assert!(!on(None, RenderingMode::Auto, true));
}
#[test]
fn adapter_names_match_loosely_and_the_environment_wins() {
assert!(adapter_matches("NVIDIA GeForce RTX 5060 Ti", "nvidia"));
assert!(adapter_matches("Intel(R) Arc(tm) A370M Graphics (DG2)", " ARC A370M "));
assert!(!adapter_matches("Intel(R) Iris(R) Xe Graphics (ADL GT2)", "arc"));
assert!(!adapter_matches("anything", ""));
assert!(!adapter_matches("anything", " "));
assert_eq!(wanted_adapter(None, ""), None);
assert_eq!(wanted_adapter(Some(" "), "iris"), Some("iris".into()));
assert_eq!(wanted_adapter(Some("nvidia"), "iris"), Some("nvidia".into()));
assert_eq!(wanted_adapter(Some(&"x".repeat(1000)), "").map(|w| w.len()), Some(256));
}
#[test]
fn gpu_adapter_is_read_before_the_window_opens() {
let dir = std::env::temp_dir().join(format!("photocraft-gpu-adapter-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("preferences.json");
let read = |json: &str| {
std::fs::write(&file, json).unwrap();
read_gpu_adapter(Some(&file))
};
assert_eq!(read(r#"{"performance":{"gpuAdapter":"nvidia"}}"#), "nvidia");
assert_eq!(read(r#"{"performance":{"gpuAdapter":7}}"#), "");
assert_eq!(read(r#"{"performance":{}}"#), "");
assert_eq!(read("not json"), "");
assert_eq!(read_gpu_adapter(None), "");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
@@ -588,13 +772,13 @@ mod tests {
let intel = [cand(INTEL, T::IntegratedGpu, B::Vulkan), cand(INTEL, T::IntegratedGpu, B::Dx12), cand(INTEL, T::IntegratedGpu, B::Gl)];
let i = pick(&intel, Auto, Os::Windows).unwrap();
assert_eq!(intel[i].backend, B::Dx12);
assert!(intel_dx12_applied(&intel, i, Os::Windows));
assert!(intel_dx12_applied(&intel, i, Os::Windows, false));
// Elsewhere (and for other vendors) Vulkan stays first.
assert_eq!(intel[pick(&intel, Auto, Os::Other).unwrap()].backend, B::Vulkan);
let nv = [cand(0x10de, T::DiscreteGpu, B::Dx12), cand(0x10de, T::DiscreteGpu, B::Vulkan)];
let i = pick(&nv, Auto, Os::Windows).unwrap();
assert_eq!(nv[i].backend, B::Vulkan);
assert!(!intel_dx12_applied(&nv, i, Os::Windows));
assert!(!intel_dx12_applied(&nv, i, Os::Windows, false));
// Intel iGPU + discrete GPU: the discrete one wins (high performance), on Vulkan.
let hybrid = [intel[0], intel[1], nv[1]];
assert_eq!(pick(&hybrid, Auto, Os::Windows), Some(2));
@@ -605,6 +789,45 @@ mod tests {
assert_eq!(pick(&[], Auto, Os::Windows), None);
}
/// #4: an instance with Vulkan loads the Intel Vulkan driver, which crashed at startup, before
/// the selector picks Intel's DX12 adapter. With an Intel adapter in the DX12 list, `auto` on
/// Windows creates the instance without Vulkan.
#[test]
fn auto_on_windows_with_an_intel_adapter_leaves_vulkan_out_of_the_instance() {
use wgpu::{Backend as B, DeviceType as T};
const NVIDIA: u32 = 0x10de;
const AMD: u32 = 0x1002;
const WARP: u32 = 0x1414;
let auto = plan(Auto, None, None, false, Os::Windows);
let dx12_gl = Some(wgpu::Backends::DX12 | wgpu::Backends::GL);
assert_eq!(windows_auto_backends(&auto, Os::Windows, || vec![INTEL, WARP]), dx12_gl);
// Intel iGPU + discrete GPU: the discrete GPU is then used on DX12.
assert_eq!(windows_auto_backends(&auto, Os::Windows, || vec![NVIDIA, INTEL, WARP]), dx12_gl);
// No Intel adapter: egui's default backends, Vulkan included.
assert_eq!(windows_auto_backends(&auto, Os::Windows, || vec![NVIDIA, WARP]), None);
assert_eq!(windows_auto_backends(&auto, Os::Windows, || vec![AMD]), None);
assert_eq!(windows_auto_backends(&auto, Os::Windows, Vec::new), None);
// `WGPU_BACKEND`, `--safe-gpu`, a chosen backend, a crash fallback and other platforms
// don't list the DX12 adapters at all.
let not_listed = || -> Vec<u32> { panic!("DX12 adapters listed") };
for (p, os) in [
(plan(Auto, None, Some("vulkan"), false, Os::Windows), Os::Windows),
(plan(Auto, None, None, true, Os::Windows), Os::Windows),
(plan(Vulkan, None, None, false, Os::Windows), Os::Windows),
(plan(Auto, Some(&crashed("auto", "vulkan")), None, false, Os::Windows), Os::Windows),
(plan(Auto, None, None, false, Os::Other), Os::Other),
(plan(Auto, None, None, false, Os::Mac), Os::Mac),
] {
assert_eq!(windows_auto_backends(&p, os, not_listed), None, "{p:?}");
}
// Without Vulkan adapters to compare with, System Info still names the Intel default.
let listed = [cand(INTEL, T::IntegratedGpu, B::Dx12), cand(WARP, T::Cpu, B::Dx12), cand(INTEL, T::IntegratedGpu, B::Gl)];
let i = pick(&listed, Auto, Os::Windows).unwrap();
assert_eq!(i, 0);
assert!(intel_dx12_applied(&listed, i, Os::Windows, true));
assert!(!intel_dx12_applied(&listed, i, Os::Windows, false));
}
#[test]
fn cpu_prefers_software_adapters() {
use wgpu::{Backend as B, DeviceType as T};
@@ -869,7 +1092,7 @@ mod tests {
// The app's setup replaces wgpu's default (`Auto`: DXC by name from the search path).
let mut setup = egui_wgpu::WgpuSetup::without_display_handle();
let plan = plan(Auto, None, None, false, Os::Windows);
configure(&mut setup, &plan, Os::Windows, Arc::new(Mutex::new(None)), Arc::default());
configure(&mut setup, &plan, Os::Windows, None, Arc::new(Mutex::new(None)), Arc::default());
let egui_wgpu::WgpuSetup::CreateNew(create) = &setup else { panic!("a new instance") };
if std::env::var_os("WGPU_DX12_COMPILER").is_none() {
let safe = match &create.instance_descriptor.backend_options.dx12.shader_compiler {
@@ -893,13 +1116,13 @@ mod tests {
let mut setup = egui_wgpu::WgpuSetup::without_display_handle();
let egui_wgpu::WgpuSetup::CreateNew(before) = &setup else { panic!("a new instance") };
let before = before.instance_descriptor.flags;
configure(&mut setup, &plan(Auto, None, None, safe_gpu, os), os, Arc::new(Mutex::new(None)), Arc::default());
configure(&mut setup, &plan(Auto, None, None, safe_gpu, os), os, None, Arc::new(Mutex::new(None)), Arc::default());
let egui_wgpu::WgpuSetup::CreateNew(create) = &setup else { panic!("a new instance") };
assert_eq!(create.instance_descriptor.flags, before, "{os:?}: flags untouched");
}
if cfg!(target_arch = "x86_64") && std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() {
let mut setup = egui_wgpu::WgpuSetup::without_display_handle();
configure(&mut setup, &plan(Auto, None, None, false, Os::Mac), Os::Mac, Arc::new(Mutex::new(None)), Arc::default());
configure(&mut setup, &plan(Auto, None, None, false, Os::Mac), Os::Mac, None, Arc::new(Mutex::new(None)), Arc::default());
let egui_wgpu::WgpuSetup::CreateNew(create) = &setup else { panic!("a new instance") };
assert!(!create.instance_descriptor.flags.contains(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL));
}
+177 -12
View File
@@ -4,7 +4,9 @@
//! lost device, the crash guard's panic report) vanished. A launch from a desktop menu has no
//! terminal, so the file is what a bug report can attach: `<settings dir>/logs/photocraft.log`
//! (see `app_dirs`). Each start moves the previous log to `photocraft.1.log` (and that one to
//! `.2`), so the log of a run that crashed survives the next launch.
//! `.2`), so the log of a run that crashed survives the next launch. A start while another
//! instance is still running leaves that instance's log alone and writes `photocraft-<pid>.log`
//! (see [`rotate`]).
//!
//! Levels: `info` for PhotoCraft's own crates, `warn` for everything else (wgpu and naga are
//! chatty). `RUST_LOG` replaces that with env_logger-style directives: `debug`,
@@ -15,7 +17,7 @@
//! written once the file is attached. Writing never panics: a file that can't be created or
//! written leaves standard error as the only sink.
use std::fs::File;
use std::fs::{File, OpenOptions, TryLockError};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock, PoisonError};
@@ -116,10 +118,76 @@ fn numbered(dir: &Path, n: usize) -> PathBuf {
if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("photocraft.{n}.log")) }
}
/// Shift the previous logs up one (`.log` → `.1.log` → … → `.<KEEP>.log`, the oldest dropped)
/// and create a fresh, empty log file in `dir` (created if missing).
pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
/// Held by the instance that writes [`LOG_FILE`], from its start until it exits. A separate lock
/// file rather than a lock on the log itself: Windows locks are mandatory, and a locked log could
/// not be opened for a bug report while the app runs.
pub const OWNER_LOCK: &str = "photocraft.lock";
/// Held while one start rotates and prunes the logs, so two starts at once can't interleave.
pub const ROTATE_LOCK: &str = "rotate.lock";
/// The log of an instance started while another one was writing [`LOG_FILE`].
fn instance_log(dir: &Path, pid: u32) -> PathBuf {
dir.join(format!("photocraft-{pid}.log"))
}
/// The lock file that marks [`instance_log`] as in use.
fn instance_lock(dir: &Path, pid: u32) -> PathBuf {
dir.join(format!("photocraft-{pid}.lock"))
}
/// The process id in an [`instance_log`] file name.
fn instance_pid(name: &str) -> Option<u32> {
let digits = name.strip_prefix("photocraft-")?.strip_suffix(".log")?;
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
digits.parse().ok()
}
fn open_lock(path: &Path) -> std::io::Result<File> {
OpenOptions::new().read(true).write(true).create(true).truncate(false).open(path)
}
/// Take `file`'s lock without waiting: `Ok(true)` holds it, `Ok(false)` when another process holds it.
fn try_hold(file: &File) -> std::io::Result<bool> {
match file.try_lock() {
Ok(()) => Ok(true),
Err(TryLockError::WouldBlock) => Ok(false),
Err(TryLockError::Error(e)) => Err(e),
}
}
/// Open a fresh, empty log file in `dir` (created if missing).
///
/// Normally the previous logs shift up one (`.log` → `.1.log` → … → `.<KEEP>.log`, the oldest
/// dropped). While another instance is still writing [`LOG_FILE`] (it holds [`OWNER_LOCK`]), the
/// chain is left alone and this start writes `photocraft-<pid>.log` instead: rotating would move
/// that instance's live log away and, a few launches later, delete it (#6432). Such side-by-side
/// logs are pruned to the newest [`KEEP`] once their instances have exited. On a file system
/// without locks every start rotates, as before.
pub fn rotate(dir: &Path) -> std::io::Result<LogFile> {
std::fs::create_dir_all(dir)?;
// Waits only for another start's few file operations; without locking support, go on.
let guard = open_lock(&dir.join(ROTATE_LOCK)).ok().filter(|g| g.lock().is_ok());
let log = rotate_locked(dir);
drop(guard);
log
}
fn rotate_locked(dir: &Path) -> std::io::Result<LogFile> {
prune_instance_logs(dir);
let owner = open_lock(&dir.join(OWNER_LOCK))?;
let state = try_hold(&owner);
if let Ok(false) = state {
let pid = std::process::id();
let lock = open_lock(&instance_lock(dir, pid))?;
let held = try_hold(&lock).unwrap_or(false);
let path = instance_log(dir, pid);
let file = File::create(&path)?;
return Ok(LogFile { path, file, owner: held.then_some(lock) });
}
// This start holds the owner lock (or the file system has no locks).
let held = state.unwrap_or(false);
for n in (1..=KEEP).rev() {
match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e),
@@ -128,7 +196,40 @@ pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
}
let path = numbered(dir, 0);
let file = File::create(&path)?;
Ok((path, file))
Ok(LogFile { path, file, owner: held.then_some(owner) })
}
/// Remove the side-by-side logs of instances that have exited, all but the newest [`KEEP`].
/// A log whose lock file is held belongs to a running instance and is never touched. Failures
/// are ignored: pruning only saves disk space.
fn prune_instance_logs(dir: &Path) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
let mut finished: Vec<(SystemTime, u32)> = Vec::new();
for entry in entries.flatten() {
let Some(pid) = entry.file_name().to_str().and_then(instance_pid) else { continue };
// A missing lock file means nobody holds it; an unknown lock state counts as running.
let running = match OpenOptions::new().read(true).write(true).open(instance_lock(dir, pid)) {
Ok(lock) => !try_hold(&lock).unwrap_or(false),
Err(_) => false,
};
if !running {
let modified = entry.metadata().and_then(|m| m.modified()).unwrap_or(SystemTime::UNIX_EPOCH);
finished.push((modified, pid));
}
}
finished.sort_unstable_by(|a, b| b.cmp(a));
for &(_, pid) in finished.iter().skip(KEEP) {
let _ = std::fs::remove_file(instance_log(dir, pid));
let _ = std::fs::remove_file(instance_lock(dir, pid));
}
}
/// A log file opened by [`rotate`].
pub struct LogFile {
pub path: PathBuf,
pub file: File,
/// The held lock that marks `path` as in use; `None` where locking isn't supported.
owner: Option<File>,
}
/// One formatted log line: `<timestamp> <LEVEL> [<thread>] <target>: <message>\n`.
@@ -205,17 +306,20 @@ pub struct AppLogger {
filter: Filter,
stderr: bool,
sink: Mutex<Sink>,
/// The lock that marks the attached log file as in use (see [`rotate`]); held until exit.
owner: Mutex<Option<File>>,
}
impl AppLogger {
pub fn new(filter: Filter, stderr: bool) -> AppLogger {
AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)) }
AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)), owner: Mutex::new(None) }
}
/// Rotate the logs in `dir` and send records to the fresh file; returns its path.
pub fn attach_dir(&self, dir: &Path) -> Result<PathBuf, String> {
let (path, file) = rotate(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let LogFile { path, file, owner } = rotate(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
self.sink.lock().unwrap_or_else(PoisonError::into_inner).attach(file);
*self.owner.lock().unwrap_or_else(PoisonError::into_inner) = owner;
Ok(path)
}
}
@@ -316,9 +420,10 @@ mod tests {
#[test]
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
let _spawns = crate::no_child_spawns();
let dir = temp_dir("rotate");
for run in 1..=4 {
let (path, _file) = rotate(&dir).expect("rotate");
let LogFile { path, file: _file, owner: _owner } = rotate(&dir).expect("rotate");
assert_eq!(path, dir.join(LOG_FILE));
assert_eq!(std::fs::read_to_string(&path).expect("read"), "");
std::fs::write(&path, format!("run {run}")).expect("write");
@@ -330,6 +435,66 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// #6432: a second launch moved the running instance's live log to `.1.log`, a third to
/// `.2.log`, and a fourth deleted it.
#[test]
fn a_second_start_leaves_the_running_instance_log_alone() {
let _spawns = crate::no_child_spawns();
let dir = temp_dir("second");
let mut first = rotate(&dir).expect("first start");
assert_eq!(first.path, dir.join(LOG_FILE));
first.file.write_all(b"first, before\n").expect("write");
for _ in 0..KEEP + 1 {
let second = rotate(&dir).expect("second start");
assert_eq!(second.path, instance_log(&dir, std::process::id()));
assert_eq!(read(&second.path), "");
}
first.file.write_all(b"first, after\n").expect("write");
assert_eq!(read(&dir.join(LOG_FILE)), "first, before\nfirst, after\n");
assert!(!dir.join("photocraft.1.log").exists(), "the running instance's log was rotated");
// Once the first instance is gone, the next start rotates as before.
drop(first);
let third = rotate(&dir).expect("third start");
assert_eq!(third.path, dir.join(LOG_FILE));
assert_eq!(read(&dir.join("photocraft.1.log")), "first, before\nfirst, after\n");
drop(third);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn instance_logs_are_pruned_only_when_no_one_holds_them() {
let _spawns = crate::no_child_spawns();
let dir = temp_dir("prune");
let first = rotate(&dir).expect("first start");
// Earlier side-by-side runs that have exited, oldest first; the oldest has no lock file
// (as if its lock file had been removed), the others an unlocked one.
let now = SystemTime::now();
for (age, pid) in [(400u64, 1u32), (300, 2), (200, 3), (100, 4)] {
let path = instance_log(&dir, pid);
std::fs::write(&path, format!("run {pid}")).expect("write");
File::options().write(true).open(&path).expect("open").set_modified(now - Duration::from_secs(age)).expect("mtime");
if pid != 1 {
std::fs::write(instance_lock(&dir, pid), "").expect("lock file");
}
}
// One older still running: its log is never removed, however old.
let live = instance_log(&dir, 5);
std::fs::write(&live, "live").expect("write");
File::options().write(true).open(&live).expect("open").set_modified(now - Duration::from_secs(900)).expect("mtime");
let held = File::create(instance_lock(&dir, 5)).expect("lock file");
held.try_lock().expect("hold");
let second = rotate(&dir).expect("second start");
assert_eq!(second.path, instance_log(&dir, std::process::id()));
// The newest KEEP finished ones stay, the rest go with their lock files.
assert!(instance_log(&dir, 4).exists() && instance_log(&dir, 3).exists());
assert!(!instance_log(&dir, 2).exists() && !instance_log(&dir, 1).exists());
assert!(!instance_lock(&dir, 2).exists());
assert_eq!(read(&live), "live");
drop((first, second, held));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
let dir = temp_dir("blocked");
@@ -355,7 +520,7 @@ mod tests {
#[test]
fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() {
let dir = temp_dir("pending");
let (path, file) = rotate(&dir).expect("rotate");
let LogFile { path, file, .. } = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
sink.write("early 1\n");
sink.write("early 2\n");
@@ -368,7 +533,7 @@ mod tests {
#[test]
fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
let dir = temp_dir("bounded");
let (path, file) = rotate(&dir).expect("rotate");
let LogFile { path, file, .. } = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
for i in 0..MAX_PENDING + 5 {
sink.write(&format!("line {i}\n"));
@@ -384,7 +549,7 @@ mod tests {
#[test]
fn the_file_stops_at_its_size_cap_with_one_note() {
let dir = temp_dir("cap");
let (path, file) = rotate(&dir).expect("rotate");
let LogFile { path, file, .. } = rotate(&dir).expect("rotate");
let mut sink = Sink::new(100);
sink.attach(file);
for i in 0..50 {
+453 -62
View File
@@ -1,8 +1,23 @@
//! Photocraft desktop app.
//! PhotoCraft desktop app.
//!
//! Usage: `photocraft [--control <port>] [--control-token <64-hex> |
//! --control-token-file <path>] [--automation-read-root <dir>]
//! [--automation-write-root <dir>] [--safe-gpu] [--in-window-menus] [files…]`
//! [--automation-write-root <dir>] [--safe-gpu] [--in-window-menus] [--present-mode <mode>]
//! [--frame-latency <n>] [--stroke-trace <stderr|file.csv>] [--new-instance] [--] [files…]`
//! (`photocraft --help` prints [`USAGE`]; `--version` prints the version).
//!
//! Any other argument starting with `-` is a usage error (exit status 2, issue #6431), so a typo
//! such as `--safegpu` doesn't open the window and then fail to open the flag as a file. A file
//! whose name starts with `-` goes after `--`.
//!
//! On Windows and Linux a launch hands its files to the PhotoCraft that is already running (new
//! tabs in its window) and exits; `--new-instance` starts a separate copy instead, as does any
//! `--control` launch (see `single_instance`).
//!
//! `--present-mode` (`fifo`, `autovsync`, `novsync`) and `--frame-latency` (1–3) override how
//! frames reach the display for this launch (`PHOTOCRAFT_PRESENT_MODE` / `…_FRAME_LATENCY`;
//! `presentation`); `--stroke-trace` times each brush stroke's frames (`PHOTOCRAFT_STROKE_TRACE`;
//! `photocraft_ui_egui::stroke_trace`).
//!
//! `--safe-gpu` starts with the CPU renderer (no GPU canvas; a software adapter for the window
//! where the platform has one) for this launch, e.g. after a graphics driver crash. A start that
@@ -44,10 +59,19 @@ mod linux_libs;
mod logging;
mod monitor_profile;
mod photoshop_settings;
// Save As through the desktop portal, which reports the file type picked (#6121).
#[cfg(target_os = "linux")]
mod portal_save;
mod presentation;
// The print portal needs the session bus (zbus, Linux only); its pure helpers are tested everywhere.
#[cfg(any(target_os = "linux", test))]
mod print_portal;
mod screen_color;
mod services;
// Windows gets pen pressure from winit (WM_POINTER); the web runner has its own listener.
#[cfg(any(target_os = "macos", target_os = "linux", test))]
// macOS gets one instance from LaunchServices (open-documents Apple events).
#[cfg(any(not(target_os = "macos"), test))]
mod single_instance;
// Tablet support (macOS AppKit monitor, Linux X11/Wayland, Windows barrel button).
mod tablet;
mod ui_state;
mod window_frame;
@@ -132,21 +156,188 @@ fn parse_control_port(value: &str, source: &str) -> Result<u16, String> {
value.trim().parse().map_err(|_| format!("{source}: `{value}` is not a valid port (expected a number from 0 to 65535)"))
}
/// Process exit status for the collected `--control` / `PHOTOCRAFT_CONTROL_PORT` errors: `None`
/// when there are none, else 2 (a command-line usage error), never 0.
fn control_args_exit_code(errors: &[String]) -> Option<i32> {
/// Process exit status for the collected usage errors (a bad `--control` /
/// `PHOTOCRAFT_CONTROL_PORT`, an unknown option): `None` when there are none, else 2 (a
/// command-line usage error), never 0.
fn usage_exit_code(errors: &[String]) -> Option<i32> {
if errors.is_empty() { None } else { Some(2) }
}
/// `photocraft --help`. Keep in step with the options `main` accepts and the module docs.
const USAGE: &str = "\
Usage: photocraft [options] [--] [files…]
Opens PhotoCraft with the given files.
Options:
-h, --help Print this help and exit
--version Print the version, commit and build date and exit
--safe-gpu Start with the CPU renderer for this launch (after a graphics driver crash)
--in-window-menus macOS: keep the menus inside the window instead of the menu bar
--new-instance Windows and Linux: start a separate PhotoCraft instead of opening
the files in the one already running
--present-mode <mode> How frames reach the display: fifo, autovsync or novsync
--frame-latency <n> Frames queued for the display: 1 to 3
--stroke-trace <stderr|file.csv>
Time each brush stroke's frames
--control <port> Start the localhost JSON-lines control server on this port
--control-token <64-hex> Control server token (default: a new random one, printed)
--control-token-file <path> Read the control server token from this file, or create it
with a new one
--automation-read-root <dir> Directory the control server may read files from
--automation-write-root <dir>
Directory the control server may write files to
-- Treat every later argument as a file, even one starting with `-`
A bad --present-mode, --frame-latency or --stroke-trace value is reported and ignored.
Environment: PHOTOCRAFT_CONTROL_PORT, PHOTOCRAFT_CONTROL_TOKEN, PHOTOCRAFT_CONTROL_TOKEN_FILE,
PHOTOCRAFT_AUTOMATION_READ_ROOT, PHOTOCRAFT_AUTOMATION_WRITE_ROOT, PHOTOCRAFT_PRESENT_MODE,
PHOTOCRAFT_FRAME_LATENCY, PHOTOCRAFT_STROKE_TRACE, PHOTOCRAFT_IN_WINDOW_MENUS, PHOTOCRAFT_NEW_INSTANCE,
PHOTOCRAFT_CONFIG_DIR.
";
/// Print to standard output for `--help` / `--version`. Unlike `println!`, never panics when
/// stdout is closed or a pipe (`photocraft --help | head -1`).
fn print_stdout(text: &str) {
use std::io::Write as _;
let mut out = std::io::stdout().lock();
let _ = out.write_all(text.as_bytes()).and_then(|()| out.flush());
}
/// Everything the desktop launcher takes from the command line, on top of the environment
/// defaults handed in to `parse_launch_args`.
#[derive(Debug, Default, PartialEq, Eq)]
struct LaunchArgs {
control_port: Option<u16>,
control_token: Option<String>,
control_token_file: Option<std::path::PathBuf>,
automation_read_root: Option<std::path::PathBuf>,
automation_write_root: Option<std::path::PathBuf>,
files: Vec<String>,
/// Arguments that are not valid Unicode (issue #1108): a file manager can hand over a Latin-1
/// file name on Linux, and `std::env::args()` would panic before any window opened. Paths are
/// reported in a notice once the app is up; a control token is a usage error like a bad port.
unreadable_paths: Vec<String>,
safe_gpu: bool,
new_instance: bool,
/// Options that apply only to the process they're given to; a launch with any keeps its own.
launch_options: Vec<&'static str>,
present_mode_arg: Option<String>,
frame_latency_arg: Option<String>,
stroke_trace_arg: Option<String>,
in_window_menus: bool,
/// `--help` / `-h`: print [`USAGE`] and exit 0; parsing stops there.
help: bool,
/// `--version`: print the version and exit 0; parsing stops there.
version: bool,
/// Usage errors (a bad or missing control/automation value, an unknown option); a non-empty
/// list fails the launch with exit status 2 (issues #701, #6429, #6431).
errors: Vec<String>,
}
/// Parse the desktop launch arguments over the environment defaults in `out`. A control or
/// automation flag with no value is a usage error naming the flag (issue #6429), never a silent
/// fall back to a generated token or to no root: like `--control` with a bad port, it exits 2.
/// So is an unknown option (#6431); a file whose name starts with `-` goes after `--`.
fn parse_launch_args(args: impl Iterator<Item = std::ffi::OsString>, mut out: LaunchArgs) -> LaunchArgs {
let mut args = args;
// After `--`, every argument is a file, even one that starts with `-`.
let mut only_files = false;
while let Some(a) = args.next() {
if only_files {
match a.into_string() {
Ok(s) => out.files.push(s),
Err(raw) => out.unreadable_paths.push(raw.to_string_lossy().into_owned()),
}
continue;
}
match a.to_str() {
Some("--") => only_files = true,
Some("--help" | "-h") => {
out.help = true;
break;
}
Some("--control") => match args.next() {
Some(value) => match parse_control_port(&value.to_string_lossy(), "--control") {
Ok(port) => out.control_port = Some(port),
Err(error) => out.errors.push(error),
},
None => out.errors.push("--control: missing port value (expected `--control <port>`)".to_string()),
},
Some("--control-token") => match args.next().map(std::ffi::OsString::into_string) {
Some(Ok(token)) => out.control_token = Some(token),
Some(Err(raw)) => out.errors.push(format!("--control-token: value is not valid Unicode (`{}`)", raw.to_string_lossy())),
None => out.errors.push("--control-token: missing token value (expected `--control-token <token>`)".to_string()),
},
Some("--control-token-file") => match args.next() {
Some(value) => out.control_token_file = Some(std::path::PathBuf::from(value)),
None => out.errors.push("--control-token-file: missing file path value (expected `--control-token-file <path>`)".to_string()),
},
Some("--automation-read-root") => {
out.launch_options.push("--automation-read-root");
match args.next() {
Some(value) => out.automation_read_root = Some(std::path::PathBuf::from(value)),
None => out.errors.push("--automation-read-root: missing directory value (expected `--automation-read-root <dir>`)".to_string()),
}
}
Some("--automation-write-root") => {
out.launch_options.push("--automation-write-root");
match args.next() {
Some(value) => out.automation_write_root = Some(std::path::PathBuf::from(value)),
None => out.errors.push("--automation-write-root: missing directory value (expected `--automation-write-root <dir>`)".to_string()),
}
}
Some("--safe-gpu") => {
out.launch_options.push("--safe-gpu");
out.safe_gpu = true;
}
Some(flag @ ("--present-mode" | "--frame-latency" | "--stroke-trace")) => {
out.launch_options.push(match flag {
"--present-mode" => "--present-mode",
"--frame-latency" => "--frame-latency",
_ => "--stroke-trace",
});
let flag = flag.to_string();
match args.next().map(std::ffi::OsString::into_string) {
Some(Ok(v)) => match flag.as_str() {
"--present-mode" => out.present_mode_arg = Some(v),
"--frame-latency" => out.frame_latency_arg = Some(v),
_ => out.stroke_trace_arg = Some(v),
},
// A bad value is reported and ignored; it never fails the launch.
_ => eprintln!("photocraft: {flag}: missing or unreadable value; ignored"),
}
}
Some("--in-window-menus") => {
out.launch_options.push("--in-window-menus");
out.in_window_menus = true;
}
Some("--new-instance") => out.new_instance = true,
Some("--version") => {
out.version = true;
break;
}
// Old macOS passes a process serial number when launched from Finder.
Some(s) if s.starts_with("-psn_") => {}
// A typo such as `--safegpu`, or `--help` spelt another way, must not open the window
// and then fail to open the flag as a file (#6431).
Some(s) if s.starts_with('-') => out.errors.push(format!("unknown option `{s}` (put `--` before a file name that starts with `-`)")),
Some(s) => out.files.push(s.to_owned()),
None => out.unreadable_paths.push(a.to_string_lossy().into_owned()),
}
}
out
}
#[cfg(test)]
mod control_port_tests {
use super::{control_args_exit_code, parse_control_port};
use super::{parse_control_port, usage_exit_code};
#[test]
fn control_port_errors_exit_non_zero() {
assert_eq!(control_args_exit_code(&[]), None);
assert_eq!(usage_exit_code(&[]), None);
let errors = vec![parse_control_port("nope", "--control").unwrap_err()];
assert_eq!(control_args_exit_code(&errors), Some(2));
assert_eq!(usage_exit_code(&errors), Some(2));
}
#[test]
@@ -167,71 +358,152 @@ mod control_port_tests {
}
}
#[cfg(test)]
mod launch_args_tests {
use super::{LaunchArgs, parse_launch_args, usage_exit_code};
use std::ffi::OsString;
fn parse(values: &[&str]) -> LaunchArgs {
parse_launch_args(values.iter().map(OsString::from), LaunchArgs::default())
}
#[test]
fn flags_without_a_value_are_usage_errors() {
// Issue #6429: a missing value must fail the launch, not silently fall back to a
// generated token or to no root.
for flag in ["--control", "--control-token", "--control-token-file", "--automation-read-root", "--automation-write-root"] {
let parsed = parse(&[flag]);
assert_eq!(parsed.errors.len(), 1, "{flag}: {:?}", parsed.errors);
assert!(parsed.errors[0].starts_with(flag), "{}", parsed.errors[0]);
assert!(parsed.errors[0].contains("missing"), "{}", parsed.errors[0]);
assert_eq!(usage_exit_code(&parsed.errors), Some(2));
}
}
#[test]
fn missing_value_does_not_clear_an_environment_default() {
// `--automation-read-root` with no value must not drop a root set through the env var.
let defaults = LaunchArgs { automation_read_root: Some("/env/read".into()), automation_write_root: Some("/env/write".into()), ..Default::default() };
let parsed = parse_launch_args(["--automation-read-root"].iter().map(OsString::from), defaults);
assert_eq!(parsed.automation_read_root.as_deref(), Some(std::path::Path::new("/env/read")));
assert_eq!(parsed.automation_write_root.as_deref(), Some(std::path::Path::new("/env/write")));
assert_eq!(parsed.errors.len(), 1, "{:?}", parsed.errors);
}
#[test]
fn values_are_parsed_and_no_errors_remain() {
let parsed = parse(&[
"--control",
"50494",
"--control-token",
"abcd",
"--control-token-file",
"/tmp/token",
"--automation-read-root",
"/r",
"--automation-write-root",
"/w",
"--safe-gpu",
"--in-window-menus",
"--new-instance",
"--present-mode",
"mailbox",
"photo.psd",
]);
assert!(parsed.errors.is_empty(), "{:?}", parsed.errors);
assert_eq!(parsed.control_port, Some(50494));
assert_eq!(parsed.control_token.as_deref(), Some("abcd"));
assert_eq!(parsed.control_token_file.as_deref(), Some(std::path::Path::new("/tmp/token")));
assert_eq!(parsed.automation_read_root.as_deref(), Some(std::path::Path::new("/r")));
assert_eq!(parsed.automation_write_root.as_deref(), Some(std::path::Path::new("/w")));
assert!(parsed.safe_gpu);
assert!(parsed.in_window_menus);
assert!(parsed.new_instance);
assert_eq!(parsed.present_mode_arg.as_deref(), Some("mailbox"));
assert_eq!(parsed.launch_options, vec!["--automation-read-root", "--automation-write-root", "--safe-gpu", "--in-window-menus", "--present-mode"]);
assert_eq!(parsed.files, vec!["photo.psd".to_string()]);
}
#[test]
fn version_stops_parsing() {
let parsed = parse(&["--version", "--control"]);
assert!(parsed.version);
assert!(parsed.errors.is_empty(), "{:?}", parsed.errors);
}
#[test]
fn help_stops_parsing_and_double_dash_ends_options() {
let parsed = parse(&["-h", "--safegpu", "--control"]);
assert!(parsed.help);
assert!(parsed.errors.is_empty(), "{:?}", parsed.errors);
let parsed = parse(&["--safegpu", "--", "--help", "-x"]);
assert!(!parsed.help);
assert_eq!(parsed.errors.len(), 1, "{:?}", parsed.errors);
assert!(parsed.errors[0].contains("unknown option `--safegpu`"), "{}", parsed.errors[0]);
assert_eq!(parsed.files, vec!["--help".to_string(), "-x".to_string()]);
}
}
fn main() -> eframe::Result {
// First, so the panic hook and every start-up warning are recorded (`logging`).
let logger = logging::install();
crash_guard::install_hook();
// The system font scan can take seconds (minutes on some Macs): run it off the UI thread.
photocraft_ui_egui::type_tool::warm_fonts();
let mut control_port: Option<u16> = None;
let mut control_arg_errors: Vec<String> = Vec::new();
let mut launch = LaunchArgs {
automation_read_root: std::env::var_os("PHOTOCRAFT_AUTOMATION_READ_ROOT").map(std::path::PathBuf::from),
automation_write_root: std::env::var_os("PHOTOCRAFT_AUTOMATION_WRITE_ROOT").map(std::path::PathBuf::from),
in_window_menus: std::env::var_os("PHOTOCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0"),
..Default::default()
};
if let Some(value) = std::env::var("PHOTOCRAFT_CONTROL_PORT").ok().filter(|v| !v.trim().is_empty()) {
match parse_control_port(&value, "PHOTOCRAFT_CONTROL_PORT") {
Ok(port) => control_port = Some(port),
Err(error) => control_arg_errors.push(error),
Ok(port) => launch.control_port = Some(port),
Err(error) => launch.errors.push(error),
}
}
let mut control_token = None;
let mut control_token_file = None;
let mut automation_read_root = std::env::var_os("PHOTOCRAFT_AUTOMATION_READ_ROOT").map(std::path::PathBuf::from);
let mut automation_write_root = std::env::var_os("PHOTOCRAFT_AUTOMATION_WRITE_ROOT").map(std::path::PathBuf::from);
let mut files = Vec::new();
// Arguments that are not valid Unicode (issue #1108): a file manager can hand over a Latin-1
// file name on Linux, and `std::env::args()` would panic before any window opened. Paths are
// reported in a notice once the app is up; a control token is a usage error like a bad port.
let mut unreadable_paths: Vec<String> = Vec::new();
let mut safe_gpu = false;
let mut in_window_menus = std::env::var_os("PHOTOCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0");
let mut args = std::env::args_os().skip(1);
while let Some(a) = args.next() {
match a.to_str() {
Some("--control") => match args.next() {
Some(value) => match parse_control_port(&value.to_string_lossy(), "--control") {
Ok(port) => control_port = Some(port),
Err(error) => control_arg_errors.push(error),
},
None => control_arg_errors.push("--control: missing port value (expected `--control <port>`)".to_string()),
},
Some("--control-token") => match args.next().map(std::ffi::OsString::into_string) {
Some(Ok(token)) => control_token = Some(token),
Some(Err(raw)) => control_arg_errors.push(format!("--control-token: value is not valid Unicode (`{}`)", raw.to_string_lossy())),
None => control_token = None,
},
Some("--control-token-file") => control_token_file = args.next().map(std::path::PathBuf::from),
Some("--automation-read-root") => automation_read_root = args.next().map(std::path::PathBuf::from),
Some("--automation-write-root") => automation_write_root = args.next().map(std::path::PathBuf::from),
Some("--safe-gpu") => safe_gpu = true,
Some("--in-window-menus") => in_window_menus = true,
Some("--version") => {
println!("photocraft {}", photocraft_engine::build_info::long_version());
return Ok(());
}
// Old macOS passes a process serial number when launched from Finder.
Some(s) if s.starts_with("-psn_") => {}
Some(s) => files.push(s.to_owned()),
None => unreadable_paths.push(a.to_string_lossy().into_owned()),
}
launch = parse_launch_args(std::env::args_os().skip(1), launch);
if launch.help {
print_stdout(USAGE);
return Ok(());
}
if launch.version {
print_stdout(&format!("photocraft {}\n", photocraft_engine::build_info::long_version()));
return Ok(());
}
// A malformed control port must not silently drop the control server (issue #701): name the
// bad value and fail the launch, like `photocraft-cli serve --port` does for the same typo.
// Exit status 2 is the usual command-line usage error, so a launcher sees the failure.
if let Some(code) = control_args_exit_code(&control_arg_errors) {
for error in &control_arg_errors {
// A malformed control port must not silently drop the control server (issue #701), a control
// or automation flag without its value must not silently fall back to a generated token or
// to no root (issue #6429), and an unknown option must not open the window (#6431): name the
// bad argument and fail the launch, like `photocraft-cli` does for the same typos. Exit
// status 2 is the usual command-line usage error, so a launcher sees the failure.
if let Some(code) = usage_exit_code(&launch.errors) {
for error in &launch.errors {
eprintln!("photocraft: {error}");
}
eprintln!("Run `photocraft --help` for usage.");
std::process::exit(code);
}
let LaunchArgs {
control_port,
control_token,
control_token_file,
automation_read_root,
automation_write_root,
files,
unreadable_paths,
safe_gpu,
new_instance,
mut launch_options,
present_mode_arg,
frame_latency_arg,
stroke_trace_arg,
in_window_menus,
help: _,
version: _,
errors: _,
} = launch;
// The log file lives under the settings directory; opened after the arguments, so `--version`
// and usage errors leave no file behind. Records logged until now are written to it first.
@@ -243,6 +515,53 @@ fn main() -> eframe::Result {
}
}
// An adapter named in the environment is chosen at startup, which a forwarded launch never reaches.
let rendering_mode = gpu_startup::read_rendering_prefs(services::prefs_file().as_deref()).1;
if gpu_startup::adapter_override_is_process_specific(std::env::var("PHOTOCRAFT_GPU_ADAPTER").ok().as_deref(), rendering_mode, gpu_startup::ADAPTER_CHOICE) {
launch_options.push("PHOTOCRAFT_GPU_ADAPTER");
}
// One PhotoCraft per user (#3708): files opened from Explorer or a file manager while it runs
// become tabs in its window. Only with the per-user settings folder (a portable copy's may be
// shared between users), and not for automation, `--new-instance` or per-process options.
#[cfg(not(target_os = "macos"))]
let single_instance = {
let new_instance_env = std::env::var("PHOTOCRAFT_NEW_INSTANCE").ok();
let context = single_instance::LaunchContext {
per_user_settings: app_dirs::current().mode == app_dirs::Mode::Platform,
new_instance_flag: new_instance,
new_instance_env: new_instance_env.as_deref(),
control: control_port.is_some(),
unreadable_paths: !unreadable_paths.is_empty(),
launch_options: &launch_options,
};
match (single_instance::skip_reason(&context), services::config_dir()) {
(Some(reason), _) => {
log::info!("running as a separate PhotoCraft: {reason}");
None
}
(None, None) => None,
(None, Some(dir)) => {
let paths: Vec<String> = files.iter().map(|f| single_instance::absolute(f)).collect();
match single_instance::claim(&dir, &paths) {
single_instance::Launch::Forwarded => {
log::info!("handed {} file(s) to the running PhotoCraft", paths.len());
return Ok(());
}
single_instance::Launch::Primary(primary) => Some(primary),
single_instance::Launch::Standalone(reason) => {
log::warn!("starting a separate PhotoCraft: {reason}");
None
}
}
}
}
};
// Lives until the event loop returns, then `shutdown` lets the next launch become the primary.
#[cfg(not(target_os = "macos"))]
let single_instance = &single_instance;
#[cfg(target_os = "macos")]
let _ = (new_instance, &launch_options);
// The display server to open the window on: Xwayland for a pen on Wayland, which gives this
// app no pen input (#639).
#[cfg(target_os = "linux")]
@@ -312,6 +631,11 @@ fn main() -> eframe::Result {
let tablet_monitor = std::cell::OnceCell::new();
#[cfg(target_os = "macos")]
let tablet_monitor = &tablet_monitor;
// The Windows pointer message hook; dropping it unhooks when the event loop returns.
#[cfg(target_os = "windows")]
let tablet_monitor_win = std::cell::OnceCell::new();
#[cfg(target_os = "windows")]
let tablet_monitor_win = &tablet_monitor_win;
// Read the displays' ICC profiles while the window opens (colour-managed canvas; `None`
// where the platform has no reader).
@@ -363,7 +687,24 @@ fn main() -> eframe::Result {
}
let gpu_note: std::sync::Arc<std::sync::Mutex<Option<String>>> = Default::default();
// The adapter's real texture limits (egui asks for 8192 px), so big documents stay on the GPU.
gpu_startup::configure(&mut options.wgpu_options.wgpu_setup, &plan, os, sentinel.clone(), gpu_note.clone());
let wanted_adapter =
gpu_startup::wanted_adapter(std::env::var("PHOTOCRAFT_GPU_ADAPTER").ok().as_deref(), &gpu_startup::read_gpu_adapter(services::prefs_file().as_deref()));
gpu_startup::configure(&mut options.wgpu_options.wgpu_setup, &plan, os, wanted_adapter, sentinel.clone(), gpu_note.clone());
// Preferences › Performance › Low-Latency Drawing (#1343): without vsync a stroke shows about
// 50 ms sooner; wgpu picks Immediate, else Mailbox, else Fifo by what the driver offers.
if gpu_startup::read_low_latency(services::prefs_file().as_deref()) {
options.wgpu_options.surface.present_mode = eframe::wgpu::PresentMode::AutoNoVsync;
}
// Testers' present mode and frame latency overrides (#1343), after the preference.
let present = presentation::Overrides::from_args_or_env(present_mode_arg.as_deref(), frame_latency_arg.as_deref());
options.wgpu_options.surface = present.apply(options.wgpu_options.surface);
let present_desc = presentation::describe(&options.wgpu_options.surface, &present);
// Without vsync, frames are capped at 240 fps (#4472) so repaint loops don't spin flat out.
let uncapped = !matches!(
options.wgpu_options.surface.present_mode,
eframe::wgpu::PresentMode::Fifo | eframe::wgpu::PresentMode::FifoRelaxed | eframe::wgpu::PresentMode::AutoVsync
);
log::info!("presentation: {present_desc}");
let sentinel_ms = t_sentinel.elapsed().as_secs_f64() * 1000.0;
log::info!("GPU startup: {:?} ({sentinel_ms:.2} ms)", plan);
let retry_cpu = !safe_gpu && plan.backend != photocraft_engine::prefs::GpuBackend::Cpu;
@@ -391,7 +732,16 @@ fn main() -> eframe::Result {
services::xwayland_command(var("FLATPAK_ID").as_deref(), var("APPIMAGE").as_deref(), std::env::var_os("DISPLAY").is_some());
}
}
let mut app = PhotocraftApp::new(Session::new(), services);
#[cfg(target_os = "linux")]
let session = {
let mut session = Session::new();
session.print_spooler = print_portal::spooler();
session
};
#[cfg(not(target_os = "linux"))]
let session = Session::new();
let mut app = PhotocraftApp::new(session, services);
app.min_frame_ms = uncapped.then_some(1000.0 / 240.0);
app.integrated_titlebar = cfg!(target_os = "macos");
app.custom_titlebar = custom_titlebar;
// Only the title bar's free gap drags the window, never the menus (mac_window.rs).
@@ -423,6 +773,10 @@ fn main() -> eframe::Result {
// composites on the CPU).
let info = &mut app.perf.gpu_info;
info.preference = pref.name().to_string();
info.present = present_desc.clone();
if let Some(v) = &stroke_trace_arg {
app.stroke_trace.set(v);
}
info.selected = if plan.env.is_some() { "env".into() } else { plan.backend.name().to_string() };
info.fallback = match (plan.reason.clone(), gpu_note.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone()) {
(Some(reason), Some(note)) => Some(format!("{reason}. {note}")),
@@ -431,9 +785,16 @@ fn main() -> eframe::Result {
info.canvas = "cpu".into();
if let Some(rs) = cc.wgpu_render_state.clone() {
app.perf.gpu_info.set_adapter(&rs.adapter.get_info());
app.perf.gpu_info.set_available(&rs.available_adapters);
let software_window = rs.adapter.get_info().device_type == eframe::wgpu::DeviceType::Cpu;
if software_window && mode != photocraft_engine::prefs::RenderingMode::Cpu {
app.perf.gpu_info.fallback = Some("No compatible hardware graphics adapter; using software graphics.".into());
// Keep an earlier note, such as a requested adapter that couldn't be used.
let software = "No compatible hardware graphics adapter; using software graphics.";
let fallback = &mut app.perf.gpu_info.fallback;
*fallback = Some(match fallback.take() {
Some(earlier) => format!("{earlier}. {software}"),
None => software.into(),
});
}
if !software_window
&& plan.backend != photocraft_engine::prefs::GpuBackend::Cpu
@@ -485,6 +846,11 @@ fn main() -> eframe::Result {
}
#[cfg(not(target_os = "macos"))]
let _ = in_window_menus;
// Files from later launches (Explorer, file managers) open here as new tabs.
#[cfg(not(target_os = "macos"))]
if let Some(primary) = single_instance {
app.services.os_events = Some(primary.connect(&cc.egui_ctx));
}
// Where file drags and drops are (winit 0.30 doesn't say).
app.services.cursor_pos = cursor::service(cc);
// The custom title bar's undecorated window must stay borderless, or its caption and
@@ -505,8 +871,13 @@ fn main() -> eframe::Result {
if let Some(monitor) = tablet::install_macos(&app.stylus.feed) {
let _ = tablet_monitor.set(monitor);
}
#[cfg(target_os = "windows")]
if let Some(monitor) = tablet::install_windows() {
let _ = tablet_monitor_win.set(monitor);
}
#[cfg(target_os = "linux")]
tablet::spawn_x11(&app.stylus.feed, display);
app.services.right_button_down = tablet::right_button_service();
// Paths on the command line (Linux/Windows file associations, `photocraft a.psd`).
app.open_paths(&files);
if !unreadable_paths.is_empty() {
@@ -522,6 +893,13 @@ fn main() -> eframe::Result {
Ok(Box::new(app))
}),
);
// The window is gone: refuse further files and free the lock, so a launch that is waiting (or
// the CPU-compatibility relaunch below) becomes the primary instead of handing its files to a
// process that is exiting.
#[cfg(not(target_os = "macos"))]
if let Some(primary) = single_instance {
primary.shutdown();
}
// Closed or failed outside graphics initialization: not a driver crash. A renderer error
// keeps the marker, so the next start tries a safer backend, unless there is no safer one:
// a failed CPU start would otherwise pin every later start to CPU.
@@ -565,6 +943,19 @@ fn main() -> eframe::Result {
result
}
/// Tests that spawn child processes hold this for reading, tests that check who holds a file lock
/// for writing ([`no_child_spawns`]): until its `exec`, a child holds a copy of every open file,
/// `flock`s included, so a spawn in another test could keep a lock this process just dropped held
/// for a moment.
#[cfg(test)]
pub(crate) static CHILD_SPAWNS: std::sync::RwLock<()> = std::sync::RwLock::new(());
/// Held by a test that checks who holds a file lock: no other test spawns a child meanwhile.
#[cfg(test)]
pub(crate) fn no_child_spawns() -> std::sync::RwLockWriteGuard<'static, ()> {
CHILD_SPAWNS.write().unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[cfg(test)]
mod tests {
#[test]
+1
View File
@@ -495,6 +495,7 @@ mod tests {
#[cfg(unix)]
#[test]
fn a_hung_helper_is_stopped_and_big_replies_dont_block() {
let _spawns = crate::CHILD_SPAWNS.read().unwrap_or_else(std::sync::PoisonError::into_inner);
let t0 = std::time::Instant::now();
let e = run_helper(std::process::Command::new("sleep").arg("30"), std::time::Duration::from_millis(300)).unwrap_err();
assert!(e.contains("took longer than"), "{e}");
+183
View File
@@ -0,0 +1,183 @@
//! File › Save As through the XDG desktop portal on Linux (#6121, #4328, #4394).
//!
//! The GTK file chooser behind the portal keeps the typed name when the user picks another file
//! type, and rfd 0.17 reads only the file name from the portal's answer, so `image.psd` with PNG
//! picked came back as `image.psd` and was written as a PSD. The answer does say which type was
//! picked (`current_filter`), so Save As asks the portal itself and gives a name the dialog left
//! alone that type's extension ([`crate::services::with_chosen_type`]). rfd stays the fallback
//! (zenity, or no portal), and this can go once an rfd release reads the picked type
//! (PolyMeilex/rfd#341).
//!
//! The dialog is attached to the window on X11. On Wayland it has no parent: naming the window to
//! the portal needs an xdg-foreign handle, which takes `unsafe` code the workspace forbids.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value};
/// One file type in the dialog: its label and extensions (`("PNG", ["png"])`).
pub type Filter = (String, Vec<String>);
/// A Save dialog to show.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SaveRequest {
/// The portal's parent window identifier (`x11:<xid>`), or empty.
pub parent: String,
/// The name the dialog shows: the stem of the suggested file (`test`), see
/// `services::dialog_file_name`.
pub name: Option<String>,
/// The suggested file's name (`test.psd`): a name that comes back with its extension still
/// takes the extension of the type picked ([`crate::services::with_chosen_type`]).
pub suggested: Option<String>,
pub folder: Option<PathBuf>,
pub filters: Vec<Filter>,
/// The filter the dialog starts on.
pub current: Option<usize>,
}
/// What the user chose: the path, as typed, and the label of the file type picked.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Saved {
pub path: PathBuf,
pub filter: Option<String>,
}
/// The portal's parent window identifier for `window`: `x11:<xid>` on X11, empty elsewhere.
pub fn parent_id(window: Option<eframe::wgpu::rwh::RawWindowHandle>) -> String {
use eframe::wgpu::rwh::RawWindowHandle;
match window {
Some(RawWindowHandle::Xlib(h)) => format!("x11:{:x}", h.window),
Some(RawWindowHandle::Xcb(h)) => format!("x11:{:x}", h.window.get()),
_ => String::new(),
}
}
/// Shows the portal's Save dialog and waits for it. `Ok(None)` is Cancel; `Err` means the portal
/// couldn't show it (no portal, no FileChooser), so the caller falls back to rfd.
pub fn save_file(conn: &zbus::blocking::Connection, req: &SaveRequest) -> Result<Option<Saved>, String> {
use futures_lite::StreamExt;
use zbus::blocking::Proxy;
let unique = conn.unique_name().ok_or("Portal connection has no unique name")?.as_str().trim_start_matches(':').replace('.', "_");
static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
let token = format!("photocraft_save_{}_{}", std::process::id(), NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed));
let path = format!("/org/freedesktop/portal/desktop/request/{unique}/{token}");
// Listen before asking, so a quick answer isn't missed.
let request = Proxy::new(conn, "org.freedesktop.portal.Desktop", path.as_str(), "org.freedesktop.portal.Request").map_err(|e| e.to_string())?;
let mut replies = futures_lite::future::block_on(request.inner().receive_signal("Response")).map_err(|e| e.to_string())?;
let chooser = Proxy::new(conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.FileChooser")
.map_err(|e| e.to_string())?;
let handle: OwnedObjectPath = chooser.call("SaveFile", &(req.parent.as_str(), "Save As", options(req, &token))).map_err(|e| e.to_string())?;
// A portal older than 0.9 ignores `handle_token` and answers on a request path of its own,
// which it returns: listen there instead, as the Request documentation asks.
let reply = if handle.as_str() == path {
futures_lite::future::block_on(replies.next())
} else {
let request = Proxy::new(conn, "org.freedesktop.portal.Desktop", handle.as_str(), "org.freedesktop.portal.Request").map_err(|e| e.to_string())?;
let mut replies = futures_lite::future::block_on(request.inner().receive_signal("Response")).map_err(|e| e.to_string())?;
futures_lite::future::block_on(replies.next())
}
.ok_or("The save dialog closed without an answer")?;
let (code, results): (u32, HashMap<String, OwnedValue>) = reply.body().deserialize().map_err(|e| e.to_string())?;
answer(code, &results)
}
/// The `SaveFile` options for `req`.
fn options<'a>(req: &'a SaveRequest, token: &'a str) -> HashMap<&'static str, Value<'a>> {
let mut o: HashMap<&'static str, Value<'a>> = HashMap::from([("handle_token", Value::from(token)), ("modal", Value::from(true))]);
if let Some(name) = &req.name {
o.insert("current_name", Value::from(name.as_str()));
}
if let Some(folder) = &req.folder {
// A byte string with its terminating NUL, as the portal specifies.
use std::os::unix::ffi::OsStrExt;
let mut bytes = folder.as_os_str().as_bytes().to_vec();
bytes.push(0);
o.insert("current_folder", Value::from(bytes));
}
let wire = |(label, exts): &Filter| (label.clone(), exts.iter().map(|e| (0u32, format!("*.{e}"))).collect::<Vec<_>>());
if !req.filters.is_empty() {
o.insert("filters", Value::from(req.filters.iter().map(wire).collect::<Vec<_>>()));
}
if let Some(f) = req.current.and_then(|i| req.filters.get(i)) {
o.insert("current_filter", Value::from(wire(f)));
}
o
}
/// Reads the portal's `Response`: code 0 with the first `uris` entry. 1 is Cancel, and 2 a dialog
/// closed some other way, such as with Esc in GNOME's: no file either, and not a reason to show
/// rfd's dialog instead (it did, so Esc opened a second Save As).
fn answer(code: u32, results: &HashMap<String, OwnedValue>) -> Result<Option<Saved>, String> {
match code {
0 => {}
1 | 2 => return Ok(None),
_ => return Err(format!("the save dialog failed (portal response {code})")),
}
let uris: Vec<String> = results
.get("uris")
.ok_or("no file in the portal's answer")?
.try_clone()
.map_err(|e| e.to_string())?
.try_into()
.map_err(|e: zbus::zvariant::Error| e.to_string())?;
let uri = uris.first().ok_or("no file in the portal's answer")?;
let path = file_uri_path(uri).ok_or_else(|| format!("not a local file: {uri}"))?;
let filter = results.get("current_filter").and_then(|v| {
let (label, _globs): (String, Vec<(u32, String)>) = v.try_clone().ok()?.try_into().ok()?;
Some(label)
});
Ok(Some(Saved { path, filter }))
}
/// `file:///home/me/A%20b.psd` → `/home/me/A b.psd`; `None` for other schemes.
fn file_uri_path(uri: &str) -> Option<PathBuf> {
let rest = uri.strip_prefix("file://")?;
// Only local files: `file:///…` or `file://localhost/…`.
let rest = rest.strip_prefix("localhost").unwrap_or(rest);
if !rest.starts_with('/') {
return None;
}
let bytes = rest.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%'
&& let Some(b) = bytes.get(i + 1..i + 3).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok())
{
out.push(b);
i += 3;
} else {
out.push(bytes[i]);
i += 1;
}
}
use std::os::unix::ffi::OsStringExt;
Some(PathBuf::from(std::ffi::OsString::from_vec(out)))
}
/// Save As on Linux: the portal's dialog, with the name given the extension of the file type
/// picked. When that changes the name to one that already exists, the dialog opens again under
/// the new name, so the portal asks before replacing it (it asked only about the typed name).
pub fn save_as(conn: &zbus::blocking::Connection, mut req: SaveRequest) -> Result<Option<PathBuf>, String> {
loop {
let Some(saved) = save_file(conn, &req)? else { return Ok(None) };
let picked = saved.filter.as_deref().and_then(|label| req.filters.iter().position(|(l, _)| l == label));
// With one type offered there is nothing to pick, and a typed extension is the user's.
let exts: &[String] = match picked {
Some(i) if req.filters.len() > 1 => &req.filters[i].1,
_ => &[],
};
let path = crate::services::with_chosen_type(&saved.path, req.suggested.as_deref(), exts);
if path == saved.path || !path.exists() {
return Ok(Some(path));
}
req.name = path.file_name().map(|n| n.to_string_lossy().into_owned());
req.suggested = req.name.clone();
req.folder = path.parent().map(Path::to_path_buf);
req.current = picked;
}
}
#[cfg(test)]
mod tests;
+237
View File
@@ -0,0 +1,237 @@
use super::*;
use std::sync::{Arc, Mutex};
#[test]
fn file_uris_become_local_paths() {
assert_eq!(file_uri_path("file:///home/me/Untitled-1.psd"), Some(PathBuf::from("/home/me/Untitled-1.psd")));
assert_eq!(file_uri_path("file:///home/me/A%20b%C3%A9.png"), Some(PathBuf::from("/home/me/A bé.png")));
assert_eq!(file_uri_path("file://localhost/tmp/x.png"), Some(PathBuf::from("/tmp/x.png")));
assert_eq!(file_uri_path("sftp://host/x.png"), None);
assert_eq!(file_uri_path("file://host/x.png"), None);
}
#[test]
fn x11_windows_are_named_to_the_portal() {
use eframe::wgpu::rwh::{RawWindowHandle, XlibWindowHandle};
assert_eq!(parent_id(Some(RawWindowHandle::Xlib(XlibWindowHandle::new(0x3a0000f)))), "x11:3a0000f");
assert_eq!(parent_id(None), "");
}
/// What the mock portal answers to one `SaveFile` call: the response code, and the file and
/// filter label the user "picked".
type Script = Vec<(u32, Option<(&'static str, &'static str)>)>;
/// The options of each `SaveFile` call the mock portal received: current_name and the label of
/// current_filter.
type Seen = Arc<Mutex<Vec<(Option<String>, Option<String>)>>>;
/// A private session bus (`dbus-daemon`) with a mock `org.freedesktop.portal.FileChooser` that
/// answers `SaveFile` from `script`. `None` when there is no `dbus-daemon` to run.
fn mock_portal(script: Script) -> Option<(zbus::blocking::Connection, Seen, std::process::Child)> {
mock_portal_with(script, false)
}
/// [`mock_portal`]; with `own_paths` it is a portal older than 0.9, which ignores `handle_token`,
/// answers on a request path of its own and returns that from `SaveFile`, as a real dialog does
/// once the user is done with it (here after 300 ms).
fn mock_portal_with(script: Script, own_paths: bool) -> Option<(zbus::blocking::Connection, Seen, std::process::Child)> {
use std::io::BufRead;
let spawns = crate::CHILD_SPAWNS.read().unwrap_or_else(std::sync::PoisonError::into_inner);
let mut daemon =
std::process::Command::new("dbus-daemon").args(["--session", "--nofork", "--print-address=1"]).stdout(std::process::Stdio::piped()).spawn().ok()?;
drop(spawns);
let mut address = String::new();
std::io::BufReader::new(daemon.stdout.take()?).read_line(&mut address).ok()?;
let address = address.trim().to_string();
let server = zbus::blocking::connection::Builder::address(address.as_str()).unwrap().name("org.freedesktop.portal.Desktop").unwrap().build().unwrap();
let seen: Seen = Arc::default();
let log = seen.clone();
// A stream only gets the messages that arrive after it exists: made in the thread, it could
// miss a `SaveFile` sent before the thread got going, and the call would wait forever.
let messages = zbus::blocking::MessageIterator::from(&server);
std::thread::spawn(move || {
let mut script = script.into_iter();
for msg in messages {
let Ok(msg) = msg else { break };
let header = msg.header();
if header.message_type() != zbus::message::Type::MethodCall || header.member().map(|m| m.as_str()) != Some("SaveFile") {
continue;
}
let (_parent, _title, options): (String, String, HashMap<String, OwnedValue>) = msg.body().deserialize().unwrap();
let text = |key: &str| options.get(key).and_then(|v| String::try_from(v.try_clone().ok()?).ok());
let filter = options.get("current_filter").and_then(|v| {
let (label, _): (String, Vec<(u32, String)>) = v.try_clone().ok()?.try_into().ok()?;
Some(label)
});
log.lock().unwrap().push((text("current_name"), filter));
let sender = header.sender().unwrap().as_str().trim_start_matches(':').replace('.', "_");
let token = if own_paths { format!("old{}", log.lock().unwrap().len()) } else { text("handle_token").unwrap() };
let path = format!("/org/freedesktop/portal/desktop/request/{sender}/{token}");
server.reply(&header, &OwnedObjectPath::try_from(path.as_str()).unwrap()).unwrap();
if own_paths {
std::thread::sleep(std::time::Duration::from_millis(300));
}
let (code, pick) = script.next().unwrap_or((2, None));
let mut results: HashMap<&str, Value<'_>> = HashMap::new();
if let Some((uri, label)) = pick {
results.insert("uris", Value::from(vec![uri.to_string()]));
results.insert("current_filter", Value::from((label.to_string(), vec![(0u32, "*.x".to_string())])));
}
server
.emit_signal(
header.sender().cloned().map(zbus::names::BusName::from),
path.as_str(),
"org.freedesktop.portal.Request",
"Response",
&(code, results),
)
.unwrap();
}
});
let client = zbus::blocking::connection::Builder::address(address.as_str()).unwrap().build().unwrap();
Some((client, seen, daemon))
}
fn request(name: &str) -> SaveRequest {
let filters = [("PSD Document", &["psd", "psb"][..]), ("PNG", &["png"]), ("JPEG", &["jpg"])];
SaveRequest {
parent: String::new(),
name: Some(name.into()),
suggested: Some(name.into()),
folder: Some(PathBuf::from("/tmp")),
filters: filters.iter().map(|(l, e)| (l.to_string(), e.iter().map(|s| s.to_string()).collect())).collect(),
current: Some(0),
}
}
/// #6121 end to end through a real D-Bus: the name comes back as typed with the type picked,
/// and Save As writes it with that type's extension.
#[test]
fn save_as_follows_the_file_type_picked_in_the_portal_dialog() {
let dir = std::env::temp_dir().join(format!("photocraft-portal-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let typed = format!("file://{}/Untitled-1.psd", dir.display());
let typed: &'static str = Box::leak(typed.into_boxed_str());
let Some((conn, seen, mut daemon)) = mock_portal(vec![(0, Some((typed, "PNG"))), (1, None), (0, Some((typed, "PSD Document")))]) else {
eprintln!("no dbus-daemon: skipped");
return;
};
// PNG picked with `Untitled-1.psd` still in the name: written as Untitled-1.png.
assert_eq!(save_as(&conn, request("Untitled-1.psd")).unwrap(), Some(dir.join("Untitled-1.png")));
assert_eq!(seen.lock().unwrap()[0], (Some("Untitled-1.psd".into()), Some("PSD Document".into())), "the request carried the name and type");
// Cancel.
assert_eq!(save_as(&conn, request("Untitled-1.psd")).unwrap(), None);
// The type the name already has: unchanged.
assert_eq!(save_as(&conn, request("Untitled-1.psd")).unwrap(), Some(dir.join("Untitled-1.psd")));
daemon.kill().ok();
std::fs::remove_dir_all(&dir).ok();
}
/// The dialog opens on the bare name (`test`, see `services::dialog_file_name`), so GTK doesn't
/// ask to replace test.psd when PNG is picked: the type picked gives the extension, and a name
/// that then exists is asked about under that name.
#[test]
fn a_bare_name_takes_the_picked_type_and_an_existing_target_is_asked_about() {
let dir = std::env::temp_dir().join(format!("photocraft-portal-bare-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("test.psd"), b"open document").unwrap();
let uri = |name: &str| -> &'static str { Box::leak(format!("file://{}/{name}", dir.display()).into_boxed_str()) };
let Some((conn, seen, mut daemon)) = mock_portal(vec![
(0, Some((uri("test"), "PNG"))),
(0, Some((uri("test.psd"), "PNG"))),
(0, Some((uri("test"), "PSD Document"))),
(0, Some((uri("test.psd"), "PSD Document"))),
]) else {
eprintln!("no dbus-daemon: skipped");
return;
};
// As File › Save As opens it: `test` shown, `test.psd` suggested.
let opened = || SaveRequest { suggested: Some("test.psd".into()), ..request("test") };
// PNG picked: test.png, and nothing was asked about test.psd.
assert_eq!(save_as(&conn, opened()).unwrap(), Some(dir.join("test.png")));
// The suggested extension back in the name (typed again, or GTK's completion) with PNG
// picked: test.png too, not a PSD written over test.psd.
assert_eq!(save_as(&conn, opened()).unwrap(), Some(dir.join("test.png")));
// The type untouched: test.psd, which exists, so the dialog asks again as test.psd (the
// portal's replace prompt is then about the file that is written).
assert_eq!(save_as(&conn, opened()).unwrap(), Some(dir.join("test.psd")));
let seen = seen.lock().unwrap().clone();
let names: Vec<_> = seen.iter().map(|(name, _)| name.as_deref()).collect();
assert_eq!(names, [Some("test"), Some("test"), Some("test"), Some("test.psd")], "{seen:?}");
assert_eq!(seen[3].1.as_deref(), Some("PSD Document"));
daemon.kill().ok();
std::fs::remove_dir_all(&dir).ok();
}
/// GNOME's dialog closed with Esc answers 2 ("ended some other way"): no file, like Cancel, and
/// no fallback to rfd, which opened a second Save As.
#[test]
fn a_dialog_closed_some_other_way_is_a_cancel() {
let Some((conn, _seen, mut daemon)) = mock_portal(vec![(2, None)]) else {
eprintln!("no dbus-daemon: skipped");
return;
};
assert_eq!(save_as(&conn, request("test")), Ok(None));
daemon.kill().ok();
}
/// An extension the user typed is theirs: `art.png` with the PSD type left as the dialog opened
/// saves a PNG, as it did before the type was read.
#[test]
fn a_typed_extension_wins_over_an_untouched_file_type() {
let typed: &'static str = "file:///tmp/photocraft-none/art.png";
let Some((conn, _seen, mut daemon)) = mock_portal(vec![(0, Some((typed, "PSD Document")))]) else {
eprintln!("no dbus-daemon: skipped");
return;
};
assert_eq!(save_as(&conn, request("Untitled-1.psd")).unwrap(), Some(PathBuf::from("/tmp/photocraft-none/art.png")));
daemon.kill().ok();
}
/// A portal older than 0.9 answers on a request path of its own: the wait follows the handle
/// `SaveFile` returns, as the Request documentation asks, instead of the predicted path.
#[test]
fn the_answer_is_awaited_on_the_handle_the_portal_returns() {
let typed: &'static str = "file:///tmp/photocraft-none/Untitled-1.psd";
let Some((conn, _seen, mut daemon)) = mock_portal_with(vec![(0, Some((typed, "PNG")))], true) else {
eprintln!("no dbus-daemon: skipped");
return;
};
assert_eq!(save_as(&conn, request("Untitled-1.psd")).unwrap(), Some(PathBuf::from("/tmp/photocraft-none/Untitled-1.png")));
daemon.kill().ok();
}
/// When the extension the type gives names a file that exists, the dialog opens again under that
/// name (so the portal asks before replacing it) instead of overwriting it unasked.
#[test]
fn a_renamed_file_that_exists_is_confirmed_in_the_dialog() {
let dir = std::env::temp_dir().join(format!("photocraft-portal-exists-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("art.png"), b"old").unwrap();
let typed: &'static str = Box::leak(format!("file://{}/art.psd", dir.display()).into_boxed_str());
let confirmed: &'static str = Box::leak(format!("file://{}/art.png", dir.display()).into_boxed_str());
let Some((conn, seen, mut daemon)) = mock_portal(vec![(0, Some((typed, "PNG"))), (0, Some((confirmed, "PNG")))]) else {
eprintln!("no dbus-daemon: skipped");
return;
};
assert_eq!(save_as(&conn, request("art.psd")).unwrap(), Some(dir.join("art.png")));
let seen = seen.lock().unwrap().clone();
assert_eq!(seen.len(), 2, "asked again: {seen:?}");
assert_eq!(seen[1], (Some("art.png".into()), Some("PNG".into())), "under the new name, on the picked type");
assert_eq!(std::fs::read(dir.join("art.png")).unwrap(), b"old", "nothing written by the dialog");
daemon.kill().ok();
std::fs::remove_dir_all(&dir).ok();
}
/// With only one type offered (the Swatches panel's .aco) a typed extension is kept.
#[test]
fn a_single_file_type_keeps_the_typed_extension() {
let typed: &'static str = "file:///tmp/photocraft-none/Swatches.ase";
let Some((conn, _seen, mut daemon)) = mock_portal(vec![(0, Some((typed, "Color Swatches")))]) else {
eprintln!("no dbus-daemon: skipped");
return;
};
let req = SaveRequest { filters: vec![("Color Swatches".into(), vec!["aco".into()])], ..request("Swatches.aco") };
assert_eq!(save_as(&conn, req).unwrap(), Some(PathBuf::from("/tmp/photocraft-none/Swatches.ase")));
daemon.kill().ok();
}
+121
View File
@@ -0,0 +1,121 @@
//! Launch overrides for how frames reach the display, so testers can compare input-to-pixel
//! latency without a rebuild (#1343): `PHOTOCRAFT_PRESENT_MODE` (`fifo`, `autovsync`, or `novsync`,
//! also spelled `immediate`, `mailbox` or `autonovsync`: no vsync, wgpu's Immediate → Mailbox → Fifo
//! by what the driver offers) and `PHOTOCRAFT_FRAME_LATENCY` (`1` to `3`, frames the swap chain may
//! queue). Unset, the platform default applies. A value that isn't one of these is ignored with a
//! warning in the log, never a failed start; a mode the driver may not offer is never requested.
//! The result is logged at start and shown in the stroke trace.
use eframe::egui_wgpu::SurfaceConfig;
use eframe::wgpu::PresentMode;
/// The present mode and frame latency this launch asked for (`None`: the default).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Overrides {
pub mode: Option<PresentMode>,
pub latency: Option<u32>,
}
/// Parse the two variables' values. Returns the overrides and a warning per rejected value.
pub fn parse(mode: Option<&str>, latency: Option<&str>) -> (Overrides, Vec<String>) {
let mut warnings = Vec::new();
let mode = mode.map(str::trim).filter(|v| !v.is_empty()).and_then(|v| {
let m = match v.to_ascii_lowercase().as_str() {
// Only modes every surface takes: wgpu crashes on an explicit Immediate, Mailbox or
// FifoRelaxed the driver doesn't offer (X11/NVIDIA has no Mailbox, #1343). Without
// vsync, wgpu picks Immediate, else Mailbox, else Fifo.
"fifo" | "vsync" => PresentMode::Fifo,
"autovsync" => PresentMode::AutoVsync,
"novsync" | "immediate" | "mailbox" | "autonovsync" => PresentMode::AutoNoVsync,
_ => {
warnings.push(format!("PHOTOCRAFT_PRESENT_MODE={v:?} isn't fifo, autovsync or novsync (immediate, mailbox); ignored"));
return None;
}
};
Some(m)
});
let latency = latency.map(str::trim).filter(|v| !v.is_empty()).and_then(|v| match v.parse::<u32>() {
Ok(n @ 1..=3) => Some(n),
_ => {
warnings.push(format!("PHOTOCRAFT_FRAME_LATENCY={v:?} isn't 1, 2 or 3; ignored"));
None
}
});
(Overrides { mode, latency }, warnings)
}
impl Overrides {
/// `--present-mode` / `--frame-latency` when given, else `PHOTOCRAFT_PRESENT_MODE` /
/// `PHOTOCRAFT_FRAME_LATENCY`; rejected values are logged.
pub fn from_args_or_env(mode: Option<&str>, latency: Option<&str>) -> Self {
let env_mode = std::env::var("PHOTOCRAFT_PRESENT_MODE").ok();
let env_latency = std::env::var("PHOTOCRAFT_FRAME_LATENCY").ok();
let (o, warnings) = parse(mode.or(env_mode.as_deref()), latency.or(env_latency.as_deref()));
for w in warnings {
log::warn!("{w}");
}
o
}
/// `surface` with the overrides applied.
pub fn apply(&self, surface: SurfaceConfig) -> SurfaceConfig {
SurfaceConfig {
present_mode: self.mode.unwrap_or(surface.present_mode),
desired_maximum_frame_latency: self.latency.or(surface.desired_maximum_frame_latency),
}
}
}
/// How `surface` presents, for the stroke trace and System Info: "fifo, latency 2", with
/// "(override)" after a value an environment variable set. wgpu's GL backend ignores the latency.
pub fn describe(surface: &SurfaceConfig, overrides: &Overrides) -> String {
let mode = match surface.present_mode {
PresentMode::AutoVsync => "autovsync",
PresentMode::AutoNoVsync => "autonovsync",
PresentMode::Fifo => "fifo",
PresentMode::FifoRelaxed => "fiforelaxed",
PresentMode::Immediate => "immediate",
PresentMode::Mailbox => "mailbox",
};
let latency = surface.desired_maximum_frame_latency.map_or("default".to_string(), |n| n.to_string());
let tag = |set: bool| if set { " (override)" } else { "" };
format!("{mode}{}, latency {latency}{}", tag(overrides.mode.is_some()), tag(overrides.latency.is_some()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn values_parse_and_bad_ones_are_ignored_with_a_warning() {
assert_eq!(parse(None, None), (Overrides::default(), vec![]));
assert_eq!(parse(Some(""), Some(" ")), (Overrides::default(), vec![]));
let (o, w) = parse(Some(" Mailbox "), Some("1"));
assert_eq!((o.mode, o.latency, w.len()), (Some(PresentMode::AutoNoVsync), Some(1), 0));
assert_eq!(parse(Some("vsync"), None).0.mode, Some(PresentMode::Fifo));
// Never a mode a driver may lack (wgpu crashes on it): no vsync falls back by support.
for v in ["IMMEDIATE", "mailbox", "novsync", "autonovsync"] {
assert_eq!(parse(Some(v), None).0.mode, Some(PresentMode::AutoNoVsync), "{v}");
}
for (mode, latency) in
[(Some("fast"), None), (None, Some("0")), (None, Some("4")), (None, Some("-1")), (None, Some("one")), (None, Some("99999999999"))]
{
let (o, w) = parse(mode, latency);
assert_eq!((o, w.len()), (Overrides::default(), 1), "{mode:?} {latency:?}");
}
}
#[test]
fn overrides_replace_only_what_they_set_and_are_described() {
let default = SurfaceConfig::HIGH_THROUGHPUT;
assert_eq!(Overrides::default().apply(default), default);
let latency_only = Overrides { mode: None, latency: Some(1) };
let s = latency_only.apply(default);
assert_eq!((s.present_mode, s.desired_maximum_frame_latency), (PresentMode::AutoVsync, Some(1)));
assert_eq!(describe(&s, &latency_only), "autovsync, latency 1 (override)");
let novsync = Overrides { mode: Some(PresentMode::AutoNoVsync), latency: None };
assert_eq!(describe(&novsync.apply(default), &novsync), "autonovsync (override), latency 2");
let none = SurfaceConfig { present_mode: PresentMode::Fifo, desired_maximum_frame_latency: None };
assert_eq!(describe(&none, &Overrides::default()), "fifo, latency default");
}
}
+239
View File
@@ -0,0 +1,239 @@
//! Printing through the XDG Print portal (`org.freedesktop.portal.Print`): the system print
//! dialog, which a sandboxed app can reach without a CUPS socket. The engine prints over IPP
//! when it can reach a print server (`photocraft-ipp`); this is its fallback when it cannot, and
//! the only route with `PHOTOCRAFT_PRINT_PORTAL=1` (`0` disables it). `PreparePrint` shows the
//! dialog seeded with the job's copies and paper; `Print` hands it the PDF. Both answer through a
//! `Request` object's `Response` signal, which this module waits for on its own thread so the
//! window keeps painting while the dialog is open; the outcome goes to the log.
//! https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Print.html
#[cfg(target_os = "linux")]
use photocraft_engine::print_cmds::{PrintJob, Spooler};
/// The spooler to install on the session, unless `PHOTOCRAFT_PRINT_PORTAL=0`. The portal itself
/// is looked up per job, so a missing one costs nothing at start-up.
#[cfg(target_os = "linux")]
pub fn spooler() -> Option<Spooler> {
let setting = std::env::var("PHOTOCRAFT_PRINT_PORTAL").ok();
if setting.as_deref() == Some("0") {
return None;
}
Some(Spooler { name: "portal", preferred: setting.as_deref() == Some("1"), run: std::sync::Arc::new(print) })
}
/// Opens the system print dialog for the job and returns as soon as it is up.
#[cfg(target_os = "linux")]
fn print(job: &PrintJob) -> Result<String, String> {
let conn = zbus::blocking::connection::Builder::session()
.and_then(|b| b.method_timeout(std::time::Duration::from_secs(5)).build())
.map_err(|e| format!("no session bus for the print portal ({e}); print to PDF instead"))?;
let proxy = portal(&conn)?;
let version: u32 = proxy.get_property("version").map_err(|e| format!("no print portal on this desktop ({e}); print to PDF instead"))?;
// The document goes to a private temporary file (0600, created atomically by `tempfile`),
// not a guessable name in a shared /tmp. The guard owns the file: it lives on the portal
// thread until the dialog is done, and a failure to start that thread drops it here.
let mut file = tempfile::Builder::new().prefix("photocraft-print-").suffix(".pdf").tempfile().map_err(|e| format!("cannot create the print file: {e}"))?;
std::io::Write::write_all(&mut file, &job.pdf).map_err(|e| format!("cannot write the print file: {e}"))?;
let (title, settings, page_setup) = (job.title.clone(), settings(job), page_setup(job));
std::thread::Builder::new()
.name("print-portal".into())
.spawn(move || {
let outcome = dialog(&conn, &title, &settings, &page_setup, file.path());
drop(file);
match outcome {
Ok(what) => log::info!("print portal (v{version}): {what}"),
Err(e) => log::warn!("print portal (v{version}): {e}"),
}
})
.map_err(|e| format!("cannot start the print thread: {e}"))?;
Ok("the system print dialog".into())
}
#[cfg(target_os = "linux")]
fn portal(conn: &zbus::blocking::Connection) -> Result<zbus::blocking::Proxy<'static>, String> {
zbus::blocking::Proxy::new(conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Print")
.map_err(|e| e.to_string())
}
/// `PreparePrint` then `Print`, each waited for on its request's `Response` signal.
#[cfg(target_os = "linux")]
fn dialog(
conn: &zbus::blocking::Connection,
title: &str,
settings: &[(&'static str, Seed)],
page_setup: &[(&'static str, Seed)],
pdf: &std::path::Path,
) -> Result<String, String> {
use std::collections::HashMap;
use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value};
type Dict<'a> = HashMap<&'a str, Value<'a>>;
fn as_dict(pairs: &[(&'static str, Seed)]) -> Dict<'static> {
pairs
.iter()
.map(|(k, v)| {
let value = match v {
Seed::Text(t) => Value::from(t.clone()),
Seed::Mm(mm) => Value::from(*mm),
};
(*k, value)
})
.collect()
}
let proxy = portal(conn)?;
let sender = conn.unique_name().map(|n| n.as_str().to_string()).ok_or("the bus connection has no name")?;
let wait = |token: &str| -> Result<zbus::blocking::proxy::SignalIterator<'static>, String> {
let path = request_path(&sender, token);
let request = zbus::blocking::Proxy::new(conn, "org.freedesktop.portal.Desktop", path, "org.freedesktop.portal.Request").map_err(|e| e.to_string())?;
request.receive_signal("Response").map_err(|e| e.to_string())
};
let response = |mut signals: zbus::blocking::proxy::SignalIterator<'static>| -> Result<HashMap<String, OwnedValue>, String> {
let message = signals.next().ok_or("the print portal went away")?;
let (code, results): (u32, HashMap<String, OwnedValue>) = message.body().deserialize().map_err(|e| format!("odd portal reply: {e}"))?;
match code {
0 => Ok(results),
1 => Err("cancelled in the print dialog".into()),
_ => Err("the print dialog failed".into()),
}
};
let token = format!("photocraft_prepare_{}", nonce());
let signals = wait(&token)?;
let mut options: Dict<'_> = HashMap::new();
options.insert("handle_token", Value::from(token.as_str()));
let _: OwnedObjectPath =
proxy.call("PreparePrint", &("", title, as_dict(settings), as_dict(page_setup), options)).map_err(|e| format!("PreparePrint failed: {e}"))?;
let results = response(signals)?;
let prepared = results.get("token").and_then(|v| u32::try_from(v.clone()).ok()).ok_or("the print dialog gave no token")?;
let file = std::fs::File::open(pdf).map_err(|e| format!("cannot reopen the print file: {e}"))?;
let token = format!("photocraft_print_{}", nonce());
let signals = wait(&token)?;
let mut options: Dict<'_> = HashMap::new();
options.insert("handle_token", Value::from(token.as_str()));
options.insert("token", Value::from(prepared));
let fd = zbus::zvariant::Fd::from(std::os::fd::AsFd::as_fd(&file));
let _: OwnedObjectPath = proxy.call("Print", &("", title, fd, options)).map_err(|e| format!("Print failed: {e}"))?;
response(signals)?;
Ok("printed through the system print dialog".into())
}
#[cfg(target_os = "linux")]
fn nonce() -> u64 {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos() as u64).unwrap_or(0);
t ^ N.fetch_add(1, Ordering::Relaxed)
}
/// The request object the portal answers on: the sender's unique name without its colon and
/// with dots as underscores, then the handle token.
fn request_path(unique_name: &str, token: &str) -> String {
format!("/org/freedesktop/portal/desktop/request/{}/{token}", unique_name.trim_start_matches(':').replace('.', "_"))
}
/// A value seeded into the dialog: GtkPrintSettings are strings; a GtkPageSetup's sizes are
/// doubles in millimetres (`gtk_paper_size_new_from_gvariant` ignores anything else).
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
enum Seed {
Text(String),
Mm(f64),
}
fn text(s: &str) -> Seed {
Seed::Text(s.to_string())
}
/// The standard paper sizes, as (PWG name, PPD name, width mm, height mm), portrait. GTK's
/// print settings take the PWG name; its page setup finds the size by the PPD name.
const PAPERS: [(&str, &str, f64, f64); 8] = [
("na_letter", "Letter", 215.9, 279.4),
("na_legal", "Legal", 215.9, 355.6),
("na_ledger", "Ledger", 279.4, 431.8),
("iso_a3", "A3", 297.0, 420.0),
("iso_a4", "A4", 210.0, 297.0),
("iso_a5", "A5", 148.0, 210.0),
("na_index-4x6", "4x6", 101.6, 152.4),
("na_5x7", "5x7", 127.0, 177.8),
];
const MM_PER_PT: f64 = 25.4 / 72.0;
type Paper = (&'static str, &'static str, f64, f64);
/// The job's paper: its row in `PAPERS` when it is a standard size, and the portrait size in
/// millimetres.
fn paper(paper_pt: (f64, f64)) -> (Option<&'static Paper>, f64, f64) {
let (w, h) = (paper_pt.0.min(paper_pt.1) * MM_PER_PT, paper_pt.0.max(paper_pt.1) * MM_PER_PT);
let row = PAPERS.iter().find(|(_, _, pw, ph)| (pw - w).abs() < 1.0 && (ph - h).abs() < 1.0);
(row, w, h)
}
fn orientation(landscape: bool) -> Seed {
text(if landscape { "landscape" } else { "portrait" })
}
/// `PreparePrint`'s settings (GtkPrintSettings keys, string values).
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn settings_for(copies: u32, paper_pt: (f64, f64), landscape: bool) -> Vec<(&'static str, Seed)> {
let (row, w, h) = paper(paper_pt);
let mut v = vec![("n-copies", text(&copies.to_string())), ("orientation", orientation(landscape))];
match row {
Some((pwg, _, _, _)) => v.push(("paper-format", text(pwg))),
None => v.extend([("paper-width", text(&format!("{w:.2}"))), ("paper-height", text(&format!("{h:.2}")))]),
}
v
}
/// `PreparePrint`'s page setup (GtkPageSetup keys): the size in millimetres, found by its PPD
/// name when it has one, else a custom size named by its dimensions.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn page_setup_for(paper_pt: (f64, f64), landscape: bool) -> Vec<(&'static str, Seed)> {
let (row, w, h) = paper(paper_pt);
let mut v = vec![("Orientation", orientation(landscape)), ("Width", Seed::Mm(w)), ("Height", Seed::Mm(h))];
match row {
Some((_, ppd, _, _)) => v.push(("PPDName", text(ppd))),
None => v.push(("Name", text(&format!("custom_{w:.0}x{h:.0}mm")))),
}
v
}
#[cfg(target_os = "linux")]
fn settings(job: &PrintJob) -> Vec<(&'static str, Seed)> {
settings_for(job.copies, job.paper, job.landscape)
}
#[cfg(target_os = "linux")]
fn page_setup(job: &PrintJob) -> Vec<(&'static str, Seed)> {
page_setup_for(job.paper, job.landscape)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_path_follows_the_portal_rule() {
assert_eq!(request_path(":1.42", "photocraft_prepare_7"), "/org/freedesktop/portal/desktop/request/1_42/photocraft_prepare_7");
}
#[test]
fn standard_papers_get_their_pwg_and_ppd_names_and_custom_ones_their_size() {
// A4 landscape as the engine lays it out: width > height, still A4.
let s = settings_for(2, (841.89, 595.28), true);
assert_eq!(s, vec![("n-copies", text("2")), ("orientation", text("landscape")), ("paper-format", text("iso_a4"))]);
let p = page_setup_for((841.89, 595.28), true);
assert_eq!(p[0], ("Orientation", text("landscape")));
let Seed::Mm(w) = p[1].1 else { panic!("width is millimetres") };
let Seed::Mm(h) = p[2].1 else { panic!("height is millimetres") };
assert!((w - 210.0).abs() < 0.01 && (h - 297.0).abs() < 0.01, "{w} x {h}");
assert_eq!(p[3], ("PPDName", text("A4")));
assert_eq!(settings_for(1, (612.0, 792.0), false)[2], ("paper-format", text("na_letter")));
assert_eq!(page_setup_for((612.0, 792.0), false)[3], ("PPDName", text("Letter")));
assert_eq!(settings_for(1, (288.0, 432.0), false)[2], ("paper-format", text("na_index-4x6")));
// 100 x 200 mm is nobody's standard.
let custom = settings_for(1, (283.46, 566.93), false);
assert_eq!(&custom[2..], &[("paper-width", text("100.00")), ("paper-height", text("200.00"))]);
assert_eq!(page_setup_for((283.46, 566.93), false)[3], ("Name", text("custom_100x200mm")));
}
}
+281 -46
View File
@@ -6,7 +6,7 @@ use photocraft_doc::{Document, Layer, Size};
use photocraft_format::RecoveryStore;
use photocraft_geom::Rect;
use photocraft_ui_egui::{FileDialogAnswer, FileDialogReply, FileDialogRequest, Recoverable, Services};
use std::cell::RefCell;
use std::cell::{Cell, RefCell};
use std::future::Future;
use std::path::{Path, PathBuf};
use std::pin::Pin;
@@ -20,9 +20,9 @@ mod recovery_tests;
/// Photoshop brushes (.abr), gradients (.grd), swatches (.aco, .ase) and keyboard shortcut sets
/// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts.
const OPEN_EXTS: &[&str] = &[
"pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr",
"pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "cin", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw",
"arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub",
"pcraft", "pdn", "ora", "xcf", "xcfgz", "gz", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico",
"qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "cin", "heic", "heif", "hif", "dng", "cr2",
"cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub",
];
const SVG_EXTS: &[&str] = &["svg", "svgz"];
@@ -36,16 +36,48 @@ fn open_filter_extensions(extensions: &[&str]) -> Vec<String> {
extensions.iter().map(|ext| if case_sensitive_globs { ext.chars().map(class).collect() } else { ext.to_string() }).collect()
}
/// An explicit SVG filter keeps vector artwork discoverable even when a native picker defaults to
/// the PhotoCraft-only filter. SVG is also included in All Formats for normal multi-format opens.
/// File › Open's file types after All Formats, as Photoshop lists them: one per format, so a folder
/// of mixed files can be narrowed to one kind (#6337). Together they cover every extension of
/// [`OPEN_EXTS`]. An explicit SVG filter also keeps vector artwork discoverable when a native
/// picker defaults to another filter.
const OPEN_FILTERS: &[(&str, &[&str])] = &[
("PhotoCraft", &["pcraft"]),
("Photoshop", &["psd", "psb", "psdt"]),
("Affinity", &["af", "afdesign", "afphoto", "afpub"]),
("Krita", &["kra", "krz"]),
("GIMP", &["xcf", "xcfgz", "gz"]),
("OpenRaster", &["ora"]),
("Paint.NET", &["pdn"]),
("SVG", SVG_EXTS),
("PNG", &["png"]),
("JPEG", &["jpg", "jpeg"]),
("HEIF", &["heic", "heif", "hif"]),
("TIFF", &["tif", "tiff"]),
("WebP", &["webp"]),
("GIF", &["gif"]),
("BMP", &["bmp"]),
("Targa", &["tga"]),
("ICO", &["ico"]),
("QOI", &["qoi"]),
("PCX", &["pcx"]),
("DDS", &["dds"]),
("XPM", &["xpm"]),
("Portable Bitmap", &["pbm", "pgm", "ppm", "pam", "pfm"]),
("OpenEXR", &["exr"]),
("Radiance HDR", &["hdr"]),
("FITS", &["fits", "fit", "fts"]),
("DPX and Cineon", &["dpx", "cin"]),
("Camera Raw", &["dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf"]),
("Brushes", &["abr"]),
("Gradients", &["grd"]),
("Swatches", &["aco", "ase"]),
("Keyboard Shortcuts", &["kys"]),
];
fn open_filters() -> Vec<(&'static str, Vec<String>)> {
vec![
("All Formats", open_filter_extensions(OPEN_EXTS)),
("PhotoCraft", open_filter_extensions(&["pcraft"])),
("OpenRaster", open_filter_extensions(&["ora"])),
("Krita", open_filter_extensions(&["kra", "krz"])),
("SVG", open_filter_extensions(SVG_EXTS)),
]
std::iter::once(("All Formats", open_filter_extensions(OPEN_EXTS)))
.chain(OPEN_FILTERS.iter().map(|(name, exts)| (*name, open_filter_extensions(exts))))
.collect()
}
/// File › Save As formats: (filter name, extensions). The filter matching the suggested name's
@@ -68,6 +100,8 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[
("OpenEXR", &["exr"]),
("FITS", &["fits", "fit"]),
("DPX", &["dpx"]),
// Game and 3D textures; DDS Options asks for the compression and mipmaps (#1327).
("DDS", &["dds"]),
];
/// Non-document files the shell saves (Swatches panel exports): offered alone, so the dialog
@@ -95,18 +129,51 @@ fn save_filters(suggested: &str) -> Vec<(String, Vec<String>)> {
v
}
/// The name the save dialog starts with. On Windows a visible extension stays put when the user
/// picks another type (with File name extensions shown, `image.psd` saved as PhotoCraft stayed
/// `image.psd`, #4394), while a bare name gets the chosen type's extension, so there the dialog
/// gets the stem and the suggested type (listed first by [`save_filters`]) supplies `.psd`. A
/// stem that still has a dot (`scan.jpg.psd`: Windows could keep `scan.jpg`) or an upper-case
/// `path` with the extension of the file type picked in the save dialog (`chosen`), where the
/// dialog left the name alone (#6121): the format follows the extension, and the GTK file chooser
/// keeps the typed name when another type is picked. Only a name that still has the extension the
/// dialog opened with (`opened_with`, `Untitled-1.psd` with PNG picked) or none gets the type's
/// extension, as PolyMeilex/rfd#341 does. An extension the user typed that PhotoCraft saves
/// (`art.png` with the PSD type untouched, the way to save a PNG before this) is theirs and stays,
/// and so does one of the type's own (`.psb` for PSD). One PhotoCraft doesn't save (`scan.2024`)
/// is part of the name, and the type's extension is added after it.
#[cfg(any(target_os = "linux", test))]
pub(crate) fn with_chosen_type(path: &Path, opened_with: Option<&str>, chosen: &[String]) -> PathBuf {
let Some(first) = chosen.first() else { return path.to_path_buf() };
let ext = path.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase);
let opened_ext = opened_with.and_then(|n| Path::new(n).extension()).and_then(|e| e.to_str()).map(str::to_ascii_lowercase);
match ext {
Some(ext) if chosen.contains(&ext) => path.to_path_buf(),
Some(ext) if opened_ext.as_ref() == Some(&ext) => path.with_extension(first),
Some(ext) if SAVE_FILTERS.iter().chain(OTHER_SAVE_FILTERS).any(|(_, exts)| exts.contains(&ext.as_str())) => path.to_path_buf(),
_ => {
let mut name = path.as_os_str().to_owned();
name.push(".");
name.push(first);
PathBuf::from(name)
}
}
}
/// The name the save dialog starts with. Where the type picked gives a bare name its extension
/// (`adds_extension`), the dialog gets the stem and the suggested type (listed first by
/// [`save_filters`]) supplies `.psd`:
/// - On Windows a visible extension stays put when the user picks another type (with File name
/// extensions shown, `image.psd` saved as PhotoCraft stayed `image.psd`, #4394).
/// - On Linux the portal's dialog keeps the name as typed, and before returning asks whether to
/// replace a file of that name, so `test.psd` with PNG picked asked to replace test.psd, which
/// the save leaves alone (#6121). From the stem [`with_chosen_type`] gives the picked type's
/// extension, and a name that then exists is asked about under that name
/// (`portal_save::save_as`).
///
/// A stem that still has a dot (`scan.jpg.psd`: Windows could keep `scan.jpg`) or an upper-case
/// extension (the user's spelling) keeps the whole name. Elsewhere the whole name is kept: the
/// macOS panel has no type list, and on Linux rfd doesn't report which type was picked.
fn dialog_file_name(suggested: &str, windows: bool) -> Option<String> {
/// macOS panel has no type list, and rfd's Linux dialogs don't say which type was picked.
fn dialog_file_name(suggested: &str, adds_extension: bool) -> Option<String> {
let path = Path::new(suggested);
let name = path.file_name()?.to_string_lossy().into_owned();
let (Some(stem), Some(ext)) = (path.file_stem(), path.extension().and_then(|e| e.to_str())) else { return Some(name) };
let bare = windows && Path::new(stem).extension().is_none() && !ext.is_empty() && ext == ext.to_ascii_lowercase();
let bare = adds_extension && Path::new(stem).extension().is_none() && !ext.is_empty() && ext == ext.to_ascii_lowercase();
Some(if bare { stem.to_string_lossy().into_owned() } else { name })
}
@@ -127,10 +194,7 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
let dialog = if let Some(exts) = extensions {
dialog.add_filter("Supported Files", &open_filter_extensions(&exts.iter().map(String::as_str).collect::<Vec<_>>()))
} else {
open_filters()
.into_iter()
.fold(dialog, |dialog, (name, extensions)| dialog.add_filter(name, &extensions))
.add_filter("Paint.NET", &open_filter_extensions(&["pdn"]))
open_filters().into_iter().fold(dialog, |dialog, (name, extensions)| dialog.add_filter(name, &extensions))
};
if multiple {
let picked = dialog.pick_files();
@@ -148,17 +212,52 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
Box::pin(async move { picked.await.map(|folder| FileDialogAnswer::FolderTo(path_of(&folder))) })
}
FileDialogRequest::Save { suggested } => {
for (name, exts) in save_filters(&suggested) {
dialog = dialog.add_filter(name, &exts);
let filters = save_filters(&suggested);
let name = dialog_file_name(&suggested, cfg!(windows));
let dir = Path::new(&suggested).parent().filter(|p| !p.as_os_str().is_empty()).map(Path::to_path_buf);
for (name, exts) in &filters {
dialog = dialog.add_filter(name, exts);
}
if let Some(name) = dialog_file_name(&suggested, cfg!(windows)) {
if let Some(name) = &name {
dialog = dialog.set_file_name(name);
}
if let Some(dir) = Path::new(&suggested).parent().filter(|p| !p.as_os_str().is_empty()) {
if let Some(dir) = &dir {
dialog = dialog.set_directory(dir);
}
let picked = dialog.save_file();
Box::pin(async move { picked.await.map(|file| FileDialogAnswer::SaveTo(path_of(&file))) })
// On Linux the portal's own dialog, which reports the file type picked (#6121); rfd if
// the portal can't show it.
#[cfg(target_os = "linux")]
{
use eframe::wgpu::rwh::HasWindowHandle;
let parent = crate::portal_save::parent_id(parent.and_then(|p| p.window_handle().ok()).map(|h| h.as_raw()));
let req = crate::portal_save::SaveRequest {
parent,
name: dialog_file_name(&suggested, true),
suggested: Path::new(&suggested).file_name().map(|n| n.to_string_lossy().into_owned()),
folder: dir,
filters,
current: Some(0),
};
Box::pin(async move {
let portal = zbus::blocking::connection::Builder::session()
.and_then(|b| b.build())
.map_err(|e| e.to_string())
.and_then(|conn| crate::portal_save::save_as(&conn, req));
match portal {
Ok(path) => path.map(|p| FileDialogAnswer::SaveTo(p.to_string_lossy().into_owned())),
Err(e) => {
log::info!("portal save dialog unavailable ({e}), using rfd");
dialog.save_file().await.map(|file| FileDialogAnswer::SaveTo(path_of(&file)))
}
}
})
}
#[cfg(not(target_os = "linux"))]
{
let _ = filters;
let picked = dialog.save_file();
Box::pin(async move { picked.await.map(|file| FileDialogAnswer::SaveTo(path_of(&file))) })
}
}
};
// Without the thread the reply is dropped unanswered, which the app takes as Cancel.
@@ -176,7 +275,7 @@ fn path_of(file: &rfd::FileHandle) -> String {
}
/// Runs `future` to completion on this thread, sleeping until it is woken.
fn block_on<T>(future: impl Future<Output = T>) -> T {
pub(crate) fn block_on<T>(future: impl Future<Output = T>) -> T {
struct Unpark(std::thread::Thread);
impl std::task::Wake for Unpark {
fn wake(self: Arc<Self>) {
@@ -196,8 +295,8 @@ fn block_on<T>(future: impl Future<Output = T>) -> T {
}
/// Per-user settings directory: `PHOTOCRAFT_CONFIG_DIR`, else `<exe dir>/PhotoCraftData` in
/// portable mode, else the platform convention. Everything the app persists lives under it; see
/// [`crate::app_dirs`].
/// portable mode, else the platform convention. Everything the app persists lives under it
/// except, on Windows and Linux, crash-recovery autosaves; see [`crate::app_dirs`].
pub fn config_dir() -> Option<PathBuf> {
crate::app_dirs::config_dir()
}
@@ -212,8 +311,11 @@ pub fn presets_dir() -> Option<PathBuf> {
config_dir().map(|d| d.join("Presets"))
}
/// Crash-recovery autosaves: under the settings directory, except where that is synced with the
/// settings: the Windows roaming profile and the Linux config folder (see
/// [`crate::app_dirs::resolve_recovery`]).
fn recovery_dir() -> Option<PathBuf> {
config_dir().map(|d| d.join("Recovery"))
crate::app_dirs::recovery_dir()
}
/// Write `bytes` crash-safely (temp file beside the target, fsync, rename, directory fsync; see
@@ -394,6 +496,7 @@ fn shell_word(s: &str) -> String {
pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) -> Services {
let clip: Rc<RefCell<Option<arboard::Clipboard>>> = Rc::default();
let clip_unreadable: Rc<Cell<bool>> = Rc::default();
let automation_read = automation.clone().map(|workspace| {
Box::new(move |path: &str| {
let bytes = workspace.read(path).map_err(|error| error.to_string())?;
@@ -436,6 +539,11 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
save_file: Some(Arc::new(save_file)),
file_dialog: Some(Box::new(show_file_dialog)),
write: Some(Box::new(|path: &str, bytes: &[u8]| write_atomic(Path::new(path), bytes))),
file_stamp: Some(Box::new(|path: &str| {
// Follows a symlink, as Save writes through it to the file it points at.
let meta = std::fs::metadata(path).ok()?;
Some(photocraft_ui_egui::FileStamp { len: meta.len(), modified: meta.modified().ok() })
})),
automation_read,
automation_write,
automation_open_pcraft,
@@ -462,26 +570,47 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
}),
clipboard_get_image: Some({
let clip = clip.clone();
let unreadable = clip_unreadable.clone();
Box::new(move || {
let mut slot = clip.try_borrow_mut().ok()?;
let mut slot = clip.try_borrow_mut().map_err(|_| "clipboard is busy".to_string())?;
let cb = match slot.as_mut() {
Some(c) => c,
None => slot.insert(arboard::Clipboard::new().ok()?),
None => {
// arboard opens only when some backend works. In a Flatpak on Wayland none
// does (#5031): the sandbox hides the data-control protocol from it, and
// `--socket=fallback-x11` gives no X server while a compositor runs.
let opened = arboard::Clipboard::new();
unreadable.set(opened.is_err());
slot.insert(opened.map_err(|e| e.to_string())?)
}
};
// Copied files first: Finder also puts the file's icon on the clipboard as an
// image, which would otherwise paste instead of the file.
if let Some(img) = cb.get().file_list().ok().and_then(|paths| image_from_files(&paths)) {
return Some(img);
match cb.get().file_list() {
Ok(paths) => {
if let Some(img) = image_from_files(&paths) {
return Ok(Some(img));
}
}
Err(error @ arboard::Error::ClipboardOccupied) => return Err(error.to_string()),
Err(_) => {}
}
if let Ok(img) = cb.get_image() {
return Some((img.width as u32, img.height as u32, img.bytes.into_owned()));
match cb.get_image() {
Ok(img) => Ok(Some((img.width as u32, img.height as u32, img.bytes.into_owned()))),
Err(error) => {
#[cfg(target_os = "linux")]
if let Some(image) = wayland_clipboard_image() {
return Ok(Some(image));
}
match error {
arboard::Error::ContentNotAvailable => Ok(None),
_ => Err(error.to_string()),
}
}
}
#[cfg(target_os = "linux")]
return wayland_clipboard_image();
#[cfg(not(target_os = "linux"))]
None
})
}),
clipboard_unreadable: Some(Box::new(move || clip_unreadable.get())),
load_prefs: Some(Box::new(|| std::fs::read_to_string(prefs_file()?).ok())),
photoshop_shortcuts: Some(Box::new(|| crate::photoshop_settings::live_keyboard_shortcuts(&|k| std::env::var_os(k)))),
save_prefs: Some(Box::new(|text: &str| write_atomic(&prefs_file().ok_or("no config directory")?, text.as_bytes()))),
@@ -585,6 +714,92 @@ mod tests {
use photocraft_ui_egui::{PhotocraftApp, prefs_ui};
use serde_json::{Value, json};
/// #6121: the file type picked in the save dialog gives the name its extension where the
/// dialog left the one it opened with, and only there.
#[test]
fn the_picked_file_type_gives_the_name_its_extension() {
let exts = |e: &[&str]| e.iter().map(|s| s.to_string()).collect::<Vec<_>>();
let (png, psd) = (exts(&["png"]), exts(&["psd", "psb"]));
let opened = Some("Untitled-1.psd");
let cases = [
// PNG picked, the name as the dialog opened (or renamed, its extension kept).
("/pics/Untitled-1.psd", opened, &png, "/pics/Untitled-1.png"),
("/pics/art.psd", opened, &png, "/pics/art.png"),
("/pics/ART.PSD", opened, &png, "/pics/ART.png"),
// No extension: the type's. One PhotoCraft doesn't save is part of the name.
("/pics/art", opened, &png, "/pics/art.png"),
("/pics/scan.2024", opened, &png, "/pics/scan.2024.png"),
// Typed, an extension PhotoCraft saves is the user's: kept, whatever the type says
// (the PSD type untouched, or PNG picked and .jpg typed).
("/pics/art.png", opened, &psd, "/pics/art.png"),
("/pics/art.jpg", opened, &png, "/pics/art.jpg"),
("/pics/art.pcraft", opened, &png, "/pics/art.pcraft"),
// Already the type's: kept, including its other extensions.
("/pics/art.png", opened, &png, "/pics/art.png"),
("/pics/art.PNG", opened, &png, "/pics/art.PNG"),
("/pics/big.psb", opened, &psd, "/pics/big.psb"),
// The dialog opened on a .pcraft: that is the extension the type replaces.
("/pics/art.pcraft", Some("art.pcraft"), &png, "/pics/art.png"),
("/pics/art.psd", Some("art.pcraft"), &png, "/pics/art.psd"),
];
for (typed, opened, chosen, want) in cases {
assert_eq!(with_chosen_type(Path::new(typed), opened, chosen), PathBuf::from(want), "{typed} (opened {opened:?})");
}
// No type picked: the name as typed.
assert_eq!(with_chosen_type(Path::new("/pics/art.psd"), opened, &[]), PathBuf::from("/pics/art.psd"));
}
/// Regression using the real clipboard on an isolated Windows CI runner.
#[cfg(target_os = "windows")]
#[test]
#[ignore = "requires exclusive use of the Windows clipboard"]
fn busy_native_clipboard_preserves_copied_shape() {
for id in ["edit.paste", "edit.pasteSpecial.pasteInPlace"] {
let mut native = native(None);
let services = Services {
clipboard_set_image: native.clipboard_set_image.take(),
clipboard_get_image: native.clipboard_get_image.take(),
..Default::default()
};
let mut app = PhotocraftApp::new(Session::new(), services);
app.run("file.new", json!({"width": 80, "height": 60})).unwrap();
app.run("shape.create", json!({"kind": "rect", "rect": [10, 10, 40, 20], "fill": "#ff0000"})).unwrap();
app.run("edit.copy", json!({})).unwrap();
assert!(app.session.clipboard.as_ref().unwrap().layers.is_some());
assert!(app.services.clipboard_get_image.as_mut().unwrap()().unwrap().is_some(), "native export succeeded");
app.run("file.new", json!({"width": 80, "height": 60})).unwrap();
// Another thread holds the OS clipboard open beyond arboard's bounded retry window.
let (ready_tx, ready_rx) = std::sync::mpsc::channel();
let (release_tx, release_rx) = std::sync::mpsc::channel();
let holder = std::thread::spawn(move || {
// A non-null window distinguishes this owner from arboard's null-window opens.
let guard = winsafe::HWND::GetDesktopWindow().OpenClipboard().unwrap();
ready_tx.send(()).unwrap();
release_rx.recv_timeout(std::time::Duration::from_secs(10)).unwrap();
drop(guard);
});
ready_rx.recv_timeout(std::time::Duration::from_secs(10)).unwrap();
let contention = arboard::Clipboard::new().unwrap().get_image().err();
let first = app.run(id, json!({}));
release_tx.send(()).unwrap();
holder.join().unwrap();
assert!(matches!(contention, Some(arboard::Error::ClipboardOccupied)), "diagnostic must actually occupy the clipboard: {contention:?}");
eprintln!("paste while clipboard occupied: {first:?}");
assert!(first.is_err(), "report contention rather than a successful no-op");
assert!(
app.session.clipboard.as_ref().is_some_and(|clip| clip.layers.is_some()),
"a temporary OS read failure must not discard the copied editable shape"
);
app.run(id, json!({})).unwrap();
let state = app.session.active().unwrap();
assert!(
matches!(state.doc.layer(state.active_layer.unwrap()).unwrap().content, photocraft_doc::LayerContent::Shape(_)),
"retry keeps the shape editable"
);
}
}
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn open_filters_cover_uppercase_and_mixed_case_extensions() {
@@ -619,6 +834,26 @@ mod tests {
assert!(svg_filter.iter().any(|glob| matches(glob, "svg")) && svg_filter.iter().any(|glob| matches(glob, "svgz")));
}
/// #6337: after All Formats, every format File › Open reads has its own filter, and no filter
/// offers an extension All Formats doesn't.
#[test]
fn every_open_format_has_its_own_filter() {
let filters = open_filters();
assert_eq!(filters[0], ("All Formats", open_filter_extensions(OPEN_EXTS)), "All Formats comes first");
let mut named: Vec<&str> = OPEN_FILTERS.iter().flat_map(|(_, exts)| exts.iter().copied()).collect();
let total = named.len();
named.sort_unstable();
named.dedup();
assert_eq!(named.len(), total, "an extension is listed under two filters");
let mut all = OPEN_EXTS.to_vec();
all.sort_unstable();
assert_eq!(named, all, "the named filters cover All Formats exactly");
let names: Vec<&str> = filters.iter().map(|(name, _)| *name).collect();
for name in ["Photoshop", "Affinity", "HEIF", "PNG", "JPEG", "TIFF", "Portable Bitmap", "OpenEXR", "Radiance HDR", "Paint.NET"] {
assert!(names.contains(&name), "{name}");
}
}
#[cfg(not(all(unix, not(target_os = "macos"))))]
#[test]
fn open_filters_keep_literal_extensions_on_windows_and_macos() {
@@ -734,7 +969,7 @@ mod tests {
assert!(format.caps().write, "{name}: .{ext} can't be written");
}
}
for ext in ["ico", "gif", "bmp"] {
for ext in ["ico", "gif", "bmp", "dds"] {
assert!(save_filters("Untitled-1.psd").iter().any(|(_, exts)| exts.iter().any(|e| e == ext)), ".{ext} is offered");
}
assert_eq!(save_filters("icon.ico")[0].0, "ICO", "an .ico name leads with its own type");
+648
View File
@@ -0,0 +1,648 @@
//! One PhotoCraft per user on Windows and Linux (#3708): a file opened from Explorer or a file
//! manager lands as a new tab in the running window, like Photoshop, instead of starting a second
//! copy of the program.
//!
//! The first launch takes an exclusive OS lock on `instance.lock` in the settings directory (the OS
//! drops it when the process ends, crash included), listens on a loopback port and writes the port
//! plus a fresh 256-bit token to `instance.json`. A later launch that finds the lock taken sends its
//! paths there; the primary queues them for the UI as [`OsEvent::Open`], which opens each one and
//! brings the window forward. Explorer starts one process per file when several are opened at once;
//! the lock decides the race, so they all end up as tabs in one window.
//!
//! The primary answers `ok` only once the UI has taken the paths. A launch that hears anything else
//! (the primary is still starting, hung, or quitting) keeps retrying the lock, so it becomes the
//! primary itself as soon as the old one lets go, and when its time runs out it starts a separate
//! instance: a file is never acknowledged and then dropped. [`Primary::shutdown`] runs when the
//! event loop ends (and before the CPU-compatibility relaunch), so a closing PhotoCraft refuses new
//! files and frees the lock.
//!
//! Only for the per-user settings folder ([`skip_reason`]): a portable copy's folder beside the
//! executable, or a `PHOTOCRAFT_CONFIG_DIR`, may be shared between users.
//! macOS doesn't need this: LaunchServices already sends a running app an open-documents event.
// Only the tests use it on macOS.
#![cfg_attr(target_os = "macos", allow(dead_code))]
use std::fs::{File, OpenOptions};
use std::io::{BufReader, Read, Write};
use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream};
use std::path::Path;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::mpsc::{SyncSender, sync_channel};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::{Duration, Instant};
use photocraft_automation::security::{LineRead, generate_token, read_bounded_line, token_matches};
use photocraft_ui_egui::{OsEvent, OsEventsFn};
use serde_json::{Value, json};
const LOCK_FILE: &str = "instance.lock";
const INFO_FILE: &str = "instance.json";
/// More paths than this in one request is not a file manager.
const MAX_PATHS: usize = 4096;
/// Launches served at once; a stalled client holds only its own slot, never the next launch.
const MAX_CLIENTS: usize = 8;
/// Kept out of the hand-off's budget, so the primary answers before the launch gives up on it.
const REPLY_MARGIN: Duration = Duration::from_millis(500);
/// How long each side waits (shortened in tests).
#[derive(Clone, Copy, Debug)]
pub struct Timing {
/// A later launch's whole budget for handing its paths over (or taking the lock over).
pub handoff: Duration,
/// How long the primary waits for its UI to take a launch's paths before refusing them.
pub ack: Duration,
/// Total time a connection gets to send its request line.
pub request: Duration,
}
/// Generous enough for a primary that is still opening its window.
pub const TIMING: Timing = Timing { handoff: Duration::from_secs(10), ack: Duration::from_secs(8), request: Duration::from_secs(5) };
/// Outcome of [`claim`].
pub enum Launch {
/// This process is the running instance; keep [`Primary`] alive and attach it to the app.
Primary(Primary),
/// The running instance took the paths; this process should exit.
Forwarded,
/// Couldn't do either (reason given): run as an independent instance.
Standalone(String),
}
/// What decides whether a launch takes part (see [`skip_reason`]).
#[derive(Clone, Copy)]
pub struct LaunchContext<'a> {
/// The settings folder is the platform's per-user one (not portable, not an override).
pub per_user_settings: bool,
/// `--new-instance`.
pub new_instance_flag: bool,
/// `PHOTOCRAFT_NEW_INSTANCE`, if set.
pub new_instance_env: Option<&'a str>,
/// `--control` or `PHOTOCRAFT_CONTROL_PORT`: automation gets its own process.
pub control: bool,
/// Paths that aren't valid Unicode: this launch reports them.
pub unreadable_paths: bool,
/// Options that only apply to the process they're given to (`--safe-gpu`, …).
pub launch_options: &'a [&'static str],
}
/// Why this launch runs as its own instance, or `None` to take part in single-instance.
pub fn skip_reason(c: &LaunchContext) -> Option<String> {
if !c.per_user_settings {
return Some("the settings folder isn't the per-user one (portable or PHOTOCRAFT_CONFIG_DIR)".into());
}
if c.new_instance_flag || c.new_instance_env.is_some_and(|v| !v.is_empty() && v != "0") {
return Some("--new-instance".into());
}
if c.control {
return Some("automation (--control) runs its own process".into());
}
if c.unreadable_paths {
return Some("some paths aren't valid Unicode".into());
}
if !c.launch_options.is_empty() {
return Some(format!("{} only applies to a new process", c.launch_options.join(", ")));
}
None
}
/// The running instance: holds the lock and receives other launches' paths.
pub struct Primary {
lock: Mutex<Option<File>>,
shared: Arc<Shared>,
}
struct Shared {
inbox: Mutex<Inbox>,
/// Set by [`Primary::shutdown`]: new connections are dropped, waiting ones refused.
closed: AtomicBool,
}
#[derive(Default)]
struct Inbox {
pending: Vec<Pending>,
ctx: Option<egui::Context>,
next_id: u64,
}
/// Paths waiting for the UI; `taken` tells the connection that sent them.
struct Pending {
id: u64,
paths: Vec<String>,
taken: SyncSender<()>,
}
impl Primary {
/// The queue the UI drains (see `Services::os_events`); a later launch wakes `ctx`.
pub fn connect(&self, ctx: &egui::Context) -> OsEventsFn {
let mut inbox = self.shared.inbox.lock().unwrap_or_else(PoisonError::into_inner);
if !inbox.pending.is_empty() {
ctx.request_repaint();
}
inbox.ctx = Some(ctx.clone());
drop(inbox);
let shared = Arc::clone(&self.shared);
Box::new(move || shared.take())
}
/// Stop taking files and release the lock: launches waiting on this one are refused and take
/// the lock over themselves. Call when the event loop has ended, and before relaunching.
pub fn shutdown(&self) {
self.shared.closed.store(true, Ordering::Release);
// Dropping the senders refuses every connection still waiting for the UI.
self.shared.inbox.lock().unwrap_or_else(PoisonError::into_inner).pending.clear();
drop(self.lock.lock().unwrap_or_else(PoisonError::into_inner).take());
}
}
impl Drop for Primary {
fn drop(&mut self) {
self.shutdown();
}
}
impl Shared {
/// Everything queued, acknowledged to the senders while the inbox is locked (see `hand_over`).
fn take(&self) -> Vec<OsEvent> {
let mut inbox = self.inbox.lock().unwrap_or_else(PoisonError::into_inner);
inbox
.pending
.drain(..)
.map(|p| {
let _ = p.taken.try_send(());
OsEvent::Open(p.paths)
})
.collect()
}
/// Queue `paths` for the UI and wait up to `wait` for it to take them. False (and nothing left
/// queued) when it didn't: the sender then retries or starts its own instance.
fn hand_over(&self, paths: Vec<String>, wait: Duration) -> bool {
let (taken, rx) = sync_channel(1);
let id = {
let mut inbox = self.inbox.lock().unwrap_or_else(PoisonError::into_inner);
if self.closed.load(Ordering::Acquire) {
return false;
}
let id = inbox.next_id;
inbox.next_id = id.wrapping_add(1);
inbox.pending.push(Pending { id, paths, taken });
if let Some(ctx) = &inbox.ctx {
ctx.request_repaint();
}
id
};
if rx.recv_timeout(wait).is_ok() {
return true;
}
// Timed out or refused: withdraw, unless the UI took them in the meantime (it acknowledges
// while holding the lock, so the answer is settled here).
let mut inbox = self.inbox.lock().unwrap_or_else(PoisonError::into_inner);
if rx.try_recv().is_ok() {
return true;
}
inbox.pending.retain(|p| p.id != id);
false
}
}
/// Become the running instance under `dir` or hand `paths` (absolute) to the one that is.
pub fn claim(dir: &Path, paths: &[String]) -> Launch {
claim_with(dir, paths, TIMING)
}
/// [`claim`] with explicit timeouts. Until `timing.handoff` runs out, each round tries the lock
/// (free once a quitting or crashed primary is gone) and then the running primary.
pub fn claim_with(dir: &Path, paths: &[String], timing: Timing) -> Launch {
if let Err(e) = std::fs::create_dir_all(dir) {
return Launch::Standalone(format!("{}: {e}", dir.display()));
}
let lock_path = dir.join(LOCK_FILE);
let lock = match OpenOptions::new().read(true).write(true).create(true).truncate(false).open(&lock_path) {
Ok(lock) => lock,
Err(e) => return Launch::Standalone(format!("{}: {e}", lock_path.display())),
};
let deadline = Instant::now() + timing.handoff;
let mut last = "no instance.json".to_string();
loop {
match lock.try_lock() {
Ok(()) => {
return match serve(dir, lock, timing) {
Ok(primary) => Launch::Primary(primary),
Err(e) => Launch::Standalone(e),
};
}
Err(std::fs::TryLockError::WouldBlock) => {}
Err(std::fs::TryLockError::Error(e)) => return Launch::Standalone(format!("{}: {e}", lock_path.display())),
}
if let Some((port, token)) = read_info(dir) {
match send(port, &token, paths, deadline) {
Ok(()) => return Launch::Forwarded,
Err(e) => last = e,
}
}
if Instant::now() >= deadline {
return Launch::Standalone(format!("the running PhotoCraft didn't take the files ({last})"));
}
std::thread::sleep(Duration::from_millis(100));
}
}
fn serve(dir: &Path, lock: File, timing: Timing) -> Result<Primary, String> {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).map_err(|e| format!("loopback listener: {e}"))?;
let port = listener.local_addr().map_err(|e| format!("loopback listener: {e}"))?.port();
let token = generate_token().map_err(|e| e.to_string())?;
write_info(dir, port, &token)?;
let shared = Arc::new(Shared { inbox: Mutex::default(), closed: AtomicBool::new(false) });
let listening = Arc::clone(&shared);
let spawned = std::thread::Builder::new().name("single-instance".into()).spawn(move || {
let token = Arc::new(token);
let clients = Arc::new(AtomicUsize::new(0));
loop {
let stream = match listener.accept() {
Ok((stream, _)) => stream,
// Back off instead of spinning when accept keeps failing (e.g. out of descriptors).
Err(_) => {
std::thread::sleep(Duration::from_millis(100));
continue;
}
};
// Closing: the sender sees the connection drop and takes the lock over.
if listening.closed.load(Ordering::Acquire) {
continue;
}
// Each launch on its own thread, so one that connects and stalls doesn't hold the
// ones after it; past the cap a connection is dropped and that launch retries.
if clients.fetch_add(1, Ordering::AcqRel) >= MAX_CLIENTS {
clients.fetch_sub(1, Ordering::AcqRel);
continue;
}
let (token, shared, slot) = (Arc::clone(&token), Arc::clone(&listening), Arc::clone(&clients));
let spawned = std::thread::Builder::new().name("single-instance-client".into()).spawn(move || {
serve_client(stream, &token, &shared, timing);
slot.fetch_sub(1, Ordering::AcqRel);
});
if spawned.is_err() {
clients.fetch_sub(1, Ordering::AcqRel);
}
}
});
spawned.map_err(|e| format!("single-instance listener thread: {e}"))?;
Ok(Primary { lock: Mutex::new(Some(lock)), shared })
}
/// Read one request, hand its paths to the UI and answer `ok` only if the UI took them.
fn serve_client(stream: TcpStream, token: &str, shared: &Shared, timing: Timing) {
let taken = match read_request(&stream, timing.request) {
Some(line) => match parse_request(&line, token) {
Some((paths, wait)) => shared.hand_over(paths, wait.min(timing.ack)),
None => false,
},
None => return,
};
let reply = if taken { json!({"ok": true}) } else { json!({"ok": false, "error": "not taken"}) };
let mut out = stream;
let _ = out.set_write_timeout(Some(timing.request));
let _ = writeln!(out, "{reply}").and_then(|()| out.flush());
}
/// A reader that fails once `until` passes, however slowly the bytes trickle in.
struct Deadline<'a> {
stream: &'a TcpStream,
until: Instant,
}
impl Read for Deadline<'_> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
let left = self.until.checked_duration_since(Instant::now()).filter(|d| !d.is_zero());
let left = left.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::TimedOut, "request deadline passed"))?;
self.stream.set_read_timeout(Some(left))?;
let mut stream = self.stream;
stream.read(buf)
}
}
/// The request line, if it arrives complete within `total`.
fn read_request(stream: &TcpStream, total: Duration) -> Option<String> {
let mut reader = BufReader::new(Deadline { stream, until: Instant::now() + total });
let mut line = String::new();
matches!(read_bounded_line(&mut reader, &mut line), Ok(LineRead::Line)).then_some(line)
}
/// Port and token, written to a temporary file and renamed so a reader never sees half of it.
/// On Unix the file is the user's alone (0600): another local user who read the token could make
/// this app open any path. (Windows keeps the per-user ACL of the settings folder.)
fn write_info(dir: &Path, port: u16, token: &str) -> Result<(), String> {
let path = dir.join(INFO_FILE);
let tmp = dir.join(format!("{INFO_FILE}.{}.tmp", std::process::id()));
let body = json!({"port": port, "token": token, "pid": std::process::id()}).to_string();
// A leftover from a crash could carry wider permissions; the mode applies only on create.
let _ = std::fs::remove_file(&tmp);
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
let written = options.open(&tmp).and_then(|mut f| f.write_all(body.as_bytes())).and_then(|()| std::fs::rename(&tmp, &path));
written.map_err(|e| {
let _ = std::fs::remove_file(&tmp);
format!("{}: {e}", path.display())
})
}
fn read_info(dir: &Path) -> Option<(u16, String)> {
let v: Value = serde_json::from_slice(&std::fs::read(dir.join(INFO_FILE)).ok()?).ok()?;
let port = u16::try_from(v.get("port")?.as_u64()?).ok()?;
Some((port, v.get("token")?.as_str()?.to_string()))
}
/// `{"token":…,"open":[…],"wait_ms":…}`: the paths, and how long the sender will wait for the UI.
fn parse_request(line: &str, token: &str) -> Option<(Vec<String>, Duration)> {
let v: Value = serde_json::from_str(line).ok()?;
if !token_matches(token, v.get("token")?.as_str()?) {
return None;
}
let open = v.get("open")?.as_array()?;
if open.len() > MAX_PATHS {
return None;
}
let paths = open.iter().map(|p| p.as_str().map(str::to_string)).collect::<Option<Vec<_>>>()?;
let wait = Duration::from_millis(v.get("wait_ms").and_then(Value::as_u64).unwrap_or(0));
Some((paths, wait))
}
/// Offer `paths` to the primary on `port`; `Ok` once its UI has taken them.
fn send(port: u16, token: &str, paths: &[String], deadline: Instant) -> Result<(), String> {
let left = deadline.checked_duration_since(Instant::now()).filter(|d| !d.is_zero()).ok_or("out of time")?;
let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
let mut stream = TcpStream::connect_timeout(&addr, left).map_err(|e| e.to_string())?;
// The primary must answer before our deadline, so it waits a little less than we do.
let wait_ms = u64::try_from(left.saturating_sub(REPLY_MARGIN).as_millis()).unwrap_or(u64::MAX);
let request = json!({"token": token, "open": paths, "wait_ms": wait_ms});
stream.set_write_timeout(Some(left)).map_err(|e| e.to_string())?;
writeln!(stream, "{request}").and_then(|()| stream.flush()).map_err(|e| e.to_string())?;
let mut reader = BufReader::new(Deadline { stream: &stream, until: deadline });
let mut line = String::new();
match read_bounded_line(&mut reader, &mut line) {
Ok(LineRead::Line) => {}
Ok(_) => return Err("no reply".into()),
Err(e) => return Err(e.to_string()),
}
let ok = serde_json::from_str::<Value>(&line).ok().and_then(|v| v.get("ok")?.as_bool()) == Some(true);
if ok { Ok(()) } else { Err("not taken".into()) }
}
/// Absolute form of a command-line path, so the running instance (another working directory)
/// opens the same file.
pub fn absolute(path: &str) -> String {
std::path::absolute(path).map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|_| path.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use std::sync::mpsc::channel;
const FAST: Timing = Timing { handoff: Duration::from_millis(1500), ack: Duration::from_millis(500), request: Duration::from_millis(800) };
fn temp_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("photocraft-single-instance-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
dir
}
/// Launch with `paths` on another thread (it blocks until the primary's UI takes them).
fn launch(dir: &Path, paths: &[&str], timing: Timing) -> std::thread::JoinHandle<Launch> {
let (dir, paths): (PathBuf, Vec<String>) = (dir.to_path_buf(), paths.iter().map(|p| p.to_string()).collect());
std::thread::spawn(move || claim_with(&dir, &paths, timing))
}
/// Play the UI: drain until `n` events arrived (or 5 s passed).
fn drain(ui: &mut OsEventsFn, n: usize) -> Vec<OsEvent> {
let mut got = Vec::new();
let deadline = Instant::now() + Duration::from_secs(5);
while got.len() < n && Instant::now() < deadline {
got.extend(ui());
std::thread::sleep(Duration::from_millis(10));
}
got
}
fn primary(dir: &Path) -> Primary {
match claim_with(dir, &[], FAST) {
Launch::Primary(p) => p,
_ => panic!("first launch should be primary"),
}
}
#[test]
fn second_launch_hands_its_paths_to_the_first() {
let dir = temp_dir("handoff");
let primary = primary(&dir);
let mut ui = primary.connect(&egui::Context::default());
let paths = ["C:\\art\\one.psd", "/home/a/two é.png"];
let second = launch(&dir, &paths, TIMING);
assert_eq!(drain(&mut ui, 1), vec![OsEvent::Open(paths.iter().map(|p| p.to_string()).collect())]);
assert!(matches!(second.join().unwrap(), Launch::Forwarded));
// A plain second start (no files) just brings the window forward.
let third = launch(&dir, &[], TIMING);
assert_eq!(drain(&mut ui, 1), vec![OsEvent::Open(Vec::new())]);
assert!(matches!(third.join().unwrap(), Launch::Forwarded));
drop(primary);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn simultaneous_launches_all_land_in_one_instance() {
let dir = temp_dir("race");
let (tx, rx) = channel();
for i in 0..6 {
let (dir, tx) = (dir.clone(), tx.clone());
std::thread::spawn(move || {
let _ = tx.send(claim(&dir, &[format!("file{i}.psd")]));
});
}
drop(tx);
let (mut primaries, mut forwarded, mut events) = (Vec::new(), 0, Vec::new());
let mut ui: Option<OsEventsFn> = None;
let deadline = Instant::now() + Duration::from_secs(10);
while primaries.len() + forwarded < 6 && Instant::now() < deadline {
while let Ok(launch) = rx.try_recv() {
match launch {
Launch::Primary(p) => primaries.push(p),
Launch::Forwarded => forwarded += 1,
Launch::Standalone(reason) => panic!("standalone: {reason}"),
}
}
if ui.is_none()
&& let Some(p) = primaries.first()
{
ui = Some(p.connect(&egui::Context::default()));
}
if let Some(ui) = ui.as_mut() {
events.extend(ui());
}
std::thread::sleep(Duration::from_millis(10));
}
assert_eq!((primaries.len(), forwarded, events.len()), (1, 5, 5));
drop(primaries);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_crashed_instance_leaves_nothing_in_the_way() {
let dir = temp_dir("stale");
std::fs::create_dir_all(&dir).unwrap();
// instance.json from a process that is gone; the lock is free.
std::fs::write(dir.join(INFO_FILE), r#"{"port":1,"token":"x","pid":1}"#).unwrap();
let primary = primary(&dir);
assert!(read_info(&dir).is_some_and(|(port, _)| port != 1));
drop(primary);
// Released with the handle, as on process exit.
assert!(matches!(claim_with(&dir, &[], FAST), Launch::Primary(_)));
let _ = std::fs::remove_dir_all(&dir);
}
/// Review of #3755, point 1 (and the quitting half of point 3): a launch waiting on a primary
/// that shuts down (closing, or about to relaunch itself in CPU mode) isn't told `ok`; it takes
/// the lock over and opens the file itself.
#[test]
fn a_launch_waiting_on_a_closing_instance_takes_over() {
let dir = temp_dir("closing");
let primary = primary(&dir);
// The UI never drains (it is shutting down, or never came up).
let waiting = launch(&dir, &["a.psd"], FAST);
std::thread::sleep(Duration::from_millis(200));
primary.shutdown();
assert!(matches!(waiting.join().unwrap(), Launch::Primary(_)), "the waiting launch becomes the primary");
// After shutdown new connections are refused too.
let (port, token) = read_info(&dir).unwrap();
drop(primary);
let short = Instant::now() + Duration::from_millis(300);
assert!(send(port, &token, &["b.psd".into()], short).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
/// Review of #3755, point 3: a primary whose UI never takes the paths (hung) doesn't
/// acknowledge them, and doesn't keep them queued to open later as well.
#[test]
fn a_hung_instance_never_swallows_a_file() {
let dir = temp_dir("hung-ui");
let primary = primary(&dir);
let mut ui = primary.connect(&egui::Context::default());
let started = Instant::now();
assert!(matches!(claim_with(&dir, &["a.psd".into()], FAST), Launch::Standalone(_)));
assert!(started.elapsed() < FAST.handoff + Duration::from_secs(1), "{:?}", started.elapsed());
assert!(ui().is_empty(), "withdrawn, not opened later");
drop(primary);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn requests_without_the_token_or_malformed_are_rejected() {
let token = generate_token().unwrap();
let ok = parse_request(&json!({"token": token, "open": ["a"], "wait_ms": 250}).to_string(), &token);
assert_eq!(ok, Some((vec!["a".to_string()], Duration::from_millis(250))));
let wrong = generate_token().unwrap();
assert_eq!(parse_request(&json!({"token": wrong, "open": ["a"]}).to_string(), &token), None);
for bad in ["", "not json", "{}", r#"{"token":5}"#, &json!({"token": token}).to_string(), &json!({"token": token, "open": [1]}).to_string()] {
assert_eq!(parse_request(bad, &token), None, "{bad:?}");
}
let many: Vec<String> = (0..=MAX_PATHS).map(|i| i.to_string()).collect();
assert_eq!(parse_request(&json!({"token": token, "open": many}).to_string(), &token), None);
}
#[test]
fn a_stalled_client_does_not_hold_up_the_next_launch() {
let dir = temp_dir("stalled");
let primary = primary(&dir);
let mut ui = primary.connect(&egui::Context::default());
let (port, _) = read_info(&dir).unwrap();
// Connects and never sends a line (each would otherwise hold the listener).
let stalled: Vec<TcpStream> = (0..3).map(|_| TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap()).collect();
let started = Instant::now();
let next = launch(&dir, &["b.psd"], FAST);
assert_eq!(drain(&mut ui, 1), vec![OsEvent::Open(vec!["b.psd".into()])]);
assert!(matches!(next.join().unwrap(), Launch::Forwarded));
assert!(started.elapsed() < FAST.request, "took {:?}", started.elapsed());
drop(stalled);
drop(primary);
let _ = std::fs::remove_dir_all(&dir);
}
/// A client that trickles a byte at a time can't keep its slot past the request deadline.
#[test]
fn a_trickling_client_is_cut_off_at_the_request_deadline() {
let dir = temp_dir("trickle");
let primary = primary(&dir);
let (port, _) = read_info(&dir).unwrap();
let mut slow = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap();
let started = Instant::now();
let mut cut_off = false;
while started.elapsed() < FAST.request * 4 {
if slow.write_all(b"{").is_err() {
cut_off = true;
break;
}
std::thread::sleep(Duration::from_millis(100));
}
assert!(cut_off, "still connected after {:?}", started.elapsed());
assert!(started.elapsed() < FAST.request * 3, "{:?}", started.elapsed());
drop(primary);
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn the_token_file_is_private_to_the_user() {
use std::os::unix::fs::PermissionsExt;
let dir = temp_dir("mode");
std::fs::create_dir_all(&dir).unwrap();
// A wide leftover temporary file from a crashed start doesn't widen the new one.
let tmp = dir.join(format!("{INFO_FILE}.{}.tmp", std::process::id()));
std::fs::write(&tmp, "{}").unwrap();
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o644)).unwrap();
write_info(&dir, 1, &generate_token().unwrap()).unwrap();
let mode = std::fs::metadata(dir.join(INFO_FILE)).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn an_unreachable_instance_falls_back_to_a_standalone_launch() {
let dir = temp_dir("unreachable");
std::fs::create_dir_all(&dir).unwrap();
// Someone holds the lock but nothing listens on the advertised port.
let held = File::create(dir.join(LOCK_FILE)).unwrap();
held.lock().unwrap();
let closed = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap().local_addr().unwrap().port();
write_info(&dir, closed, &generate_token().unwrap()).unwrap();
assert!(matches!(claim_with(&dir, &["a.psd".into()], FAST), Launch::Standalone(_)));
drop(held);
let _ = std::fs::remove_dir_all(&dir);
}
/// Review of #3755, point 2 and the smaller points: who takes part in single-instance.
#[test]
fn only_per_user_launches_without_process_options_take_part() {
let base = LaunchContext {
per_user_settings: true,
new_instance_flag: false,
new_instance_env: None,
control: false,
unreadable_paths: false,
launch_options: &[],
};
assert_eq!(skip_reason(&base), None);
// A portable copy's folder or a PHOTOCRAFT_CONFIG_DIR may be shared between users.
assert!(skip_reason(&LaunchContext { per_user_settings: false, ..base }).is_some());
assert!(skip_reason(&LaunchContext { new_instance_flag: true, ..base }).is_some());
assert!(skip_reason(&LaunchContext { new_instance_env: Some("1"), ..base }).is_some());
assert_eq!(skip_reason(&LaunchContext { new_instance_env: Some("0"), ..base }), None);
assert_eq!(skip_reason(&LaunchContext { new_instance_env: Some(""), ..base }), None);
assert!(skip_reason(&LaunchContext { control: true, ..base }).is_some());
assert!(skip_reason(&LaunchContext { unreadable_paths: true, ..base }).is_some());
let reason = skip_reason(&LaunchContext { launch_options: &["--safe-gpu"], ..base });
assert!(reason.is_some_and(|r| r.contains("--safe-gpu")));
}
}
+29 -6
View File
@@ -1,10 +1,12 @@
//! Pen tablet pressure, tilt, rotation and eraser end on macOS and Linux X11 (issue #79).
//! Pen tablet pressure, tilt, rotation and eraser end on macOS and Linux X11 (issue #79), and
//! barrel button on Windows (#5905).
//!
//! winit 0.30 drops tablet data on both, so `photocraft-tablet` reads it beside winit (an AppKit
//! local event monitor; XInput2 raw events on a second X connection) and this module writes each
//! sample into the UI's [`StylusFeed`], where the canvas reads it exactly like the web runner's
//! Pointer Events and automation's simulated pen. Windows needs nothing here: winit forwards
//! `WM_POINTER` pressure as touch force.
//! winit 0.30 drops tablet data on both macOS and X11, so `photocraft-tablet` reads it beside winit
//! (an AppKit local event monitor; XInput2 raw events on a second X connection) and this module writes
//! each sample into the UI's [`StylusFeed`], where the canvas reads it exactly like the web runner's
//! Pointer Events and automation's simulated pen. On Windows, winit forwards `WM_POINTER` pressure
//! as touch force, but drops `PEN_FLAG_BARREL` (`0x0001`); `photocraft-tablet` recovers the barrel
//! button through a thread-local message hook beside winit.
//!
//! Wayland compositors give a pen only to clients that bind the tablet protocol
//! (`zwp_tablet_v2`), which winit 0.30 doesn't (and binding it on winit's connection needs
@@ -19,15 +21,36 @@ use photocraft_tablet::Sample;
use photocraft_ui_egui::stylus::{PenSample, StylusFeed};
/// A tablet sample as the UI's pen sample.
#[allow(dead_code)]
pub fn pen_sample(s: Sample) -> PenSample {
PenSample { pressure: s.pressure, tilt_x: s.tilt_x, tilt_y: s.tilt_y, rotation: s.rotation, eraser: s.eraser }.sanitized()
}
/// Writes tablet samples into `feed` (`None` = a mouse).
#[allow(dead_code)]
pub fn sink(feed: StylusFeed) -> impl Fn(Option<Sample>) + Send + 'static {
move |s| feed.set(s.map(pen_sample))
}
/// Install the Windows pointer message hook (main thread; keep the result until the event loop
/// returns). On failure this logs why: the pen barrel button is then missed.
#[cfg(target_os = "windows")]
pub fn install_windows() -> Option<photocraft_tablet::windows::Monitor> {
photocraft_tablet::windows::Monitor::install().map_err(|e| log::warn!("{e}")).ok()
}
/// Stylus barrel button down check (Windows). The right mouse button is left to egui, which
/// reports the logical secondary button (swapped buttons included) for the app's own window only.
#[cfg(target_os = "windows")]
pub fn right_button_service() -> Option<photocraft_ui_egui::RightButtonDownFn> {
Some(Box::new(photocraft_tablet::windows::is_barrel_button_down))
}
#[cfg(not(target_os = "windows"))]
pub fn right_button_service() -> Option<photocraft_ui_egui::RightButtonDownFn> {
None
}
/// Install the AppKit tablet monitor (main thread, once winit created the shared application:
/// from eframe's app creation, not before the event loop). Keep the result until the event loop
/// returns. On failure this logs why: pens then paint like a mouse.
+117 -4
View File
@@ -20,6 +20,11 @@
//! the work area plus its own frame, clear of the taskbar. winit pins the client area to the work
//! area either way (`WM_NCCALCSIZE`), so the app's pixels do not move.
//!
//! An auto-hidden taskbar reserves no work area, so that trim leaves the maximize covering the
//! whole monitor, and the shell, taking it for a full-screen app, no longer reveals the taskbar
//! when the pointer reaches its edge (#4845). On each edge with an auto-hidden taskbar the guard
//! instead stops the window one pixel short of the monitor, as other custom-frame apps do.
//!
//! The Windows calls go through safe `winsafe` wrappers; the workspace forbids `unsafe`, and
//! winsafe can't wrap a raw handle without it, so the window is found by its handle instead.
@@ -29,7 +34,20 @@ pub use platform::service;
mod platform {
use eframe::wgpu::rwh::{HasWindowHandle, RawWindowHandle};
use photocraft_ui_egui::WindowFrameFn;
use winsafe::{HWND, HwndPlace, POINT, SIZE, co};
use winsafe::prelude::Handle;
use winsafe::{AtomStr, HWND, HwndPlace, POINT, SIZE, co};
/// A rect in desktop coordinates: `(left, top, right, bottom)`, right and bottom exclusive.
type Rect = (i32, i32, i32, i32);
/// A monitor edge.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Edge {
Left,
Top,
Right,
Bottom,
}
/// A guard that keeps the window borderless each frame, or `None` when the app uses the
/// system title bar (the window is decorated) or its handle can't be found.
@@ -99,8 +117,11 @@ mod platform {
return;
};
let (m, w) = (mi.rcMonitor, mi.rcWork);
let Some((left, top, right, bottom)) =
clamped_rect((r.left, r.top, r.right, r.bottom), (m.left, m.top, m.right, m.bottom), (w.left, w.top, w.right, w.bottom))
let (rect, monitor, work) = ((r.left, r.top, r.right, r.bottom), (m.left, m.top, m.right, m.bottom), (w.left, w.top, w.right, w.bottom));
// The taskbars (a couple of `FindWindowEx` calls) are only looked up while the window
// covers the whole monitor, which a trimmed maximize no longer does.
let Some((left, top, right, bottom)) = clamped_rect(rect, monitor, work)
.or_else(|| covers(rect, monitor).then(|| short_of_autohide_taskbars(rect, monitor, work, &taskbar_edges(monitor))).flatten())
else {
return;
};
@@ -153,6 +174,69 @@ mod platform {
(corrected != rect).then_some(corrected)
}
/// Whether `rect` reaches every edge of `monitor`: what the shell takes for a full-screen app.
fn covers(rect: Rect, monitor: Rect) -> bool {
rect.0 <= monitor.0 && rect.1 <= monitor.1 && rect.2 >= monitor.2 && rect.3 >= monitor.3
}
/// The edges of `monitor` that have a taskbar (the primary `Shell_TrayWnd` or a secondary
/// monitor's `Shell_SecondaryTrayWnd`), shown or hidden.
fn taskbar_edges(monitor: Rect) -> Vec<Edge> {
let mut edges = Vec::new();
for class in ["Shell_TrayWnd", "Shell_SecondaryTrayWnd"] {
let mut after: Option<HWND> = None;
// One taskbar per monitor; the bound only guards against a misbehaving shell.
for _ in 0..64 {
let Ok(Some(tray)) = HWND::NULL.FindWindowEx(after.as_ref(), AtomStr::from_str(class), None) else {
break;
};
if let Ok(r) = tray.GetWindowRect() {
edges.extend(taskbar_edge((r.left, r.top, r.right, r.bottom), monitor));
}
after = Some(tray);
}
}
edges
}
/// The edge of `monitor` a taskbar with rect `taskbar` sits on: the one nearest its centre,
/// or `None` when it is on another monitor. An auto-hidden taskbar is parked mostly past its
/// edge with a sliver left on screen, so its centre may lie outside the monitor.
fn taskbar_edge(taskbar: Rect, monitor: Rect) -> Option<Edge> {
let (tl, tt, tr, tb) = (i64::from(taskbar.0), i64::from(taskbar.1), i64::from(taskbar.2), i64::from(taskbar.3));
let (ml, mt, mr, mb) = (i64::from(monitor.0), i64::from(monitor.1), i64::from(monitor.2), i64::from(monitor.3));
if tl >= tr || tt >= tb || ml >= mr || mt >= mb || tr <= ml || tl >= mr || tb <= mt || tt >= mb {
return None;
}
// Doubled centre and distances, so nothing rounds.
let (cx, cy) = (tl + tr, tt + tb);
[(Edge::Left, cx - 2 * ml), (Edge::Top, cy - 2 * mt), (Edge::Right, 2 * mr - cx), (Edge::Bottom, 2 * mb - cy)]
.into_iter()
.min_by_key(|&(_, distance)| distance)
.map(|(edge, _)| edge)
}
/// A maximize covering its monitor, trimmed to stop one pixel short of each edge where a
/// taskbar reserves no work area (it auto-hides), so the shell still reveals it there.
/// `None` when there is nothing to trim.
fn short_of_autohide_taskbars(rect: Rect, monitor: Rect, work: Rect, taskbars: &[Edge]) -> Option<Rect> {
if !covers(rect, monitor) {
return None;
}
let (mut l, mut t, mut r, mut b) = rect;
for edge in taskbars {
match edge {
Edge::Left if work.0 == monitor.0 => l = monitor.0.checked_add(1)?,
Edge::Top if work.1 == monitor.1 => t = monitor.1.checked_add(1)?,
Edge::Right if work.2 == monitor.2 => r = monitor.2.checked_sub(1)?,
Edge::Bottom if work.3 == monitor.3 => b = monitor.3.checked_sub(1)?,
_ => {}
}
}
let trimmed = (l, t, r, b);
(l < r && t < b && trimmed != rect).then_some(trimmed)
}
/// `style` without the OS title bar and frame (`WS_CAPTION`, which already includes
/// `WS_BORDER`). The sizing border (`WS_THICKFRAME`) stays, so edge resizing and Aero snap
/// keep working, as they did with winit 0.27's undecorated windows.
@@ -162,7 +246,7 @@ mod platform {
#[cfg(test)]
mod tests {
use super::{borderless, clamped_rect};
use super::{Edge, borderless, clamped_rect, short_of_autohide_taskbars, taskbar_edge};
use winsafe::co;
#[test]
@@ -221,6 +305,35 @@ mod platform {
assert_eq!(clamped_rect((-500, -8, 1428, 1088), monitor, work), None, "dragged");
assert_eq!(clamped_rect((0, 48, 960, 1080), monitor, work), None, "half-screen");
}
#[test]
fn a_maximize_leaves_a_pixel_strip_on_an_auto_hidden_taskbar_edge() {
// #4845: an auto-hidden taskbar reserves no work area.
let monitor = (0, 0, 1920, 1080);
let rect = (-8, -8, 1928, 1088);
for (edge, expected) in
[(Edge::Bottom, (-8, -8, 1928, 1079)), (Edge::Top, (-8, 1, 1928, 1088)), (Edge::Left, (1, -8, 1928, 1088)), (Edge::Right, (-8, -8, 1919, 1088))]
{
assert_eq!(short_of_autohide_taskbars(rect, monitor, monitor, &[edge]), Some(expected));
assert_eq!(short_of_autohide_taskbars(expected, monitor, monitor, &[edge]), None, "already trimmed");
}
assert_eq!(short_of_autohide_taskbars(rect, monitor, monitor, &[]), None, "no taskbar");
// A taskbar that reserves its work area is the clamp's job.
assert_eq!(short_of_autohide_taskbars(rect, monitor, (0, 0, 1920, 1032), &[Edge::Bottom]), None);
assert_eq!(short_of_autohide_taskbars((0, 0, 960, 1080), monitor, monitor, &[Edge::Bottom]), None, "half-screen");
}
#[test]
fn a_taskbar_sits_on_the_monitor_edge_nearest_its_centre() {
let monitor = (0, 0, 1920, 1080);
assert_eq!(taskbar_edge((0, 1032, 1920, 1080), monitor), Some(Edge::Bottom), "shown");
assert_eq!(taskbar_edge((0, 1078, 1920, 1126), monitor), Some(Edge::Bottom), "auto-hidden");
assert_eq!(taskbar_edge((0, -46, 1920, 2), monitor), Some(Edge::Top));
assert_eq!(taskbar_edge((-60, 0, 2, 1080), monitor), Some(Edge::Left));
assert_eq!(taskbar_edge((1918, 0, 1980, 1080), monitor), Some(Edge::Right));
assert_eq!(taskbar_edge((1920, 1032, 3840, 1080), monitor), None, "other monitor");
assert!(taskbar_edge((i32::MIN, i32::MIN, i32::MAX, i32::MAX), monitor).is_some(), "no overflow");
}
}
}
@@ -0,0 +1,95 @@
//! `photocraft --help` and unknown options must not open the window (#6431): before the fix,
//! any argument the loop didn't know was taken as a file, so `--help` or a typo such as
//! `--safegpu` launched the GUI and logged "Couldn't open --help".
//!
//! These run the real binary, and every case must exit before the window opens: Windows and
//! macOS runners have a desktop, so a launch that gets that far hangs until the test times out.
//! Cases whose arguments are valid add a bad `--control` value, which fails the launch with the
//! other usage errors, so stderr shows whether those arguments were taken as unknown options.
/// Run `photocraft` with `args` in an isolated config dir; return the exit
/// status, stdout and stderr.
fn run_photocraft(args: &[&str]) -> (std::process::ExitStatus, String, String) {
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("photocraft-usage-{}-{n}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let output = std::process::Command::new(env!("CARGO_BIN_EXE_photocraft"))
.args(args)
.env("PHOTOCRAFT_CONFIG_DIR", &dir)
.env_remove("PHOTOCRAFT_CONTROL_PORT")
.env_remove("DISPLAY")
.env_remove("WAYLAND_DISPLAY")
.env_remove("WAYLAND_SOCKET")
.output()
.expect("failed to run photocraft binary");
std::fs::remove_dir_all(&dir).ok();
(output.status, String::from_utf8_lossy(&output.stdout).into_owned(), String::from_utf8_lossy(&output.stderr).into_owned())
}
#[test]
fn help_prints_usage_and_exits_0() {
for flag in ["--help", "-h"] {
let (status, stdout, stderr) = run_photocraft(&[flag]);
assert_eq!(status.code(), Some(0), "{flag} must exit 0, stderr: {stderr}");
assert!(stdout.starts_with("Usage: photocraft"), "{flag} stdout: {stdout}");
for option in ["--safe-gpu", "--new-instance", "--control <port>", "--version", "--"] {
assert!(stdout.contains(option), "{flag} usage must list {option}: {stdout}");
}
assert!(!stderr.contains("Couldn't open"), "{flag} was taken as a file: {stderr}");
}
}
#[test]
fn help_wins_over_later_arguments() {
let (status, stdout, _) = run_photocraft(&["--help", "picture.psd", "--safegpu"]);
assert_eq!(status.code(), Some(0));
assert!(stdout.starts_with("Usage: photocraft"), "stdout: {stdout}");
}
#[test]
fn unknown_options_exit_2_naming_them() {
for flag in ["--safegpu", "-x", "--help=yes"] {
let (status, stdout, stderr) = run_photocraft(&[flag]);
assert_eq!(status.code(), Some(2), "{flag} must be a usage error, stderr: {stderr}");
assert!(stderr.contains(&format!("unknown option `{flag}`")), "stderr: {stderr}");
assert!(stderr.contains("photocraft --help"), "stderr should point at --help: {stderr}");
assert!(stdout.is_empty(), "stdout: {stdout}");
}
}
#[test]
fn unknown_option_errors_are_reported_with_control_port_errors() {
let (status, _, stderr) = run_photocraft(&["--control", "nope", "--safegpu"]);
assert_eq!(status.code(), Some(2), "stderr: {stderr}");
assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}");
assert!(stderr.contains("unknown option `--safegpu`"), "stderr: {stderr}");
}
#[test]
fn arguments_after_double_dash_are_files() {
// `--` ends the options: `--help` and `-x` after it are file names, so neither prints usage
// nor is an unknown option; only the bad port is reported.
let (status, stdout, stderr) = run_photocraft(&["--control", "nope", "--", "--help", "-x"]);
assert_eq!(status.code(), Some(2), "stderr: {stderr}");
assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}");
assert!(!stdout.contains("Usage:"), "stdout: {stdout}");
assert!(!stderr.contains("unknown option"), "stderr: {stderr}");
}
#[test]
fn macos_process_serial_number_is_still_ignored() {
// Old macOS passes `-psn_<n>` when launching from Finder; it is neither a file nor an error.
let (status, _, stderr) = run_photocraft(&["--control", "nope", "-psn_0_12345"]);
assert_eq!(status.code(), Some(2), "stderr: {stderr}");
assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}");
assert!(!stderr.contains("unknown option"), "stderr: {stderr}");
}
#[test]
fn new_instance_is_a_known_option() {
let (status, _, stderr) = run_photocraft(&["--control", "nope", "--new-instance"]);
assert_eq!(status.code(), Some(2), "stderr: {stderr}");
assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}");
assert!(!stderr.contains("unknown option"), "stderr: {stderr}");
}
+12
View File
@@ -22,6 +22,18 @@ fn main() {
use photocraft_ui_egui::native_menu;
use serde_json::json;
// Without a harness nothing answers cargo-nextest's `--list`, so it ran the whole test as its
// listing and failed on the output. List the one test, as libtest's terse format would, and
// nothing for `--ignored` (nextest asks twice). Running it passes `<name> --exact`, which falls
// through to the test below.
let args: Vec<String> = std::env::args().collect();
if args.iter().any(|a| a == "--list") {
if !args.iter().any(|a| a == "--ignored") {
println!("mac_menu_appkit: test");
}
return;
}
let mtm = MainThreadMarker::new().expect("harness = false runs on the main thread");
let nsapp = NSApplication::sharedApplication(mtm);
nsapp.finishLaunching();
+13
View File
@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<ellipse cx="12" cy="12" rx="10" ry="7" stroke-dasharray="3.5 3.23" />
</svg>

After

Width:  |  Height:  |  Size: 281 B

+4 -4
View File
@@ -9,8 +9,8 @@
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m11 10 3 3" />
<path d="M6.5 21A3.5 3.5 0 1 0 3 17.5a2.62 2.62 0 0 1-.708 1.792A1 1 0 0 0 3 21z" />
<path d="M9.969 17.031 21.378 5.624a1 1 0 0 0-3.002-3.002L6.967 14.031" />
<path d="M6.5 12a5.5 5.5 0 1 0 5.5 5.5" stroke-dasharray="2 3" />
<path d="M14 3H7a4 4 0 0 0-4 4v10a4 4 0 0 0 4 4h7" stroke-dasharray="2 3" />
<path d="m14 10 2.1 2.1" />
<path d="M10.85 17.7A2.45 2.45 0 1 0 8.4 15.25a1.834 1.834 0 0 1-.496 1.254A.7 .7 0 0 0 8.4 17.7z" />
<path d="M13.278 14.922 21.265 6.937a.7 .7 0 0 0-2.101-2.101L11.177 12.822" />
</svg>

Before

Width:  |  Height:  |  Size: 466 B

After

Width:  |  Height:  |  Size: 502 B

+3
View File
@@ -5,6 +5,9 @@
- **Headless:** an in-process `Headless` session owns the engine and performs file I/O.
- **Bridge:** `BridgeClient` forwards tools to a desktop application started with `photocraft --control <port>`.
For a source checkout, first [install the CLI on PATH and register the server](../../../docs/mcp.md#install-and-register).
The installed binary survives `cargo clean`; a symlink into `target/release` does not.
Start the headless server with:
```sh
+1
View File
@@ -19,6 +19,7 @@ Every route that writes a document to a flat format (Save As, the CLI, MCP `doc_
- Formats without transparency (JPEG, Radiance HDR) get the image composited over white, as Export As and Quick Export do; in CMYK, white is no ink.
- Formats that can't embed an ICC profile (GIF, BMP, TGA, QOI, ICO, Netpbm/PFM) get RGB documents in another profile (linear light, Display P3, …) converted to sRGB through the colour engine (perceptual intent), since an untagged file is read as sRGB. OpenEXR and Radiance HDR get linear sRGB instead (linear gray for a grayscale OpenEXR), which is how they are tagged when opened.
- TGA, as Photoshop's Targa: an RGB Targa is 24 or 32 bits per pixel. Save As and Save a Copy ask (Targa Options, starting at 32 with an alpha channel, else 24); Export As has a Resolution choice (Auto, 24 or 32 bits/pixel); scripts pass `tgaBits` (`--tga-bits` on the CLI). At 32 bits the document's alpha channel is the file's alpha when it has exactly one (spot channels don't count; with several, none is used), and the colours are written exactly, never premultiplied or cleared where the alpha is 0, so a game texture's mask and its colours both survive. Without a usable alpha channel the layers' transparency is the alpha (opaque without any). 24 bits write no alpha, compositing transparency over white. Without a choice: 32 with an alpha channel or transparency, else 24. A 32-bit Targa opens as an opaque Background with its alpha as "Alpha 1", so it round-trips. Grayscale Targas are 8-bit.
- DDS, for game and 3D textures: Save As and Save a Copy ask (DDS Options) for the compression (None, uncompressed and lossless; BC1/DXT1, whose alpha is on or off; BC2/DXT3; BC3/DXT5) and whether to generate mipmaps (the full chain down to 1×1, each level a box filter of the one above). Scripts pass `ddsCompression` (`none`, `bc1`, `bc2`, `bc3`) and `ddsMipmaps` (`--dds-compression`, `--dds-mipmaps` on the CLI). Opening one reads its top mip level.
Flat TIFF and JPEG exports of composited CMYK documents convert the RGB composite back through
the document's CMYK ICC profile (relative colorimetric with black point compensation) and embed
+29
View File
@@ -159,6 +159,35 @@ sync_github_name = false
real_name = "Ufuk Efe"
sync_github_name = true
[people.nicolasfalesy]
sync_github_name = true
[people.sidney]
real_name = "Sidney Markowitz"
sync_github_name = true
[people.TaylorBurke]
sync_github_name = true
[people.1Git2Clone]
sync_github_name = true
[people.Silvertree2010]
display_name = "Netter"
sync_github_name = false
[people.Byxis]
sync_github_name = true
[people.00xJS]
sync_github_name = true
[people.guillehr2]
real_name = "Guillem Hermida"
sync_github_name = true
[people.fjamesprice]
sync_github_name = true
[people.ha1des]
sync_github_name = true
+8 -1
View File
@@ -139,11 +139,18 @@ zstd window, 4096 saved revisions, array lengths no longer than the
bytes left, 16 777 216 decoded values per document stream (fields and array elements together, counted before
anything is allocated: a value takes about 40 bytes in memory however few it took in the file; the largest of
those 189 public documents uses 3.3 million), 384 levels of object nesting, 128 levels of layers, 500 000 layers, four million curve
nodes per curve, 64 megapixels per pixel layer (cropped to its content first) and 1024 gradient
nodes per curve, 4 levels of spot colours defined through spot colours, 64 megapixels per pixel layer (cropped to its content first) and 1024 gradient
stops; `photocraft-io` adds 300 000 pixels a side per document and 64 megapixels per image on the
canvas. Every archive entry's CRC-32 and size must match. Malformed input returns an `Error`; the
crate has no panics outside tests.
Compound outlines have a separate active-reference cycle check and a depth cap of 64. Across
the document, geometry expansion admits at most 500 000 combined outline calls and operand-slot
visits (including nulls and non-object values), and four million points created or copied into
compounds. Operand slots are charged before filtering, without first collecting an operand list.
A compound that crosses a limit or contains a cycle is omitted
with a warning as a whole; ordinary shared operands remain valid and keep each occurrence's transform.
## Privacy
Affinity documents can hold the folder they were saved in, user names, original image paths and
+93 -17
View File
@@ -6,10 +6,103 @@ use crate::stream::{ObjId, Tag, Value};
/// Nodes per curve object.
const MAX_NODES: usize = 4_000_000;
const MAX_COMPOUND_DEPTH: usize = 64;
const MAX_OUTLINE_VISITS: usize = 500_000;
const MAX_OUTLINE_POINTS: usize = 4_000_000;
/// Geometry is expanded per occurrence, since parametric outlines can depend on their world
/// transform. Bound work across the entire document instead of memoizing by object id alone.
pub(crate) struct Budget {
visits_left: usize,
points_left: usize,
}
impl Default for Budget {
fn default() -> Self {
Self { visits_left: MAX_OUTLINE_VISITS, points_left: MAX_OUTLINE_POINTS }
}
}
impl Budget {
/// Charge an outline call or a raw compound operand slot before inspecting it.
fn visit(&mut self) -> Result<(), &'static str> {
self.visits_left = self.visits_left.checked_sub(1).ok_or("compound geometry budget exhausted")?;
Ok(())
}
/// Charge both leaf geometry and each copy into a compound before allocating that copy.
fn path(&mut self, path: &Path) -> Result<(), &'static str> {
let points = path.subpaths.iter().fold(0usize, |n, s| n.saturating_add(1).saturating_add(s.segments.len().saturating_mul(3)));
let Some(left) = self.points_left.checked_sub(points) else {
// Do not rebuild an expensive leaf for every subsequent root after exhaustion.
self.visits_left = 0;
return Err("compound geometry budget exhausted");
};
self.points_left = left;
Ok(())
}
}
/// The node-space outline of a curve, shape or compound node, and whether it fills even-odd.
/// Instances (master-page or symbol copies) take the geometry of their master.
pub(crate) fn outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> Option<(Path, bool)> {
match outline_inner(r, id, class, world, &mut Vec::new()) {
Ok(path) => path,
Err(reason) => {
r.warn(reason);
None
}
}
}
fn outline_inner(r: &mut Reader, id: ObjId, class: Tag, world: Affine, active: &mut Vec<ObjId>) -> Result<Option<(Path, bool)>, &'static str> {
r.geometry_budget.visit()?;
if active.contains(&id) {
return Err("cyclic compound shape");
}
// Independent of the layer-tree limit: Comp operands do not pass through Reader::node.
if active.len() >= MAX_COMPOUND_DEPTH {
return Err("compound depth exceeds 64 levels");
}
if class != Tag::of(b"Comp") {
let path = leaf_outline(r, id, class, world);
if let Some((path, _)) = &path {
r.geometry_budget.path(path)?;
}
return Ok(path);
}
active.push(id);
let result = compound(r, id, world, active);
active.pop();
result
}
fn compound(r: &mut Reader, id: ObjId, world: Affine, active: &mut Vec<ObjId>) -> Result<Option<(Path, bool)>, &'static str> {
let s = r.s;
// One path of all operands' contours, filled even-odd, as before. A safety failure rejects
// the whole outline: a partial add/subtract shape could silently change its meaning.
let mut path = Path::default();
// Charge every slot before filtering nulls or malformed values. Stream::objs would scan
// and allocate the whole list first, repeating uncharged work for each shared occurrence.
let children = if let Some(Value::Array(children)) = s.field(id, b"Chld") { children.as_slice() } else { &[] };
for child in children {
r.geometry_budget.visit()?;
let Value::Obj(Some(c)) = child else { continue };
let c = *c;
let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
let Some(class) = s.class(c) else { return Ok(None) };
if s.enumeration(c, b"ComO").is_some_and(|(op, _)| op != 0 && op != 2) {
r.warn("compound shapes that intersect or exclude (imported as add)");
}
if let Some((p, _)) = outline_inner(r, c, class, local.then(world), active)? {
r.geometry_budget.path(&p)?;
path.subpaths.extend(p.transformed(local).subpaths);
}
}
Ok(Some((path, true)))
}
fn leaf_outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> Option<(Path, bool)> {
let s = r.s;
match &class.0.to_be_bytes() {
b"PCrv" | b"TxtC" => {
@@ -18,23 +111,6 @@ pub(crate) fn outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> O
let multi = path.subpaths.len() > 1;
Some((path, multi))
}
b"Comp" => {
// One path of all the operands' contours in the compound's space, filled even-odd: this
// matches add and subtract operands in every public sample; a true union of overlapping
// added operands would differ.
let mut path = Path::default();
for c in s.objs(id, b"Chld") {
let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
let class = s.class(c)?;
if s.enumeration(c, b"ComO").is_some_and(|(op, _)| op != 0 && op != 2) {
r.warn("compound shapes that intersect or exclude (imported as add)");
}
if let Some((p, _)) = outline(r, c, class, local.then(world)) {
path.subpaths.extend(p.transformed(local).subpaths);
}
}
Some((path, true))
}
_ => {
let (shape, box_owner) = match s.obj(id, b"Shpe") {
Some(sh) => (sh, id),
+11 -1
View File
@@ -298,6 +298,7 @@ pub(crate) struct Reader<'s, 'a, 'b> {
warnings: BTreeMap<String, usize>,
nodes: usize,
active: HashSet<ObjId>,
pub(crate) geometry_budget: crate::geometry::Budget,
/// Decoded pixels still available ([`Limits::max_pixel_bytes`] less what the layers read so
/// far hold).
pixels_left: usize,
@@ -305,7 +306,16 @@ pub(crate) struct Reader<'s, 'a, 'b> {
impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
fn new(s: &'s Stream, archive: &'b mut Archive<'a>, limits: Limits) -> Self {
Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new(), pixels_left: limits.max_pixel_bytes }
Self {
s,
archive,
dpi: 72.0,
warnings: BTreeMap::new(),
nodes: 0,
active: HashSet::new(),
geometry_budget: crate::geometry::Budget::default(),
pixels_left: limits.max_pixel_bytes,
}
}
/// Admit a layer's decoded pixels before they are allocated (#4960). `rgba` is the layer's
+16 -1
View File
@@ -134,8 +134,19 @@ fn floats(v: &Value) -> Option<Vec<f64>> {
}
}
/// Spot colours defined through spot colours. A spot colour (`Pant`) is read through its RGB
/// equivalent, else its base colour, which in real files hold plain colours. Colours are shared
/// objects, so one can link to itself, and with both links followed at every level a colour read
/// costs up to 2^(depth+1) lookups.
const MAX_SPOT_DEPTH: usize = 4;
/// A colour object (`RGBA`, `HSLA`, `CMYK`, `GRAY`, `LABA`, `Pant`).
pub(crate) fn color(r: &mut Reader, id: ObjId) -> Option<Color> {
color_at(r, id, 0)
}
/// [`color`] of an object `depth` spot colours down.
fn color_at(r: &mut Reader, id: ObjId, depth: usize) -> Option<Color> {
let s = r.s;
let class = s.class(id)?;
let col = || -> Option<Vec<f64>> {
@@ -166,8 +177,12 @@ pub(crate) fn color(r: &mut Reader, id: ObjId) -> Option<Color> {
Color::Lab { l: 100.0 * u(0)?, a: 255.0 * u(1)? - 128.0, b: 255.0 * u(2)? - 128.0, alpha: u(3)? }
}
b"Pant" => {
if depth >= MAX_SPOT_DEPTH {
r.warn("spot colours nested deeper than 4 levels (left empty)");
return None;
}
r.warn("spot colours (imported as their RGB equivalent)");
return s.obj(id, b"srgb").and_then(|c| color(r, c)).or_else(|| s.obj(id, b"base").and_then(|c| color(r, c)));
return s.obj(id, b"srgb").and_then(|c| color_at(r, c, depth + 1)).or_else(|| s.obj(id, b"base").and_then(|c| color_at(r, c, depth + 1)));
}
b"RegC" => Color::Cmyk { c: 1.0, m: 1.0, y: 1.0, k: 1.0, a: 1.0 },
_ => {
+2 -5
View File
@@ -94,11 +94,8 @@ pub fn preview(bytes: &[u8]) -> Result<Preview<'_>, Error> {
}
image_data = true;
}
b"IEND" => {
if !image_data {
return Err(Error::Malformed("PNG has no image data"));
}
}
b"IEND" if !image_data => return Err(Error::Malformed("PNG has no image data")),
b"IEND" => {}
_ if kind.first().is_some_and(u8::is_ascii_uppercase) => {
return Err(Error::Unsupported("unknown critical PNG chunk"));
}
+171
View File
@@ -538,3 +538,174 @@ mod pixels {
);
}
}
/// Spot colours that link to themselves or to each other. Each document is read on a 1 MiB
/// stack, the smallest main-thread stack PhotoCraft runs on (Windows), first in a child process
/// that must finish within 20 s. A stack overflow aborts the process it happens in, so unbounded
/// recursion fails the one test that caused it instead of taking the other tests in the binary
/// down with it.
mod spot_colours {
use std::io::{Read, Write};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use crate::container::Limits;
use crate::model::{Document, Kind};
use crate::paint::{Color, Paint};
use crate::stream::Tag;
use crate::synth::{self, F, Method, tag};
/// A one-page document whose page holds `top`, read in a child process first and then here.
fn read(top: Vec<F>) -> Document {
let spread = F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(top))]);
let data = synth::stream(&[(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]);
let bytes = synth::container(&[("doc.dat", &data, Method::Zlib)], None);
let test = std::thread::current().name().unwrap_or("reader").to_owned();
read_in_child(&bytes, &test);
read_on_small_stack(bytes, &test).unwrap()
}
/// Reads on a 1 MiB thread named after the test, so a stack overflow message names the test.
fn read_on_small_stack(bytes: Vec<u8>, test: &str) -> Result<Document, crate::Error> {
let reader = std::thread::Builder::new().name(test.to_owned()).stack_size(1 << 20).spawn(move || crate::read(&bytes, Limits::default())).unwrap();
reader.join().unwrap_or_else(|panic| std::panic::resume_unwind(panic))
}
/// Set in the child process to the name of the test it reads for.
const CHILD: &str = "PHOTOCRAFT_AFFINITY_READ_CHILD";
/// Runs `read_child` in a new process of this test binary on `bytes` and fails the test if
/// that process crashes, panics or takes over 20 s, or never read the document.
fn read_in_child(bytes: &[u8], test: &str) {
let mut child = Command::new(std::env::current_exe().unwrap())
.args(["tests::spot_colours::read_child", "--exact", "--ignored", "--nocapture", "--test-threads=1"])
.env(CHILD, test)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let drain = |mut pipe: Box<dyn Read + Send>| {
std::thread::spawn(move || {
let mut text = String::new();
let _ = pipe.read_to_string(&mut text);
text
})
};
let stdout = drain(Box::new(child.stdout.take().unwrap()));
let stderr = drain(Box::new(child.stderr.take().unwrap()));
// Fed from a thread, so a child that never reads cannot block the deadline below; a child
// that dies first breaks the pipe and is reported by its exit status.
let (mut stdin, input) = (child.stdin.take().unwrap(), bytes.to_vec());
let feed = std::thread::spawn(move || {
let _ = stdin.write_all(&input);
});
let deadline = Instant::now() + Duration::from_secs(20);
let status = loop {
if let Some(status) = child.try_wait().unwrap() {
break Some(status);
}
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
break None;
}
std::thread::sleep(Duration::from_millis(10));
};
feed.join().unwrap();
let output = format!("{}{}", stdout.join().unwrap(), stderr.join().unwrap());
match status {
None => panic!("reading the document did not finish within 20 s:\n{output}"),
Some(status) if !status.success() => panic!("reading the document failed in a child process ({status}):\n{output}"),
// A filter that matched no test would also exit successfully, having read nothing.
Some(_) => assert!(output.contains(&format!("read {} bytes", bytes.len())), "the child process did not read the document:\n{output}"),
}
}
/// Not a test of its own: `read_in_child` runs it in a child process to read the document on
/// its stdin. Without the variable, as when run with `--ignored`, it does nothing.
#[test]
#[ignore = "run by read_in_child in a child process"]
fn read_child() {
let Some(test) = std::env::var_os(CHILD) else { return };
let mut bytes = Vec::new();
std::io::stdin().read_to_end(&mut bytes).unwrap();
let len = bytes.len();
let _ = read_on_small_stack(bytes, &test.to_string_lossy());
println!("read {len} bytes");
}
/// An ellipse filled with `colour`.
fn filled(id: u32, colour: F) -> F {
F::Def(
id,
vec![tag(b"ShpN")],
vec![
(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 1.0, 1.0])),
(tag(b"Shpe"), F::Def(id + 1_000_000, vec![tag(b"ShpE")], vec![])),
(tag(b"BFFl"), F::Shared(vec![F::Def(id + 2_000_000, vec![tag(b"FilS")], vec![(tag(b"Colr"), colour)])])),
],
)
}
fn spot(id: u32, fields: Vec<(Tag, F)>) -> F {
F::Def(id, vec![tag(b"Pant")], fields)
}
fn fills(doc: &Document) -> &[Paint] {
match &doc.spreads[0].nodes[0].kind {
Kind::Shape { fills, .. } => fills,
other => panic!("expected a shape, got {other:?}"),
}
}
fn warned(doc: &Document, what: &str) -> bool {
doc.warnings.iter().any(|w| w.contains(what))
}
#[test]
fn a_spot_colour_that_is_its_own_rgb_equivalent_is_refused() {
let doc = read(vec![filled(10, spot(20, vec![(tag(b"srgb"), F::Ref(20))]))]);
assert!(fills(&doc).is_empty(), "{:?}", fills(&doc));
assert!(warned(&doc, "spot colours nested deeper than 4 levels"), "{:?}", doc.warnings);
}
#[test]
fn spot_colours_that_are_each_others_base_and_rgb_equivalent_are_refused() {
let doc = read(vec![filled(10, spot(20, vec![(tag(b"base"), spot(21, vec![(tag(b"srgb"), F::Ref(20))]))]))]);
assert!(fills(&doc).is_empty(), "{:?}", fills(&doc));
assert!(warned(&doc, "spot colours nested deeper than 4 levels"), "{:?}", doc.warnings);
}
#[test]
fn spot_colours_of_spot_colours_do_not_take_exponential_time() {
// Each level's RGB equivalent and base are the next level, and the last is in an unknown
// model, so both are followed everywhere: 2^41 colour reads before the fix.
fn level(id: u32, left: u32) -> F {
if left == 0 {
return F::Def(id, vec![tag(b"XXXX")], vec![]);
}
spot(id, vec![(tag(b"srgb"), level(id + 1, left - 1)), (tag(b"base"), F::Ref(id + 1))])
}
let doc = read(vec![filled(10, level(100, 40))]);
assert!(fills(&doc).is_empty(), "{:?}", fills(&doc));
}
/// `spots` spot colours, each defined through the next, around an opaque RGB colour.
fn spots(id: u32, spots: u32) -> F {
if spots == 0 {
let rgba: Vec<u8> = [1.0f32, 0.5, 0.25, 1.0].iter().flat_map(|c| c.to_le_bytes()).collect();
return F::Def(id, vec![tag(b"RGBA")], vec![(tag(b"_col"), F::Struct(rgba))]);
}
spot(id, vec![(tag(b"srgb"), self::spots(id + 1, spots - 1))])
}
#[test]
fn spot_colours_nest_four_levels_and_no_deeper() {
let doc = read(vec![filled(10, spots(100, 4))]);
assert_eq!(fills(&doc), [Paint::Solid(Color::Rgb { r: 1.0, g: 0.5, b: 0.25, a: 1.0 })]);
let doc = read(vec![filled(10, spots(100, 5))]);
assert!(fills(&doc).is_empty(), "{:?}", fills(&doc));
assert!(warned(&doc, "spot colours nested deeper than 4 levels"), "{:?}", doc.warnings);
}
}
+212
View File
@@ -0,0 +1,212 @@
//! #6423: compound operands form a graph, not necessarily a tree.
#![cfg(feature = "synth")]
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use photocraft_affinity::model::{Document, Kind};
use photocraft_affinity::synth::{self, F, Method, tag};
use photocraft_affinity::{Limits, read};
use std::time::{Duration, Instant};
fn compound(id: u32, children: Vec<F>) -> F {
F::Def(id, vec![tag(b"Comp")], vec![(tag(b"Chld"), F::Shared(children))])
}
fn rectangle() -> F {
F::Def(100, vec![tag(b"ShpN")], vec![(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 10.0, 20.0])), (tag(b"Shpe"), F::Def(101, vec![tag(b"ShNR")], vec![]))])
}
fn document_bytes(definitions: Vec<F>, roots: Vec<F>) -> Vec<u8> {
let data = synth::stream(&[
(tag(b"Defs"), F::Shared(definitions)),
(
tag(b"DocR"),
F::Def(
1,
vec![tag(b"DocN")],
vec![(
tag(b"Chld"),
F::Shared(vec![F::Def(
2,
vec![tag(b"Sprd")],
vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(roots))],
)]),
)],
),
),
]);
synth::container(&[("doc.dat", &data, Method::Zlib)], None)
}
fn document(definitions: Vec<F>, roots: Vec<F>) -> Document {
read(&document_bytes(definitions, roots), Limits::default()).unwrap()
}
// A stack overflow aborts rather than unwinds. Keep that failure (and an exponential hang)
// inside a child process, so the regression fails without killing the entire test runner.
fn isolated(name: &str, check: impl FnOnce()) {
if std::env::var("PHOTOCRAFT_COMPOUND_CASE").as_deref() == Ok(name) {
check();
return;
}
let mut child = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", name, "--nocapture"])
.env("PHOTOCRAFT_COMPOUND_CASE", name)
.spawn()
.unwrap();
let start = Instant::now();
loop {
if let Some(status) = child.try_wait().unwrap() {
assert!(status.success(), "compound import failed: {status}");
break;
}
if start.elapsed() > Duration::from_secs(10) {
child.kill().unwrap();
child.wait().unwrap();
panic!("compound import exceeded ten seconds");
}
std::thread::sleep(Duration::from_millis(20));
}
}
fn unsupported(doc: &Document, warning: &str) {
assert!(matches!(doc.spreads[0].nodes[0].kind, Kind::Unsupported));
assert!(doc.warnings.iter().any(|w| w.contains(warning)), "{:?}", doc.warnings);
}
#[test]
fn self_referencing_compound_is_reported_without_aborting() {
isolated("self_referencing_compound_is_reported_without_aborting", || {
let doc = document(vec![], vec![compound(10, vec![F::Ref(10)]), rectangle()]);
unsupported(&doc, "cyclic compound");
assert!(matches!(doc.spreads[0].nodes[1].kind, Kind::Shape { .. }), "a cycle must not poison unrelated shapes");
});
}
#[test]
fn indirectly_cyclic_compound_is_not_imported_as_a_partial_shape() {
isolated("indirectly_cyclic_compound_is_not_imported_as_a_partial_shape", || {
let doc = document(vec![rectangle()], vec![compound(10, vec![F::Ref(100), compound(11, vec![F::Ref(10)])])]);
unsupported(&doc, "cyclic compound");
});
}
fn nested(levels: u32, repeated: bool) -> Document {
// Flat definitions bypass the stream parser's nesting cap: only references form the depth.
let mut definitions = vec![rectangle()];
let mut previous = 100;
for id in 200..200 + levels {
let children = if repeated { vec![F::Ref(previous), F::Ref(previous)] } else { vec![F::Ref(previous)] };
definitions.push(compound(id, children));
previous = id;
}
document(definitions, vec![F::Ref(previous)])
}
#[test]
fn deep_compound_graph_is_bounded_on_a_small_stack() {
isolated("deep_compound_graph_is_bounded_on_a_small_stack", || {
std::thread::Builder::new().stack_size(512 * 1024).spawn(|| unsupported(&nested(1000, false), "compound depth")).unwrap().join().unwrap();
});
}
#[test]
fn shared_operands_cannot_expand_exponentially() {
isolated("shared_operands_cannot_expand_exponentially", || unsupported(&nested(40, true), "compound geometry budget"));
}
#[test]
fn shared_empty_operands_also_have_a_work_limit() {
isolated("shared_empty_operands_also_have_a_work_limit", || {
let mut definitions = vec![compound(100, vec![])];
let mut previous = 100;
for id in 200..240 {
definitions.push(compound(id, vec![F::Ref(previous), F::Ref(previous)]));
previous = id;
}
unsupported(&document(definitions, vec![F::Ref(previous)]), "compound geometry budget");
});
}
#[test]
fn geometry_budget_is_shared_across_layer_roots() {
isolated("geometry_budget_is_shared_across_layer_roots", || {
let mut definitions = vec![rectangle()];
let mut previous = 100;
for id in 200..214 {
definitions.push(compound(id, vec![F::Ref(previous), F::Ref(previous)]));
previous = id;
}
let mut roots = vec![F::Ref(previous); 12];
roots.push(F::Ref(100));
let doc = document(definitions, roots);
let nodes = &doc.spreads[0].nodes;
assert!(matches!(nodes[0].kind, Kind::Shape { .. }), "a modest shared graph still imports");
assert!(matches!(nodes.last().unwrap().kind, Kind::Unsupported), "later roots cannot reset the exhausted budget");
assert!(doc.warnings.iter().any(|w| w.contains("compound geometry budget")));
});
}
#[test]
fn ordinary_shared_operands_keep_both_transformed_copies() {
let mut translated = compound(201, vec![F::Ref(100)]);
let F::Def(_, _, fields) = &mut translated else { unreachable!() };
fields.push((tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, 30.0, 0.0, 1.0, 40.0])));
let doc = document(vec![rectangle(), compound(200, vec![F::Ref(100)]), translated], vec![compound(202, vec![F::Ref(200), F::Ref(201)])]);
assert!(doc.warnings.is_empty(), "{:?}", doc.warnings);
let Kind::Shape { path, .. } = &doc.spreads[0].nodes[0].kind else { panic!("expected compound outline") };
assert_eq!(path.subpaths.len(), 2);
for (a, b) in path.subpaths[0].segments.iter().flatten().zip(path.subpaths[1].segments.iter().flatten()) {
assert_eq!(b.x, a.x + 30.0);
assert_eq!(b.y, a.y + 40.0);
}
}
#[test]
fn null_operand_slots_cannot_bypass_geometry_budget() {
isolated("null_operand_slots_cannot_bypass_geometry_budget", || {
let bytes = document_bytes(vec![compound(10, vec![F::Null; 1_000_000])], vec![F::Ref(10); 20_000]);
let start = Instant::now();
let doc = read(&bytes, Limits::default()).unwrap();
eprintln!("one million nulls / 20,000 placements: {} bytes, read {:.3}s", bytes.len(), start.elapsed().as_secs_f64());
unsupported(&doc, "compound geometry budget");
assert_eq!(doc.spreads[0].nodes.len(), 20_000);
assert!(doc.spreads[0].nodes.iter().all(|n| matches!(n.kind, Kind::Unsupported)));
});
}
#[test]
fn null_operand_slots_share_budget_across_layer_roots() {
isolated("null_operand_slots_share_budget_across_layer_roots", || {
let doc = document(vec![compound(10, vec![F::Null; 1_000])], vec![F::Ref(10); 1_000]);
assert!(matches!(doc.spreads[0].nodes[0].kind, Kind::Shape { .. }), "a small empty compound remains valid");
assert!(matches!(doc.spreads[0].nodes.last().unwrap().kind, Kind::Unsupported));
assert!(doc.warnings.iter().any(|w| w.contains("compound geometry budget")));
});
}
#[test]
fn null_operand_slots_cannot_leave_a_partial_shape() {
isolated("null_operand_slots_cannot_leave_a_partial_shape", || {
let mut slots = vec![F::Ref(100)];
slots.extend(vec![F::Null; 1_000_000]);
let doc = document(vec![rectangle(), compound(10, slots)], vec![F::Ref(10)]);
unsupported(&doc, "compound geometry budget");
});
}
#[test]
fn non_object_operand_slots_also_consume_budget() {
isolated("non_object_operand_slots_also_consume_budget", || {
let comp = F::Def(10, vec![tag(b"Comp")], vec![(tag(b"Chld"), F::U8s(vec![0; 1_000_000]))]);
unsupported(&document(vec![comp], vec![F::Ref(10)]), "compound geometry budget");
});
}
#[test]
fn ordinary_null_operand_slots_preserve_shared_contours() {
let doc = document(vec![rectangle(), compound(10, vec![F::Null, F::Ref(100), F::Null, F::Ref(100), F::Null])], vec![F::Ref(10)]);
assert!(doc.warnings.is_empty(), "{:?}", doc.warnings);
let Kind::Shape { path, .. } = &doc.spreads[0].nodes[0].kind else { panic!("expected compound outline") };
assert_eq!(path.subpaths.len(), 2);
assert_eq!(path.subpaths[0], path.subpaths[1]);
}
@@ -0,0 +1,35 @@
By downloading, copying, installing or using the software you agree to this license.
If you do not agree to this license, do not download, install,
copy or use the software.
License Agreement For libfacedetection
(3-clause BSD License)
Copyright (c) 2015-2019, Shiqi Yu, all rights reserved.
[email protected]
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the names of the copyright holders nor the names of the contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
This software is provided by the copyright holders and contributors "as is" and
any express or implied warranties, including, but not limited to, the implied
warranties of merchantability and fitness for a particular purpose are disclaimed.
In no event shall copyright holders or contributors be liable for any direct,
indirect, incidental, special, exemplary, or consequential damages
(including, but not limited to, procurement of substitute goods or services;
loss of use, data, or profits; or business interruption) however caused
and on any theory of liability, whether in contract, strict liability,
or tort (including negligence or otherwise) arising in any way out of
the use of this software, even if advised of the possibility of such damage.
Binary file not shown.
+42
View File
@@ -0,0 +1,42 @@
//! Time and peak memory of the Photomerge multi-band blend: `n` RGB sources of `mp` megapixels
//! (3:2), each overlapping the next by half its width.
//! `cargo run --release -p photocraft-algo --example bench_multiband [n] [mp]` (default 4 × 24 MP).
//! Peak memory is the growth of the process's high-water mark during the blend (Linux only).
use std::time::Instant;
use photocraft_algo::panorama::{self, RoiImage};
/// The process's peak resident set in MiB (`VmHWM`), where `/proc` has it.
fn peak_mib() -> Option<f64> {
let s = std::fs::read_to_string("/proc/self/status").ok()?;
let kb: f64 = s.lines().find(|l| l.starts_with("VmHWM:"))?.split_whitespace().nth(1)?.parse().ok()?;
Some(kb / 1024.0)
}
fn main() {
let mut args = std::env::args().skip(1);
let n: usize = args.next().and_then(|a| a.parse().ok()).unwrap_or(4).max(1);
let mp: f64 = args.next().and_then(|a| a.parse().ok()).unwrap_or(24.0);
let sh = ((mp * 1e6 / 1.5).sqrt().round() as usize).max(2);
let sw = sh * 3 / 2;
let step = sw / 2;
let (cw, ch) = (step * (n - 1) + sw, sh);
let rois: Vec<RoiImage> = (0..n)
.map(|k| {
let px = (0..sw * sh * 3).map(|i| ((i / 3 % sw * 7 + i / 3 / sw * 3 + k * 31 + i % 3 * 50) % 251) as f32 / 251.0).collect();
RoiImage { x0: (step * k) as i32, y0: 0, w: sw, h: sh, ch: 3, px, alpha: vec![1.0; sw * sh] }
})
.collect();
// Each canvas column belongs to the image whose centre is nearest, as a seam would split it.
let wts: Vec<Vec<f32>> = (0..n)
.map(|k| (0..sw * sh).map(|i| if ((step * k + i % sw + step / 2) / step).saturating_sub(1).min(n - 1) == k { 1.0 } else { 0.0 }).collect())
.collect();
let levels = panorama::blend_levels(cw, ch).min(7);
let before = peak_mib();
let t0 = Instant::now();
let (px, cov) = panorama::multiband(cw, ch, &rois, &wts, levels);
let ms = t0.elapsed().as_secs_f64() * 1000.0;
let peak = peak_mib().zip(before).map_or("n/a".to_string(), |(a, b)| format!("{:.0} MiB", a - b));
println!("{n} × {sw}×{sh} ({mp} MP) RGB, canvas {cw}×{ch}, {levels} levels: {ms:.0} ms, peak +{peak} during the blend");
std::hint::black_box((px, cov));
}
+69 -4
View File
@@ -212,12 +212,17 @@ fn box_pass_px<const N: usize>(src: &[f32], dst: &mut [f32], r: usize) {
}
/// Box passes of the given radii in turn along the line in `s.line` (`w`-float elements, as many
/// as `dst` holds plus twice the radii's sum; consumed), ending in `dst`.
fn line_passes(dst: &mut [f32], w: usize, radii: &[usize], s: &mut Scratch) {
/// as `dst` holds plus twice the radii's sum; consumed), ending in `dst`. Stops early (leaving
/// `dst` unfinished) when `ctl` is cancelled before a pass: a column strip's passes take tens of
/// milliseconds on a tall image, too long to finish once cancelled (#6205).
fn line_passes(dst: &mut [f32], w: usize, radii: &[usize], s: &mut Scratch, ctl: &Interrupt) {
let Scratch { line, next, acc } = s;
let Some(len) = dst.len().checked_div(w) else { return };
let mut tail: usize = radii.iter().sum();
for (k, &r) in radii.iter().enumerate() {
if ctl.cancelled() {
return;
}
tail -= r;
if k + 1 == radii.len() {
box_pass(line, dst, w, r, acc);
@@ -281,7 +286,7 @@ pub(crate) fn box_blur(
(0..left).for_each(|_| s.line.extend_from_slice(first));
s.line.extend_from_slice(&row);
(0..right).for_each(|_| s.line.extend_from_slice(last));
line_passes(dst, n, &radii, &mut s);
line_passes(dst, n, &radii, &mut s, ctl);
}
});
// Vertical passes, strip by strip, the same way down the columns.
@@ -300,7 +305,10 @@ pub(crate) fn box_blur(
(0..rows).for_each(|y| s.line.extend_from_slice(row(y)));
(0..bottom).for_each(|_| s.line.extend_from_slice(row(rows - 1)));
let mut res = vec![0.0f32; oh * w];
line_passes(&mut res, w, &radii, &mut s);
line_passes(&mut res, w, &radii, &mut s, ctl);
if ctl.cancelled() {
return (t, Vec::new());
}
unpremultiply(&mut res, n, alpha);
(t, res)
});
@@ -1180,4 +1188,61 @@ mod tests {
}
}
}
/// A column strip's passes stop between passes once cancelled, not only before the strip
/// (#6205); uncancelled, every pass runs.
#[test]
fn line_passes_stop_between_passes_when_cancelled() {
use std::sync::atomic::{AtomicUsize, Ordering};
let (radii, w, len) = ([2usize, 3, 2], 4usize, 50usize);
let reach: usize = radii.iter().sum();
let line: Vec<f32> = (0..(len + 2 * reach) * w).map(|i| (i % 7) as f32 + 1.0).collect();
let run = |ctl: &Interrupt| {
let mut s = Scratch { line: line.clone(), ..Scratch::default() };
let mut dst = vec![0.0f32; len * w];
line_passes(&mut dst, w, &radii, &mut s, ctl);
dst
};
assert!(run(&Interrupt::NONE).iter().all(|v| *v > 0.0));
let calls = AtomicUsize::new(0);
let cancel = || calls.fetch_add(1, Ordering::SeqCst) >= 1;
// Cancelled after the first pass: the last pass (the one writing `dst`) never runs.
assert!(run(&Interrupt::cancel_only(&cancel)).iter().all(|v| *v == 0.0));
assert_eq!(calls.load(Ordering::SeqCst), 2);
}
/// A box blur cancelled at any of its checks returns `None` (never a half-blurred result);
/// one that is asked but never cancelled is bit-identical to the uninterruptible run.
#[test]
fn box_blur_honours_a_cancel_at_every_check() {
use std::sync::atomic::{AtomicUsize, Ordering};
let (n, src) = (4usize, Rect::new(0, 0, 150, 90));
let px: Vec<f32> = (0..src.width() as usize * src.height() as usize * n).map(|i| (i as f32 * 0.13).sin() * 0.5 + 0.5).collect();
let read = |r: Rect, buf: &mut Vec<f32>| {
buf.clear();
let (x0, x1) = ((r.x0 * n as i32) as usize, (r.x1 * n as i32) as usize);
let row = r.y0 as usize * src.width() as usize * n;
buf.extend_from_slice(&px[row + x0..row + x1]);
};
let Some(boxes) = box_widths(&FilterParams::GaussianBlur { radius: 12.0 }) else { panic!("box passes") };
let out = Rect::new(-5, 3, 155, 80);
let want = box_blur(src, &read, out, n, true, &boxes, &Interrupt::NONE).unwrap();
let checks = AtomicUsize::new(0);
let count = || {
checks.fetch_add(1, Ordering::SeqCst);
false
};
let got = box_blur(src, &read, out, n, true, &boxes, &Interrupt::cancel_only(&count)).unwrap();
assert_eq!(got.len(), want.len());
for ((ta, a), (tb, b)) in got.iter().zip(&want) {
assert_eq!(ta, tb);
assert!(a.iter().zip(b).all(|(x, y)| x.to_bits() == y.to_bits()), "strip {ta:?} differs");
}
let total = checks.load(Ordering::SeqCst);
for k in (0..total).step_by((total / 40).max(1)) {
let seen = AtomicUsize::new(0);
let cancel = || seen.fetch_add(1, Ordering::SeqCst) >= k;
assert!(box_blur(src, &read, out, n, true, &boxes, &Interrupt::cancel_only(&cancel)).is_none(), "cancelled at check {k}");
}
}
}
+1 -1
View File
@@ -335,7 +335,7 @@ pub(crate) fn lens_blur(src: &Image, out: Rect, ctx: &Ctx, spec: &LensSpec) -> V
}
src.get(x, y, n - 1)
}
DepthSource::LayerMask => spec.depth_map?.get(x, y, 0),
DepthSource::LayerMask | DepthSource::Channel => spec.depth_map?.get(x, y, 0),
};
Some(if spec.invert { 1.0 - d } else { d })
};
+2 -8
View File
@@ -67,22 +67,16 @@ impl Encoding {
pub(crate) fn of(gamma: f32) -> Self {
Encoding(if gamma.is_finite() && gamma > 0.0 { gamma.clamp(0.5, 4.0) } else { 0.0 })
}
/// A value in this encoding as linear light.
pub(crate) fn decode(self, v: f32) -> f32 {
if self.0 > 0.0 { v.max(0.0).powf(self.0) } else { srgb_to_linear(v) }
}
/// Linear light back into this encoding, the inverse of [`Encoding::decode`].
pub(crate) fn encode(self, v: f32) -> f32 {
if self.0 > 0.0 { v.max(0.0).powf(1.0 / self.0) } else { linear_to_srgb(v) }
}
}
fn srgb_encode(v: f32) -> f32 {
linear_to_srgb(v)
}
fn srgb_decode(v: f32) -> f32 {
srgb_to_linear(v)
}
fn luma_linear(c: [f32; 3]) -> f32 {
luma(c)
}
+187 -18
View File
@@ -79,23 +79,48 @@ fn mirror(i: isize, n: usize) -> usize {
const TAPS: [f32; 5] = [1.0 / 16.0, 4.0 / 16.0, 6.0 / 16.0, 4.0 / 16.0, 1.0 / 16.0];
/// Planes smaller than this run their rows on one thread: the filter builds a whole pyramid per
/// reference (up to 96), and splitting its small levels into one-row tasks cost more in rayon's
/// scheduling than the rows themselves (a fifth of a Shadows render on 36 MP, measured).
const PAR_MIN: usize = 1 << 15;
/// [`par_rows`] for a plane, on one thread when it is small (see [`PAR_MIN`]).
fn rows(buf: &mut [f32], w: usize, f: impl Fn(usize, &mut [f32]) + Sync + Send) {
if buf.len() < PAR_MIN {
buf.chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| f(y, row));
} else {
par_rows(buf, w, 1, f);
}
}
/// The binomial 5-tap blur, sampled every `step` pixels from 0 (1 = full resolution).
fn blur_step(p: &Plane, step: usize) -> Plane {
let (w2, h2) = (p.w.div_ceil(step), p.h.div_ceil(step));
// Columns first at the kept rows, then rows at the kept columns.
// Columns first at the kept rows, then rows at the kept columns. Each sum adds the same terms
// in the same order as a `mirror` per tap would, so the result is bit for bit the same; the
// source rows are found once per row and the columns mirrored only near the edges.
let mut tmp = vec![0.0f32; p.w * h2];
par_rows(&mut tmp, p.w, 1, |y2, row| {
rows(&mut tmp, p.w, |y2, row| {
let y = (y2 * step) as isize;
let src: [&[f32]; 5] = std::array::from_fn(|k| {
let r = mirror(y + k as isize - 2, p.h);
&p.v[r * p.w..(r + 1) * p.w]
});
for (x, o) in row.iter_mut().enumerate() {
*o = (0..5).map(|k| TAPS[k] * p.v[mirror(y + k as isize - 2, p.h) * p.w + x]).sum();
*o = (0..5).map(|k| TAPS[k] * src[k][x]).sum();
}
});
let mut out = vec![0.0f32; w2 * h2];
par_rows(&mut out, w2, 1, |y2, row| {
rows(&mut out, w2, |y2, row| {
let src = &tmp[y2 * p.w..(y2 + 1) * p.w];
for (x2, o) in row.iter_mut().enumerate() {
let x = (x2 * step) as isize;
*o = (0..5).map(|k| TAPS[k] * src[mirror(x + k as isize - 2, p.w)]).sum();
let x = x2 * step;
*o = if x >= 2 && x + 2 < p.w {
let s = &src[x - 2..x + 3];
(0..5).map(|k| TAPS[k] * s[k]).sum()
} else {
(0..5).map(|k| TAPS[k] * src[mirror(x as isize + k as isize - 2, p.w)]).sum()
};
}
});
Plane { w: w2, h: h2, v: out }
@@ -107,14 +132,78 @@ fn down(p: &Plane) -> Plane {
}
/// `p` brought up to `w × h`: zeros between samples, then the blur times four.
///
/// Three samples in four are zero, so their taps are skipped instead of blurring a zero-filled
/// plane: the nonzero terms are added in the same order (skipping `+0.0` terms changes at most the
/// sign of a zero, and the filter only adds and exponentiates these values).
fn up(p: &Plane, w: usize, h: usize) -> Plane {
let mut z = Plane { w, h, v: vec![0.0; w * h] };
for y in 0..p.h.min(h.div_ceil(2)) {
for x in 0..p.w.min(w.div_ceil(2)) {
z.v[2 * y * w + 2 * x] = p.v[y * p.w + x] * 4.0;
let (lh, lw) = (p.h.min(h.div_ceil(2)), p.w.min(w.div_ceil(2)));
// The source sample behind index `i` of a zero-stuffed axis of length `n` holding `lim` samples.
let src = |i: isize, n: usize, lim: usize| {
let m = mirror(i, n);
(m.is_multiple_of(2) && m / 2 < lim).then_some(m / 2)
};
// Columns first, only at the even columns (the odd ones are all zero).
let mut tmp = vec![0.0f32; lw * h];
if lw > 0 {
rows(&mut tmp, lw, |y, row| {
let taps: [Option<&[f32]>; 5] = std::array::from_fn(|k| src(y as isize + k as isize - 2, h, lh).map(|r| &p.v[r * p.w..r * p.w + lw]));
for (j, o) in row.iter_mut().enumerate() {
*o = (0..5).filter_map(|k| taps[k].map(|s| TAPS[k] * (s[j] * 4.0))).sum();
}
});
}
let mut out = vec![0.0f32; w * h];
rows(&mut out, w, |y, row| {
let t = &tmp[y * lw..(y + 1) * lw];
for (x, o) in row.iter_mut().enumerate() {
*o = if x >= 2 && x + 2 < w {
// Away from the edges no tap mirrors.
(0..5)
.filter_map(|k| {
let i = x + k - 2;
(i.is_multiple_of(2) && i / 2 < lw).then(|| TAPS[k] * t[i / 2])
})
.sum()
} else {
(0..5).filter_map(|k| src(x as isize + k as isize - 2, w, lw).map(|j| TAPS[k] * t[j])).sum()
};
}
});
Plane { w, h, v: out }
}
/// The source rows of [`up`]`(p, w, h)` at output row `y`, one per tap (`None` where the
/// zero-stuffed row is all zeros).
fn up_rows(p: &Plane, h: usize, y: usize) -> [Option<usize>; 5] {
let lh = p.h.min(h.div_ceil(2));
std::array::from_fn(|k| {
let m = mirror(y as isize + k as isize - 2, h);
(m.is_multiple_of(2) && m / 2 < lh).then_some(m / 2)
})
}
/// One sample of [`up`]`(p, w, h)`: column `x` of the output row whose [`up_rows`] are `rows`.
/// It adds the same terms in the same order as `up`, so it gives the same bits.
fn up_at(p: &Plane, w: usize, rows: &[Option<usize>; 5], x: usize) -> f32 {
let lw = p.w.min(w.div_ceil(2));
let col = |j: usize| -> f32 { (0..5).filter_map(|k| rows[k].map(|r| TAPS[k] * (p.v[r * p.w + j] * 4.0))).sum() };
if x >= 2 && x + 2 < w {
// Away from the edges no tap mirrors.
(0..5)
.filter_map(|k| {
let i = x + k - 2;
(i.is_multiple_of(2) && i / 2 < lw).then(|| TAPS[k] * col(i / 2))
})
.sum()
} else {
(0..5)
.filter_map(|k| {
let m = mirror(x as isize + k as isize - 2, w);
(m.is_multiple_of(2) && m / 2 < lw).then(|| TAPS[k] * col(m / 2))
})
.sum()
}
blur_step(&z, 1)
}
/// Levels down to about 2 pixels on the short side.
@@ -154,21 +243,21 @@ fn llf(u: &Plane, remap: impl Fn(f32, f32) -> f32 + Sync) -> Plane {
for k in 0..refs {
let r = lo + step * k as f32;
let mut m = Plane { w: u.w, h: u.h, v: u.v.clone() };
par_rows(&mut m.v, u.w, 1, |_, row| {
rows(&mut m.v, u.w, |_, row| {
for v in row.iter_mut() {
*v = remap(*v, r);
}
});
let gr = gaussian(m, n);
for l in 0..n - 1 {
let coarse = up(&gr[l + 1], gr[l].w, gr[l].h);
let (gl, fine, lw) = (&g[l].v, &gr[l].v, g[l].w);
par_rows(&mut lap[l], lw, 1, |y, row| {
let (gl, fine, lw, lh, coarse) = (&g[l].v, &gr[l].v, g[l].w, g[l].h, &gr[l + 1]);
rows(&mut lap[l], lw, |y, row| {
let taps = up_rows(coarse, lh, y);
for (x, o) in row.iter_mut().enumerate() {
let i = y * lw + x;
let wgt = (1.0 - (gl[i].clamp(lo, hi) - r).abs() / step).max(0.0);
if wgt > 0.0 {
*o += wgt * (fine[i] - coarse.v[i]);
*o += wgt * (fine[i] - up_at(coarse, lw, &taps, x));
}
}
});
@@ -188,8 +277,10 @@ fn llf(u: &Plane, remap: impl Fn(f32, f32) -> f32 + Sync) -> Plane {
/// The `q` quantile (0 … 1) of `v`.
fn quantile(v: &[f32], q: f32) -> f32 {
let mut s = v.to_vec();
s.sort_by(f32::total_cmp);
s[((s.len() - 1) as f32 * q).round() as usize]
// Selecting the one element is linear, a full sort is not (5 sorts of the work copy were a
// fifteenth of a render). `total_cmp` ties are bit-identical, so it is the sorted element.
let i = ((s.len() - 1) as f32 * q).round() as usize;
*s.select_nth_unstable_by(i, f32::total_cmp).1
}
/// 1st and 99th percentiles of the log luminance on a copy at most 256 pixels across.
@@ -337,6 +428,84 @@ mod tests {
const SRGB: Encoding = Encoding(0.0);
/// The plain blur: a `mirror` per tap over the whole plane, the reference for [`blur_step`].
fn blur_step_reference(p: &Plane, step: usize) -> Plane {
let (w2, h2) = (p.w.div_ceil(step), p.h.div_ceil(step));
let mut tmp = vec![0.0f32; p.w * h2];
for y2 in 0..h2 {
for x in 0..p.w {
tmp[y2 * p.w + x] = (0..5).map(|k| TAPS[k] * p.v[mirror((y2 * step) as isize + k as isize - 2, p.h) * p.w + x]).sum();
}
}
let mut out = vec![0.0f32; w2 * h2];
for y2 in 0..h2 {
for x2 in 0..w2 {
out[y2 * w2 + x2] = (0..5).map(|k| TAPS[k] * tmp[y2 * p.w + mirror((x2 * step) as isize + k as isize - 2, p.w)]).sum();
}
}
Plane { w: w2, h: h2, v: out }
}
/// The plain upsampling: a zero-stuffed plane blurred whole, the reference for [`up`].
fn up_reference(p: &Plane, w: usize, h: usize) -> Plane {
let mut z = Plane { w, h, v: vec![0.0; w * h] };
for y in 0..p.h.min(h.div_ceil(2)) {
for x in 0..p.w.min(w.div_ceil(2)) {
z.v[2 * y * w + 2 * x] = p.v[y * p.w + x] * 4.0;
}
}
blur_step_reference(&z, 1)
}
fn noise_plane(w: usize, h: usize, seed: u32) -> Plane {
let mut s = seed.wrapping_mul(2_654_435_761).max(1);
let v = (0..w * h)
.map(|_| {
s ^= s << 13;
s ^= s >> 17;
s ^= s << 5;
(s % 20_000) as f32 / 1000.0 - 16.0
})
.collect();
Plane { w, h, v }
}
#[test]
fn the_fast_pyramid_matches_the_plain_one_exactly() {
// Odd, even, tiny and large-enough-to-run-in-parallel sizes, as the pyramid's levels are.
for (i, (w, h)) in [(1, 1), (1, 7), (2, 2), (3, 5), (5, 3), (8, 6), (37, 23), (64, 64), (301, 211)].into_iter().enumerate() {
let p = noise_plane(w, h, i as u32 + 1);
for step in [1, 2] {
assert_eq!(blur_step(&p, step).v, blur_step_reference(&p, step).v, "blur {w}×{h} step {step}");
}
for (uw, uh) in [(w * 2, h * 2), (w * 2 - 1, h * 2 - 1), (w * 2, h * 2 - 1), (w.max(2) * 2 - 3, h * 2)] {
let full = up(&p, uw, uh);
assert_eq!(full.v, up_reference(&p, uw, uh).v, "up {w}×{h} → {uw}×{uh}");
// The filter samples `up` one pixel at a time; every sample has the same bits.
for y in 0..uh {
let taps = up_rows(&p, uh, y);
for x in 0..uw {
assert_eq!(up_at(&p, uw, &taps, x).to_bits(), full.v[y * uw + x].to_bits(), "up_at {w}×{h} → {uw}×{uh} at {x},{y}");
}
}
}
}
}
#[test]
fn quantile_is_the_sorted_element() {
let p = noise_plane(301, 211, 9);
let mut v = p.v.clone();
// Ties and signed zeros, which `total_cmp` orders by their bits.
v.extend([0.0, -0.0, 0.0, -0.0, 3.5, 3.5, f32::MIN_POSITIVE]);
let mut sorted = v.clone();
sorted.sort_by(f32::total_cmp);
for q in [0.0, 0.001, 0.01, 0.25, 0.5, 0.99, 1.0] {
let want = sorted[((sorted.len() - 1) as f32 * q).round() as usize];
assert_eq!(quantile(&v, q).to_bits(), want.to_bits(), "q {q}");
}
}
fn grey(w: usize, h: usize, f: impl Fn(usize, usize) -> f32) -> Vec<[f32; 4]> {
(0..w * h).map(|i| f(i % w, i / w)).map(|v| [v, v, v, 1.0]).collect()
}
+2 -2
View File
@@ -369,14 +369,14 @@ pub(crate) fn apply(px: &mut [[f32; 4]], width: usize, w: f32, b: f32, enc: Enco
if per_channel {
for c in 0..3 {
let over = (lin[c] - 1.0).max(0.0);
q[c] = enc.encode(super::srgb_decode(grey(super::srgb_encode(lin[c].min(1.0)))) + over);
q[c] = enc.encode(super::srgb_to_linear(grey(super::linear_to_srgb(lin[c].min(1.0)))) + over);
}
} else {
let y = super::luma_linear(lin).max(0.0);
if y <= 1e-7 {
continue;
}
let y2 = super::srgb_decode(grey(super::srgb_encode(y.min(1.0)))) + (y - 1.0).max(0.0);
let y2 = super::srgb_to_linear(grey(super::linear_to_srgb(y.min(1.0)))) + (y - 1.0).max(0.0);
let k = y2 / y;
for c in 0..3 {
q[c] = enc.encode(lin[c] * k);
+164
View File
@@ -566,6 +566,133 @@ pub fn level_transform(ch: usize, from: &[f32], to: &[f32], a: Adapt) -> Vec<(f3
.collect()
}
/// One stroke of the Content-Aware Fill workspace's Sampling Brush: a hard round brush of
/// diameter `size` along `points` (document px), adding to the sampling area or taking from it.
#[derive(Clone, Debug, PartialEq)]
pub struct SamplingStroke {
pub add: bool,
pub size: f64,
pub points: Vec<[f64; 2]>,
}
impl SamplingStroke {
/// Bounds of the pixels the stroke covers.
pub fn bounds(&self) -> Rect {
let r = (self.size / 2.0).max(0.5);
self.points.iter().fold(Rect::EMPTY, |acc, p| {
let b = Rect::new(
(p[0] - r).floor() as i32,
(p[1] - r).floor() as i32,
((p[0] + r).ceil() as i32).saturating_add(1),
((p[1] + r).ceil() as i32).saturating_add(1),
);
if acc.is_empty() { b } else { acc.union(&b) }
})
}
}
/// Squared distance from `p` to the segment `a`–`b`.
fn seg_dist2(p: [f64; 2], a: [f64; 2], b: [f64; 2]) -> f64 {
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
let l2 = dx * dx + dy * dy;
let t = if l2 > 0.0 { (((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 };
let (qx, qy) = (a[0] + t * dx - p[0], a[1] + t * dy - p[1]);
qx * qx + qy * qy
}
/// Paint `strokes` in order into `mask` (row-major over `area`): a pixel whose centre lies within
/// half the brush size of a stroke's path is set (adding) or cleared (subtracting). A mask that
/// does not cover `area` exactly is left as it is.
pub fn paint_strokes(mask: &mut [bool], area: Rect, strokes: &[SamplingStroke]) {
let w = area.width() as usize;
if mask.len() != w * area.height() as usize {
return;
}
for s in strokes {
let r = (s.size / 2.0).max(0.5);
let r2 = r.powi(2);
let segs: Vec<([f64; 2], [f64; 2])> = match s.points.as_slice() {
[] => continue,
[p] => vec![(*p, *p)],
pts => pts.windows(2).map(|p| (p[0], p[1])).collect(),
};
// A stroke covers the union of its segments' pixels: each segment only looks at its own
// bounds.
for (a, b) in segs {
let bounds = Rect::new(
(a[0].min(b[0]) - r).floor() as i32,
(a[1].min(b[1]) - r).floor() as i32,
((a[0].max(b[0]) + r).ceil() as i32).saturating_add(1),
((a[1].max(b[1]) + r).ceil() as i32).saturating_add(1),
)
.intersect(&area);
for y in bounds.y0..bounds.y1 {
for x in bounds.x0..bounds.x1 {
let c = [f64::from(x) + 0.5, f64::from(y) + 0.5];
if seg_dist2(c, a, b) <= r2
&& let Some(m) = mask.get_mut((y - area.y0) as usize * w + (x - area.x0) as usize)
{
*m = s.add;
}
}
}
}
}
}
/// The pixels of `area` (row-major) whose centres lie inside the polygon `pts` (even-odd rule):
/// what a lasso outlines.
pub fn polygon_mask(area: Rect, pts: &[[f64; 2]]) -> Vec<bool> {
let (w, h) = (area.width() as usize, area.height() as usize);
let mut out = vec![false; w * h];
if pts.len() < 3 {
return out;
}
let mut xs: Vec<f64> = Vec::new();
for row in 0..h {
let cy = f64::from(area.y0) + row as f64 + 0.5;
xs.clear();
for i in 0..pts.len() {
let (a, b) = (pts[i], pts[(i + 1) % pts.len()]);
if (a[1] <= cy) != (b[1] <= cy) {
xs.push(a[0] + (cy - a[1]) / (b[1] - a[1]) * (b[0] - a[0]));
}
}
xs.sort_by(f64::total_cmp);
for pair in xs.as_chunks::<2>().0 {
// Pixel centres x + 0.5 within [pair0, pair1).
let x0 = ((pair[0] - 0.5).ceil() as i64 - i64::from(area.x0)).clamp(0, w as i64) as usize;
let x1 = ((pair[1] - 0.5).ceil() as i64 - i64::from(area.x0)).clamp(0, w as i64) as usize;
out[row * w + x0..row * w + x1.max(x0)].iter_mut().for_each(|v| *v = true);
}
}
out
}
/// Grow (`by` > 0) or shrink (`by` < 0) a mask by `|by|` pixels (Euclidean): the lasso's Expand
/// and Contract.
pub fn grow_mask(mask: &[bool], w: usize, h: usize, by: i32) -> Vec<bool> {
if by == 0 || w == 0 || h == 0 {
return mask.to_vec();
}
let r = f32::from(u16::try_from(by.unsigned_abs()).unwrap_or(u16::MAX));
if by > 0 {
let d = crate::selection::edt(mask, w, h);
d.iter().map(|v| *v <= r).collect()
} else {
let outside: Vec<bool> = mask.iter().map(|v| !v).collect();
let d = crate::selection::edt(&outside, w, h);
d.iter().map(|v| *v > r).collect()
}
}
/// The Sampling Brush's starting size for a selection with bounds `hole`: a tenth of the sampling
/// window's side, as Photoshop opens the workspace (20 px for a 50 px selection, 40 for 100).
pub fn default_brush_size(hole: Rect) -> f64 {
let (w, h) = (f64::from(hole.width().max(50)), f64::from(hole.height().max(50)));
(0.4 * (w * h).sqrt()).round()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -577,6 +704,43 @@ mod tests {
(img, hole)
}
#[test]
fn strokes_paint_their_path_and_leave_a_mismatched_mask_alone() {
let area = Rect::new(10, 20, 50, 40);
let (w, h) = (40, 20);
let strokes = [
SamplingStroke { add: true, size: 6.0, points: vec![[12.0, 25.0], [40.0, 25.0], [45.0, 35.0]] },
SamplingStroke { add: false, size: 3.0, points: vec![[30.0, 25.0]] },
SamplingStroke { add: true, size: 2.0, points: Vec::new() },
];
let mut mask = vec![false; w * h];
paint_strokes(&mut mask, area, &strokes);
// The same pixels as testing every pixel against every segment of each stroke in turn.
let mut want = vec![false; w * h];
for s in &strokes {
let r2 = (s.size / 2.0).max(0.5).powi(2);
let segs: Vec<_> = if s.points.len() == 1 { vec![(s.points[0], s.points[0])] } else { s.points.windows(2).map(|p| (p[0], p[1])).collect() };
for (i, m) in want.iter_mut().enumerate() {
let c = [f64::from(area.x0) + (i % w) as f64 + 0.5, f64::from(area.y0) + (i / w) as f64 + 0.5];
if segs.iter().any(|(a, b)| seg_dist2(c, *a, *b) <= r2) {
*m = s.add;
}
}
}
assert_eq!(mask, want);
assert!(mask[5 * w + 10] && !mask[5 * w + 20], "added along the path, cleared under the subtracting dab");
// A mask shorter or longer than the area is not touched (and nothing panics).
for len in [0, 7, w * h - 1, w * h + 3] {
let mut short = vec![false; len];
paint_strokes(&mut short, area, &strokes);
assert!(short.iter().all(|v| !v), "len {len}");
}
// Hostile coordinates stay inside the area.
let mut m = vec![false; w * h];
let wild = [SamplingStroke { add: true, size: 5000.0, points: vec![[f64::MAX, -1e300], [f64::NAN, 1e7]] }];
paint_strokes(&mut m, area, &wild);
}
#[test]
fn cancelled_fill_stops_and_progress_reaches_one() {
use std::sync::atomic::{AtomicU32, Ordering};
+36 -19
View File
@@ -28,32 +28,47 @@ pub(crate) fn centre(b: Rect) -> (f32, f32, f32) {
(b.x0 as f32 + w / 2.0, b.y0 as f32 + h / 2.0, w.min(h) / 2.0)
}
/// The bounds' centre and half width and height: Twirl, Pinch and Spherize act inside the ellipse
/// inscribed in the bounds, as Photoshop's do, so on a wide or tall image the effect reaches all
/// four edges instead of stopping at a circle fitted to the shorter side (#5643). A point maps to
/// unit-circle coordinates `(u, v)` and back.
fn ellipse(b: Rect) -> (f32, f32, f32, f32) {
let (w, h) = (b.width() as f32, b.height() as f32);
(b.x0 as f32 + w / 2.0, b.y0 as f32 + h / 2.0, w / 2.0, h / 2.0)
}
pub(crate) fn twirl(src: &Image, out: Rect, ctx: &Ctx, angle: f32) -> Vec<f32> {
let (cx, cy, rr) = centre(ctx.bounds);
let (cx, cy, hx, hy) = ellipse(ctx.bounds);
let a = angle.to_radians();
remap(src, out, ctx, Edge::Repeat, |x, y| {
let (dx, dy) = (x - cx, y - cy);
let r = (dx * dx + dy * dy).sqrt();
if r >= rr || rr <= 0.0 {
if hx <= 0.0 || hy <= 0.0 {
return (x, y);
}
let t = a * (1.0 - r / rr);
let (u, v) = ((x - cx) / hx, (y - cy) / hy);
let r = (u * u + v * v).sqrt();
if r >= 1.0 {
return (x, y);
}
let t = a * (1.0 - r);
let (s, c) = t.sin_cos();
(cx + dx * c - dy * s, cy + dx * s + dy * c)
(cx + (u * c - v * s) * hx, cy + (u * s + v * c) * hy)
})
}
pub(crate) fn pinch(src: &Image, out: Rect, ctx: &Ctx, amount: f32) -> Vec<f32> {
let (cx, cy, rr) = centre(ctx.bounds);
let (cx, cy, hx, hy) = ellipse(ctx.bounds);
let a = (amount / 100.0).clamp(-1.0, 1.0);
remap(src, out, ctx, Edge::Repeat, |x, y| {
let (dx, dy) = (x - cx, y - cy);
let r = (dx * dx + dy * dy).sqrt();
if r >= rr || r <= 0.0 {
if hx <= 0.0 || hy <= 0.0 {
return (x, y);
}
let rs = rr * (r / rr).powf(1.0 - a * 0.5);
(cx + dx * rs / r, cy + dy * rs / r)
let (u, v) = ((x - cx) / hx, (y - cy) / hy);
let r = (u * u + v * v).sqrt();
if r >= 1.0 || r <= 0.0 {
return (x, y);
}
let k = r.powf(1.0 - a * 0.5) / r;
(cx + u * k * hx, cy + v * k * hy)
})
}
@@ -64,20 +79,22 @@ fn sphere_1d(rn: f32, a: f32) -> f32 {
}
pub(crate) fn spherize(src: &Image, out: Rect, ctx: &Ctx, amount: f32, mode: SpherizeMode) -> Vec<f32> {
let b = ctx.bounds;
let (cx, cy, rr) = centre(b);
let (hx, hy) = (b.width() as f32 / 2.0, b.height() as f32 / 2.0);
let (cx, cy, hx, hy) = ellipse(ctx.bounds);
let a = (amount / 100.0).clamp(-1.0, 1.0);
remap(src, out, ctx, Edge::Repeat, |x, y| {
let (dx, dy) = (x - cx, y - cy);
match mode {
SpherizeMode::Normal => {
let r = (dx * dx + dy * dy).sqrt();
if r >= rr || r <= 0.0 {
if hx <= 0.0 || hy <= 0.0 {
return (x, y);
}
let rs = rr * sphere_1d(r / rr, a);
(cx + dx * rs / r, cy + dy * rs / r)
let (u, v) = (dx / hx, dy / hy);
let r = (u * u + v * v).sqrt();
if r >= 1.0 || r <= 0.0 {
return (x, y);
}
let k = sphere_1d(r, a) / r;
(cx + u * k * hx, cy + v * k * hy)
}
SpherizeMode::HorizontalOnly => {
if dx.abs() >= hx {
+90
View File
@@ -0,0 +1,90 @@
//! Face detection: a port of libfacedetection's CNN (Shiqi Yu, BSD-3; `data/LICENSE-libfacedetection.txt`).
//!
//! The network is the YuNet design: a backbone of 1×1 pointwise and 3×3 depthwise convolutions with
//! 2×2 max pooling, a feature pyramid at strides 8, 16 and 32, and per-cell heads for the face score,
//! the box and five landmarks (eyes, nose tip, mouth corners). The weights (`data/libfacedetection.bin`,
//! converted to half precision from the library's `facedetectcnn-data.cpp`) are about 100 KB, and
//! inference is plain Rust: no runtime, so it works offline.
//!
//! [`detect`] takes straight RGBA in `0..=1` and returns faces in pixel coordinates of that image.
//!
//! The browser build (wasm32) leaves the network and its weights out, as `photocraft-raw` does with
//! its JPEG XL decoder: they would push the wasm over the per-file cap of static hosts
//! (packaging/web/package.sh). There [`AVAILABLE`] is false and [`detect`] finds nothing.
#[cfg(not(target_arch = "wasm32"))]
mod cnn;
/// Whether this build can detect faces (not the browser build: see the module docs).
pub const AVAILABLE: bool = cfg!(not(target_arch = "wasm32"));
/// Longest side the network looks at; larger images are scaled down first (faces smaller than
/// about 10 px at this scale are missed).
pub const MAX_SIDE: usize = 1280;
/// One detected face, in pixels of the image passed to [`detect`].
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Face {
/// Confidence `0..=1` (the library reports faces above 0.5 as reliable).
pub score: f32,
/// Left, top, width and height.
pub rect: [f32; 4],
/// Right eye, left eye (as seen in the image: the first is on the image's left), nose tip,
/// right mouth corner, left mouth corner.
pub landmarks: [[f32; 2]; 5],
}
/// Faces in `px` (straight RGBA `0..=1`, `w × h`, row-major) scoring at least `min_score`, best
/// first. Transparent pixels count as white. `None` when `px` doesn't match the size.
#[cfg(not(target_arch = "wasm32"))]
pub fn detect(px: &[[f32; 4]], w: usize, h: usize, min_score: f32) -> Option<Vec<Face>> {
if w == 0 || h == 0 || px.len() != w.checked_mul(h)? {
return None;
}
// Scale down (box filter) so the longer side is at most MAX_SIDE.
let step = w.max(h).div_ceil(MAX_SIDE).max(1);
let (sw, sh) = (w.div_ceil(step), h.div_ceil(step));
let mut bgr = vec![[0.0f32; 3]; sw * sh];
for y in 0..sh {
for x in 0..sw {
let (mut acc, mut n) = ([0.0f32; 3], 0.0f32);
for yy in y * step..((y + 1) * step).min(h) {
for xx in x * step..((x + 1) * step).min(w) {
let [r, g, b, a] = px[yy * w + xx];
let a = a.clamp(0.0, 1.0);
for (k, v) in [b, g, r].into_iter().enumerate() {
acc[k] += v.clamp(0.0, 1.0) * a + (1.0 - a);
}
n += 1.0;
}
}
bgr[y * sw + x] = acc.map(|v| (v / n.max(1.0) * 255.0).round());
}
}
let s = step as f32;
let faces = cnn::network(&bgr, sw, sh)?
.into_iter()
.filter(|f| f.score >= min_score)
.map(|f| Face { score: f.score, rect: f.rect.map(|v| v * s), landmarks: f.landmarks.map(|[x, y]| [x * s, y * s]) })
.collect();
Some(faces)
}
/// The browser build has no detector (see the module docs): always `None`.
#[cfg(target_arch = "wasm32")]
pub fn detect(_px: &[[f32; 4]], _w: usize, _h: usize, _min_score: f32) -> Option<Vec<Face>> {
None
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use super::*;
#[test]
fn no_faces_in_flat_or_tiny_images_and_bad_sizes_are_refused() {
assert_eq!(detect(&vec![[0.5, 0.5, 0.5, 1.0]; 64 * 48], 64, 48, 0.5), Some(vec![]));
assert_eq!(detect(&[[1.0, 1.0, 1.0, 1.0]], 1, 1, 0.5), Some(vec![]));
assert_eq!(detect(&[[1.0; 4]; 3], 2, 2, 0.5), None);
assert_eq!(detect(&[], 0, 0, 0.5), None);
}
}
+323
View File
@@ -0,0 +1,323 @@
//! The network itself (native builds only: see the parent module's docs).
use super::Face;
use crate::par;
/// The weights: `PCFACE1\0`, the layer count (u16), then per layer its input channels (u16), filters
/// (u16) and kind (u8: 0 pointwise, 1 depthwise), the weights and the biases, all little-endian f16.
static WEIGHTS: &[u8] = include_bytes!("../../data/libfacedetection.bin");
const MAGIC: &[u8] = b"PCFACE1\0";
const LAYERS: usize = 53;
/// Faces whose boxes overlap more than this (intersection over union) are one face.
const NMS_OVERLAP: f32 = 0.45;
/// Candidates kept before non-maximum suppression, as the library does.
const TOP_K: usize = 1000;
struct Layer {
channels: usize,
filters: usize,
depthwise: bool,
weights: Vec<f32>,
biases: Vec<f32>,
}
/// A feature map: `rows × cols` cells of `channels` values each.
#[derive(Clone)]
struct Blob {
rows: usize,
cols: usize,
channels: usize,
data: Vec<f32>,
}
impl Blob {
fn new(rows: usize, cols: usize, channels: usize) -> Self {
Blob { rows, cols, channels, data: vec![0.0; rows * cols * channels] }
}
}
fn f16_to_f32(h: u16) -> f32 {
let sign = if h & 0x8000 != 0 { -1.0 } else { 1.0 };
let exp = i32::from((h >> 10) & 0x1f);
let frac = f32::from(h & 0x3ff);
match exp {
0 => sign * frac * (2.0f32).powi(-24),
31 => {
if frac == 0.0 {
sign * f32::INFINITY
} else {
f32::NAN
}
}
_ => sign * (1.0 + frac / 1024.0) * (2.0f32).powi(exp - 15),
}
}
fn parse(bytes: &[u8]) -> Option<Vec<Layer>> {
let rest = bytes.strip_prefix(MAGIC)?;
let mut at = 0usize;
let u16_at = |n: &mut usize| -> Option<u16> {
let b = rest.get(*n..*n + 2)?;
*n += 2;
Some(u16::from_le_bytes([b[0], b[1]]))
};
let count = usize::from(u16_at(&mut at)?);
if count != LAYERS {
return None;
}
let mut layers = Vec::with_capacity(count);
for _ in 0..count {
let channels = usize::from(u16_at(&mut at)?);
let filters = usize::from(u16_at(&mut at)?);
let depthwise = *rest.get(at)? == 1;
at += 1;
let nw = if depthwise { 9 * channels } else { filters * channels };
let mut read = |n: usize| -> Option<Vec<f32>> { (0..n).map(|_| u16_at(&mut at).map(f16_to_f32)).collect() };
let weights = read(nw)?;
let biases = read(filters)?;
if depthwise && filters != channels {
return None;
}
layers.push(Layer { channels, filters, depthwise, weights, biases });
}
(at == rest.len()).then_some(layers)
}
/// A 1×1 or 3×3 depthwise convolution with bias, then ReLU when `relu`.
fn conv(x: &Blob, l: &Layer, relu: bool) -> Option<Blob> {
if x.channels != l.channels {
return None;
}
let mut out = Blob::new(x.rows, x.cols, l.filters);
let (rows, cols, cin, cout) = (x.rows, x.cols, x.channels, l.filters);
par::chunks_mut(&mut out.data, cols * cout, rows * cols * cout > 1 << 16, |row, line| {
for col in 0..cols {
let o = &mut line[col * cout..(col + 1) * cout];
if l.depthwise {
o.copy_from_slice(&l.biases);
for r in row.saturating_sub(1)..(row + 2).min(rows) {
for c in col.saturating_sub(1)..(col + 2).min(cols) {
let k = (r + 1 - row) * 3 + (c + 1 - col);
let input = &x.data[(r * cols + c) * cin..(r * cols + c + 1) * cin];
let w = &l.weights[k * cin..(k + 1) * cin];
for ((o, i), w) in o.iter_mut().zip(input).zip(w) {
*o += i * w;
}
}
}
} else {
let input = &x.data[(row * cols + col) * cin..(row * cols + col + 1) * cin];
for (f, o) in o.iter_mut().enumerate() {
let w = &l.weights[f * cin..(f + 1) * cin];
*o = l.biases[f] + input.iter().zip(w).map(|(i, w)| i * w).sum::<f32>();
}
}
if relu {
for v in o.iter_mut() {
*v = v.max(0.0);
}
}
}
});
Some(out)
}
/// Pointwise then depthwise (ReLU after the depthwise one when `relu`).
fn conv_dp(x: &Blob, p: &Layer, d: &Layer, relu: bool) -> Option<Blob> {
conv(&conv(x, p, false)?, d, relu)
}
fn unit(x: &Blob, l: &[Layer]) -> Option<Blob> {
let [p1, d1, p2, d2] = l else { return None };
conv_dp(&conv_dp(x, p1, d1, true)?, p2, d2, true)
}
fn max_pool(x: &Blob) -> Blob {
let rows = x.rows.saturating_sub(3).div_ceil(2) + 1;
let cols = x.cols.saturating_sub(3).div_ceil(2) + 1;
let mut out = Blob::new(rows, cols, x.channels);
for r in 0..rows {
for c in 0..cols {
let o = (r * cols + c) * x.channels;
for ch in 0..x.channels {
let mut m = f32::NEG_INFINITY;
for rr in r * 2..(r * 2 + 2).min(x.rows) {
for cc in c * 2..(c * 2 + 2).min(x.cols) {
m = m.max(x.data[(rr * x.cols + cc) * x.channels + ch]);
}
}
out.data[o + ch] = m;
}
}
}
out
}
/// `small` upsampled ×2 (nearest) plus `big`.
fn up_add(small: &Blob, big: &Blob) -> Option<Blob> {
if small.rows * 2 != big.rows || small.cols * 2 != big.cols || small.channels != big.channels {
return None;
}
let mut out = big.clone();
let ch = big.channels;
for r in 0..big.rows {
for c in 0..big.cols {
let s = ((r / 2) * small.cols + c / 2) * ch;
let o = (r * big.cols + c) * ch;
for k in 0..ch {
out.data[o + k] += small.data[s + k];
}
}
}
Some(out)
}
/// The network's input: each cell holds the 3×3 neighbourhood (stride 2) of BGR values `0..=255`,
/// so the first, 3×3 stride-2 convolution is a pointwise one. The size is padded to 32.
fn input(bgr: &[[f32; 3]], w: usize, h: usize) -> Blob {
let rows = h.div_ceil(32) * 16;
let cols = w.div_ceil(32) * 16;
let mut b = Blob::new(rows, cols, 32);
for r in 0..rows {
for c in 0..cols {
let cell = (r * cols + c) * 32;
for fy in 0..3 {
let Some(y) = (r * 2 + fy).checked_sub(1).filter(|&y| y < h) else { continue };
for fx in 0..3 {
let Some(x) = (c * 2 + fx).checked_sub(1).filter(|&x| x < w) else { continue };
let o = cell + (fy * 3 + fx) * 3;
b.data[o..o + 3].copy_from_slice(&bgr[y * w + x]);
}
}
}
}
b
}
/// The heads of one pyramid level, decoded to candidate faces (in network pixels).
fn candidates(feat: &Blob, l: &[Layer], heads: [usize; 4], stride: f32, out: &mut Vec<Face>) -> Option<()> {
let head = |i: usize| conv_dp(feat, l.get(i)?, l.get(i + 1)?, false);
let (cls, reg, obj, kps) = (head(heads[0])?, head(heads[1])?, head(heads[2])?, head(heads[3])?);
if cls.channels != 1 || obj.channels != 1 || reg.channels != 4 || kps.channels != 10 {
return None;
}
let sigmoid = |v: f32| 1.0 / (1.0 + (-v.clamp(-88.0, 88.0)).exp());
for r in 0..feat.rows {
for c in 0..feat.cols {
let i = r * feat.cols + c;
let score = (sigmoid(cls.data[i]) * sigmoid(obj.data[i])).sqrt();
if score < 0.2 {
continue;
}
let (px, py) = (c as f32 * stride, r as f32 * stride);
let b = &reg.data[i * 4..i * 4 + 4];
let (cx, cy) = (b[0] * stride + px, b[1] * stride + py);
let (bw, bh) = (b[2].exp() * stride, b[3].exp() * stride);
let k = &kps.data[i * 10..i * 10 + 10];
let mut landmarks = [[0.0; 2]; 5];
for (n, lm) in landmarks.iter_mut().enumerate() {
*lm = [k[2 * n] * stride + px, k[2 * n + 1] * stride + py];
}
out.push(Face { score, rect: [cx - bw / 2.0, cy - bh / 2.0, bw, bh], landmarks });
}
}
Some(())
}
fn iou(a: &[f32; 4], b: &[f32; 4]) -> f32 {
let w = ((a[0] + a[2]).min(b[0] + b[2]) - a[0].max(b[0])).max(0.0);
let h = ((a[1] + a[3]).min(b[1] + b[3]) - a[1].max(b[1])).max(0.0);
let i = w * h;
if i <= 0.0 { 0.0 } else { i / (a[2] * a[3] + b[2] * b[3] - i) }
}
/// The heads (cls, reg, obj, kps: their first layer) of the stride 8, 16 and 32 levels.
const HEADS: [[usize; 4]; 3] = [[29, 35, 41, 47], [31, 37, 43, 49], [33, 39, 45, 51]];
/// The backbone and feature pyramid: the stride 8, 16 and 32 feature maps the heads read.
fn features(bgr: &[[f32; 3]], w: usize, h: usize, l: &[Layer]) -> Option<[Blob; 3]> {
let x = conv(&input(bgr, w, h), l.first()?, true)?;
let x = max_pool(&conv_dp(&x, l.get(1)?, l.get(2)?, true)?);
let x = unit(&unit(&x, l.get(3..7)?)?, l.get(7..11)?)?;
let f8 = unit(&max_pool(&x), l.get(11..15)?)?;
let f16 = unit(&max_pool(&f8), l.get(15..19)?)?;
let f32_ = unit(&max_pool(&f16), l.get(19..23)?)?;
let f32_ = conv_dp(&f32_, l.get(27)?, l.get(28)?, true)?;
let f16 = conv_dp(&up_add(&f32_, &f16)?, l.get(25)?, l.get(26)?, true)?;
let f8 = conv_dp(&up_add(&f16, &f8)?, l.get(23)?, l.get(24)?, true)?;
Some([f8, f16, f32_])
}
pub(super) fn network(bgr: &[[f32; 3]], w: usize, h: usize) -> Option<Vec<Face>> {
let l = parse(WEIGHTS)?;
let levels = features(bgr, w, h, &l)?;
let mut faces = Vec::new();
// Heads (cls, reg, obj, kps) per level, as in the library's model.
for ((feat, heads), stride) in levels.iter().zip(HEADS).zip([8.0, 16.0, 32.0]) {
candidates(feat, &l, heads, stride, &mut faces)?;
}
faces.sort_by(|a, b| b.score.total_cmp(&a.score));
faces.truncate(TOP_K);
let mut kept: Vec<Face> = Vec::new();
for f in faces {
if kept.iter().all(|k| iou(&k.rect, &f.rect) <= NMS_OVERLAP) {
kept.push(f);
}
}
Some(kept)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn weights_parse() {
let l = parse(WEIGHTS).expect("bundled weights");
assert_eq!(l.len(), LAYERS);
assert_eq!((l[0].channels, l[0].filters, l[0].depthwise), (32, 16, false));
assert_eq!((l[2].channels, l[2].filters, l[2].depthwise), (16, 16, true));
assert!(l.iter().all(|l| l.weights.iter().chain(&l.biases).all(|v| v.is_finite())));
// Damaged weights are refused, not read past their end.
assert!(parse(&WEIGHTS[..WEIGHTS.len() - 1]).is_none());
assert!(parse(b"nope").is_none());
}
/// The sum of every head's raw output on a fixed test image matches libfacedetection's own C++
/// (computed with its f32 weights; ours are f16, hence the tolerance).
#[test]
fn heads_match_the_reference_implementation() {
let (w, h) = (96usize, 72usize);
let bgr: Vec<[f32; 3]> = (0..w * h)
.map(|i| {
let (x, y) = (i % w, i / w);
let (r, g, b) = ((x * 7 + y * 3) % 256, (x * x + y * 5) % 256, (x * y * 3 + 11) % 256);
[b as f32, g as f32, r as f32]
})
.collect();
let l = parse(WEIGHTS).unwrap();
let levels = features(&bgr, w, h, &l).unwrap();
let reference =
[27.647039, 549.579923, -1645.299678, 914.784787, -6.149318, 35.705980, -454.742148, 163.847111, 1.267630, 28.415509, -85.662257, 62.294208];
let mut got = Vec::new();
for (feat, heads) in levels.iter().zip(HEADS) {
for first in heads {
let o = conv_dp(feat, &l[first], &l[first + 1], false).unwrap();
got.push(o.data.iter().map(|&v| f64::from(v)).sum::<f64>());
}
}
for (g, r) in got.iter().zip(reference) {
assert!((g - r).abs() <= 0.01 * r.abs().max(10.0), "{got:?}");
}
}
#[test]
fn f16_decoding() {
assert_eq!(f16_to_f32(0x3c00), 1.0);
assert_eq!(f16_to_f32(0xc000), -2.0);
assert_eq!(f16_to_f32(0x0000), 0.0);
assert_eq!(f16_to_f32(0x3555), 0.333_251_95);
assert_eq!(f16_to_f32(0x0001), 2.0f32.powi(-24));
}
}
+264 -59
View File
@@ -45,12 +45,9 @@ pub const PATCH_RADIUS: usize = 15;
struct Rng(u64);
impl Rng {
/// The next value of the SplitMix64 sequence.
fn next(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
crate::photo_util::splitmix64(&mut self.0)
}
fn unit(&mut self) -> f64 {
(self.next() >> 11) as f64 / (1u64 << 53) as f64
@@ -60,25 +57,98 @@ impl Rng {
}
}
/// Columns per band of the vertical blur pass: 64 f32 = 256 bytes = four cache lines per row, so
/// walking down a band touches whole lines, and the band's working set stays in cache.
const BLUR_BAND: usize = 64;
/// Box blur of radius `r` (edges clamped): a running f32 sum along rows, then along columns.
///
/// Both passes are parallel and give bit for bit what the one-line-at-a-time version gave: every
/// output sums its window in the same order. Rows are independent. For the column pass, the
/// image is cut into bands of columns; each column keeps its own f32 running sum, started from
/// its first window summed in order and updated in the same order, so only the loop nest changes
/// (the inner loop runs across a band's columns and vectorises), never any column's arithmetic.
fn blur(w: usize, h: usize, v: &[f32], r: usize) -> Vec<f32> {
let pass = |src: &[f32], horizontal: bool| -> Vec<f32> {
let mut dst = vec![0.0f32; src.len()];
let (n, m) = if horizontal { (h, w) } else { (w, h) };
for line in 0..n {
let at = |k: i64| {
let k = k.clamp(0, m as i64 - 1) as usize;
if horizontal { src[line * w + k] } else { src[k * w + line] }
};
let mut acc: f32 = (-(r as i64)..=r as i64).map(at).sum();
for k in 0..m {
let i = if horizontal { line * w + k } else { k * w + line };
dst[i] = acc / (2 * r + 1) as f32;
acc += at(k as i64 + r as i64 + 1) - at(k as i64 - r as i64);
if w == 0 || h == 0 {
return Vec::new();
}
let den = (2 * r + 1) as f32;
let mut tmp = vec![0.0f32; w * h];
par_rows(&mut tmp, w, |line, dst| {
let src = &v[line * w..(line + 1) * w];
let at = |k: i64| src[k.clamp(0, w as i64 - 1) as usize];
let mut acc: f32 = (-(r as i64)..=r as i64).map(at).sum();
for (k, o) in dst.iter_mut().enumerate() {
*o = acc / den;
acc += at(k as i64 + r as i64 + 1) - at(k as i64 - r as i64);
}
});
let mut dst = vec![0.0f32; w * h];
// One list of row segments per band, each segment a `&mut` slice of `dst`: no `unsafe`.
let mut bands: Vec<Vec<&mut [f32]>> = (0..w.div_ceil(BLUR_BAND)).map(|_| Vec::with_capacity(h)).collect();
for row in dst.chunks_exact_mut(w) {
for (band, seg) in bands.iter_mut().zip(row.chunks_mut(BLUR_BAND)) {
band.push(seg);
}
}
let src = &tmp;
let run = |bi: usize, segs: &mut [&mut [f32]], acc: &mut Vec<f32>| {
let (x0, bw) = (bi * BLUR_BAND, segs.first().map_or(0, |s| s.len()));
let row = |k: i64| {
let o = (k.clamp(0, h as i64 - 1) as usize) * w + x0;
&src[o..o + bw]
};
// The first window, summed in order from the same start as `Iterator::sum` uses.
acc.clear();
acc.resize(bw, std::iter::empty::<f32>().sum());
for k in -(r as i64)..=r as i64 {
for (t, &x) in acc.iter_mut().zip(row(k)) {
*t += x;
}
}
for (k, seg) in segs.iter_mut().enumerate() {
let (add, sub) = (row(k as i64 + r as i64 + 1), row(k as i64 - r as i64));
for (((o, t), &p), &m) in seg.iter_mut().zip(acc.iter_mut()).zip(add).zip(sub) {
*o = *t / den;
*t += p - m;
}
}
dst
};
pass(&pass(v, true), false)
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
bands.into_par_iter().enumerate().for_each_init(Vec::new, |acc, (bi, mut segs)| run(bi, &mut segs, acc));
}
#[cfg(target_arch = "wasm32")]
{
let mut acc = Vec::new();
for (bi, mut segs) in bands.into_iter().enumerate() {
run(bi, &mut segs, &mut acc);
}
}
dst
}
/// Runs `f(y, row)` over the `w`-wide rows of `buf`, in parallel on native targets.
fn par_rows(buf: &mut [f32], w: usize, f: impl Fn(usize, &mut [f32]) + Sync + Send) {
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
buf.par_chunks_mut(w).enumerate().for_each(|(y, row)| f(y, row));
}
#[cfg(target_arch = "wasm32")]
buf.chunks_mut(w).enumerate().for_each(|(y, row)| f(y, row));
}
/// [`par_rows`] over three row-aligned buffers in lockstep.
fn par_rows3(a: &mut [f32], b: &mut [f32], c: &mut [f32], w: usize, f: impl Fn(usize, &mut [f32], &mut [f32], &mut [f32]) + Sync + Send) {
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
a.par_chunks_mut(w).zip(b.par_chunks_mut(w)).zip(c.par_chunks_mut(w)).enumerate().for_each(|(y, ((a, b), c))| f(y, a, b, c));
}
#[cfg(target_arch = "wasm32")]
a.chunks_mut(w).zip(b.chunks_mut(w)).zip(c.chunks_mut(w)).enumerate().for_each(|(y, ((a, b), c))| f(y, a, b, c));
}
/// Harris corners: at most `max` corners at least `min_dist` pixels apart, strongest first,
@@ -88,28 +158,40 @@ pub fn harris(w: usize, h: usize, img: &[f32], max: usize, min_dist: f32, valid:
return Vec::new();
}
let (mut xx, mut yy, mut xy) = (vec![0.0f32; w * h], vec![0.0f32; w * h], vec![0.0f32; w * h]);
for y in 1..h - 1 {
// Rows are independent (each output pixel reads only `img`), so they run in parallel.
par_rows3(&mut xx, &mut yy, &mut xy, w, |y, xxr, yyr, xyr| {
if y == 0 || y + 1 == h {
return;
}
for x in 1..w - 1 {
let p = |dx: i64, dy: i64| img[((y as i64 + dy) as usize) * w + (x as i64 + dx) as usize];
// Sobel.
let gx = (p(1, -1) + 2.0 * p(1, 0) + p(1, 1)) - (p(-1, -1) + 2.0 * p(-1, 0) + p(-1, 1));
let gy = (p(-1, 1) + 2.0 * p(0, 1) + p(1, 1)) - (p(-1, -1) + 2.0 * p(0, -1) + p(1, -1));
let i = y * w + x;
xx[i] = gx * gx;
yy[i] = gy * gy;
xy[i] = gx * gy;
xxr[x] = gx * gx;
yyr[x] = gy * gy;
xyr[x] = gx * gy;
}
}
});
// Two box passes approximate the Gaussian window.
let (xx, yy, xy) = (blur(w, h, &blur(w, h, &xx, 1), 1), blur(w, h, &blur(w, h, &yy, 1), 1), blur(w, h, &blur(w, h, &xy, 1), 1));
let r: Vec<f32> = (0..w * h).map(|i| xx[i] * yy[i] - xy[i] * xy[i] - 0.04 * (xx[i] + yy[i]).powi(2)).collect();
let mut r = vec![0.0f32; w * h];
par_rows(&mut r, w, |y, row| {
for (x, o) in row.iter_mut().enumerate() {
let i = y * w + x;
*o = xx[i] * yy[i] - xy[i] * xy[i] - 0.04 * (xx[i] + yy[i]).powi(2);
}
});
let peak = r.iter().copied().fold(0.0f32, f32::max);
if peak <= 0.0 {
return Vec::new();
}
let m = PATCH_RADIUS + 1;
let mut cand: Vec<Corner> = Vec::new();
for y in m..h - m {
// Rows are scanned in parallel and joined in row order; the sort below fully orders the
// candidates anyway (score, then y, then x), so the order they arrive in cannot matter.
let rows = par_map((h - m).saturating_sub(m), |i| {
let y = m + i;
let mut found: Vec<Corner> = Vec::new();
for x in m..w - m {
let v = r[y * w + x];
if v < peak * 0.01 {
@@ -125,9 +207,11 @@ pub fn harris(w: usize, h: usize, img: &[f32], max: usize, min_dist: f32, valid:
continue;
}
}
cand.push(Corner { x: x as f32, y: y as f32, score: v });
found.push(Corner { x: x as f32, y: y as f32, score: v });
}
}
found
});
let mut cand: Vec<Corner> = rows.into_iter().flatten().collect();
cand.sort_by(|a, b| b.score.total_cmp(&a.score).then(a.y.total_cmp(&b.y)).then(a.x.total_cmp(&b.x)));
// Minimum spacing via a coarse occupancy grid.
let cell = min_dist.max(1.0);
@@ -176,37 +260,37 @@ pub fn describe(w: usize, h: usize, img: &[f32], corners: &[Corner], upright: bo
let (xi, yi) = ((x.round() as i64).clamp(0, w as i64 - 1) as usize, (y.round() as i64).clamp(0, h as i64 - 1) as usize);
smooth[yi * w + xi]
};
corners
.iter()
.map(|c| {
let (s, co) = if upright {
(0.0, 1.0)
} else {
let (mut m10, mut m01) = (0.0f32, 0.0f32);
let r = PATCH_RADIUS as i64;
for dy in -r..=r {
for dx in -r..=r {
if dx * dx + dy * dy <= r * r {
let v = at(c.x + dx as f32, c.y + dy as f32);
m10 += dx as f32 * v;
m01 += dy as f32 * v;
}
// Every descriptor depends only on its own corner and the smoothed image: computed in
// parallel, kept in corner order.
par_map(corners.len(), |ci| {
let c = &corners[ci];
let (s, co) = if upright {
(0.0, 1.0)
} else {
let (mut m10, mut m01) = (0.0f32, 0.0f32);
let r = PATCH_RADIUS as i64;
for dy in -r..=r {
for dx in -r..=r {
if dx * dx + dy * dy <= r * r {
let v = at(c.x + dx as f32, c.y + dy as f32);
m10 += dx as f32 * v;
m01 += dy as f32 * v;
}
}
m01.atan2(m10).sin_cos()
};
let mut d = [0u64; 4];
for (i, p) in pairs().iter().enumerate() {
let rot = |x: f32, y: f32| (c.x + x * co - y * s, c.y + x * s + y * co);
let (ax, ay) = rot(p[0], p[1]);
let (bx, by) = rot(p[2], p[3]);
if at(ax, ay) < at(bx, by) {
d[i / 64] |= 1 << (i % 64);
}
}
d
})
.collect()
m01.atan2(m10).sin_cos()
};
let mut d = [0u64; 4];
for (i, p) in pairs().iter().enumerate() {
let rot = |x: f32, y: f32| (c.x + x * co - y * s, c.y + x * s + y * co);
let (ax, ay) = rot(p[0], p[1]);
let (bx, by) = rot(p[2], p[3]);
if at(ax, ay) < at(bx, by) {
d[i / 64] |= 1 << (i % 64);
}
}
d
})
}
pub fn hamming(a: &Descriptor, b: &Descriptor) -> u32 {
@@ -560,3 +644,124 @@ mod tests {
assert_eq!(hamming(&[0, 0, 0, 0], &[1, 3, 0, u64::MAX]), 67);
}
}
/// The row / column-band parallel `blur`, `harris` and `describe` against the serial code they
/// replaced. Every output must stay bit for bit the same.
#[cfg(test)]
mod parallel_tests {
use super::*;
/// The serial blur exactly as it was before the parallelisation.
fn ref_blur(w: usize, h: usize, v: &[f32], r: usize) -> Vec<f32> {
let pass = |src: &[f32], horizontal: bool| -> Vec<f32> {
let mut dst = vec![0.0f32; src.len()];
let (n, m) = if horizontal { (h, w) } else { (w, h) };
for line in 0..n {
let at = |k: i64| {
let k = k.clamp(0, m as i64 - 1) as usize;
if horizontal { src[line * w + k] } else { src[k * w + line] }
};
let mut acc: f32 = (-(r as i64)..=r as i64).map(at).sum();
for k in 0..m {
let i = if horizontal { line * w + k } else { k * w + line };
dst[i] = acc / (2 * r + 1) as f32;
acc += at(k as i64 + r as i64 + 1) - at(k as i64 - r as i64);
}
}
dst
};
pass(&pass(v, true), false)
}
fn mix(mut z: u64) -> u64 {
z = z.wrapping_add(0x9E37_79B9_7F4A_7C15);
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
/// Integer-only data with negatives, signed zeros and large values, so a different order of
/// additions cannot hide.
fn noise(len: usize, seed: u64) -> Vec<f32> {
(0..len as u64)
.map(|i| {
let z = mix(seed.wrapping_mul(0x1234_5678_9ABC).wrapping_add(i));
let v = (z >> 40) as f32 / 16_777_216.0;
match z & 31 {
0 => -v,
1 => -0.0,
2 => v * 1000.0,
_ => v,
}
})
.collect()
}
/// Blocks of random brightness: lots of strong corners, no libm in the making. Only the
/// recorded-hash test below uses it, and that one is Linux x86_64 only.
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
fn blocks(w: usize, h: usize, seed: u64) -> Vec<f32> {
(0..w * h).map(|i| (mix(seed + ((i % w) / 8) as u64 * 4096 + ((i / w) / 8) as u64) >> 40) as f32 / 16_777_216.0).collect()
}
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
fn fnv(parts: &[u64]) -> u64 {
let mut h = 0xcbf2_9ce4_8422_2325u64;
for p in parts {
h ^= p;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
h
}
#[test]
fn blur_matches_the_serial_code() {
// Widths around the 64-column band (1 band, band + 1, several bands, a ragged last one),
// 1-wide and 1-high images, and radii 0, 1, 2, larger than the image.
let widths = [1usize, 2, 3, 31, 63, 64, 65, 127, 128, 129, 200];
let heights = [1usize, 2, 3, 5, 17, 40];
for (a, &w) in widths.iter().enumerate() {
for (b, &h) in heights.iter().enumerate() {
let v = noise(w * h, (a * 10 + b) as u64);
for r in [0usize, 1, 2, 3, 7, 50] {
let (got, want) = (blur(w, h, &v, r), ref_blur(w, h, &v, r));
let (g, e): (Vec<u32>, Vec<u32>) = (got.iter().map(|x| x.to_bits()).collect(), want.iter().map(|x| x.to_bits()).collect());
assert_eq!(g, e, "blur {w}x{h} r={r}");
}
}
}
assert!(blur(0, 0, &[], 1).is_empty());
}
// `harris` and `describe` go through the platform's hypot / ln / cos / atan2 / sin_cos, so the
// hashes recorded from the old code only hold where those are the same functions.
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
#[test]
fn harris_and_describe_hashes_recorded_from_the_serial_code() {
// Sizes: a whole number of 64-column bands and a ragged one; with and without a validity mask.
for (w, h, max, md, want_corners, want_desc) in
[(211usize, 157usize, 300usize, 6.0f32, 0xeccc6928d3ae3b8cu64, 0xd1e6caae4d68f253u64), (128, 97, 60, 3.0, 0xe3f6615117fa7da7, 0x818ae7e965ecf88f)]
{
let img = blocks(w, h, 7);
let valid: Vec<bool> = (0..w * h).map(|i| !((i % w) > w / 2 && (i / w) < h / 3)).collect();
let mut parts = Vec::new();
for v in [None, Some(valid.as_slice())] {
let c = harris(w, h, &img, max, md, v);
for k in &c {
parts.extend([k.x.to_bits() as u64, k.y.to_bits() as u64, k.score.to_bits() as u64]);
}
parts.push(c.len() as u64);
}
assert_eq!(fnv(&parts), want_corners, "harris {w}x{h}");
let c = harris(w, h, &img, max, md, None);
assert!(c.len() > 10, "{} corners", c.len());
let mut parts = Vec::new();
for upright in [false, true] {
for d in describe(w, h, &img, &c, upright) {
parts.extend(d);
}
}
assert_eq!(fnv(&parts), want_desc, "describe {w}x{h}");
}
}
}
+3 -5
View File
@@ -65,15 +65,13 @@ const SOURCE_MOMENTS_MAX: usize = 1 << 20;
#[derive(Clone)]
pub(crate) struct Rng(u64);
impl Rng {
/// A generator whose sequence is fixed by `seed`.
pub(crate) fn new(seed: u64) -> Self {
Rng(seed ^ 0x9E37_79B9_7F4A_7C15)
}
/// The next value of the SplitMix64 sequence.
pub(crate) fn next(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
crate::photo_util::splitmix64(&mut self.0)
}
/// Skip `n` numbers (the same state as after `n` calls of [`Rng::next`]).
pub(crate) fn skip(&mut self, n: u64) {
+25 -3
View File
@@ -32,6 +32,7 @@ mod distort;
mod distort2;
pub mod erase;
pub mod exif;
pub mod faces;
pub mod features;
mod fxutil;
mod gallery;
@@ -481,7 +482,8 @@ pub enum FilterParams {
distribution: Distribution,
monochromatic: bool,
seed: u32,
/// Depth for [`DepthSource::LayerMask`] (channel 0, document coordinates), supplied by the caller.
/// Depth for [`DepthSource::LayerMask`] and [`DepthSource::Channel`] (channel 0, document
/// coordinates), supplied by the caller.
#[serde(skip)]
depth_map: Option<std::sync::Arc<Image>>,
},
@@ -743,8 +745,8 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec<f
FilterParams::AddNoise { amount, distribution, monochromatic, seed } => noise::add(src, out, ctx, *amount, *distribution, *monochromatic, *seed),
FilterParams::Median { radius } => noise::median(src, out, *radius, None),
FilterParams::DustAndScratches { radius, threshold } => noise::median(src, out, *radius, Some(*threshold)),
FilterParams::Minimum { radius, preserve } => other::min_max_preserve(src, out, *radius, false, *preserve),
FilterParams::Maximum { radius, preserve } => other::min_max_preserve(src, out, *radius, true, *preserve),
FilterParams::Minimum { radius, preserve } => min_max_ink(src, out, ctx, *radius, false, *preserve),
FilterParams::Maximum { radius, preserve } => min_max_ink(src, out, ctx, *radius, true, *preserve),
FilterParams::Offset { horizontal, vertical, undefined } => other::offset(src, out, ctx, *horizontal, *vertical, *undefined),
FilterParams::Mosaic { cell_size } => stylize::mosaic(src, out, ctx, *cell_size),
FilterParams::Emboss { angle, height, amount } => stylize::emboss(src, out, ctx, *angle, *height, *amount),
@@ -908,6 +910,23 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec<f
}
}
/// Minimum / Maximum in the document's polarity. CMYK samples are ink, so there Maximum spreads
/// paper white and Minimum spreads ink (as Photoshop does): the colour channels run inverted,
/// alpha keeps its usual polarity.
fn min_max_ink(src: &Image, out: Rect, ctx: &Ctx, radius: f32, max: bool, preserve: Preserve) -> Vec<f32> {
if !fxutil::subtractive(ctx) {
return other::min_max_preserve(src, out, radius, max, preserve);
}
let n = src.ch.max(1);
let cc = if ctx.alpha { n - 1 } else { n };
let flip = |data: &mut [f32]| data.chunks_exact_mut(n).for_each(|px| px.iter_mut().take(cc).for_each(|v| *v = 1.0 - *v));
let mut inv = src.clone();
flip(&mut inv.data);
let mut res = other::min_max_preserve(&inv, out, radius, max, preserve);
flip(&mut res);
res
}
/// Output tile size used by [`apply`].
pub const TILE: i32 = 256;
@@ -971,6 +990,7 @@ fn auto_tile(params: &FilterParams, bounds: Rect) -> i32 {
}
/// [`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(
surface: &Surface,
params: &FilterParams,
@@ -1013,6 +1033,8 @@ fn apply_tiled_with_impl(
ctl: &photocraft_raster::Interrupt,
prepare_fast_paths: bool,
) -> Option<Surface> {
// Every dispatch path needs a positive step, including Motion Blur's fallback.
let tile = tile.max(1);
if ctl.cancelled() {
return None;
}
+7 -3
View File
@@ -8,7 +8,8 @@ use crate::image::Image;
use crate::{Ctx, HsbModel};
/// Custom: `Σ kernel·pixel / scale + offset/255` per colour channel (alpha
/// kept). Kernels shorter than 25 entries are zero-padded.
/// kept). Kernels shorter than 25 entries are zero-padded. CMYK samples are
/// ink, so there it runs on `1 - ink` and a positive offset brightens.
pub(crate) fn custom(src: &Image, out: Rect, ctx: &Ctx, kernel: &[f32], scale: f32, offset: f32) -> Vec<f32> {
let n = src.ch;
let cc = ncol(ctx, n);
@@ -16,6 +17,7 @@ pub(crate) fn custom(src: &Image, out: Rect, ctx: &Ctx, kernel: &[f32], scale: f
(0..25).filter_map(|i| kernel.get(i).copied().filter(|v| *v != 0.0).map(|v| (i as i32 % 5 - 2, i as i32 / 5 - 2, v))).collect();
let s = if scale == 0.0 { 1.0 } else { scale };
let off = offset / 255.0;
let sub = crate::fxutil::subtractive(ctx);
let mut res = src.crop(out);
if k.is_empty() {
return res;
@@ -23,8 +25,10 @@ pub(crate) fn custom(src: &Image, out: Rect, ctx: &Ctx, kernel: &[f32], scale: f
for (i, px) in res.chunks_exact_mut(n).enumerate() {
let (x, y) = xy(out, i);
for (c, v) in px.iter_mut().enumerate().take(cc) {
let sum: f32 = k.iter().map(|&(dx, dy, w)| src.get(x + dx, y + dy, c) * w).sum();
*v = sum / s + off;
// CMYK: convolve brightness (1 - ink), then back to ink.
let lum = |v: f32| if sub { 1.0 - v } else { v };
let sum: f32 = k.iter().map(|&(dx, dy, w)| lum(src.get(x + dx, y + dy, c)) * w).sum();
*v = lum(sum / s + off);
}
}
res
+17 -4
View File
@@ -89,10 +89,7 @@ fn composite_area(
let i = ((y - r.y0) as usize * w + (x - r.x0) as usize) * n;
let px = &mut region[i..i + n];
let d = photocraft_raster::to_rgba(&fmt, px);
let mut o = blend::composite(blend_mode, d, color(x, y), k);
if lock_transparency {
o[3] = d[3];
}
let o = if lock_transparency { blend::composite_atop(blend_mode, d, color(x, y), k) } else { blend::composite(blend_mode, d, color(x, y), k) };
from_rgba_into(&fmt, o, &mut enc);
// A colour picked as CMYK inks replaces fully covered pixels of a CMYK target as it is.
if let Some(i) = inks
@@ -296,6 +293,22 @@ mod tests {
assert_eq!(s.pixel(5, 1)[3], 0.0, "outside the selection");
}
#[test]
fn locked_gradient_blends_source_atop_on_semi_transparent_pixels() {
// Locked half-transparent white, flat black gradient at 50%: colour halfway, alpha kept (#5783).
for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] {
for mode in [BlendMode::Normal, BlendMode::Multiply] {
let mut s = Surface::new(fmt);
let a = Rect::new(0, 0, 4, 1);
s.fill_rect(a, &[1.0, 1.0, 1.0, 0.5]);
let black = [0.0, 0.0, 0.0, 1.0];
paint_gradient(&mut s, a, (0.0, 0.0), (4.0, 0.0), GradientShape::Linear, &[(0.0, black), (1.0, black)], false, 0.5, mode, false, None, true);
let p = s.rgba(2, 0);
assert!((p[0] - 0.5).abs() < 0.01 && (p[3] - 0.5).abs() < 0.01, "{fmt:?} {mode:?} {p:?}");
}
}
}
#[test]
fn bucket_fills_contiguous_region() {
let mut s = Surface::new(PixelFormat::RGBA8);
+671 -108
View File
@@ -35,7 +35,7 @@
#![allow(clippy::needless_range_loop)] // index loops mirror the maths (pixels × channels)
use crate::features::{self, Descriptor, Model};
use crate::photo_util::{Normal, Rng, par_map, solve_dense};
use crate::photo_util::{Normal, Rng, par_map, par_rows, solve_dense};
use crate::transform::Homography;
/// Photomerge layouts.
@@ -996,58 +996,99 @@ pub fn blend_levels(w: usize, h: usize) -> usize {
n
}
/// `a[i] ⊕ b[i]` for every `i` (the shorter length wins, like `zip`), in parallel on native
/// targets. Purely elementwise, so the result is the serial result bit for bit.
fn par_zip(a: &[f32], b: &[f32], f: impl Fn(f32, f32) -> f32 + Sync + Send) -> Vec<f32> {
const CHUNK: usize = 1 << 14;
let mut out = vec![0.0f32; a.len().min(b.len())];
let fill = |o: &mut [f32], a: &[f32], b: &[f32]| {
for ((o, &a), &b) in o.iter_mut().zip(a).zip(b) {
*o = f(a, b);
}
};
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
out.par_chunks_mut(CHUNK).zip(a.par_chunks(CHUNK)).zip(b.par_chunks(CHUNK)).for_each(|((o, a), b)| fill(o, a, b));
}
#[cfg(target_arch = "wasm32")]
out.chunks_mut(CHUNK).zip(a.chunks(CHUNK)).zip(b.chunks(CHUNK)).for_each(|((o, a), b)| fill(o, a, b));
out
}
/// `f(a[i])` for every `i`, in parallel on native targets (elementwise, so bit for bit serial).
fn par_map1(a: &[f32], f: impl Fn(f32) -> f32 + Sync + Send) -> Vec<f32> {
par_zip(a, a, |x, _| f(x))
}
// `reduce`, `expand` and `push_pull` run one pyramid level for one image at a time, and the
// images themselves only fill part of a many-core machine, so each of them also runs its rows
// in parallel. Every output element is computed by the same expression, in the same order, as
// the one-thread loops they replace (rows never read each other's output), so the result is
// bit for bit unchanged.
fn reduce(w: usize, h: usize, ch: usize, src: &[f32]) -> (usize, usize, Vec<f32>) {
let (nw, nh) = (w.div_ceil(2), h.div_ceil(2));
let k = [1.0f32, 4.0, 6.0, 4.0, 1.0];
// Separable 5-tap binomial, then decimate.
let mut tmp = vec![0.0f32; nw * h * ch];
for y in 0..h {
par_rows(&mut tmp, nw, ch, |y, row| {
let line = &src[y * w * ch..(y + 1) * w * ch];
for x in 0..nw {
// The clamped source column of each tap is the same for every channel.
let xs: [usize; 5] = std::array::from_fn(|t| (2 * x as i64 + t as i64 - 2).clamp(0, w as i64 - 1) as usize * ch);
for c in 0..ch {
let mut s = 0.0;
for (t, kv) in k.iter().enumerate() {
let xx = (2 * x as i64 + t as i64 - 2).clamp(0, w as i64 - 1) as usize;
s += kv * src[(y * w + xx) * ch + c];
for (kv, xx) in k.iter().zip(xs) {
s += kv * line[xx + c];
}
tmp[(y * nw + x) * ch + c] = s / 16.0;
row[x * ch + c] = s / 16.0;
}
}
}
});
let mut out = vec![0.0f32; nw * nh * ch];
for y in 0..nh {
for x in 0..nw {
for c in 0..ch {
let mut s = 0.0;
for (t, kv) in k.iter().enumerate() {
let yy = (2 * y as i64 + t as i64 - 2).clamp(0, h as i64 - 1) as usize;
s += kv * tmp[(yy * nw + x) * ch + c];
}
out[(y * nw + x) * ch + c] = s / 16.0;
par_rows(&mut out, nw, ch, |y, row| {
// Each output row is five source rows added in tap order: the same sum per element as
// before (`0 + k0·a + k1·b + …`), but over whole rows so it vectorises.
for (t, kv) in k.iter().enumerate() {
let yy = (2 * y as i64 + t as i64 - 2).clamp(0, h as i64 - 1) as usize;
for (o, &v) in row.iter_mut().zip(&tmp[yy * nw * ch..(yy + 1) * nw * ch]) {
*o += kv * v;
}
}
}
for o in row.iter_mut() {
*o /= 16.0;
}
});
(nw, nh, out)
}
fn expand(sw: usize, sh: usize, ch: usize, src: &[f32], w: usize, h: usize) -> Vec<f32> {
// Bilinear upsampling at half-pixel-aligned positions.
let mut out = vec![0.0f32; w * h * ch];
for y in 0..h {
let fy = ((y as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (sh - 1) as f32);
let (y0, ty) = (fy.floor() as usize, fy - fy.floor());
let y1 = (y0 + 1).min(sh - 1);
for x in 0..w {
// Source columns and weights depend on x only: worked out once instead of for every row.
// The clamped position is never negative, so truncating is `floor` (and spares the libm call).
let cols: Vec<(usize, usize, f32)> = (0..w)
.map(|x| {
let fx = ((x as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (sw - 1) as f32);
let (x0, tx) = (fx.floor() as usize, fx - fx.floor());
let x1 = (x0 + 1).min(sw - 1);
for c in 0..ch {
let p = |xx: usize, yy: usize| src[(yy * sw + xx) * ch + c];
let top = p(x0, y0) + (p(x1, y0) - p(x0, y0)) * tx;
let bot = p(x0, y1) + (p(x1, y1) - p(x0, y1)) * tx;
out[(y * w + x) * ch + c] = top + (bot - top) * ty;
let x0 = fx as usize;
(x0 * ch, (x0 + 1).min(sw - 1) * ch, fx - x0 as f32)
})
.collect();
par_rows(&mut out, w, ch, |y, row| {
let fy = ((y as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (sh - 1) as f32);
let y0 = fy as usize;
let ty = fy - y0 as f32;
let y1 = (y0 + 1).min(sh - 1);
let (r0, r1) = (&src[y0 * sw * ch..(y0 + 1) * sw * ch], &src[y1 * sw * ch..(y1 + 1) * sw * ch]);
for (px, &(x0, x1, tx)) in row.chunks_exact_mut(ch.max(1)).zip(&cols) {
for (c, o) in px.iter_mut().enumerate() {
let top = r0[x0 + c] + (r0[x1 + c] - r0[x0 + c]) * tx;
let bot = r1[x0 + c] + (r1[x1 + c] - r1[x0 + c]) * tx;
*o = top + (bot - top) * ty;
}
}
}
});
out
}
@@ -1058,47 +1099,99 @@ fn push_pull(w: usize, h: usize, ch: usize, px: &mut [f32], wt: &[f32]) {
return;
}
let (nw, nh) = (w.div_ceil(2), h.div_ceil(2));
// Coarse sums. A coarse row is fed by two fine rows; walking them in raster order adds each
// coarse cell's (up to) four fine pixels in the order the serial scatter-add did, from zero.
let mut cpx = vec![0.0f32; nw * nh * ch];
let mut cw = vec![0.0f32; nw * nh];
for y in 0..h {
for x in 0..w {
let (i, j) = (y * w + x, (y / 2) * nw + x / 2);
let a = wt[i];
cw[j] += a;
for c in 0..ch {
cpx[j * ch + c] += a * px[i * ch + c];
par_rows(&mut cw, nw, 1, |cy, row| {
for y in 2 * cy..(2 * cy + 2).min(h) {
for (x, &a) in wt[y * w..(y + 1) * w].iter().enumerate() {
row[x / 2] += a;
}
}
}
for j in 0..nw * nh {
if cw[j] > 0.0 {
for c in 0..ch {
cpx[j * ch + c] /= cw[j];
});
let px_in: &[f32] = px;
par_rows(&mut cpx, nw, ch, |cy, row| {
for y in 2 * cy..(2 * cy + 2).min(h) {
for x in 0..w {
let (i, j) = (y * w + x, x / 2);
let a = wt[i];
for c in 0..ch {
row[j * ch + c] += a * px_in[i * ch + c];
}
}
cw[j] = 1.0;
}
});
// Normalise by the weight (this reads `cw` before it is reset to 0 / 1 below).
par_rows(&mut cpx, nw, ch, |cy, row| {
for cx in 0..nw {
let wj = cw[cy * nw + cx];
if wj > 0.0 {
for c in 0..ch {
row[cx * ch + c] /= wj;
}
}
}
});
for v in cw.iter_mut() {
if *v > 0.0 {
*v = 1.0;
}
}
if cw.contains(&0.0) && cw.iter().any(|v| *v > 0.0) {
push_pull(nw, nh, ch, &mut cpx, &cw);
}
for y in 0..h {
par_rows(px, w, ch, |y, row| {
for x in 0..w {
let i = y * w + x;
if wt[i] < 1.0 {
let j = (y / 2) * nw + x / 2;
let a = wt[i];
for c in 0..ch {
px[i * ch + c] = a * px[i * ch + c] + (1.0 - a) * cpx[j * ch + c];
row[x * ch + c] = a * row[x * ch + c] + (1.0 - a) * cpx[j * ch + c];
}
}
}
}
});
}
/// One image's pyramid in [`multiband`]: the grid origin `(gx0, gy0)` at level 0, the Laplacian
/// levels `(w, h, colours)` and the Gaussian weight levels.
type PyramidBand = (i32, i32, Vec<(usize, usize, Vec<f32>)>, Vec<Vec<f32>>);
/// The part of `band` on panorama row `py` of level `l`: the panorama column its first pixel
/// lands on, its width, its weight row and its colour row. `None` when it misses the row.
fn band_row(band: &PyramidBand, l: usize, ch: usize, py: usize) -> Option<(usize, usize, &[f32], &[f32])> {
let (ox, oy) = ((band.0 >> l) as usize, (band.1 >> l) as usize);
let (lw, lh, colours) = &band.2[l];
(py >= oy && py - oy < *lh).then(|| {
let y = py - oy;
(ox, *lw, &band.3[l][y * lw..(y + 1) * lw], &colours[y * lw * ch..(y + 1) * lw * ch])
})
}
/// Estimated bytes of band construction [`multiband`] keeps in flight at once. Images are built
/// and accumulated in batches under it, so its peak memory doesn't grow with the image count
/// (#7758). Four 24 MP RGB sources (~1.2 GB each) fit in one batch: building bands side by side
/// uses a many-core machine better than one band's row-parallel work, and one at a time made the
/// usual 2–4 image Photomerge 12–36% slower on a 32-thread desktop.
#[cfg(not(target_arch = "wasm32"))]
const BAND_BUDGET: usize = 5 << 30;
/// Without threads the bands of a batch are built one after another anyway, so each is added as
/// soon as it is built (the browser's address space is 4 GB).
#[cfg(target_arch = "wasm32")]
const BAND_BUDGET: usize = 0;
/// Multi-band blend of `imgs` into a `w × h` panorama (origin 0,0) with one weight map per image
/// over the same grid as its ROI (`weights[i]` has `w_i × h_i` entries). Returns the colour
/// channels (`ch` per pixel) and the total coverage.
pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec<f32>], levels: usize) -> (Vec<f32>, Vec<f32>) {
multiband_batched(w, h, imgs, weights, levels, BAND_BUDGET)
}
/// [`multiband`], building at most `budget` estimated bytes of bands at a time (at least one
/// image). Each pixel still adds the bands in image order, so the result doesn't depend on it.
fn multiband_batched(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec<f32>], levels: usize, budget: usize) -> (Vec<f32>, Vec<f32>) {
let ch = imgs.first().map_or(1, |i| i.ch);
let levels = levels.max(1);
let align = 1i32 << (levels - 1);
@@ -1112,87 +1205,144 @@ pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec<f32>], le
let mut wsum: Vec<Vec<f32>> = sizes.iter().map(|&(lw, lh)| vec![0.0; lw * lh]).collect();
let margin = align * 4;
// Each image on its ROI grown by a margin and aligned to the coarsest level's grid.
let bands = par_map(imgs.len(), |k| {
let im = &imgs[k];
let grid = |im: &RoiImage| {
let gx0 = ((im.x0 - margin).max(0) / align) * align;
let gy0 = ((im.y0 - margin).max(0) / align) * align;
let gx1 = (im.x0 + im.w as i32 + margin).min(w as i32);
let gy1 = (im.y0 + im.h as i32 + margin).min(h as i32);
let (gw, gh) = ((gx1 - gx0).max(1) as usize, (gy1 - gy0).max(1) as usize);
(gx0, gy0, (gx1 - gx0).max(1) as usize, (gy1 - gy0).max(1) as usize)
};
// Building one band holds its grid's colour and weight planes about three times over: the
// pyramids are 4/3 of a plane, and the grid copy, coverage and `expand` scratch come on top.
let cost = |im: &RoiImage| {
let (_, _, gw, gh) = grid(im);
gw.saturating_mul(gh).saturating_mul(ch + 1).saturating_mul(4 * 3)
};
let band = |k: usize| -> PyramidBand {
let im = &imgs[k];
let (gx0, gy0, gw, gh) = grid(im);
// Each grid pixel takes the image pixel that lands on it (every image pixel lands on at
// most one), so rows of the grid are independent and fill in parallel.
let src_of = |gx: usize, gy: usize| -> Option<usize> {
let (x, y) = (gx as i64 + gx0 as i64 - im.x0 as i64, gy as i64 + gy0 as i64 - im.y0 as i64);
(x >= 0 && y >= 0 && (x as usize) < im.w && (y as usize) < im.h).then(|| y as usize * im.w + x as usize)
};
let mut px = vec![0.0f32; gw * gh * ch];
let mut cov = vec![0.0f32; gw * gh];
let mut wt = vec![0.0f32; gw * gh];
for y in 0..im.h {
for x in 0..im.w {
let (gx, gy) = (im.x0 + x as i32 - gx0, im.y0 + y as i32 - gy0);
if gx < 0 || gy < 0 || gx as usize >= gw || gy as usize >= gh {
continue;
par_rows(&mut px, gw, ch, |gy, row| {
for gx in 0..gw {
if let Some(i) = src_of(gx, gy) {
row[gx * ch..(gx + 1) * ch].copy_from_slice(&im.px[i * ch..(i + 1) * ch]);
}
let (i, g) = (y * im.w + x, gy as usize * gw + gx as usize);
cov[g] = im.alpha[i];
wt[g] = weights[k][i] * im.alpha[i].min(1.0);
px[g * ch..(g + 1) * ch].copy_from_slice(&im.px[i * ch..(i + 1) * ch]);
}
}
let bin: Vec<f32> = cov.iter().map(|a| if *a > 0.5 { 1.0 } else { 0.0 }).collect();
});
let mut cov = vec![0.0f32; gw * gh];
par_rows(&mut cov, gw, 1, |gy, row| {
for (gx, o) in row.iter_mut().enumerate() {
if let Some(i) = src_of(gx, gy) {
*o = im.alpha[i];
}
}
});
let mut wt = vec![0.0f32; gw * gh];
par_rows(&mut wt, gw, 1, |gy, row| {
for (gx, o) in row.iter_mut().enumerate() {
if let Some(i) = src_of(gx, gy) {
*o = weights[k][i] * im.alpha[i].min(1.0);
}
}
});
let bin: Vec<f32> = par_map1(&cov, |a| if a > 0.5 { 1.0 } else { 0.0 });
drop(cov);
push_pull(gw, gh, ch, &mut px, &bin);
// Laplacian of the colours, Gaussian of the weights.
drop(bin);
// Laplacian of the colours, Gaussian of the weights. Planes move into the pyramids rather
// than being copied: with one image built at a time, a copy is a serial pass over it.
let mut lap: Vec<(usize, usize, Vec<f32>)> = Vec::new();
let mut gau: Vec<Vec<f32>> = Vec::new();
let (mut cw, mut chh, mut cur, mut cwt) = (gw, gh, px, wt);
for l in 0..levels {
if l + 1 == levels {
lap.push((cw, chh, cur.clone()));
gau.push(cwt.clone());
lap.push((cw, chh, cur));
gau.push(cwt);
break;
}
let (nw, nh, down) = reduce(cw, chh, ch, &cur);
let up = expand(nw, nh, ch, &down, cw, chh);
lap.push((cw, chh, cur.iter().zip(&up).map(|(a, b)| a - b).collect()));
gau.push(cwt.clone());
lap.push((cw, chh, par_zip(&cur, &up, |a, b| a - b)));
let (_, _, wdown) = reduce(cw, chh, 1, &cwt);
gau.push(cwt);
cw = nw;
chh = nh;
cur = down;
cwt = wdown;
}
(gx0, gy0, lap, gau)
});
for (gx0, gy0, lap, gau) in &bands {
for (l, ((lw, lh, band), g)) in lap.iter().zip(gau).enumerate() {
let (ox, oy) = ((gx0 >> l) as usize, (gy0 >> l) as usize);
let (pw, ph) = sizes[l];
for y in 0..*lh {
let py = oy + y;
if py >= ph {
break;
}
for x in 0..*lw {
let px = ox + x;
if px >= pw {
break;
}
let (i, p) = (y * lw + x, py * pw + px);
let k = g[i];
if k <= 0.0 {
continue;
}
wsum[l][p] += k;
for c in 0..ch {
acc[l][p * ch + c] += k * band[i * ch + c];
}
}
}
};
// Build the bands a batch at a time and add each batch before building the next. Each pixel
// adds the bands in the same order as the serial loop (per level, bands one after another;
// batches follow each other in image order), but rows of a level are independent, so they
// run in parallel: every row walks the batch's bands itself.
let mut start = 0;
while start < imgs.len() {
let mut end = start + 1;
let mut bytes = cost(&imgs[start]);
while let Some(im) = imgs.get(end)
&& bytes.saturating_add(cost(im)) <= budget
{
bytes = bytes.saturating_add(cost(im));
end += 1;
}
let bands = par_map(end - start, |j| band(start + j));
for l in 0..levels {
let pw = sizes[l].0;
par_rows(&mut wsum[l], pw, 1, |py, row| {
for b in &bands {
let Some((ox, lw, g, _)) = band_row(b, l, ch, py) else { continue };
for x in 0..lw {
let px = ox + x;
if px >= pw {
break;
}
let k = g[x];
if k <= 0.0 {
continue;
}
row[px] += k;
}
}
});
par_rows(&mut acc[l], pw, ch, |py, row| {
for b in &bands {
let Some((ox, lw, g, band)) = band_row(b, l, ch, py) else { continue };
for x in 0..lw {
let px = ox + x;
if px >= pw {
break;
}
let k = g[x];
if k <= 0.0 {
continue;
}
for c in 0..ch {
row[px * ch + c] += k * band[x * ch + c];
}
}
}
});
}
start = end;
}
for l in 0..levels {
let (pw, ph) = sizes[l];
for p in 0..pw * ph {
let k = wsum[l][p];
for c in 0..ch {
acc[l][p * ch + c] = if k > 1e-8 { acc[l][p * ch + c] / k } else { 0.0 };
let pw = sizes[l].0;
let ws = &wsum[l];
par_rows(&mut acc[l], pw, ch, |py, row| {
for x in 0..pw {
let k = ws[py * pw + x];
for c in 0..ch {
row[x * ch + c] = if k > 1e-8 { row[x * ch + c] / k } else { 0.0 };
}
}
}
});
}
// Collapse.
// `levels ≥ 1`, so there is always a coarsest level.
@@ -1201,22 +1351,27 @@ pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec<f32>], le
let (pw, ph) = sizes[l];
let (sw, sh) = sizes[l + 1];
let up = expand(sw, sh, ch, &cur, pw, ph);
cur = acc[l].iter().zip(&up).map(|(a, b)| a + b).collect();
cur = par_zip(&acc[l], &up, |a, b| a + b);
}
// Coverage: union of the images.
// Coverage: union of the images. Rows are independent; each walks the images in order, so a
// pixel sees the same sequence of `max` calls as in the serial loop.
let mut cov = vec![0.0f32; w * h];
for im in imgs {
for y in 0..im.h {
par_rows(&mut cov, w, 1, |py, row| {
for im in imgs {
let y = py as i64 - im.y0 as i64;
if y < 0 || y as usize >= im.h {
continue;
}
for x in 0..im.w {
let (px, py) = (im.x0 + x as i32, im.y0 + y as i32);
if px < 0 || py < 0 || px as usize >= w || py as usize >= h {
let px = im.x0 + x as i32;
if px < 0 || px as usize >= w {
continue;
}
let p = py as usize * w + px as usize;
cov[p] = cov[p].max(im.alpha[y * im.w + x]);
let p = px as usize;
row[p] = row[p].max(im.alpha[y as usize * im.w + x]);
}
}
}
});
(cur, cov)
}
@@ -1431,3 +1586,411 @@ mod tests {
assert!(cov.iter().all(|c| *c == 1.0));
}
}
/// The row-parallel `reduce` / `expand` / `push_pull` / `multiband` against the serial code they
/// replaced, kept below verbatim as the reference. Every output must stay bit for bit the same.
#[cfg(test)]
mod parallel_tests {
use super::*;
fn mix(mut z: u64) -> u64 {
z = z.wrapping_add(0x9E37_79B9_7F4A_7C15);
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
/// Deterministic data from integer arithmetic only (no libm): mostly `0..1`, with negatives,
/// signed zeros and large values so a different order of operations cannot hide.
fn noise(len: usize, seed: u64) -> Vec<f32> {
(0..len as u64)
.map(|i| {
let z = mix(seed.wrapping_mul(0x1234_5678_9ABC).wrapping_add(i));
let v = (z >> 40) as f32 / 16_777_216.0;
match z & 31 {
0 => -v,
1 => -0.0,
2 => v * 1000.0,
3 => 0.0,
_ => v,
}
})
.collect()
}
/// Weights with exact zeros, exact ones and partial values, in blobs and noise.
fn weights(w: usize, h: usize, seed: u64) -> Vec<f32> {
(0..w * h)
.map(|i| {
let (x, y) = (i % w, i / w);
let z = mix(seed + i as u64);
if (x / 5 + y / 3) % 3 == 0 {
0.0
} else if z.is_multiple_of(7) {
((z >> 20) % 100) as f32 / 100.0
} else {
1.0
}
})
.collect()
}
fn bits(v: &[f32]) -> Vec<u32> {
v.iter().map(|x| x.to_bits()).collect()
}
fn fnv(parts: &[&[f32]]) -> u64 {
let mut h = 0xcbf2_9ce4_8422_2325u64;
for p in parts {
for x in *p {
h ^= x.to_bits() as u64;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
}
h
}
/// Sizes that cross the odd / even and tiny cases (1 wide, 1 high, 1×1, odd, power of two + 1).
const SIZES: [(usize, usize); 12] = [(1, 1), (1, 9), (9, 1), (2, 2), (3, 5), (4, 4), (17, 13), (64, 40), (65, 41), (97, 61), (130, 7), (7, 130)];
fn ref_reduce(w: usize, h: usize, ch: usize, src: &[f32]) -> (usize, usize, Vec<f32>) {
let (nw, nh) = (w.div_ceil(2), h.div_ceil(2));
let k = [1.0f32, 4.0, 6.0, 4.0, 1.0];
// Separable 5-tap binomial, then decimate.
let mut tmp = vec![0.0f32; nw * h * ch];
for y in 0..h {
for x in 0..nw {
for c in 0..ch {
let mut s = 0.0;
for (t, kv) in k.iter().enumerate() {
let xx = (2 * x as i64 + t as i64 - 2).clamp(0, w as i64 - 1) as usize;
s += kv * src[(y * w + xx) * ch + c];
}
tmp[(y * nw + x) * ch + c] = s / 16.0;
}
}
}
let mut out = vec![0.0f32; nw * nh * ch];
for y in 0..nh {
for x in 0..nw {
for c in 0..ch {
let mut s = 0.0;
for (t, kv) in k.iter().enumerate() {
let yy = (2 * y as i64 + t as i64 - 2).clamp(0, h as i64 - 1) as usize;
s += kv * tmp[(yy * nw + x) * ch + c];
}
out[(y * nw + x) * ch + c] = s / 16.0;
}
}
}
(nw, nh, out)
}
fn ref_expand(sw: usize, sh: usize, ch: usize, src: &[f32], w: usize, h: usize) -> Vec<f32> {
// Bilinear upsampling at half-pixel-aligned positions.
let mut out = vec![0.0f32; w * h * ch];
for y in 0..h {
let fy = ((y as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (sh - 1) as f32);
let (y0, ty) = (fy.floor() as usize, fy - fy.floor());
let y1 = (y0 + 1).min(sh - 1);
for x in 0..w {
let fx = ((x as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (sw - 1) as f32);
let (x0, tx) = (fx.floor() as usize, fx - fx.floor());
let x1 = (x0 + 1).min(sw - 1);
for c in 0..ch {
let p = |xx: usize, yy: usize| src[(yy * sw + xx) * ch + c];
let top = p(x0, y0) + (p(x1, y0) - p(x0, y0)) * tx;
let bot = p(x0, y1) + (p(x1, y1) - p(x0, y1)) * tx;
out[(y * w + x) * ch + c] = top + (bot - top) * ty;
}
}
}
out
}
fn ref_push_pull(w: usize, h: usize, ch: usize, px: &mut [f32], wt: &[f32]) {
if w <= 1 && h <= 1 {
return;
}
let (nw, nh) = (w.div_ceil(2), h.div_ceil(2));
let mut cpx = vec![0.0f32; nw * nh * ch];
let mut cw = vec![0.0f32; nw * nh];
for y in 0..h {
for x in 0..w {
let (i, j) = (y * w + x, (y / 2) * nw + x / 2);
let a = wt[i];
cw[j] += a;
for c in 0..ch {
cpx[j * ch + c] += a * px[i * ch + c];
}
}
}
for j in 0..nw * nh {
if cw[j] > 0.0 {
for c in 0..ch {
cpx[j * ch + c] /= cw[j];
}
cw[j] = 1.0;
}
}
if cw.contains(&0.0) && cw.iter().any(|v| *v > 0.0) {
ref_push_pull(nw, nh, ch, &mut cpx, &cw);
}
for y in 0..h {
for x in 0..w {
let i = y * w + x;
if wt[i] < 1.0 {
let j = (y / 2) * nw + x / 2;
let a = wt[i];
for c in 0..ch {
px[i * ch + c] = a * px[i * ch + c] + (1.0 - a) * cpx[j * ch + c];
}
}
}
}
}
fn ref_multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec<f32>], levels: usize) -> (Vec<f32>, Vec<f32>) {
let ch = imgs.first().map_or(1, |i| i.ch);
let levels = levels.max(1);
let align = 1i32 << (levels - 1);
// Level sizes of the panorama.
let mut sizes = vec![(w, h)];
for _ in 1..levels {
let Some(&(pw, ph)) = sizes.last() else { break };
sizes.push((pw.div_ceil(2), ph.div_ceil(2)));
}
let mut acc: Vec<Vec<f32>> = sizes.iter().map(|&(lw, lh)| vec![0.0; lw * lh * ch]).collect();
let mut wsum: Vec<Vec<f32>> = sizes.iter().map(|&(lw, lh)| vec![0.0; lw * lh]).collect();
let margin = align * 4;
// Each image on its ROI grown by a margin and aligned to the coarsest level's grid.
let bands = par_map(imgs.len(), |k| {
let im = &imgs[k];
let gx0 = ((im.x0 - margin).max(0) / align) * align;
let gy0 = ((im.y0 - margin).max(0) / align) * align;
let gx1 = (im.x0 + im.w as i32 + margin).min(w as i32);
let gy1 = (im.y0 + im.h as i32 + margin).min(h as i32);
let (gw, gh) = ((gx1 - gx0).max(1) as usize, (gy1 - gy0).max(1) as usize);
let mut px = vec![0.0f32; gw * gh * ch];
let mut cov = vec![0.0f32; gw * gh];
let mut wt = vec![0.0f32; gw * gh];
for y in 0..im.h {
for x in 0..im.w {
let (gx, gy) = (im.x0 + x as i32 - gx0, im.y0 + y as i32 - gy0);
if gx < 0 || gy < 0 || gx as usize >= gw || gy as usize >= gh {
continue;
}
let (i, g) = (y * im.w + x, gy as usize * gw + gx as usize);
cov[g] = im.alpha[i];
wt[g] = weights[k][i] * im.alpha[i].min(1.0);
px[g * ch..(g + 1) * ch].copy_from_slice(&im.px[i * ch..(i + 1) * ch]);
}
}
let bin: Vec<f32> = cov.iter().map(|a| if *a > 0.5 { 1.0 } else { 0.0 }).collect();
ref_push_pull(gw, gh, ch, &mut px, &bin);
// Laplacian of the colours, Gaussian of the weights.
let mut lap: Vec<(usize, usize, Vec<f32>)> = Vec::new();
let mut gau: Vec<Vec<f32>> = Vec::new();
let (mut cw, mut chh, mut cur, mut cwt) = (gw, gh, px, wt);
for l in 0..levels {
if l + 1 == levels {
lap.push((cw, chh, cur.clone()));
gau.push(cwt.clone());
break;
}
let (nw, nh, down) = ref_reduce(cw, chh, ch, &cur);
let up = ref_expand(nw, nh, ch, &down, cw, chh);
lap.push((cw, chh, cur.iter().zip(&up).map(|(a, b)| a - b).collect()));
gau.push(cwt.clone());
let (_, _, wdown) = ref_reduce(cw, chh, 1, &cwt);
cw = nw;
chh = nh;
cur = down;
cwt = wdown;
}
(gx0, gy0, lap, gau)
});
for (gx0, gy0, lap, gau) in &bands {
for (l, ((lw, lh, band), g)) in lap.iter().zip(gau).enumerate() {
let (ox, oy) = ((gx0 >> l) as usize, (gy0 >> l) as usize);
let (pw, ph) = sizes[l];
for y in 0..*lh {
let py = oy + y;
if py >= ph {
break;
}
for x in 0..*lw {
let px = ox + x;
if px >= pw {
break;
}
let (i, p) = (y * lw + x, py * pw + px);
let k = g[i];
if k <= 0.0 {
continue;
}
wsum[l][p] += k;
for c in 0..ch {
acc[l][p * ch + c] += k * band[i * ch + c];
}
}
}
}
}
for l in 0..levels {
let (pw, ph) = sizes[l];
for p in 0..pw * ph {
let k = wsum[l][p];
for c in 0..ch {
acc[l][p * ch + c] = if k > 1e-8 { acc[l][p * ch + c] / k } else { 0.0 };
}
}
}
// Collapse.
// `levels ≥ 1`, so there is always a coarsest level.
let Some(mut cur) = acc.pop() else { return (vec![0.0; w * h * ch], vec![0.0; w * h]) };
for l in (0..levels - 1).rev() {
let (pw, ph) = sizes[l];
let (sw, sh) = sizes[l + 1];
let up = ref_expand(sw, sh, ch, &cur, pw, ph);
cur = acc[l].iter().zip(&up).map(|(a, b)| a + b).collect();
}
// Coverage: union of the images.
let mut cov = vec![0.0f32; w * h];
for im in imgs {
for y in 0..im.h {
for x in 0..im.w {
let (px, py) = (im.x0 + x as i32, im.y0 + y as i32);
if px < 0 || py < 0 || px as usize >= w || py as usize >= h {
continue;
}
let p = py as usize * w + px as usize;
cov[p] = cov[p].max(im.alpha[y * im.w + x]);
}
}
}
(cur, cov)
}
#[test]
fn reduce_and_expand_match_the_serial_code() {
for (n, &(w, h)) in SIZES.iter().enumerate() {
for ch in [1usize, 3, 4] {
let src = noise(w * h * ch, n as u64 * 7 + ch as u64);
let (nw, nh, got) = reduce(w, h, ch, &src);
let (rw, rh, want) = ref_reduce(w, h, ch, &src);
assert_eq!((nw, nh), (rw, rh));
assert_eq!(bits(&got), bits(&want), "reduce {w}x{h}x{ch}");
// Expand back to the original size and to sizes that are not twice the source's.
for (ew, eh) in [(w, h), (2 * nw, 2 * nh), (2 * nw - 1, 2 * nh + 1), (w + 3, h + 5)] {
let got = expand(nw, nh, ch, &want, ew, eh);
let want_up = ref_expand(nw, nh, ch, &want, ew, eh);
assert_eq!(bits(&got), bits(&want_up), "expand {nw}x{nh}x{ch} to {ew}x{eh}");
}
}
}
}
#[test]
fn push_pull_matches_the_serial_code() {
for (n, &(w, h)) in SIZES.iter().enumerate() {
for ch in [1usize, 3] {
for variant in 0..3u64 {
let wt = match variant {
0 => weights(w, h, n as u64),
1 => vec![1.0; w * h],
// A single covered pixel: every level of the recursion has to fill from it.
_ => (0..w * h).map(|i| if i == (w * h) / 3 { 1.0 } else { 0.0 }).collect(),
};
let px = noise(w * h * ch, 100 + n as u64);
let (mut got, mut want) = (px.clone(), px);
push_pull(w, h, ch, &mut got, &wt);
ref_push_pull(w, h, ch, &mut want, &wt);
assert_eq!(bits(&got), bits(&want), "push_pull {w}x{h}x{ch} variant {variant}");
}
}
}
}
/// ROIs of assorted sizes and offsets (some sticking out of the canvas), with holes in the alpha.
fn rois(w: usize, h: usize, ch: usize, seed: u64) -> (Vec<RoiImage>, Vec<Vec<f32>>) {
let specs =
[(0i32, 0i32, w * 2 / 3, h), (w as i32 / 3, -(h as i32) / 5, w * 2 / 3 + 1, h * 3 / 4 + 1), (-(w as i32) / 4, h as i32 / 2, w / 2 + 1, h / 2 + 3)];
let imgs: Vec<RoiImage> = specs
.iter()
.enumerate()
.map(|(k, &(x0, y0, iw, ih))| {
let (iw, ih) = (iw.max(1), ih.max(1));
let mut alpha = vec![1.0f32; iw * ih];
for (i, a) in alpha.iter_mut().enumerate() {
let z = mix(seed + k as u64 * 1000 + i as u64);
if (i % iw) / 4 % 5 == 2 && (i / iw) % 6 < 3 {
*a = 0.0;
} else if z.is_multiple_of(11) {
*a = 0.3;
}
}
RoiImage { x0, y0, w: iw, h: ih, ch, px: noise(iw * ih * ch, seed + k as u64), alpha }
})
.collect();
let wts = imgs.iter().enumerate().map(|(k, r)| weights(r.w, r.h, seed + 50 + k as u64)).collect();
(imgs, wts)
}
#[test]
fn multiband_matches_the_serial_code() {
for (n, &(w, h)) in [(1usize, 1usize), (5, 3), (40, 30), (97, 61), (130, 70), (64, 64), (201, 129)].iter().enumerate() {
for ch in [1usize, 3] {
let (imgs, wts) = rois(w, h, ch, n as u64 * 31 + ch as u64);
for levels in [1usize, 2, 3, 5, 7] {
let got = multiband(w, h, &imgs, &wts, levels);
let want = ref_multiband(w, h, &imgs, &wts, levels);
assert_eq!(bits(&got.0), bits(&want.0), "colours {w}x{h}x{ch} levels {levels}");
assert_eq!(bits(&got.1), bits(&want.1), "coverage {w}x{h}x{ch} levels {levels}");
}
}
}
// No images at all, and zero levels.
assert_eq!(multiband(9, 7, &[], &[], 3).0.len(), ref_multiband(9, 7, &[], &[], 3).0.len());
}
/// Batching the bands (#7758) changes when they are built, not the result. Budgets from none
/// (one image per batch) up to everything in steps of a fifth pass through every split of the
/// three images.
#[test]
fn multiband_batches_match_the_serial_code() {
let budgets: Vec<usize> =
std::iter::successors(Some(1024.0f64), |b| Some(b * 1.2)).take_while(|b| *b < 4e6).map(|b| b as usize).chain([0, usize::MAX]).collect();
for (n, &(w, h)) in [(40usize, 30usize), (97, 61), (201, 129)].iter().enumerate() {
for ch in [1usize, 3] {
let (imgs, wts) = rois(w, h, ch, n as u64 * 17 + ch as u64);
for levels in [1usize, 3, 5] {
let want = ref_multiband(w, h, &imgs, &wts, levels);
for &budget in &budgets {
let got = multiband_batched(w, h, &imgs, &wts, levels, budget);
assert_eq!(bits(&got.0), bits(&want.0), "colours {w}x{h}x{ch} levels {levels} budget {budget}");
assert_eq!(bits(&got.1), bits(&want.1), "coverage {w}x{h}x{ch} levels {levels} budget {budget}");
}
}
}
}
}
/// Hashes recorded from the code before it was made parallel (plain f32 arithmetic only, so
/// they hold on every platform).
#[test]
fn multiband_hashes_recorded_from_the_serial_code() {
let (imgs, wts) = rois(97, 61, 3, 5);
let (px, cov) = multiband(97, 61, &imgs, &wts, 5);
assert_eq!(fnv(&[&px, &cov]), 0xf89a5726082ebb6c, "97x61 rgb, 5 levels");
let (imgs, wts) = rois(130, 70, 1, 9);
let (px, cov) = multiband(130, 70, &imgs, &wts, 7);
assert_eq!(fnv(&[&px, &cov]), 0xb7d7ddc59d201730, "130x70 grey, 7 levels");
let wt = weights(65, 41, 3);
let mut px = noise(65 * 41 * 3, 4);
push_pull(65, 41, 3, &mut px, &wt);
assert_eq!(fnv(&[&px]), 0x50ea36a84ca9a68f, "push_pull 65x41 rgb");
let (nw, nh, r) = reduce(65, 41, 3, &px);
assert_eq!(fnv(&[&r, &expand(nw, nh, 3, &r, 65, 41)]), 0x51823acfb4206dd8, "reduce + expand 65x41 rgb");
}
}
+3
View File
@@ -182,6 +182,9 @@ pub enum DepthSource {
None,
Transparency,
LayerMask,
/// An alpha channel of the document (Photoshop lists them after Layer Mask), supplied by the
/// caller like the layer mask.
Channel,
}
/// Built-in Shape Blur kernels.
+47 -14
View File
@@ -159,15 +159,21 @@ pub(crate) fn par_map<T: Send>(n: usize, f: impl Fn(usize) -> T + Sync + Send) -
(0..n).map(f).collect()
}
/// Advances `state` one SplitMix64 step and returns the mixed output.
pub(crate) fn splitmix64(state: &mut u64) -> u64 {
*state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = *state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
/// Deterministic 64-bit generator (SplitMix64).
pub(crate) struct Rng(pub u64);
impl Rng {
/// The next value of the SplitMix64 sequence.
pub fn next(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
splitmix64(&mut self.0)
}
pub fn below(&mut self, n: usize) -> usize {
(self.next() % n.max(1) as u64) as usize
@@ -183,15 +189,7 @@ pub(crate) fn hash01(x: i64, y: i64, seed: u64) -> f32 {
(z >> 40) as f32 / (1u64 << 24) as f32
}
/// sRGB transfer function, decode (extended linearly below 0 and as a power above 1).
pub(crate) fn srgb_to_linear(v: f32) -> f32 {
if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
}
/// sRGB transfer function, encode.
pub(crate) fn linear_to_srgb(v: f32) -> f32 {
if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 }
}
pub(crate) use photocraft_color::convert::{linear_to_srgb, srgb_to_linear};
/// Bilinear sample of channel `c` of a `w × h × ch` buffer, clamped to the edges.
pub(crate) fn bilinear(buf: &[f32], w: usize, h: usize, ch: usize, c: usize, x: f32, y: f32) -> f32 {
@@ -217,6 +215,41 @@ pub(crate) fn catmull_rom(t: f32) -> [f32; 4] {
mod tests {
use super::*;
/// The shared step and [`Rng`] reproduce the inlined copies they replaced, bit for bit.
#[test]
fn splitmix64_output_is_unchanged() {
/// The SplitMix64 step as each generator inlined it before.
fn inlined(state: &mut u64) -> u64 {
*state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = *state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
let mut zero = 0;
assert_eq!([0; 3].map(|_| splitmix64(&mut zero)), [0xE220_A839_7B1D_CDAF, 0x6E78_9E6A_A1B9_65F4, 0x06C4_5D18_8009_454F]);
for seed in [0, 1, 42, 0x2545_F491_4F6C_DD1D, u64::MAX] {
let (mut shared, mut copy, mut rng) = (seed, seed, Rng(seed));
for _ in 0..10_000 {
let want = inlined(&mut copy);
assert_eq!(splitmix64(&mut shared), want);
assert_eq!(rng.next(), want);
}
}
}
/// The shared sRGB functions match the removed copies bit for bit.
#[test]
fn srgb_transfer_output_is_unchanged() {
let decode = |v: f32| if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) };
let encode = |v: f32| if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 };
for i in -2000..=6000 {
let v = i as f32 / 4000.0;
assert_eq!(srgb_to_linear(v).to_bits(), decode(v).to_bits(), "decode {v}");
assert_eq!(linear_to_srgb(v).to_bits(), encode(v).to_bits(), "encode {v}");
}
}
#[test]
fn dense_and_sparse_solvers_agree() {
// Overdetermined line fit y = 2x + 1.
+77
View File
@@ -562,6 +562,46 @@ pub fn to_bitmap(gray: &mut [f32], w: usize, method: BitmapMethod) {
}
}
/// The threshold map of Bitmap's Custom Pattern from a pattern's lightness (0..=1, row-major):
/// pattern pixels ranked by lightness, ties sharing their average rank, the lightest pixel
/// thresholding lowest (it turns white first). A map of `n` pixels thus spreads `n` evenly
/// spaced levels `(k + 0.5) / n`, whatever the pattern's own contrast, as Photoshop's does
/// (measured: a 2x2 pattern of 32/96/160/224, a 3x1 of 50/128/200 and a 7x1 ramp all give
/// exactly those levels).
pub fn custom_pattern_thresholds(lightness: &[f32]) -> Vec<f32> {
let n = lightness.len();
let key = |i: usize| lightness.get(i).copied().filter(|v| v.is_finite()).unwrap_or(0.0);
let mut order: Vec<usize> = (0..n).collect();
order.sort_by(|a, b| key(*a).total_cmp(&key(*b)));
let mut out = vec![0.5; n];
let mut start = 0;
while start < n {
let v = order.get(start).map(|i| key(*i));
let end = (start..n).find(|&j| order.get(j).map(|i| key(*i)) != v).unwrap_or(n);
// Ranks start..end share their mean.
let rank = (start + end - 1) as f32 / 2.0;
let t = 1.0 - (rank + 0.5) / n as f32;
for i in order.get(start..end).unwrap_or_default() {
if let Some(o) = out.get_mut(*i) {
*o = t;
}
}
start = end;
}
out
}
/// Bitmap's Custom Pattern: each gray value (0..=1, row-major `w` wide) turns white where it
/// reaches the threshold `map` (`mw` x `mh`, from [`custom_pattern_thresholds`]) tiled from the
/// image's top-left corner, black elsewhere. A map that doesn't fit `mw` x `mh` thresholds at 50%.
pub fn to_bitmap_custom(gray: &mut [f32], w: usize, map: &[f32], mw: usize, mh: usize) {
let fits = w > 0 && mw > 0 && mh > 0 && mw.checked_mul(mh) == Some(map.len());
for (i, v) in gray.iter_mut().enumerate() {
let t = if fits { map.get((i / w) % mh * mw + (i % w) % mw).copied().unwrap_or(0.5) } else { 0.5 };
*v = if *v >= t { 1.0 } else { 0.0 };
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -722,4 +762,41 @@ mod tests {
}
}
}
/// The first 8-bit gray level (of a 0..=255 ramp) that `t` turns white.
fn first_white(t: f32) -> u32 {
(0..=255).find(|v| *v as f32 / 255.0 >= t).unwrap_or(256)
}
#[test]
fn custom_pattern_thresholds_match_photoshop() {
// Photoshop (Image › Mode › Bitmap, Custom Pattern) on a 0..255 ramp: the first white
// level per pattern pixel, measured against defined patterns.
let l = |v: &[u8]| v.iter().map(|v| f32::from(*v) / 255.0).collect::<Vec<_>>();
let ramp7 = custom_pattern_thresholds(&l(&[0, 40, 80, 120, 160, 200, 240]));
assert_eq!(ramp7.iter().map(|t| first_white(*t)).collect::<Vec<_>>(), [237, 201, 164, 128, 92, 55, 19]);
let five = custom_pattern_thresholds(&l(&[10, 200, 90, 250, 150]));
assert_eq!(five.iter().map(|t| first_white(*t)).collect::<Vec<_>>(), [230, 77, 179, 26, 128]);
// Equal pixels share their rank: 100, 100 both sit at 50%.
let ties = custom_pattern_thresholds(&l(&[100, 100, 200, 50]));
assert_eq!(ties.iter().map(|t| first_white(*t)).collect::<Vec<_>>(), [128, 128, 32, 224]);
assert!(custom_pattern_thresholds(&[]).is_empty());
assert_eq!(custom_pattern_thresholds(&[f32::NAN, 0.3]).len(), 2);
}
#[test]
fn custom_pattern_tiles_from_the_top_left() {
// A 2x1 map: even columns need 75%, odd ones 25%.
let map = [0.75, 0.25];
let mut g = vec![0.5; 6];
to_bitmap_custom(&mut g, 3, &map, 2, 1);
assert_eq!(g, [0.0, 1.0, 0.0, 0.0, 1.0, 0.0]);
// A map that doesn't match its size, or an empty image width, is a 50% threshold.
let mut g = vec![0.4, 0.6];
to_bitmap_custom(&mut g, 2, &map, 3, 1);
assert_eq!(g, [0.0, 1.0]);
let mut g = vec![0.6];
to_bitmap_custom(&mut g, 0, &map, 2, 1);
assert_eq!(g, [1.0]);
}
}
+3 -2
View File
@@ -146,8 +146,9 @@ pub(crate) fn lens_flare(src: &Image, out: Rect, ctx: &Ctx, brightness: f32, cen
let mut c = rgba(ctx, px);
for k in 0..3 {
let a = (f[k] * bright).max(0.0);
// Screen keeps highlights below white for display-referred values.
c[k] = 1.0 - (1.0 - c[k]) * (1.0 - a.min(1.0));
// Screen (c + a·(1 − c)) keeps display-referred values below white; a 32-bit
// value already above white is left as is rather than pulled down towards it.
c[k] += a.min(1.0) * (1.0 - c[k]).max(0.0);
}
set_rgba(ctx, px, c);
}
+3 -5
View File
@@ -274,15 +274,13 @@ pub fn composite(surface: &mut Surface, prims: &[Prim], clip: Rect, selection: O
pub struct Rng(u64);
impl Rng {
/// A generator whose sequence is fixed by `seed` and `salt`.
pub fn new(seed: u32, salt: u64) -> Rng {
Rng(((seed as u64) << 32) ^ salt.wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ 0x2545_F491_4F6C_DD1D)
}
/// The next value of the SplitMix64 sequence.
pub fn next_u64(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
crate::photo_util::splitmix64(&mut self.0)
}
/// Uniform in [0, 1).
pub fn f(&mut self) -> f32 {
+29 -12
View File
@@ -124,12 +124,13 @@ pub fn local_blur(data: &[f32], w: usize, h: usize, ch: usize, alpha: Option<usi
let k: Vec<f32> = (-r..=r).map(|i| (-((i * i) as f32) / (2.0 * sigma * sigma).max(1e-6)).exp()).collect();
let ks: f32 = k.iter().sum();
let k: Vec<f32> = k.into_iter().map(|v| v / ks).collect();
// Horizontal pass over the window rows extended vertically by r.
// Horizontal pass over the window rows extended vertically by r. Rows are independent, so
// both passes run them in parallel: a 2500 px Blur dab is ~10⁹ multiply-adds (#5426).
let th = oh + 2 * r as usize;
let stride = ow * ch;
let mut tmp = vec![0.0f32; stride * th];
let mut row = vec![0.0f32; (ow + 2 * r as usize) * ch];
for ty in 0..th {
let scratch = || vec![0.0f32; (ow + 2 * r as usize) * ch];
let horizontal = |row: &mut Vec<f32>, (ty, dst): (usize, &mut [f32])| {
let y = (y0 as i32 + ty as i32 - r).clamp(0, h as i32 - 1) as usize;
// Clamp and premultiply each source pixel once, rather than once for every kernel tap.
for (rx, px) in row.chunks_exact_mut(ch).enumerate() {
@@ -145,7 +146,6 @@ pub fn local_blur(data: &[f32], w: usize, h: usize, ch: usize, alpha: Option<usi
}
}
}
let dst = &mut tmp[ty * stride..(ty + 1) * stride];
// Contiguous accumulation can be vectorized for any channel layout. Each output still
// sums the same products in the same kernel order as the scalar convolution.
for (i, kv) in k.iter().enumerate() {
@@ -153,26 +153,43 @@ pub fn local_blur(data: &[f32], w: usize, h: usize, ch: usize, alpha: Option<usi
*d += s * kv;
}
}
};
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
tmp.par_chunks_mut(stride).enumerate().for_each_init(scratch, horizontal);
}
#[cfg(target_arch = "wasm32")]
{
let mut row = scratch();
tmp.chunks_mut(stride).enumerate().for_each(|item| horizontal(&mut row, item));
}
let mut out = vec![0.0f32; ow * oh * ch];
for oy in 0..oh {
let dst = &mut out[oy * stride..(oy + 1) * stride];
let vertical = |(oy, dst): (usize, &mut [f32])| {
for (i, kv) in k.iter().enumerate() {
for (d, s) in dst.iter_mut().zip(&tmp[(oy + i) * stride..(oy + i + 1) * stride]) {
*d += s * kv;
}
}
for ox in 0..ow {
let o = (oy * ow + ox) * ch;
if let Some(a) = alpha {
let av = out[o + a];
for c in 0..ch {
if let Some(a) = alpha {
for (ox, px) in dst.chunks_exact_mut(ch).enumerate() {
let av = px[a];
for (c, v) in px.iter_mut().enumerate() {
if c != a {
out[o + c] = if av > 1e-6 { out[o + c] / av } else { data[((y0 + oy) * w + x0 + ox) * ch + c] };
*v = if av > 1e-6 { *v / av } else { data[((y0 + oy) * w + x0 + ox) * ch + c] };
}
}
}
}
};
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
out.par_chunks_mut(stride).enumerate().for_each(vertical);
}
#[cfg(target_arch = "wasm32")]
{
out.chunks_mut(stride).enumerate().for_each(vertical);
}
out
}
+6 -2
View File
@@ -126,7 +126,9 @@ pub fn object_select(sampler: &dyn Sampler, canvas: Rect, rect: Rect, work_px: u
}
let (fg, bg) = grabcut(&img, &mut trimap, 8)?;
let low: Vec<bool> = trimap.iter().map(|t| is_fg(*t)).collect();
let low = super::clean_mask(&low, lw, lh, 0.1, 0.002);
let low = super::clean_mask(&low, lw, lh, 0.1, 0.0);
// Every enclosed hole, however small, fills only when it looks like the object, so background seen through it stays open.
let low = super::fill_holes_by_model(&low, &img, &fg, &bg, 0.0, 0.05);
super::finish_region(sampler, window, step, &low, lw, lh, Some((&fg, &bg)))
}
@@ -209,7 +211,9 @@ pub fn object_at(sampler: &dyn Sampler, canvas: Rect, x: f32, y: f32, work_px: u
let mut seeds = vec![false; w * h];
seeds[sy * w + sx] = true;
let low = super::keep_seeded(&low, &seeds, w, h);
let low = super::clean_mask(&low, w, h, 0.1, 0.01);
let low = super::clean_mask(&low, w, h, 0.1, 0.0);
// As in the rectangle mode: enclosed holes fill only when they look like the object.
let low = super::fill_holes_by_model(&low, &img, &fg, &bg, 0.0, 0.05);
if !low.iter().any(|v| *v) {
return None;
}
+58
View File
@@ -912,3 +912,61 @@ fn select_subject_dump_pictures() {
write("ring", &img, None);
write("ring-selected", &img, reg.as_ref());
}
/// A dark square on a light background with a square hole that shows the background colour.
fn square_with_hole(hole: usize) -> RgbImage {
let (w, h) = (400usize, 300usize);
let mut rng = Rng::new(11);
let (x0, y0) = (100usize, 50usize);
RgbImage::from_fn(w, h, |x, y| {
let n = 0.02 * rng.normal();
let in_square = x >= x0 && x < x0 + 200 && y >= y0 && y < y0 + 200;
let in_hole = x >= x0 + 96 && x < x0 + 96 + hole && y >= y0 + 96 && y < y0 + 96 + hole;
let c = if in_square && !in_hole { [0.1, 0.12, 0.15] } else { [0.9, 0.9, 0.85] };
c.map(|v: f32| (v + n).clamp(0.0, 1.0))
})
}
#[test]
fn object_select_keeps_small_background_hole_open() {
let img = square_with_hole(8);
let s = ImageSampler { img: &img, origin: (0, 0) };
let canvas = Rect::new(0, 0, 400, 300);
let reg = grabcut::object_select(&s, canvas, Rect::new(80, 30, 320, 270), 1_000_000).unwrap();
assert!(reg.at(150, 100) >= 0.5, "the square itself is selected");
assert!(reg.at(200, 150) < 0.5, "background seen through the object stays unselected");
}
#[test]
fn object_at_keeps_small_background_hole_open() {
let img = square_with_hole(16);
let s = ImageSampler { img: &img, origin: (0, 0) };
let canvas = Rect::new(0, 0, 400, 300);
let reg = grabcut::object_at(&s, canvas, 130.0, 80.0, 1_000_000).unwrap();
assert!(reg.at(150, 100) >= 0.5, "the square itself is selected");
assert!(reg.at(203, 153) < 0.5, "background seen through the object stays unselected");
}
#[test]
fn hole_fill_without_specks_fills_object_coloured_holes_only() {
// The hole check Object Selection uses: no hole fills just for being small, a speck of the object's colour still does.
let (w, h) = (40usize, 40usize);
let mut rng = Rng::new(5);
let in_square = |x: usize, y: usize| (5..35).contains(&x) && (5..35).contains(&y);
let bg_hole = |x: usize, y: usize| (10..12).contains(&x) && (10..12).contains(&y);
let fg_hole = |x: usize, y: usize| (25..27).contains(&x) && (25..27).contains(&y);
let img = RgbImage::from_fn(w, h, |x, y| {
let n = 0.02 * rng.normal();
let c = if in_square(x, y) && !bg_hole(x, y) { [0.1, 0.12, 0.15] } else { [0.9, 0.9, 0.85] };
c.map(|v: f32| (v + n).clamp(0.0, 1.0))
});
let mask: Vec<bool> = (0..w * h).map(|i| in_square(i % w, i / w) && !bg_hole(i % w, i / w) && !fg_hole(i % w, i / w)).collect();
// The colour models, as GrabCut would fit them: the object colour vs. the backdrop colour.
let dark: Vec<bool> = (0..w * h).map(|i| in_square(i % w, i / w) && !bg_hole(i % w, i / w)).collect();
let (fs, bs): (Vec<_>, Vec<_>) = img.px.iter().zip(&dark).partition(|(_, m)| **m);
let fit = |v: Vec<(&[f32; 3], &bool)>| Gmm::fit(&v.iter().map(|(p, _)| **p).collect::<Vec<_>>(), 3, gmm::DEFAULT_REG).unwrap();
let (fg, bg) = (fit(fs), fit(bs));
let out = fill_holes_by_model(&mask, &img, &fg, &bg, 0.0, 0.05);
assert!(out[25 * w + 25], "a speck of the object's colour fills");
assert!(!out[10 * w + 10], "a speck of the background's colour stays open");
}
+391 -50
View File
@@ -23,85 +23,184 @@ pub enum Stat {
Variance,
}
fn stat(v: &mut [f32], s: Stat) -> f32 {
/// Reusable per-chunk scratch for [`Stat::Entropy`]: an 8-bit histogram plus the list of bins the
/// current pixel touched. Allocated once per parallel chunk instead of one `HashMap` per channel
/// per pixel (the old cost: ~4.6 s for 3 × 24 MP).
struct EntropyScratch {
counts: [u32; 256],
touched: Vec<u8>,
}
impl EntropyScratch {
fn new() -> Self {
Self { counts: [0; 256], touched: Vec::new() }
}
}
/// Shannon entropy (bits) of the 8-bit values in `v`, over log2(N). `v.len() >= 2`.
fn entropy(v: &[f32], scratch: &mut EntropyScratch) -> f32 {
let n = v.len() as f32;
for x in v {
let bin = (x.clamp(0.0, 1.0) * 255.0).round() as u8;
let c = &mut scratch.counts[usize::from(bin)];
if *c == 0 {
scratch.touched.push(bin);
}
*c += 1;
}
// Sum the bins in ascending order. The old HashMap summed in its (randomly seeded) iteration
// order, and float addition is not associative, so the old result could differ in the last
// bit from run to run; a fixed order makes it deterministic. Sorting only the few touched
// bins keeps this O(samples), not O(256), per pixel.
scratch.touched.sort_unstable();
let h: f32 = scratch
.touched
.iter()
.map(|&bin| {
let p = scratch.counts[usize::from(bin)] as f32 / n;
-p * p.log2()
})
.sum();
// Reset only the bins used, so the 256-entry array is never cleared wholesale.
for &bin in &scratch.touched {
scratch.counts[usize::from(bin)] = 0;
}
scratch.touched.clear();
h / n.log2()
}
/// `x` to the power `k` by repeated squaring. Every step is one IEEE 754 multiplication, so the
/// result has the same bits on every target. On Windows (MSVC), `f32::powi` with an exponent the
/// compiler cannot see as a constant calls the C runtime's `powf`, which rounds differently.
fn ipow(x: f32, k: u32) -> f32 {
let (mut base, mut k, mut acc) = (x, k, 1.0f32);
loop {
if k & 1 != 0 {
acc *= base;
}
k >>= 1;
if k == 0 {
return acc;
}
base *= base;
}
}
fn stat(v: &mut [f32], s: Stat, scratch: &mut EntropyScratch) -> f32 {
let n = v.len() as f32;
if v.is_empty() {
return 0.0;
}
// Modes that need neither the mean nor the moments return before computing them (the old code
// computed the mean for every mode and threw it away; skipping it changes no result).
match s {
Stat::Summation => return v.iter().sum::<f32>(),
Stat::Minimum => return v.iter().copied().fold(f32::MAX, f32::min),
Stat::Maximum => return v.iter().copied().fold(f32::MIN, f32::max),
Stat::Range => return v.iter().copied().fold(f32::MIN, f32::max) - v.iter().copied().fold(f32::MAX, f32::min),
Stat::Median => {
// `total_cmp` is a total order whose ties are bit-identical values, so an unstable
// sort gives exactly the stable sort's result.
v.sort_unstable_by(f32::total_cmp);
let k = v.len();
return if k % 2 == 1 { v[k / 2] } else { (v[k / 2 - 1] + v[k / 2]) / 2.0 };
}
Stat::Entropy => {
if v.len() < 2 {
return 0.0;
}
return entropy(v, scratch);
}
_ => {}
}
let mean = v.iter().sum::<f32>() / n;
let m = |k: i32| v.iter().map(|x| (x - mean).powi(k)).sum::<f32>() / n;
let m = |k: u32| v.iter().map(|x| ipow(x - mean, k)).sum::<f32>() / n;
match s {
Stat::Mean => mean,
Stat::Summation => v.iter().sum::<f32>(),
Stat::Minimum => v.iter().copied().fold(f32::MAX, f32::min),
Stat::Maximum => v.iter().copied().fold(f32::MIN, f32::max),
Stat::Range => v.iter().copied().fold(f32::MIN, f32::max) - v.iter().copied().fold(f32::MAX, f32::min),
Stat::Median => {
v.sort_by(f32::total_cmp);
let k = v.len();
if k % 2 == 1 { v[k / 2] } else { (v[k / 2 - 1] + v[k / 2]) / 2.0 }
}
Stat::Variance => m(2),
Stat::StandardDeviation => m(2).sqrt(),
Stat::Skewness => {
let sd = m(2).sqrt();
// Map −2..2 to 0..1 (0.5 = symmetric).
if sd < 1e-6 { 0.5 } else { (m(3) / sd.powi(3) / 4.0 + 0.5).clamp(0.0, 1.0) }
if sd < 1e-6 { 0.5 } else { (m(3) / ipow(sd, 3) / 4.0 + 0.5).clamp(0.0, 1.0) }
}
Stat::Kurtosis => {
let var = m(2);
// 0..10 → 0..1.
if var < 1e-12 { 0.0 } else { (m(4) / (var * var) / 10.0).clamp(0.0, 1.0) }
}
Stat::Entropy => {
if v.len() < 2 {
return 0.0;
}
let mut counts: std::collections::HashMap<u8, u32> = std::collections::HashMap::new();
for x in v.iter() {
*counts.entry((x.clamp(0.0, 1.0) * 255.0).round() as u8).or_default() += 1;
}
let h: f32 = counts
.values()
.map(|&c| {
let p = c as f32 / n;
-p * p.log2()
})
.sum();
h / n.log2()
}
// Handled above; a no-op keeps the match exhaustive without a panic path.
Stat::Summation | Stat::Minimum | Stat::Maximum | Stat::Range | Stat::Median | Stat::Entropy => 0.0,
}
}
/// Output pixels per parallel task: big enough to amortise the per-task scratch buffers and
/// rayon's scheduling, small enough (6000 tasks for 24 MP) to balance across many cores.
const CHUNK: usize = 4096;
/// Combines `layers` (each `len` pixels) with statistic `s`. Colour results retain their float
/// range; quantisation/clipping belongs to the destination surface, not the statistic.
pub fn combine(layers: &[Vec<[f32; 4]>], s: Stat) -> Vec<[f32; 4]> {
// Pick the statistic once, outside the pixel loop: each arm passes `stat` a constant mode, so
// the compiler builds one loop per statistic with the `match` folded away. Matching on `s`
// three times per pixel inside one shared loop made one thread 5–15% slower than the old
// serial loop (Mean, Variance, StdDev, Skewness, Kurtosis); this alone cuts Mean's
// instructions by a fifth.
match s {
Stat::Entropy => combine_with(layers, |v, sc| stat(v, Stat::Entropy, sc)),
Stat::Kurtosis => combine_with(layers, |v, sc| stat(v, Stat::Kurtosis, sc)),
Stat::Maximum => combine_with(layers, |v, sc| stat(v, Stat::Maximum, sc)),
Stat::Mean => combine_with(layers, |v, sc| stat(v, Stat::Mean, sc)),
Stat::Median => combine_with(layers, |v, sc| stat(v, Stat::Median, sc)),
Stat::Minimum => combine_with(layers, |v, sc| stat(v, Stat::Minimum, sc)),
Stat::Range => combine_with(layers, |v, sc| stat(v, Stat::Range, sc)),
Stat::Skewness => combine_with(layers, |v, sc| stat(v, Stat::Skewness, sc)),
Stat::StandardDeviation => combine_with(layers, |v, sc| stat(v, Stat::StandardDeviation, sc)),
Stat::Summation => combine_with(layers, |v, sc| stat(v, Stat::Summation, sc)),
Stat::Variance => combine_with(layers, |v, sc| stat(v, Stat::Variance, sc)),
}
}
fn combine_with(layers: &[Vec<[f32; 4]>], f: impl Fn(&mut [f32], &mut EntropyScratch) -> f32 + Sync + Send) -> Vec<[f32; 4]> {
let len = layers.iter().map(Vec::len).max().unwrap_or(0);
let mut out = vec![[0.0f32; 4]; len];
let mut samples: [Vec<f32>; 3] = Default::default();
for (i, o) in out.iter_mut().enumerate() {
for v in samples.iter_mut() {
v.clear();
}
let mut alpha = 0.0f32;
for l in layers {
if let Some(p) = l.get(i)
&& p[3] > 0.0
{
for c in 0..3 {
samples[c].push(p[c]);
// Every output pixel depends only on the input pixels at the same index, so chunks of the
// output are independent: `par_rows` over a `CHUNK`-wide "row" with one channel (it runs
// serially on wasm). The per-pixel arithmetic is unchanged, hence the result is bit-identical
// to the serial loop for every mode (Entropy: see [`entropy`]).
crate::photo_util::par_rows(&mut out, CHUNK, 1, |chunk, outs| {
// Scratch is per chunk, not per pixel: the old loop's three `Vec`s were already reused
// across pixels, so keep that, and give each task its own set.
// Sized to the layer count up front and filled by index (`k` samples per pixel): a
// `push` per sample re-checked the capacity and stored the length every time; indexing
// cuts the loop's instructions by another third.
let mut samples: [Vec<f32>; 3] = [0, 1, 2].map(|_| vec![0.0; layers.len()]);
let mut scratch = EntropyScratch::new();
let base = chunk * CHUNK;
for (j, o) in outs.iter_mut().enumerate() {
let i = base + j;
let mut k = 0;
let mut alpha = 0.0f32;
for l in layers {
if let Some(p) = l.get(i)
&& p[3] > 0.0
{
for c in 0..3 {
samples[c][k] = p[c];
}
k += 1;
alpha = alpha.max(p[3]);
}
alpha = alpha.max(p[3]);
}
if alpha <= 0.0 {
continue;
}
for c in 0..3 {
o[c] = f(&mut samples[c][..k], &mut scratch);
}
o[3] = alpha;
}
if alpha <= 0.0 {
continue;
}
for c in 0..3 {
o[c] = stat(&mut samples[c], s);
}
o[3] = alpha;
}
});
out
}
@@ -168,4 +267,246 @@ mod tests {
let layers = vec![px(0.5), px(0.5), px(1.0)];
assert_eq!(combine(&layers, Stat::Median)[0][0], 0.5);
}
/// Five layers over six pixels (the last layer is short). Covers opaque, partly transparent,
/// fully transparent, HDR / negative, single-sample and tied-value pixels.
fn fixture() -> Vec<Vec<[f32; 4]>> {
vec![
vec![[0.10, 0.20, 0.30, 1.0], [0.5, 0.5, 0.5, 1.0], [0.9, 0.9, 0.9, 0.0], [-2.0, 4.0, 0.0, 1.0], [0.7, 0.2, 0.1, 0.5], [0.25, 0.25, 0.25, 1.0]],
vec![[0.90, 0.60, 0.30, 1.0], [0.0, 0.0, 0.0, 0.0], [0.8, 0.8, 0.8, 0.0], [3.0, -1.0, 0.5, 0.75], [0.0, 0.0, 0.0, 0.0], [0.25, 0.75, 0.25, 1.0]],
vec![[0.20, 0.90, 0.30, 0.5], [0.1, 0.2, 0.3, 1.0], [0.7, 0.7, 0.7, 0.0], [0.5, 0.5, 0.5, 1.0], [0.0, 0.0, 0.0, 0.0], [0.5, 0.75, 0.25, 0.25]],
vec![[0.30, 0.05, 0.30, 0.25], [0.3, 0.4, 0.5, 0.5], [0.6, 0.6, 0.6, 0.0], [1.5, 1.5, 1.5, 1.0], [0.0, 0.0, 0.0, 0.0], [0.25, 0.75, 0.5, 1.0]],
vec![[0.10, 0.70, 0.99, 1.0], [0.9, 0.1, 0.2, 1.0], [0.5, 0.5, 0.5, 0.0], [0.0, 0.0, 0.0, 1.0]],
]
}
fn bits(px: &[[f32; 4]]) -> Vec<[u32; 4]> {
px.iter().map(|p| p.map(f32::to_bits)).collect()
}
/// Every mode except Entropy is bit-for-bit what the old serial loop produced. The expected
/// bit patterns were recorded by running the unmodified `combine` on [`fixture`].
#[test]
fn statistics_are_bit_identical_to_the_serial_implementation() {
let layers = fixture();
let expected: Vec<(Stat, Vec<[u32; 4]>)> = vec![
(
Stat::Kurtosis,
[
[0x3e96956b, 0x3e182e3f, 0x3ea66664, 0x3f800000],
[0x3e3d004c, 0x3e0b4396, 0x3e030e1f, 0x3f800000],
[0x00000000, 0x00000000, 0x00000000, 0x00000000],
[0x3e594eb2, 0x3e6ef420, 0x3e7ffffe, 0x3f800000],
[0x00000000, 0x00000000, 0x00000000, 0x3f000000],
[0x3e6eeeee, 0x3e6eeeee, 0x3e6eeeee, 0x3f800000],
]
.to_vec(),
),
(
Stat::Maximum,
[
[0x3f666666, 0x3f666666, 0x3f7d70a4, 0x3f800000],
[0x3f666666, 0x3f000000, 0x3f000000, 0x3f800000],
[0x00000000, 0x00000000, 0x00000000, 0x00000000],
[0x40400000, 0x40800000, 0x3fc00000, 0x3f800000],
[0x3f333333, 0x3e4ccccd, 0x3dcccccd, 0x3f000000],
[0x3f000000, 0x3f400000, 0x3f000000, 0x3f800000],
]
.to_vec(),
),
(
Stat::Mean,
[
[0x3ea3d70a, 0x3efae148, 0x3ee0418a, 0x3f800000],
[0x3ee66666, 0x3e99999a, 0x3ec00000, 0x3f800000],
[0x00000000, 0x00000000, 0x00000000, 0x00000000],
[0x3f19999a, 0x3f800000, 0x3f000000, 0x3f800000],
[0x3f333333, 0x3e4ccccd, 0x3dcccccd, 0x3f000000],
[0x3ea00000, 0x3f200000, 0x3ea00000, 0x3f800000],
]
.to_vec(),
),
(
Stat::Median,
[
[0x3e4ccccd, 0x3f19999a, 0x3e99999a, 0x3f800000],
[0x3ecccccd, 0x3e99999a, 0x3ecccccd, 0x3f800000],
[0x00000000, 0x00000000, 0x00000000, 0x00000000],
[0x3f000000, 0x3f000000, 0x3f000000, 0x3f800000],
[0x3f333333, 0x3e4ccccd, 0x3dcccccd, 0x3f000000],
[0x3e800000, 0x3f400000, 0x3e800000, 0x3f800000],
]
.to_vec(),
),
(
Stat::Minimum,
[
[0x3dcccccd, 0x3d4ccccd, 0x3e99999a, 0x3f800000],
[0x3dcccccd, 0x3dcccccd, 0x3e4ccccd, 0x3f800000],
[0x00000000, 0x00000000, 0x00000000, 0x00000000],
[0xc0000000, 0xbf800000, 0x00000000, 0x3f800000],
[0x3f333333, 0x3e4ccccd, 0x3dcccccd, 0x3f000000],
[0x3e800000, 0x3e800000, 0x3e800000, 0x3f800000],
]
.to_vec(),
),
(
Stat::Range,
[
[0x3f4ccccc, 0x3f599999, 0x3f30a3d7, 0x3f800000],
[0x3f4ccccc, 0x3ecccccd, 0x3e99999a, 0x3f800000],
[0x00000000, 0x00000000, 0x00000000, 0x00000000],
[0x40a00000, 0x40a00000, 0x3fc00000, 0x3f800000],
[0x00000000, 0x00000000, 0x00000000, 0x3f000000],
[0x3e800000, 0x3f000000, 0x3e800000, 0x3f800000],
]
.to_vec(),
),
(
Stat::Skewness,
[
[0x3f522026, 0x3ee7cf76, 0x3f600000, 0x3f800000],
[0x3f1bd152, 0x3efffffe, 0x3ee4a11b, 0x3f800000],
[0x00000000, 0x00000000, 0x00000000, 0x00000000],
[0x3eedb65a, 0x3f2ea754, 0x3f3a6c7a, 0x3f800000],
[0x3f000000, 0x3f000000, 0x3f000000, 0x3f000000],
[0x3f49e69e, 0x3e58658a, 0x3f49e69e, 0x3f800000],
]
.to_vec(),
),
(
Stat::StandardDeviation,
[
[0x3e99421e, 0x3ea23b6b, 0x3e8d4fdf, 0x3f800000],
[0x3e97739e, 0x3e21e89b, 0x3e050581, 0x3f800000],
[0x00000000, 0x00000000, 0x00000000, 0x00000000],
[0x3fd3e0b1, 0x3fd9f9e5, 0x3f0c378c, 0x3f800000],
[0x00000000, 0x00000000, 0x00000000, 0x3f000000],
[0x3dddb3d7, 0x3e5db3d7, 0x3dddb3d7, 0x3f800000],
]
.to_vec(),
),
(
Stat::Summation,
[
[0x3fcccccd, 0x401ccccd, 0x400c28f6, 0x3f800000],
[0x3fe66666, 0x3f99999a, 0x3fc00000, 0x3f800000],
[0x00000000, 0x00000000, 0x00000000, 0x00000000],
[0x40400000, 0x40a00000, 0x40200000, 0x3f800000],
[0x3f333333, 0x3e4ccccd, 0x3dcccccd, 0x3f000000],
[0x3fa00000, 0x40200000, 0x3fa00000, 0x3f800000],
]
.to_vec(),
),
(
Stat::Variance,
[
[0x3db78033, 0x3dcd9e83, 0x3d9c022a, 0x3f800000],
[0x3db33332, 0x3ccccccd, 0x3c8a3d70, 0x3f800000],
[0x00000000, 0x00000000, 0x00000000, 0x00000000],
[0x402f5c29, 0x4039999a, 0x3e99999a, 0x3f800000],
[0x00000000, 0x00000000, 0x00000000, 0x3f000000],
[0x3c400000, 0x3d400000, 0x3c400000, 0x3f800000],
]
.to_vec(),
),
];
for (mode, want) in expected {
assert_eq!(bits(&combine(&layers, mode)), want, "{mode:?}");
}
}
/// Entropy against values worked out by hand (bits of the 8-bit histogram, over log2(N)).
#[test]
fn entropy_matches_hand_computed_values() {
let out = combine(&fixture(), Stat::Entropy);
let h = |ps: &[f64]| -> f64 {
let n: f64 = ps.iter().sum();
ps.iter().map(|c| -(c / n) * (c / n).log2()).sum::<f64>() / n.log2()
};
// Pixel 1: four distinct values -> 2 bits / log2(4) = exactly 1.
assert_eq!(out[1][0], 1.0);
// Pixel 2: every sample transparent -> untouched.
assert_eq!(out[2], [0.0; 4]);
// Pixel 3, red: -2 and 0 land in bin 0, 3 and 1.5 clamp into bin 255, 0.5 is alone.
assert!((f64::from(out[3][0]) - h(&[2.0, 2.0, 1.0])).abs() < 1e-6, "{}", out[3][0]);
// Pixel 4: a single contributing sample has no entropy.
assert_eq!(out[4], [0.0, 0.0, 0.0, 0.5]);
// Pixel 5: three samples share a bin, one is alone, in every channel.
for (c, v) in out[5].iter().take(3).enumerate() {
assert!((f64::from(*v) - h(&[3.0, 1.0])).abs() < 1e-6, "channel {c}: {v}");
}
}
/// The old HashMap summed in a random order, so Entropy could differ by an ulp between runs.
/// Now the bins are summed in ascending order: repeated runs (and any thread count) agree.
#[test]
fn entropy_is_deterministic() {
let layers: Vec<Vec<[f32; 4]>> = (0..7)
.map(|k| {
(0..3 * CHUNK + 17).map(|i| [((i * 7 + k * 13) % 11) as f32 / 10.0, ((i + k) % 5) as f32 / 4.0, ((i * k) % 9) as f32 / 8.0, 1.0]).collect()
})
.collect();
let first = bits(&combine(&layers, Stat::Entropy));
for _ in 0..8 {
assert_eq!(bits(&combine(&layers, Stat::Entropy)), first);
}
}
/// Chunking is invisible: a multi-chunk image (with a ragged last chunk and layers of unequal
/// length) gives, pixel by pixel, what combining that pixel on its own gives.
#[test]
fn chunked_result_equals_per_pixel_result() {
let len = 2 * CHUNK + 5;
let layer = |k: usize, n: usize| -> Vec<[f32; 4]> {
(0..n)
.map(|i| {
[
((i * 31 + k * 7) % 101) as f32 / 100.0,
((i * 17 + k) % 53) as f32 / 52.0 - 0.2,
((i + 3 * k) % 29) as f32 / 28.0,
if (i + k).is_multiple_of(4) { 0.0 } else { 0.5 + ((i + k) % 3) as f32 / 4.0 },
]
})
.collect()
};
let layers = vec![layer(1, len), layer(2, CHUNK + 3), layer(3, len - 1), layer(4, 0), layer(5, len)];
for mode in [
Stat::Entropy,
Stat::Kurtosis,
Stat::Maximum,
Stat::Mean,
Stat::Median,
Stat::Minimum,
Stat::Range,
Stat::Skewness,
Stat::StandardDeviation,
Stat::Summation,
Stat::Variance,
] {
let whole = combine(&layers, mode);
assert_eq!(whole.len(), len);
for i in (0..len).step_by(97).chain([CHUNK - 1, CHUNK, CHUNK + 1, 2 * CHUNK - 1, 2 * CHUNK, len - 1]) {
let one: Vec<Vec<[f32; 4]>> = layers.iter().map(|l| l.get(i).map(|p| vec![*p]).unwrap_or_default()).collect();
assert_eq!(whole[i].map(f32::to_bits), combine(&one, mode)[0].map(f32::to_bits), "{mode:?} pixel {i}");
}
}
}
#[test]
fn no_layers_gives_an_empty_result() {
assert!(combine(&[], Stat::Median).is_empty());
}
/// [`ipow`] multiplies in the order `x·x²` for the cube and `x²·x²` for the fourth power.
#[test]
fn ipow_squares_and_multiplies() {
for x in [0.58f32, -0.12, -2.6, 2.4, -0.6, 1.0e-3, 0.0] {
assert_eq!(ipow(x, 0).to_bits(), 1.0f32.to_bits());
assert_eq!(ipow(x, 1).to_bits(), x.to_bits());
assert_eq!(ipow(x, 2).to_bits(), (x * x).to_bits());
assert_eq!(ipow(x, 3).to_bits(), (x * (x * x)).to_bits());
assert_eq!(ipow(x, 4).to_bits(), ((x * x) * (x * x)).to_bits());
}
}
}
+65
View File
@@ -90,6 +90,27 @@ fn radial_blur_quality_defaults_for_older_serialized_parameters() {
assert_eq!(decoded, FilterParams::RadialBlur { amount: 20.0, method: RadialMethod::Spin, quality: RadialQuality::Good, center_x: 0.5, center_y: 0.5 });
}
#[test]
fn nonpositive_tile_sizes_match_one_pixel_tiles() {
let area = Rect::new(0, 0, 4, 3);
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
let source = pattern(depth, area);
for params in all_filters().into_iter().chain([FilterParams::ShapeBlur { radius: 3.0, shape: BlurShape::Star }]) {
let expected = apply_tiled(&source, &params, area, area, None, 1, Some(area));
for tile in [0, -64, i32::MIN] {
let actual = apply_tiled(&source, &params, area, area, None, tile, Some(area));
assert_eq!(actual.read_region(area), expected.read_region(area), "{}: {depth:?}, tile={tile}", params.label());
let cancellable = apply_tiled_with(&source, &params, area, area, None, tile, Some(area), &photocraft_raster::Interrupt::NONE)
.expect("a nonpositive tile size is normalized, not rejected");
assert_eq!(cancellable.read_region(area), expected.read_region(area), "{}: {depth:?}, tile={tile}", params.label());
let cancelled = photocraft_raster::Interrupt::cancel_only(&|| true);
assert!(apply_tiled_with(&source, &params, area, area, None, tile, Some(area), &cancelled).is_none());
}
}
assert_eq!(source.read_region(area), pattern(depth, area).read_region(area));
}
}
#[test]
fn every_filter_is_tile_independent() {
let s = pattern(SampleType::U8, R);
@@ -248,6 +269,32 @@ fn minimum_maximum_spread() {
assert!((mn.pixel(10, 10)[0] - 0.5).abs() < 1e-6);
}
/// CMYK samples are ink, so Maximum spreads paper white and Minimum spreads ink, as Photoshop
/// does: Maximum r 1 removes a 1-px C 100% line on white, Minimum widens it to 3 px. Alpha keeps
/// the usual polarity (Maximum grows opaque areas, Minimum grows transparent ones).
#[test]
fn minimum_maximum_treat_cmyk_as_ink() {
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
let mut s = Surface::new(PixelFormat::new(ColorMode::Cmyk, depth, true));
s.fill_rect(R, &[0.0, 0.0, 0.0, 0.0, 1.0]);
s.fill_rect(Rect::new(20, 0, 21, 30), &[1.0, 0.0, 0.0, 0.0, 1.0]);
s.write_pixel(5, 5, &[0.0, 0.0, 0.0, 0.0, 0.0]);
for preserve in [Preserve::Squareness, Preserve::Roundness] {
let mx = run(&s, &FilterParams::Maximum { radius: 1.0, preserve });
for x in 17..24 {
assert!(mx.pixel(x, 15)[0].abs() < 1e-6, "{depth:?} {preserve:?} Maximum C at x {x}: {:?}", mx.pixel(x, 15));
}
assert!((mx.pixel(5, 5)[4] - 1.0).abs() < 1e-6, "{depth:?} {preserve:?} Maximum grows alpha");
let mn = run(&s, &FilterParams::Minimum { radius: 1.0, preserve });
for x in 17..24 {
let want = if (19..=21).contains(&x) { 1.0 } else { 0.0 };
assert!((mn.pixel(x, 15)[0] - want).abs() < 1e-6, "{depth:?} {preserve:?} Minimum C at x {x}: {:?}", mn.pixel(x, 15));
}
assert!(mn.pixel(6, 5)[4].abs() < 1e-6, "{depth:?} {preserve:?} Minimum shrinks alpha");
}
}
}
#[test]
fn maximum_roundness_grows_a_point_into_a_disc() {
// One white pixel on black: Maximum spreads it over the structuring element itself.
@@ -414,6 +461,24 @@ fn twirl_keeps_centre_and_outside() {
assert_ne!(out.pixel(20, 12), s.pixel(20, 12));
}
/// #5643: on a wide image Twirl, Pinch and Spherize fill the ellipse inscribed in the bounds, as
/// Photoshop's do, not just a circle fitted to the height: a point near the left and right edges
/// on the middle row changes, the corners don't, and a square still uses its inscribed circle.
#[test]
fn round_distortions_fill_the_inscribed_ellipse() {
let b = Rect::new(0, 0, 80, 40);
let s = pattern(SampleType::F32, b);
for p in [FilterParams::Twirl { angle: 180.0 }, FilterParams::Pinch { amount: 80.0 }, FilterParams::Spherize { amount: 80.0, mode: SpherizeMode::Normal }] {
let out = apply(&s, &p, b, b, None);
// 30 px left of centre on the middle row: outside the old 20 px circle, inside the ellipse.
assert_ne!(out.pixel(10, 21), s.pixel(10, 21), "{p:?}");
assert_ne!(out.pixel(69, 18), s.pixel(69, 18), "{p:?}");
for (x, y) in [(0, 0), (79, 0), (0, 39), (79, 39)] {
assert_eq!(out.pixel(x, y), s.pixel(x, y), "{p:?}: corner ({x}, {y}) is outside the ellipse");
}
}
}
#[test]
fn motion_blur_spreads_along_angle_only() {
let mut s = flat(SampleType::F32, R, [0.0, 0.0, 0.0, 1.0]);
+51
View File
@@ -435,6 +435,35 @@ fn lens_flare_is_brightest_at_its_centre() {
assert!(out.pixel(38, 2)[0] >= 0.2 - 1e-6, "screen never darkens");
}
#[test]
fn lens_flare_never_darkens_32_bit_values_above_one() {
// #5785: the screen formula turned 2.0 into 1.5 (and 1.0 at the core).
let p = FilterParams::LensFlare { brightness: 150.0, center_x: 0.25, center_y: 0.5, lens: LensType::Zoom };
let out = run(&flat(SampleType::F32, R, [2.0, 2.0, 2.0, 1.0]), &p);
for px in out.read_region(R).as_chunks::<4>().0 {
assert!(px[..3].iter().all(|&v| v >= 2.0), "{px:?}");
}
}
#[test]
fn lens_flare_8_bit_result_is_the_screen_blend() {
// The flare amount per pixel, read from a black 32-bit image (screen over 0 is the amount).
let p = FilterParams::LensFlare { brightness: 150.0, center_x: 0.25, center_y: 0.5, lens: LensType::Zoom };
let amount = run(&flat(SampleType::F32, R, [0.0, 0.0, 0.0, 1.0]), &p).read_region(R);
let src = pattern(SampleType::U8, R);
let base = src.read_region(R);
let expected: Vec<f32> = base
.as_chunks::<4>()
.0
.iter()
.zip(amount.as_chunks::<4>().0)
.flat_map(|(c, a)| [0, 1, 2].map(|k| 1.0 - (1.0 - c[k]) * (1.0 - a[k].min(1.0))).into_iter().chain([c[3]]))
.collect();
let mut want = Surface::new(fmt(SampleType::U8));
want.write_region(R, &expected);
assert_eq!(run(&src, &p).read_region(R), want.read_region(R));
}
#[test]
fn lighting_point_light_falls_off() {
let s = flat(SampleType::F32, R, [0.8, 0.8, 0.8, 1.0]);
@@ -608,6 +637,28 @@ fn custom_kernel_shifts_and_offsets() {
assert!((off.pixel(5, 5)[0] - 0.4).abs() < 1e-5);
}
/// CMYK samples are ink: Custom's positive offset brightens (removes ink), as in Photoshop
/// (identity, scale 1, offset +50: white stays white, C=100% becomes C≈80%). Alpha is untouched.
/// Photoshop's CMYK depths; float is unclamped on both polarities (RGB white + offset exceeds 1).
#[test]
fn custom_offset_brightens_cmyk() {
let mut id = vec![0.0; 25];
id[12] = 1.0;
let p = FilterParams::Custom { kernel: id, scale: 1.0, offset: 50.0 };
for depth in [SampleType::U8, SampleType::U16] {
let f = PixelFormat::new(ColorMode::Cmyk, depth, true);
let mut s = Surface::new(f);
s.fill_rect(R, &[0.0, 0.0, 0.0, 0.0, 0.6]);
let w = run(&s, &p).pixel(5, 5);
assert!(w[..4].iter().all(|v| v.abs() < 1e-6) && (w[4] - 0.6).abs() < 0.01, "{depth:?} white: {w:?}");
let mut s = Surface::new(f);
s.fill_rect(R, &[1.0, 0.5, 0.0, 0.0, 1.0]);
let c = run(&s, &p).pixel(5, 5);
let want = [1.0 - 50.0 / 255.0, 0.5 - 50.0 / 255.0, 0.0, 0.0, 1.0];
assert!(c.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.01), "{depth:?} C100: {c:?}");
}
}
#[test]
fn hsb_filter_encodes_and_decodes() {
let s = flat(SampleType::F32, R, [1.0, 0.0, 0.0, 1.0]);
+51
View File
@@ -65,6 +65,35 @@ pub fn replace_color(px: &mut [[f32; 4]], color: [f32; 3], fuzziness: f32, hue:
mask
}
/// Replace Color's Result swatch: `color` after the hue (±180°), saturation and lightness (±100)
/// shift, as a fully matched pixel of that colour becomes.
pub fn replace_color_result(color: [f32; 3], hue: f32, saturation: f32, lightness: f32) -> [f32; 3] {
let mut px = [[color[0], color[1], color[2], 1.0]];
replace_color_masked(&mut px, &[1.0], hue, saturation, lightness);
[px[0][0], px[0][1], px[0][2]]
}
/// The hue (±180°), saturation and lightness (±100) shift that turns `from` into `to`, as
/// Photoshop's Result Color Picker sets the sliders. A shift the sliders can't reach (a grey has
/// no hue to turn, black or white no saturation to give) goes as far as they do.
pub fn replace_color_shift(from: [f32; 3], to: [f32; 3]) -> (f32, f32, f32) {
let [fh, fs, fl] = rgb_to_hsl(from);
let [th, ts, tl] = rgb_to_hsl(to);
// Hue turns the short way round; a grey target keeps the hue where it is.
let hue = if ts <= 0.0 { 0.0 } else { ((th - fh + 0.5).rem_euclid(1.0) - 0.5) * 360.0 };
// Inverse of the shift in `replace_color_masked`: up moves toward 1, down toward 0.
let amount = |from: f32, to: f32| {
if to >= from {
if from >= 1.0 { 0.0 } else { (to - from) / (1.0 - from) * 100.0 }
} else if from <= 0.0 {
0.0
} else {
(to / from - 1.0) * 100.0
}
};
(hue.clamp(-180.0, 180.0), amount(fs, ts).clamp(-100.0, 100.0), amount(fl, tl).clamp(-100.0, 100.0))
}
/// Replace Color's hue, saturation and lightness shift, applied to each pixel as far as `mask`
/// (`0..=1`, one per pixel: the colour match, localized or not) covers it.
pub fn replace_color_masked(px: &mut [[f32; 4]], mask: &[f32], hue: f32, saturation: f32, lightness: f32) {
@@ -277,6 +306,28 @@ pub fn hdr_toning(px: &mut [[f32; 4]], w: usize, h: usize, p: &HdrToning) {
mod tests {
use super::*;
/// #6278: the Result swatch is the sampled colour after the shift, and a picked Result sets
/// the sliders that give it.
#[test]
fn replace_color_result_and_shift_round_trip() {
let from = [0.8, 0.2, 0.2];
let near = |a: [f32; 3], b: [f32; 3]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 2e-3);
assert!(near(replace_color_result(from, 0.0, 0.0, 0.0), from), "no shift, no change");
for to in [[0.2, 0.3, 0.9], [0.9, 0.85, 0.1], [0.4, 0.1, 0.1], [0.95, 0.6, 0.6], [0.1, 0.6, 0.3]] {
let (h, s, l) = replace_color_shift(from, to);
assert!((-180.0..=180.0).contains(&h) && (-100.0..=100.0).contains(&s) && (-100.0..=100.0).contains(&l));
assert!(near(replace_color_result(from, h, s, l), to), "{to:?}: ({h}, {s}, {l}) gives {:?}", replace_color_result(from, h, s, l));
}
// Red to blue turns the short way (−120°, not +240°).
assert!((replace_color_shift([1.0, 0.0, 0.0], [0.0, 0.0, 1.0]).0 + 120.0).abs() < 0.5);
// A grey target: saturation all the way down, the hue left alone.
let (h, s, _) = replace_color_shift(from, [0.5, 0.5, 0.5]);
assert_eq!((h, s), (0.0, -100.0));
// Black has no saturation to raise: the slider stays put rather than dividing by zero.
let (h, s, l) = replace_color_shift([0.0, 0.0, 0.0], [0.2, 0.6, 0.3]);
assert!(h.is_finite() && s.is_finite() && l > 0.0);
}
fn ramp(w: usize, h: usize) -> Vec<[f32; 4]> {
(0..w * h)
.map(|i| {
+74 -5
View File
@@ -231,14 +231,37 @@ pub fn quad_to_rect(q: &[[f64; 2]; 4], w: f64, h: f64) -> Option<Homography> {
/// The destination rectangle [`warp_surface`] fills for `src_rect` under `h`: the warped corners
/// plus a pixel for filter support, each side clamped to ±2²⁰. `None` when a corner maps to
/// infinity (the result is then empty). Lets a caller price a warp before allocating it.
/// infinity (the result is then empty). Use [`warp_output_size`] to price the full result before
/// admitting a transform: clipping these bounds must not hide an excessive scale.
pub fn warp_output_bounds(src_rect: Rect, h: &Homography) -> Option<Rect> {
corner_bounds(&warp_corners(src_rect, h)?)
}
/// Unclipped output dimensions, including the same filter support as [`warp_output_bounds`].
/// Position does not affect the price, so translating a small layer far away stays cheap.
/// Unrepresentable or non-finite dimensions saturate at `u64::MAX`, allowing a caller to refuse
/// the warp with a saturating pixel-budget comparison. Empty and singular inputs have no output.
pub fn warp_output_size(src_rect: Rect, h: &Homography) -> Option<(u64, u64)> {
let corners = warp_corners(src_rect, h)?;
if !corners.iter().all(|(x, y)| x.is_finite() && y.is_finite()) {
return Some((u64::MAX, u64::MAX));
}
let x0 = corners.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor();
let y0 = corners.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor();
let x1 = corners.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil();
let y1 = corners.iter().map(|c| c.1).fold(f64::MIN, f64::max).ceil();
// Float-to-integer casts saturate, including when the finite endpoints' difference overflows.
Some((((x1 - x0) as u64).saturating_add(2), ((y1 - y0) as u64).saturating_add(2)))
}
fn warp_corners(src_rect: Rect, h: &Homography) -> Option<[(f64, f64); 4]> {
if src_rect.is_empty() || h.inverse().is_none() {
return None;
}
let corners = [(src_rect.x0, src_rect.y0), (src_rect.x1, src_rect.y0), (src_rect.x1, src_rect.y1), (src_rect.x0, src_rect.y1)]
.map(|(x, y)| h.apply(x as f64, y as f64));
corner_bounds(&corners)
Some(
[(src_rect.x0, src_rect.y0), (src_rect.x1, src_rect.y0), (src_rect.x1, src_rect.y1), (src_rect.x0, src_rect.y1)]
.map(|(x, y)| h.apply(x as f64, y as f64)),
)
}
fn corner_bounds(corners: &[(f64, f64); 4]) -> Option<Rect> {
@@ -310,7 +333,12 @@ fn warp(
// A source straddling the horizon draws only its front side: the inverse w at a pixel has the
// sign of the forward w at the source point it samples, and the other side would show as a
// mirrored copy wrapped round infinity (#4863).
let front = side.is_none().then(|| h.front_side([src_rect.x0 as f64, src_rect.y0 as f64, src_rect.x1 as f64, src_rect.y1 as f64]));
// A clip wholly on one side of the inverse horizon only samples that side, so that side is drawn
// (a Perspective Crop low on a converging photo has most of the layer beyond its horizon, #7557).
let clip_w = clip.map(|c| [(c.x0, c.y0), (c.x1, c.y0), (c.x1, c.y1), (c.x0, c.y1)].map(|(x, y)| inv.w(x as f64, y as f64)));
let clip_side = clip_w.and_then(|cw| [1.0, -1.0].into_iter().find(|&s| cw.iter().all(|&w| s * w > 0.0)));
let front =
side.is_none().then(|| clip_side.unwrap_or_else(|| h.front_side([src_rect.x0 as f64, src_rect.y0 as f64, src_rect.x1 as f64, src_rect.y1 as f64])));
let bounds = corner_bounds(&corners);
let dst = match (clip, bounds) {
// The warped corners only bound the result while the whole source is in front of the horizon.
@@ -510,6 +538,31 @@ fn warp(
mod tests {
use super::*;
#[test]
fn output_size_prices_the_unclipped_extent_independently_of_position() {
let src = Rect::new(0, 0, 24, 16);
for dx in [0.0, 900_000.0, 10_000_000.0] {
let h = Homography([1.0, 0.0, dx, 0.0, 1.0, -dx, 0.0, 0.0, 1.0]);
assert_eq!(warp_output_size(src, &h), Some((26, 18)));
}
for sx in [1e7, -1e7] {
let h = Homography([sx, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]);
assert_eq!(warp_output_size(src, &h), Some((240_000_002, 18)));
}
}
#[test]
fn output_size_saturates_overflows_and_preserves_filter_support() {
let src = Rect::new(0, 0, 24, 16);
let fractional = Homography([0.5, 0.0, 0.25, 0.0, 1.0, 0.75, 0.0, 0.0, 1.0]);
let bounds = warp_output_bounds(src, &fractional).unwrap();
assert_eq!(warp_output_size(src, &fractional), Some((u64::from(bounds.width()), u64::from(bounds.height()))));
let huge = Homography([1e308, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]);
assert_eq!(warp_output_size(src, &huge), Some((u64::MAX, u64::MAX)));
assert_eq!(warp_output_size(Rect::EMPTY, &Homography::IDENTITY), None);
assert_eq!(warp_output_size(src, &Homography([0.0; 9])), None);
}
#[test]
fn the_interruptible_warp_reports_progress_and_stops_when_cancelled() {
let mut s = Surface::new(PixelFormat::RGBA8);
@@ -665,6 +718,22 @@ mod tests {
assert_eq!(ok.front_side([0.0, 0.0, 100.0, 100.0]), ok.w(50.0, 50.0).signum());
}
/// #7557: a Perspective Crop quad low on a strongly converging photo puts the horizon across
/// the layer with most of it (and its centre) on the far side. The clip maps wholly into the
/// quad, so its side is the one drawn, not the source's majority.
#[test]
fn a_clip_wholly_on_one_side_draws_that_side() {
let mut s = rgba();
s.fill_rect(Rect::new(0, 0, 100, 100), &[1.0, 0.0, 0.0, 1.0]);
let q = [[40.0, 70.0], [60.0, 70.0], [90.0, 95.0], [10.0, 95.0]];
let h = quad_to_rect(&q, 50.0, 39.0).unwrap();
assert!(h.front_side([0.0, 0.0, 100.0, 100.0]) * h.w(50.0, 80.0) < 0.0, "the source's majority is behind the quad's side");
for interp in [Interp::Nearest, Interp::Bilinear, Interp::Bicubic] {
let w = warp_surface_clipped(&s, s.content_bounds(), &h, interp, Rect::new(0, 0, 50, 39)).unwrap();
assert_eq!(w.rgba(25, 20), [1.0, 0.0, 0.0, 1.0], "{interp:?}");
}
}
#[test]
fn identity_and_translation_are_exact() {
let mut s = rgba();
+50 -10
View File
@@ -14,8 +14,9 @@
//! later (lower-right) cell wins. Work is split per destination tile (parallel on native).
use photocraft_color::PixelFormat;
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use photocraft_geom::{Rect, TileCoord};
use photocraft_raster::{Surface, Tile};
use std::sync::Arc;
use crate::transform::{Interp, bicubic};
@@ -207,7 +208,7 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
}
let n = fmt.channels();
let a = n - 1;
let work = |(t, tris): (&Rect, &Vec<[usize; 3]>)| -> Option<(Rect, Vec<f32>)> {
let work = |(t, tris): (&Rect, &Vec<[usize; 3]>)| -> Option<(TileCoord, Arc<Tile>)> {
if tris.is_empty() {
return None;
}
@@ -296,6 +297,11 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
f64::from(px[((y - foot.y0) as usize * fw + (x - foot.x0) as usize) * n + c])
}
};
// Offset of pixel (x, y) when the `taps` x `taps` block from it lies inside `foot`, so
// the samplers can index `px` directly instead of bounds-checking every tap.
let base = |x: i32, y: i32, taps: i32| -> Option<usize> {
(x >= foot.x0 && y >= foot.y0 && x + taps <= foot.x1 && y + taps <= foot.y1).then(|| ((y - foot.y0) as usize * fw + (x - foot.x0) as usize) * n)
};
let mut outp = vec![0.0f32; w * h * n];
let mut any = false;
let mut acc = [0.0f64; 8];
@@ -313,6 +319,17 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
Interp::Bilinear => {
let (ix, iy) = (u.floor() as i32, v.floor() as i32);
let (fx, fy) = (u - f64::from(ix), v - f64::from(iy));
if let Some(o) = base(ix, iy, 2) {
for (dy, wy) in [(0, 1.0 - fy), (1, fy)] {
for (dx, wx) in [(0, 1.0 - fx), (1, fx)] {
let p = &px[o + (dy * fw + dx) * n..][..n];
for (s, v) in acc.iter_mut().zip(p) {
*s += f64::from(*v) * wx * wy;
}
}
}
return;
}
for (dy, wy) in [(0, 1.0 - fy), (1, fy)] {
for (dx, wx) in [(0, 1.0 - fx), (1, fx)] {
for (c, s) in acc.iter_mut().enumerate().take(n) {
@@ -327,6 +344,21 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
let (u, v) = (snap(u), snap(v));
let (ix, iy) = (u.floor() as i32, v.floor() as i32);
let (wx, wy) = (bicubic(u - f64::from(ix)), bicubic(v - f64::from(iy)));
if let Some(o) = base(ix - 1, iy - 1, 4) {
for (j, wyj) in wy.iter().enumerate() {
for (i, wxi) in wx.iter().enumerate() {
let k = wxi * wyj;
if k == 0.0 {
continue;
}
let p = &px[o + (j * fw + i) * n..][..n];
for (s, v) in acc.iter_mut().zip(p) {
*s += f64::from(*v) * k;
}
}
}
return;
}
for (j, wyj) in wy.iter().enumerate() {
for (i, wxi) in wx.iter().enumerate() {
let k = wxi * wyj;
@@ -378,19 +410,27 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
}
outp[b + a] = al as f32;
}
any.then_some((*t, outp))
if !any {
return None;
}
// Encoded (and dropped if fully transparent) here, in parallel, rather than one tile at
// a time once all are done. `t` lies in one tile.
let mut one = Surface::new(fmt);
one.write_region(*t, &outp);
one.prune();
if one.tile_count() == 0 {
return None;
}
one.take_tiles(*t).pop()
};
#[cfg(not(target_arch = "wasm32"))]
let done: Vec<(Rect, Vec<f32>)> = {
let done: Vec<(TileCoord, Arc<Tile>)> = {
use rayon::prelude::*;
tiles.par_iter().zip(bins.par_iter()).filter_map(work).collect()
};
#[cfg(target_arch = "wasm32")]
let done: Vec<(Rect, Vec<f32>)> = tiles.iter().zip(bins.iter()).filter_map(work).collect();
for (r, v) in done {
out.write_region(r, &v);
}
out.prune();
let done: Vec<(TileCoord, Arc<Tile>)> = tiles.iter().zip(bins.iter()).filter_map(work).collect();
out.put_tiles(done);
out
}
+5 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "photocraft-automation"
description = "MCP server (rmcp) over the Photocraft engine, plus a bridge to a running GUI"
description = "MCP server (rmcp) over the PhotoCraft engine, plus a bridge to a running GUI"
version.workspace = true
edition.workspace = true
license.workspace = true
@@ -30,3 +30,7 @@ cap-std = "4"
[dev-dependencies]
rmcp = { version = "3.5", default-features = false, features = ["client", "server", "macros", "transport-io"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "sync", "net", "io-util", "time"] }
# Reading back an extended attribute the write path should have preserved (#7095).
[target.'cfg(unix)'.dev-dependencies]
rustix = { version = "1.1", default-features = false, features = ["std", "fs"] }
+4 -2
View File
@@ -19,7 +19,8 @@ pub struct Opened {
/// Decode a document that was read through a filesystem capability.
pub fn open_bytes(name: &str, bytes: &[u8]) -> Result<Opened, AutomationError> {
if Path::new(name).extension().is_some_and(|extension| extension.eq_ignore_ascii_case(photocraft_format::EXTENSION)) {
return Ok(Opened { document: photocraft_format::load_from_bytes(bytes)?, warnings: Vec::new(), source_read_only: false });
let document = photocraft_format::load_from_bytes(bytes)?;
return Ok(Opened { warnings: photocraft_io::open_warnings(&document), document, source_read_only: false });
}
let r = photocraft_io::import(name, bytes)?;
Ok(Opened { document: r.document, warnings: r.warnings, source_read_only: r.source_read_only })
@@ -29,7 +30,8 @@ pub fn open_bytes(name: &str, bytes: &[u8]) -> Result<Opened, AutomationError> {
/// natively; everything else goes through `photocraft-io` (PSD, PNG, …).
pub fn open(path: &Path) -> Result<Opened, AutomationError> {
if path.is_dir() {
return Ok(Opened { document: photocraft_format::load_path(path)?, warnings: Vec::new(), source_read_only: false });
let document = photocraft_format::load_path(path)?;
return Ok(Opened { warnings: photocraft_io::open_warnings(&document), document, source_read_only: false });
}
let bytes = photocraft_format::read_file(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?;
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
+89 -83
View File
@@ -72,6 +72,22 @@ impl Headless {
}
}
/// Run `body` with document `index` active (the active one when `None`), then make the
/// previously active document active again, on success and on error alike: an `index`
/// targets one call and must not retarget the next automation command (#7025).
fn on_doc<T>(&mut self, index: Option<usize>, body: impl FnOnce(&mut Self) -> Result<T, AutomationError>) -> Result<T, AutomationError> {
let i = self.doc_index(index)?;
let previously_active = self.session.active_index();
if previously_active != Some(i) && !self.session.set_active(i) {
return Err(AutomationError::BadRequest(format!("no document at index {i}")));
}
let result = body(self);
if let Some(active) = previously_active.filter(|&active| self.session.active_index() != Some(active)) {
self.session.set_active(active);
}
result
}
/// Open a file and make it the active document.
pub fn open(&mut self, path: &Path) -> Result<Value, AutomationError> {
let requested = path.to_str().ok_or_else(|| AutomationError::BadRequest("automation paths must be valid UTF-8".into()))?;
@@ -113,27 +129,25 @@ impl Headless {
/// automation (#2810). `path` resolves beneath the read root the way `open` resolves one,
/// and `params` carries the command's own options (`scale`, `fit`, `center`).
pub fn place(&mut self, index: Option<usize>, path: &str, params: &Value) -> Result<Value, AutomationError> {
let i = self.doc_index(index)?;
if self.session.active_index() != Some(i) && !self.session.set_active(i) {
return Err(AutomationError::BadRequest(format!("no document at index {i}")));
}
let mut opts = params.as_object().cloned().unwrap_or_default();
opts.insert("path".into(), json!(path));
// Where ambient paths are allowed the command reads the file itself, as everywhere else.
if matches!(&self.filesystem, Filesystem::TrustedLocal) {
return self.command_run("file.placeEmbedded", Value::Object(opts));
}
let bytes = match &self.filesystem {
Filesystem::Workspace(workspace) => workspace.read(path)?,
_ => {
return Err(AutomationError::BadRequest("automation filesystem access is not granted: read authority is absent".into()));
self.on_doc(index, |this| {
let mut opts = params.as_object().cloned().unwrap_or_default();
opts.insert("path".into(), json!(path));
// Where ambient paths are allowed the command reads the file itself, as everywhere else.
if matches!(&this.filesystem, Filesystem::TrustedLocal) {
return this.command_run("file.placeEmbedded", Value::Object(opts));
}
};
let name = Path::new(path).file_name().and_then(|name| name.to_str()).unwrap_or(path);
self.sync_jobs();
// The bytes are already in memory and the path resolved beneath the root capability, so
// placing them needs no filesystem check of its own (`place_bytes` is the command body).
Ok(file_cmds::place_bytes(&mut self.session, name, bytes, None, &Value::Object(opts))?)
let bytes = match &this.filesystem {
Filesystem::Workspace(workspace) => workspace.read(path)?,
_ => {
return Err(AutomationError::BadRequest("automation filesystem access is not granted: read authority is absent".into()));
}
};
let name = Path::new(path).file_name().and_then(|name| name.to_str()).unwrap_or(path);
this.sync_jobs();
// The bytes are already in memory and the path resolved beneath the root capability, so
// placing them needs no filesystem check of its own (`place_bytes` is the command body).
Ok(file_cmds::place_bytes(&mut this.session, name, bytes, None, &Value::Object(opts))?)
})
}
/// Export each exported layer of a document as its own file beneath the write root: the
@@ -143,25 +157,23 @@ impl Headless {
/// already exists, where the ambient command would, and it creates the directory before the
/// export runs, so a refused export can leave an empty directory behind — never a file.
pub fn export_layers(&mut self, index: Option<usize>, dir: &str, params: &Value) -> Result<Value, AutomationError> {
let i = self.doc_index(index)?;
if self.session.active_index() != Some(i) && !self.session.set_active(i) {
return Err(AutomationError::BadRequest(format!("no document at index {i}")));
}
if matches!(&self.filesystem, Filesystem::TrustedLocal) {
let mut opts = params.as_object().cloned().unwrap_or_default();
opts.insert("dir".into(), json!(dir));
return self.command_run("file.export.layersToFiles", Value::Object(opts));
}
self.sync_jobs();
let workspace = match &self.filesystem {
Filesystem::Workspace(workspace) => workspace,
_ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: write authority is absent".into())),
};
let mut outputs = workspace.export_sequence(dir)?;
let written = file_cmds::export_layers_with(&self.session, params, |name, bytes| {
outputs.write(name, bytes).map_err(|e| photocraft_engine::EngineError::Other(e.to_string()))
});
exported(dir, written, &mut outputs)
self.on_doc(index, |this| {
if matches!(&this.filesystem, Filesystem::TrustedLocal) {
let mut opts = params.as_object().cloned().unwrap_or_default();
opts.insert("dir".into(), json!(dir));
return this.command_run("file.export.layersToFiles", Value::Object(opts));
}
this.sync_jobs();
let workspace = match &this.filesystem {
Filesystem::Workspace(workspace) => workspace,
_ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: write authority is absent".into())),
};
let mut outputs = workspace.export_sequence(dir)?;
let written = file_cmds::export_layers_with(&this.session, params, |name, bytes| {
outputs.write(name, bytes).map_err(|e| photocraft_engine::EngineError::Other(e.to_string()))
});
exported(dir, written, &mut outputs)
})
}
/// Export each data set of a document as its own flattened file beneath the write root: the
@@ -169,25 +181,23 @@ impl Headless {
/// batch is planned first and colliding names are refused before any file is written; the
/// directory itself is created before that, so a refused batch can leave it empty behind.
pub fn export_data_sets(&mut self, index: Option<usize>, dir: &str, params: &Value) -> Result<Value, AutomationError> {
let i = self.doc_index(index)?;
if self.session.active_index() != Some(i) && !self.session.set_active(i) {
return Err(AutomationError::BadRequest(format!("no document at index {i}")));
}
if matches!(&self.filesystem, Filesystem::TrustedLocal) {
let mut opts = params.as_object().cloned().unwrap_or_default();
opts.insert("dir".into(), json!(dir));
return self.command_run("file.export.dataSetsAsFiles", Value::Object(opts));
}
self.sync_jobs();
let workspace = match &self.filesystem {
Filesystem::Workspace(workspace) => workspace,
_ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: write authority is absent".into())),
};
let mut outputs = workspace.export_sequence(dir)?;
let written = photocraft_engine::export_data_sets_with(&self.session, params, |name, bytes| {
outputs.write(name, bytes).map_err(|e| photocraft_engine::EngineError::Other(e.to_string()))
});
exported(dir, written, &mut outputs)
self.on_doc(index, |this| {
if matches!(&this.filesystem, Filesystem::TrustedLocal) {
let mut opts = params.as_object().cloned().unwrap_or_default();
opts.insert("dir".into(), json!(dir));
return this.command_run("file.export.dataSetsAsFiles", Value::Object(opts));
}
this.sync_jobs();
let workspace = match &this.filesystem {
Filesystem::Workspace(workspace) => workspace,
_ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: write authority is absent".into())),
};
let mut outputs = workspace.export_sequence(dir)?;
let written = photocraft_engine::export_data_sets_with(&this.session, params, |name, bytes| {
outputs.write(name, bytes).map_err(|e| photocraft_engine::EngineError::Other(e.to_string()))
});
exported(dir, written, &mut outputs)
})
}
/// Import a data set CSV from the read root: the capability-scoped
@@ -195,24 +205,22 @@ impl Headless {
/// in automation (#3216). The file is read beneath the root the way `open` reads one and
/// handed to the command's own parser over the text it already has.
pub fn import_data_sets(&mut self, index: Option<usize>, path: &str, params: &Value) -> Result<Value, AutomationError> {
let i = self.doc_index(index)?;
if self.session.active_index() != Some(i) && !self.session.set_active(i) {
return Err(AutomationError::BadRequest(format!("no document at index {i}")));
}
if matches!(&self.filesystem, Filesystem::TrustedLocal) {
let mut opts = params.as_object().cloned().unwrap_or_default();
opts.insert("path".into(), json!(path));
return self.command_run("file.import.variableDataSets", Value::Object(opts));
}
let text = match &self.filesystem {
Filesystem::Workspace(workspace) => {
let bytes = workspace.read(path)?;
String::from_utf8(bytes).map_err(|e| AutomationError::BadRequest(format!("automation read `{path}` is not valid UTF-8: {e}")))?
self.on_doc(index, |this| {
if matches!(&this.filesystem, Filesystem::TrustedLocal) {
let mut opts = params.as_object().cloned().unwrap_or_default();
opts.insert("path".into(), json!(path));
return this.command_run("file.import.variableDataSets", Value::Object(opts));
}
_ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: read authority is absent".into())),
};
self.sync_jobs();
Ok(photocraft_engine::import_data_sets_text(&mut self.session, &text, params)?)
let text = match &this.filesystem {
Filesystem::Workspace(workspace) => {
let bytes = workspace.read(path)?;
String::from_utf8(bytes).map_err(|e| AutomationError::BadRequest(format!("automation read `{path}` is not valid UTF-8: {e}")))?
}
_ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: read authority is absent".into())),
};
this.sync_jobs();
Ok(photocraft_engine::import_data_sets_text(&mut this.session, &text, params)?)
})
}
/// Save (`.pcraft` or any export format by extension). With no path,
@@ -266,14 +274,12 @@ impl Headless {
format.map(|f| f.trim_start_matches('.').to_ascii_lowercase()).or_else(|| target.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()));
if ext.as_deref().is_some_and(|e| file_cmds::holds(&doc, e)) {
// Saving by index must not retarget the next automation command.
let previously_active = self.session.active_index();
self.session.set_active(i);
if let Some(st) = self.session.active_mut() {
st.saved_to(target.to_string_lossy().into_owned());
}
if let Some(active) = previously_active {
self.session.set_active(active);
}
self.on_doc(Some(i), |this| {
if let Some(st) = this.session.active_mut() {
st.saved_to(target.to_string_lossy().into_owned());
}
Ok(())
})?;
}
Ok(json!({ "path": target.to_string_lossy(), "warnings": warnings }))
}
+71 -4
View File
@@ -114,10 +114,20 @@ impl Headless {
let mut opts =
photocraft_io::ExportOptions { tiff_layers: p.get("tiffLayers").and_then(Value::as_bool).unwrap_or(false), tga_bits, ..Default::default() };
opts.encode.dpx_bits = photocraft_engine::file_cmds::dpx_bits_param(&p, "doc.save").map_err(|e| bad(e.to_string()))?;
if let Some(q) = p.get("quality").and_then(Value::as_u64) {
opts.encode.jpeg_quality = q.clamp(1, 100) as u8;
opts.encode.webp_quality = q.clamp(1, 100) as u8;
opts.encode.webp_lossless = false;
opts.encode.dds = photocraft_engine::file_cmds::dds_param(&p, "doc.save").map_err(|e| bad(e.to_string()))?.unwrap_or_default();
match p.get("quality") {
None | Some(Value::Null) => {}
Some(v) => {
// Like the CLI's `--quality`: never ignore or clamp a bad value.
let q = v
.as_u64()
.filter(|q| (1..=100).contains(q))
.and_then(|q| u8::try_from(q).ok())
.ok_or_else(|| bad(format!("`quality` must be a whole number from 1 to 100, got {v}")))?;
opts.encode.jpeg_quality = q;
opts.encode.webp_quality = q;
opts.encode.webp_lossless = false;
}
}
let path = str_of(&p, "path").map(PathBuf::from);
self.save(index_of(&p)?, path.as_deref(), str_of(&p, "format"), &opts)
@@ -576,6 +586,26 @@ mod tests {
std::fs::remove_dir_all(dir).unwrap();
}
/// Regression: `quality` 85.0 or "85" was ignored (a lossless WebP, reply ok) and 0 or 500 was
/// clamped, where the CLI refuses anything but a whole number from 1 to 100.
#[test]
fn doc_save_rejects_a_bad_quality_instead_of_ignoring_or_clamping_it() {
let dir = std::env::temp_dir().join(format!("pc-rpc-save-quality-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("x.webp");
let mut h = Headless::trusted_local();
h.handle("doc.new", json!({"width": 16, "height": 16})).unwrap();
for q in [json!(85.0), json!("85"), json!(0), json!(500), json!(-5), json!(true)] {
let err = h.handle("doc.save", json!({"path": path.to_string_lossy(), "quality": q})).unwrap_err().to_string();
assert!(err.contains("quality"), "{q}: {err}");
assert!(!path.exists(), "{q} wrote a file");
}
h.handle("doc.save", json!({"path": path.to_string_lossy(), "quality": 85})).unwrap();
let bytes = std::fs::read(&path).unwrap();
assert_eq!(bytes.get(12..16), Some(&b"VP8 "[..]), "a WebP saved with a quality is lossy");
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn automation_preview_rejects_oversized_full_size_and_numeric_wraparound() {
let mut h = Headless::new();
@@ -756,6 +786,43 @@ mod tests {
std::fs::remove_dir_all(base).unwrap();
}
#[test]
fn indexed_place_and_data_set_methods_keep_the_active_document() {
let base = std::env::temp_dir().join(format!("pc-doc-index-active-{}", std::process::id()));
let root = base.join("workspace");
std::fs::create_dir_all(&root).unwrap();
let img = photocraft_codecs::Image::from_u8(4, 4, photocraft_codecs::ChannelLayout::Rgb, vec![42; 4 * 4 * 3]).unwrap();
std::fs::write(root.join("logo.png"), photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap();
std::fs::write(root.join("sets.csv"), "Data Set,show\none,true\ntwo,false\n").unwrap();
let mut h = Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&root), Some(&root)).unwrap());
h.handle("doc.new", json!({"width": 8, "height": 8, "background": "white", "name": "first"})).unwrap();
h.handle("doc.new", json!({"width": 8, "height": 8, "background": "white", "name": "second"})).unwrap();
let layer = h.handle("doc.inspect", json!({})).unwrap()["layers"][0]["id"].as_u64().expect("the layer id");
h.handle("engine.execute", json!({"command": "image.variables.define", "params": {"defs": [{"name": "show", "layer": layer, "type": "visibility"}]}}))
.unwrap();
h.handle("doc.select", json!({"index": 0})).unwrap();
let steps = [
("doc.place", json!({"path": "logo.png", "index": 1}), true),
("doc.importDataSets", json!({"path": "sets.csv", "index": 1}), true),
("doc.exportLayers", json!({"dir": "layers", "index": 1}), true),
("doc.exportDataSets", json!({"dir": "sets", "index": 1}), true),
// A failing call restores the active document too.
("doc.importDataSets", json!({"path": "missing.csv", "index": 1}), false),
];
for (method, params, succeeds) in steps {
let result = h.handle(method, params.clone());
assert_eq!(result.is_ok(), succeeds, "{method} {params}: {result:?}");
assert_eq!(h.session.active_index(), Some(0), "{method} {params} must not select document 1");
}
assert_eq!(h.session.documents()[0].doc.layer_count(), 1, "nothing was placed into the active document");
assert_eq!(h.session.documents()[1].doc.layer_count(), 2, "the placed layer is in document 1");
assert_eq!(std::fs::read_dir(root.join("sets")).unwrap().count(), 2, "document 1's data sets were exported");
// Release directory capabilities before cleanup on Windows.
drop(h);
std::fs::remove_dir_all(base).unwrap();
}
#[test]
fn trusted_local_render_keeps_full_size_behavior() {
let mut h = Headless::trusted_local();
+96 -5
View File
@@ -97,11 +97,15 @@ pub fn validate_token(token: &str) -> Result<(), AutomationError> {
}
/// Compare fixed-width tokens without an early exit on a mismatching byte.
///
/// Hex letters match in either case: [`validate_token`] accepts uppercase, and the server keeps
/// the token lowercased, so a client sending the token exactly as it was configured must still
/// authenticate (#6425). Only `A`–`Z` fold, so a non-hex byte never matches a hex digit.
pub fn token_matches(expected: &str, supplied: &str) -> bool {
if expected.len() != TOKEN_HEX_LEN || supplied.len() != TOKEN_HEX_LEN {
return false;
}
expected.bytes().zip(supplied.bytes()).fold(0u8, |different, (a, b)| different | (a ^ b)) == 0
expected.bytes().zip(supplied.bytes()).fold(0u8, |different, (a, b)| different | (a.to_ascii_lowercase() ^ b.to_ascii_lowercase())) == 0
}
/// Validate the first TCP frame without exposing any control method before authentication.
@@ -135,7 +139,27 @@ pub fn repeat_auth_reply(line: &str) -> Option<Value> {
}
fn read_token_file(path: &Path) -> Result<String, AutomationError> {
let token = std::fs::read_to_string(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?;
// Validate what we actually opened, not just the name: an existing control-token file must be
// a regular file that no other local principal can read. A freshly created one is 0600
// (`create_token_file`); an operator-supplied one is checked here so a group- or world-readable
// token is refused rather than trusted as the control credential (#4966).
let mut file = std::fs::File::open(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?;
let meta = file.metadata().map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?;
if !meta.is_file() {
return Err(AutomationError::Io(format!("{}: control token file is not a regular file", path.display())));
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if meta.mode() & 0o077 != 0 {
return Err(AutomationError::Io(format!(
"{}: control token file is readable by group or others (mode {:o}); restrict it to its owner (e.g. chmod 600)",
path.display(),
meta.mode() & 0o777
)));
}
}
let token = std::io::read_to_string(&mut file).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?;
let token = token.trim().to_owned();
validate_token(&token)?;
Ok(token)
@@ -348,6 +372,45 @@ mod tests {
assert_eq!(reply["result"]["authenticated"], true);
}
fn auth_line(token: &str) -> String {
json!({"id": 1, "method": AUTH_METHOD, "params": {"token": token}}).to_string()
}
/// #6425: `validate_token` accepts uppercase hex and the server keeps the token lowercased, so a
/// client sending the token exactly as it was configured was refused.
#[test]
fn a_token_authenticates_in_any_letter_case() {
let configured = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
let mixed = "0123456789AbCdEf0123456789aBcDeF0123456789ABCDEF0123456789abcdef";
let expected = server_token(Some(configured), None).unwrap();
for supplied in [configured, &configured.to_ascii_lowercase(), mixed] {
let (reply, authenticated) = authentication_reply(&auth_line(supplied), &expected);
assert!(authenticated, "{supplied}: {reply}");
assert_eq!(reply["result"]["authenticated"], true);
}
// The comparison is case-insensitive in both directions.
assert!(token_matches(configured, &configured.to_ascii_lowercase()));
// A different token, or a non-hex character that only folds to a hex one, still fails.
let other = "1123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
let (_, authenticated) = authentication_reply(&auth_line(other), &expected);
assert!(!authenticated);
let not_hex = "0123456789ABCDEG0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
assert!(!token_matches(&expected, not_hex));
}
/// #6425: the same for a token file whose contents are uppercase.
#[test]
fn an_uppercase_token_file_authenticates_its_exact_contents() {
let path = std::env::temp_dir().join(format!("photocraft-control-token-upper-{}-{}.txt", std::process::id(), generate_token().unwrap()));
let contents = generate_token().unwrap().to_ascii_uppercase();
create_token_file(&path, &contents).unwrap();
let expected = server_token(None, Some(&path));
std::fs::remove_file(&path).unwrap();
let (reply, authenticated) = authentication_reply(&auth_line(&contents), &expected.unwrap());
assert!(authenticated, "{reply}");
}
#[test]
fn repeat_auth_on_an_authenticated_connection_says_so() {
let line = json!({"id": 3, "method": AUTH_METHOD, "params": {"token": "anything"}}).to_string();
@@ -417,22 +480,50 @@ mod tests {
#[test]
fn an_authenticated_connection_outlives_the_authentication_deadline() {
let (server, mut client) = tcp_pair();
// The test thread writes the first line before the deadline starts. The sender writes the
// second line once the connection has authenticated and the deadline has passed.
client.write_all(b"one\n").unwrap();
let limit = Duration::from_secs(1);
let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(server, limit));
let deadline = Instant::now() + limit;
let (tx, rx) = std::sync::mpsc::channel();
let sender = std::thread::spawn(move || {
client.write_all(b"one\n").unwrap();
std::thread::sleep(Duration::from_millis(500));
rx.recv().unwrap();
std::thread::sleep((deadline + Duration::from_millis(300)).saturating_duration_since(Instant::now()));
client.write_all(b"two\n").unwrap();
});
let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(server, Duration::from_millis(200)));
let mut line = String::new();
assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap(), LineRead::Line);
assert_eq!(line, "one\n");
reader.get_mut().authenticated().unwrap();
tx.send(()).unwrap();
// Sent 300 ms after the deadline: only the idle timeout applies now.
assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap(), LineRead::Line);
assert_eq!(line, "two\n");
sender.join().unwrap();
}
#[cfg(unix)]
#[test]
fn an_existing_token_file_must_be_owner_only() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("photocraft-token-perm-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("token.txt");
let token = "0123456789abcdef".repeat(4); // 64 hex chars, passes validate_token
std::fs::write(&path, &token).unwrap();
// A group- or world-readable token is refused: the control credential must not be shared (#4966).
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
assert!(read_token_file(&path).is_err(), "a 0644 token file must be refused");
// Owner-only is opened, read and validated.
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
assert_eq!(read_token_file(&path).unwrap(), token, "a 0600 token file must be read");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn connection_limiter_releases_capacity() {
let limiter = ConnectionLimiter::new(1);
+30 -7
View File
@@ -170,6 +170,13 @@ pub struct SaveParams {
/// DPX: 8, 10, 12 or 16 bits per sample. Unset: 8 for an 8-bit document, else 16.
#[serde(default, rename = "dpxBits")]
pub dpx_bits: Option<u8>,
/// DDS: "none" (uncompressed, the default), "bc1" (DXT1: alpha on or off), "bc2" (DXT3) or
/// "bc3" (DXT5).
#[serde(default, rename = "ddsCompression")]
pub dds_compression: Option<String>,
/// DDS: also write the full mip chain, each level a box filter of the one above.
#[serde(default, rename = "ddsMipmaps")]
pub dds_mipmaps: bool,
#[serde(default)]
pub index: Option<usize>,
}
@@ -325,10 +332,11 @@ pub struct UiSetParams {
/// dashes, dashOffset and opacity; null disables the stroke). Shape stroke fields use
/// `shape.edit` names and units; colour comes from `tools.setColors`. camTransformOnDrop (bool,
/// the Content-Aware Move tool's Transform On Drop). tileGrid (bool, a developer overlay of the
/// active layer's storage tiles). layerFilter (an object patching the Layers panel filter:
/// by: kind/name/mode; kinds: a list of pixel/adjustment/type/shape/smart; name: a string;
/// mode: a blend mode name such as Multiply or Pass Through). Omitted filter fields keep
/// their current values. Other fields are an error.
/// active layer's storage tiles). layerFilter (an object patching by ("kind", "name" or
/// "mode"), kinds (a list of layer kinds), name (matches part of the layer name) and mode (a
/// blend mode name) for the Layers panel filter). strokeTrace (true, false, "stderr" or "off":
/// the stroke latency trace at runtime; a CSV file is set at launch with --stroke-trace, not
/// over this channel). Other fields are an error.
pub fields: Value,
}
@@ -1000,10 +1008,18 @@ impl PhotocraftMcp {
Some((_, Some(bits))) => Some(bits),
Some((b, None)) => return Ok(fail(format!("`dpxBits` must be 8, 10, 12 or 16, got {b}"))),
};
if let Some(q) = p.quality.filter(|q| !(1..=100).contains(q)) {
return Ok(fail(format!("`quality` must be a whole number from 1 to 100, got {q}")));
}
let dds_compression = match p.dds_compression.as_deref().map(|c| (c, photocraft_io::DdsCompression::from_name(c))) {
None => None,
Some((_, Some(c))) => Some(c),
Some((c, None)) => return Ok(fail(format!("`ddsCompression` must be none, bc1, bc2 or bc3, got {c:?}"))),
};
if let Some(b) = self.bridge_client() {
// The bridge forwards to the running app's `app.save`, which takes only an optional
// path (without one it writes back to the document's own layered file, as headless
// does) and the Targa bits, so the other options are headless-only. Saying so beats
// does), the Targa bits and the DDS options, so the other options are headless-only. Saying so beats
// saving with defaults while the caller believes their quality or format was applied.
let unsupported: Vec<&str> = [
p.format.is_some().then_some("format"),
@@ -1025,15 +1041,22 @@ impl PhotocraftMcp {
if let Some(bits) = tga_bits {
params["tgaBits"] = json!(bits.bits());
}
if let Some(c) = dds_compression {
params["ddsCompression"] = json!(c.name());
}
if p.dds_mipmaps {
params["ddsMipmaps"] = json!(true);
}
return to_result(b.call("app.save", params).await);
}
let Some(r) = self
.headless_op(move |h| {
let mut opts = photocraft_io::ExportOptions { tiff_layers: p.tiff_layers, tga_bits, ..Default::default() };
opts.encode.dpx_bits = dpx_bits;
opts.encode.dds = photocraft_io::DdsOptions { compression: dds_compression.unwrap_or_default(), mipmaps: p.dds_mipmaps };
if let Some(q) = p.quality {
opts.encode.jpeg_quality = q.clamp(1, 100);
opts.encode.webp_quality = q.clamp(1, 100);
opts.encode.jpeg_quality = q;
opts.encode.webp_quality = q;
opts.encode.webp_lossless = false;
}
let path = p.path.map(PathBuf::from);
+55 -2
View File
@@ -74,6 +74,13 @@ impl AuthorizedWorkspace {
/// as [`photocraft_format::atomic_write`], through the directory capability). On failure
/// the previous file is untouched and the temporary file is removed.
///
/// Replacing a file carries its extended attributes — macOS Finder tags, Linux `user.*` —
/// onto the file that takes its place (#7095), through the two open descriptors rather than
/// their paths, so nothing here reaches outside the write root to finish a write. The rest
/// of what [`photocraft_format::atomic_write`] preserves does not follow: its permission
/// step is tracked in #4963, and the Windows `ReplaceFileW` swap is path-based, which this
/// path may not use.
///
/// The parent directory must already exist. `cap-std` performs path
/// resolution and file creation relative to the held directory handle, so
/// a non-existent final target is supported without ambient path access.
@@ -85,10 +92,20 @@ impl AuthorizedWorkspace {
let tmp = parent.join(photocraft_format::atomic::temp_name(&leaf));
let mut options = OpenOptions::new();
options.write(true).create_new(true);
let written = root.dir.open_with(&tmp, &options).and_then(|mut file| {
let written = root.dir.open_with(&tmp, &options).and_then(|file| {
let mut file = file.into_std();
file.write_all(bytes)?;
#[cfg(unix)]
{
use std::os::fd::AsFd;
// Best effort, and only when there is a file being replaced: a save still
// succeeds for a document whose Finder tag could not come along.
if let Ok(old) = root.dir.open(&relative) {
let _ = photocraft_format::atomic::meta::copy_xattrs(old.as_fd(), file.as_fd());
}
}
// Falls back to a plain fsync on shares that refuse a full flush (#1336).
photocraft_format::atomic::sync_file(&file.into_std())
photocraft_format::atomic::sync_file(&file)
});
let renamed = written.and_then(|()| photocraft_format::atomic::retry_rename(RenameRetry::platform(), || root.dir.rename(&tmp, &root.dir, &relative)));
if let Err(e) = renamed {
@@ -369,6 +386,10 @@ fn command_uses_ambient_path(id: &str, params: &Value) -> bool {
}
// With a `path` the file is written there; without one the bytes come back as `data`.
"actions.save" | "swatches.export" => params.get("path").is_some(),
// Shortcut sets: a .kys read or written by path, or a Summarize page written by path.
"edit.keyboardShortcuts" => {
params.get("path").is_some() || ["importKys", "exportKys", "summarize"].iter().any(|k| params.get(*k).and_then(|v| v.get("path")).is_some())
}
"plugin.reload" => true,
_ => false,
}
@@ -568,6 +589,32 @@ mod tests {
assert!(temp_files(&inside).is_empty());
}
/// #7095: replacing a file through the automation path keeps the old file's extended
/// attributes, the same as a save through `atomic_write` does.
#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
#[test]
fn write_keeps_the_replaced_files_extended_attributes() {
#[cfg(target_os = "macos")]
const TAG: &str = "com.apple.metadata:_kMDItemUserTags";
#[cfg(not(target_os = "macos"))]
const TAG: &str = "user.photocraft.test";
let (inside, _, workspace) = roots("xattr");
let path = inside.join("doc.psd");
workspace.write("doc.psd", b"original").unwrap();
if rustix::fs::setxattr(&path, TAG, b"tagged", rustix::fs::XattrFlags::empty()).is_err() {
return; // a file system without extended attributes: nothing to assert
}
workspace.write("doc.psd", b"replacement, longer").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"replacement, longer");
let mut buf = [0u8; 64];
let len = rustix::fs::getxattr(&path, TAG, buf.as_mut_slice()).expect("the attribute was dropped");
assert_eq!(&buf[..len], b"tagged");
assert!(temp_files(&inside).is_empty());
}
#[cfg(unix)]
#[test]
fn write_into_read_only_folder_keeps_the_original() {
@@ -712,6 +759,12 @@ mod tests {
assert!(authorize_desktop_engine_command("image.mode.cmyk", &serde_json::json!({})).is_err());
assert!(authorize_engine_command("image.mode.rgb", &serde_json::json!({"profile": "/outside/profile.icc"})).is_err());
assert!(authorize_engine_command("image.mode.rgb", &serde_json::json!({"profile": "srgb"})).is_ok());
// Shortcut sets: text in and out is fine; a path reads or writes the filesystem.
let ks = |p: serde_json::Value| authorize_engine_command("edit.keyboardShortcuts", &p);
assert!(ks(serde_json::json!({"exportKys": true, "summarize": true, "importKys": "<photoshop-keyboard-shortcuts/>"})).is_ok());
for key in ["importKys", "exportKys", "summarize"] {
assert!(ks(serde_json::json!({ key: {"path": "/outside/set.kys"} })).is_err(), "{key}");
}
assert!(authorize_engine_command("filter.distort.displace", &serde_json::json!({"mapPath": "outside.png"})).is_err());
assert!(authorize_engine_command("layer.setAdjustment", &serde_json::json!({"file": "outside.cube"})).is_err());
assert!(authorize_engine_command("prefs.set", &serde_json::json!({"path": "colorSettings.workingRgb", "value": "outside.icc"})).is_err());
+4 -3
View File
@@ -4,14 +4,15 @@
use photocraft_automation::Headless;
use serde_json::json;
/// A tiled pixel file with one raw 64x64 tile of `bgra` at (0, 0).
/// A tiled pixel file with one raw 64x64 tile of `bgra` at (0, 0). Krita writes a raw tile
/// (flag 0) as plain interleaved pixels; only LZF tiles are split into byte planes.
fn tiles(bgra: [u8; 4]) -> Vec<u8> {
let n = 64 * 64;
let mut out = b"VERSION 2\nTILEWIDTH 64\nTILEHEIGHT 64\nPIXELSIZE 4\nDATA 1\n".to_vec();
out.extend(format!("0,0,LZF,{}\n", 1 + n * 4).into_bytes());
out.push(0);
for b in bgra {
out.extend(std::iter::repeat_n(b, n));
for _ in 0..n {
out.extend(bgra);
}
out
}
+43
View File
@@ -318,6 +318,10 @@ async fn errors_are_tool_errors_not_crashes() {
assert!(text(&r).contains("bridge"));
let r = call(&client, "doc_export", json!({})).await;
assert_eq!(r.is_error, Some(true));
// Quality 0 is refused, not clamped to 1 (serde already refuses 500, -1 and "85").
let r = call(&client, "doc_save", json!({"path": "q.jpg", "quality": 0})).await;
assert_eq!(r.is_error, Some(true));
assert!(text(&r).contains("from 1 to 100"), "{}", text(&r));
// Server still alive.
json_of(&call(&client, "session_list", json!({})).await);
client.cancel().await.unwrap();
@@ -415,6 +419,45 @@ async fn headless_doc_save_writes_targa_bits() {
cleanup(&dir);
}
/// `ddsCompression` and `ddsMipmaps` reach the app's `app.save` through the bridge; an unknown
/// compression is refused before anything is sent.
#[tokio::test(flavor = "multi_thread")]
async fn bridge_doc_save_forwards_dds_options() {
let (addr, app) = fake_app().await;
let client = connect(PhotocraftMcp::bridge(&addr, CONTROL_TOKEN).unwrap()).await;
let saved = json_of(&call(&client, "doc_save", json!({"path": "out.dds", "ddsCompression": "DXT5", "ddsMipmaps": true})).await);
assert_eq!(saved, json!({"saved": {"path": "out.dds", "ddsCompression": "bc3", "ddsMipmaps": true}}));
let r = call(&client, "doc_save", json!({"path": "out.dds", "ddsCompression": "bc7"})).await;
assert_eq!(r.is_error, Some(true), "{}", text(&r));
assert!(text(&r).contains("ddsCompression"), "{}", text(&r));
client.cancel().await.unwrap();
app.abort();
}
/// Headless `doc_save`/`doc_export` write the DDS compression (the FourCC, bytes 84–87) and mip
/// count (bytes 28–31) asked for; unset, an uncompressed texture without mipmaps.
#[tokio::test(flavor = "multi_thread")]
async fn headless_doc_save_writes_dds_options() {
let dir = tmp("dds-options");
let client = connect(headless_in(&dir)).await;
call(&client, "doc_new", json!({"width": 16, "height": 8, "background": "white"})).await;
for (tool, params, fourcc, mips) in [
("doc_export", json!({"path": "plain.dds"}), [0; 4], 1),
("doc_export", json!({"path": "bc1.dds", "ddsCompression": "bc1"}), *b"DXT1", 1),
("doc_save", json!({"path": "bc3.dds", "ddsCompression": "bc3", "ddsMipmaps": true}), *b"DXT5", 5),
] {
let r = call(&client, tool, params.clone()).await;
assert_ne!(r.is_error, Some(true), "{params}: {}", text(&r));
let bytes = std::fs::read(dir.join(params["path"].as_str().unwrap())).unwrap();
assert_eq!((&bytes[84..88], u32::from_le_bytes(bytes[28..32].try_into().unwrap())), (&fourcc[..], mips), "{params}");
}
let r = call(&client, "doc_export", json!({"path": "bad.dds", "ddsCompression": "bc7"})).await;
assert_eq!(r.is_error, Some(true), "{}", text(&r));
assert!(!dir.join("bad.dds").exists());
client.cancel().await.unwrap();
cleanup(&dir);
}
#[tokio::test(flavor = "multi_thread")]
async fn bridge_response_budget_drops_connection_without_retrying_the_operation() {
use photocraft_automation::{BridgeClient, budgets::MAX_RESPONSE_BYTES};
@@ -0,0 +1,259 @@
//! Stored smart-filter parameters must not grant automation ambient filesystem access.
use std::path::{Path, PathBuf};
use std::sync::{
Arc,
atomic::{AtomicU64, Ordering},
};
use photocraft_automation::{AuthorizedWorkspace, Headless, workspace::authorize_desktop_engine_step};
use photocraft_doc::{Document, LayerContent, LayerId};
use photocraft_engine::{Session, actions_cmds::Action, jobs::Started, smart_cmds};
use serde_json::{Value, json};
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
static NEXT: AtomicU64 = AtomicU64::new(0);
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let base = std::env::temp_dir().join(format!("pc-filter-auth-{}-{stamp}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir(&base).unwrap();
std::fs::create_dir(base.join("workspace")).unwrap();
std::fs::create_dir(base.join("outside")).unwrap();
let f = Self(base);
std::fs::write(f.root().join("stripes.png"), png(|x| if (x / 2) % 2 == 0 { 0 } else { 255 })).unwrap();
std::fs::write(f.outside("black.png"), png(|_| 0)).unwrap();
std::fs::write(f.outside("white.png"), png(|_| 255)).unwrap();
std::fs::write(f.outside("black.cube"), cube(0)).unwrap();
std::fs::write(f.outside("white.cube"), cube(1)).unwrap();
f
}
fn root(&self) -> PathBuf {
self.0.join("workspace")
}
fn outside(&self, name: &str) -> PathBuf {
self.0.join("outside").join(name)
}
fn headless(&self) -> Headless {
Headless::with_workspace(AuthorizedWorkspace::new(Some(&self.root()), Some(&self.root())).unwrap())
}
fn setup(&self, lookup: bool) -> (Headless, LayerId) {
let mut h = self.headless();
h.handle("doc.new", json!({"width": 32, "height": 32, "background": "#808080"})).unwrap();
h.handle("doc.new", json!({"width": 32, "height": 32})).unwrap();
let layer = LayerId(h.handle("doc.place", json!({"path": "stripes.png"})).unwrap()["layer"].as_u64().unwrap());
let (id, params) = if lookup {
("image.adjustments.colorLookup", json!({"layer": layer.0, "lut": "none"}))
} else {
("filter.distort.displace", json!({"layer": layer.0, "mapDocument": 0, "horizontal": 50, "vertical": 0}))
};
h.session.execute(id, params).unwrap();
(h, layer)
}
fn saved_filter(&self, lookup: bool) -> (Document, LayerId) {
let (h, layer) = self.setup(lookup);
let mut doc = (*h.session.active().unwrap().doc).clone();
let LayerContent::Smart(sm) = &mut doc.layer_mut(layer).unwrap().content else { panic!("smart object") };
sm.smart_filters[0].params = path_params(lookup, &self.outside(if lookup { "white.cube" } else { "white.png" }));
let bytes = photocraft_format::save_to_bytes(&doc, &Default::default()).unwrap();
std::fs::write(self.root().join("stored.pcraft"), bytes).unwrap();
(doc, layer)
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn png(value: impl Fn(u32) -> u8) -> Vec<u8> {
let mut pixels = Vec::new();
for _ in 0..32 {
for x in 0..32 {
pixels.extend([value(x); 3]);
}
}
let image = photocraft_codecs::Image::from_u8(32, 32, photocraft_codecs::ChannelLayout::Rgb, pixels).unwrap();
photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap()
}
fn cube(value: u8) -> String {
format!("LUT_3D_SIZE 2\n{}", format!("{value} {value} {value}\n").repeat(8))
}
fn path_params(lookup: bool, path: &Path) -> Value {
if lookup { json!({"file": path}) } else { json!({"mapPath": path, "mapDocument": null}) }
}
fn assert_denied<T: std::fmt::Debug>(result: Result<T, impl std::fmt::Display>) {
match result {
Err(error) => assert!(error.to_string().contains("ambient filesystem"), "{error}"),
Ok(value) => panic!("ambient file access was accepted: {value:?}"),
}
}
#[test]
fn parameter_edits_reject_outside_images_and_luts_without_changing_the_document() {
for lookup in [false, true] {
let f = Fixture::new();
let (mut h, layer) = f.setup(lookup);
let before = h.render_png(None, 64).unwrap();
let doc = h.session.active().unwrap().doc.clone();
let journal = h.session.journal.len();
for name in if lookup { ["black.cube", "white.cube"] } else { ["black.png", "white.png"] } {
let params = path_params(lookup, &f.outside(name));
let id = if lookup { "image.adjustments.colorLookup" } else { "filter.distort.displace" };
assert_denied(h.session.execute(id, params.clone()));
assert_denied(h.handle("engine.execute", json!({"command": "layer.smartFilter.setParams", "params": {"layer": layer.0, "params": params}})));
assert!(Arc::ptr_eq(&doc, &h.session.active().unwrap().doc));
assert_eq!(h.session.journal.len(), journal);
assert_eq!(h.render_png(None, 64).unwrap(), before);
}
// Safe numeric and embedded-data edits remain usable.
let safe = if lookup { json!({"data": cube(0), "fileName": "embedded.cube"}) } else { json!({"horizontal": 10}) };
h.session.execute("layer.smartFilter.setParams", json!({"layer": layer.0, "params": safe})).unwrap();
}
}
#[test]
fn action_playback_cannot_hide_a_smart_filter_path_edit() {
let f = Fixture::new();
let (mut h, layer) = f.setup(false);
let doc = h.session.active().unwrap().doc.clone();
h.session.actions.list.push(Action::new(
"edit filter",
vec![("layer.smartFilter.setParams".into(), json!({"layer": layer.0, "params": path_params(false, &f.outside("white.png"))}))],
));
let result = h.session.execute("actions.play", json!({"action": "edit filter"})).unwrap();
assert!(result["failed"]["error"].as_str().unwrap().contains("ambient filesystem"), "{result}");
assert!(Arc::ptr_eq(&doc, &h.session.active().unwrap().doc));
}
#[test]
fn loaded_filters_are_checked_after_merging_even_when_the_edit_contains_no_path() {
for lookup in [false, true] {
let f = Fixture::new();
let (_, layer) = f.saved_filter(lookup);
let mut h = f.headless();
h.open(Path::new("stored.pcraft")).unwrap();
let doc = h.session.active().unwrap().doc.clone();
assert_denied(h.session.execute("layer.smartFilter.setParams", json!({"layer": layer.0, "params": {"horizontal": 10, "dither": false}})));
assert!(Arc::ptr_eq(&doc, &h.session.active().unwrap().doc));
// Removing an ambient input makes the filter usable again.
let safe = if lookup { json!({"file": "", "lut": "none"}) } else { json!({"mapPath": ""}) };
h.session.execute("layer.smartFilter.setParams", json!({"layer": layer.0, "params": safe})).unwrap();
}
}
#[test]
fn loaded_filters_cannot_read_on_rerender_or_reuse_a_trusted_cached_stage() {
for lookup in [false, true] {
let f = Fixture::new();
let (doc, layer) = f.saved_filter(lookup);
for warm_cache in [false, true] {
if warm_cache {
let mut trusted = doc.clone();
assert!(smart_cmds::refresh(&mut trusted, layer).unwrap());
}
let mut h = f.headless();
h.open(Path::new("stored.pcraft")).unwrap();
let before = h.session.active().unwrap().doc.clone();
assert_denied(h.session.execute("layer.smartFilter.setVisible", json!({"layer": layer.0, "visible": true})));
assert!(Arc::ptr_eq(&before, &h.session.active().unwrap().doc));
}
}
}
#[test]
fn background_rerender_keeps_the_desktop_request_authority() {
for lookup in [false, true] {
let f = Fixture::new();
let (doc, layer) = f.saved_filter(lookup);
let mut s = Session::new();
s.add_document(doc, None);
s.authorize = Some(authorize_desktop_engine_step);
let before = s.active().unwrap().doc.clone();
let started = s.start("layer.smartFilter.setVisible", json!({"layer": layer.0, "visible": true})).unwrap();
// The desktop restores UI authority as soon as the automation request returns.
s.authorize = None;
let Started::Job(job) = started else { panic!("expected a background render") };
assert_denied(s.wait_job(job));
assert!(Arc::ptr_eq(&before, &s.active().unwrap().doc));
// The worker's restriction must not leak into subsequent trusted UI work.
s.execute("layer.smartFilter.setVisible", json!({"layer": layer.0, "visible": true})).unwrap();
}
}
#[test]
fn transferring_stored_filters_cannot_read_outside_files() {
for lookup in [false, true] {
let f = Fixture::new();
let (_, from) = f.saved_filter(lookup);
let mut h = f.headless();
h.open(Path::new("stored.pcraft")).unwrap();
let to = h.handle("doc.place", json!({"path": "stripes.png"})).unwrap()["layer"].as_u64().unwrap();
let before = h.session.active().unwrap().doc.clone();
for copy in [false, true] {
assert_denied(h.session.execute("layer.smartFilter.transfer", json!({"from": from.0, "to": to, "copy": copy})));
assert!(Arc::ptr_eq(&before, &h.session.active().unwrap().doc));
}
}
}
#[test]
fn trusted_parameter_edits_still_read_maps_and_luts_after_a_denied_request() {
for lookup in [false, true] {
let f = Fixture::new();
let (mut h, layer) = f.setup(lookup);
let edit = |path: &Path| json!({"layer": layer.0, "params": path_params(lookup, path)});
assert_denied(h.session.execute("layer.smartFilter.setParams", edit(&f.outside("white.png"))));
let mut trusted = Headless::trusted_local();
trusted.session.add_document((*h.session.active().unwrap().doc).clone(), None);
let mut renders = Vec::new();
for name in if lookup { ["black.cube", "white.cube", "black.cube"] } else { ["black.png", "white.png", "black.png"] } {
trusted.session.execute("layer.smartFilter.setParams", edit(&f.outside(name))).unwrap();
renders.push(trusted.render_png(None, 64).unwrap());
}
assert_ne!(renders[0], renders[1], "trusted path-based filter still works");
assert_eq!(renders[0], renders[2], "trusted rendering remains deterministic");
}
}
#[test]
fn a_refresh_outside_command_execution_surfaces_the_denial_instead_of_rendering_unfiltered() {
// link-watch and transform paths re-render through Session::edit, not Session::execute or
// jobs::run; the gate must reach them and the denial must not silently pass pixels through
// into the process-wide stack cache.
for lookup in [false, true] {
let f = Fixture::new();
let (doc, layer) = f.saved_filter(lookup);
let mut h = f.headless();
h.open(Path::new("stored.pcraft")).unwrap();
let before = h.session.active().unwrap().doc.clone();
// Session::edit is the outside-execute mutation path (an edit closure the UI or a
// linked-file change runs); the stored filter must be denied there too.
assert_denied(h.session.edit("Re-render", |d, _| match smart_cmds::refresh(d, layer) {
Ok(_) => Ok(()),
Err(e) => Err(e),
}));
assert!(Arc::ptr_eq(&before, &h.session.active().unwrap().doc));
// A trusted session (no gate) still renders the same document.
let mut trusted = Session::new();
trusted.add_document(doc, None);
let mut d = (*trusted.active().unwrap().doc).clone();
assert!(smart_cmds::refresh(&mut d, layer).is_ok());
}
}
#[test]
fn an_ambient_map_or_lut_never_renders_unfiltered_pixels_into_the_stack_cache() {
// The denial path must fail the render before any stage is cached process-wide.
for lookup in [false, true] {
let f = Fixture::new();
let (doc, layer) = f.saved_filter(lookup);
let mut s = Session::new();
s.add_document(doc, None);
s.authorize = Some(authorize_desktop_engine_step);
let bytes = smart_cmds::stack_cache_bytes();
assert_denied(s.execute("layer.smartFilter.setVisible", json!({"layer": layer.0, "visible": true})));
assert_eq!(bytes, smart_cmds::stack_cache_bytes(), "a denied render cached no stage");
}
}
+7
View File
@@ -20,6 +20,9 @@ t.eval(&[1.0, 0.0, 0.0], &mut out); // exact single colou
* `Transform::proof(src, proof, display, intent, bpc, simulate_paper)`: soft-proof chain.
* `GamutCheck::new(src, proof, ΔE)`: out-of-gamut test (gamut warning).
* `Lut3d::from_transform(&t, 33).to_rgba16f_bytes()`: display transform as a 3D texture.
* `Transform::gpu_matrix_shaper()`: accurate RGB input curves + matrix + output curves for
display shaders. Float tables retain near-black detail; unsupported chains return `None`
for the 3D LUT fallback. No GPU dependency is required by this crate.
* `transform::cached(src, dst, opts)`: process-wide transform cache.
* `Profile::to_bytes()`: original bytes of parsed profiles (byte-exact), or an ICC v4.3 encoding.
* `Profile::same_colors(&other)`: the same colour space whatever the encoding (every sample of
@@ -36,6 +39,10 @@ t.eval(&[1.0, 0.0, 0.0], &mut out); // exact single colou
tetrahedral interpolation (4D: linear on the first input between two tetrahedra); purely
analytic chains (e.g. Lab ↔ RGB) are evaluated exactly. Buffers ≥ 16k pixels run on rayon
(native only).
* GPU matrix/TRC display transforms reuse the 4097-entry input curves, 3×3 matrix plus
offsets, and 4096-entry output curves indexed by `x^(1/4)`. Each channel is interpolated
separately using float texels; SDR input/output are clamped to `[0, 1]`. A final RGB
33³ table cannot resolve steep inverse gamma curves near black, even with float texels.
* Rendering intents: AToB0/1/2 and BToA0/1/2 by intent with AToB0/BToA0 fallback; absolute
colorimetric scales by media white (`wtpt`; v2 display profiles treated as D50; a zero,
negative or non-finite white is refused with `CmsError::Invalid`); BPC uses
+131
View File
@@ -0,0 +1,131 @@
//! Separable RGB display transforms without a coarse 3D interpolation near black.
/// RGB input curves, a 3×3 matrix with offsets, and RGB output curves for a GPU display
/// transform. Values are floats independent of the document's pixel depth.
///
/// The input table is sampled linearly at `clamp(rgb, 0, 1) * INPUT_INTERVALS`. Apply
/// [`Self::matrix_rows`] to that result, then sample the output table at
/// `clamp(linear_rgb, 0, 1)^(1/4) * (OUTPUT_ENTRIES - 1)`. Interpolate each channel
/// separately between its two adjacent table entries. These nonuniform output samples
/// retain shadow detail where a pure gamma curve's inverse has an infinite slope.
///
/// Tables contain RGB values and a padding alpha of 1, suitable for RGBA32Float texels.
/// Use unfiltered texture loads and manual interpolation if float filtering is unavailable.
/// This representation covers SDR display output; input and output are clamped to `[0, 1]`.
/// Construction validates all sizes and values; arbitrary CLUT/proofing chains retain the
/// [`crate::Lut3d`] fallback.
#[derive(Clone, Debug, PartialEq)]
pub struct GpuMatrixShaper {
matrix: [[f32; 4]; 3],
input: Vec<[f32; 4]>,
output: Vec<[f32; 4]>,
}
impl GpuMatrixShaper {
/// The input table has one more entry than this number of equal intervals.
pub const INPUT_INTERVALS: usize = 4096;
/// Output entries are spaced uniformly in the fourth root of the linear value.
pub const OUTPUT_ENTRIES: usize = 4096;
/// Row-major matrix: each row contains three RGB coefficients followed by its offset.
pub fn matrix_rows(&self) -> [[f32; 4]; 3] {
self.matrix
}
/// RGB input curves at `i / INPUT_INTERVALS`, with padding alpha 1.
pub fn input_table(&self) -> &[[f32; 4]] {
&self.input
}
/// RGB output curves at `(i / (OUTPUT_ENTRIES - 1))^4`, with padding alpha 1.
pub fn output_table(&self) -> &[[f32; 4]] {
&self.output
}
pub(crate) fn from_parts(matrix: [[f32; 4]; 3], input: &[Vec<f32>], output: Option<&[Vec<f32>]>) -> Option<Self> {
if !matrix.iter().flatten().all(|v| v.is_finite()) {
return None;
}
let input = pack_table(if input.is_empty() { None } else { Some(input) }, Self::INPUT_INTERVALS + 1, |u| u)?;
let output = pack_table(output, Self::OUTPUT_ENTRIES, |u| u * u * u * u)?;
let maxima: [f64; 3] = std::array::from_fn(|channel| input.iter().map(|entry| f64::from(entry[channel]).abs()).fold(0.0, f64::max));
for row in matrix {
let magnitude =
row[..3].iter().zip(maxima).map(|(coefficient, maximum)| f64::from(*coefficient).abs() * maximum).sum::<f64>() + f64::from(row[3]).abs();
if magnitude > f64::from(f32::MAX) {
return None;
}
}
Some(Self { matrix, input, output })
}
}
fn pack_table(tables: Option<&[Vec<f32>]>, entries: usize, identity: impl Fn(f32) -> f32) -> Option<Vec<[f32; 4]>> {
if let Some(tables) = tables
&& (tables.len() != 3 || tables.iter().any(|t| t.len() != entries))
{
return None;
}
let mut packed: Vec<[f32; 4]> = Vec::new();
// Both lengths are fixed constants, but a failed allocation still means the caller
// should retain its existing fallback rather than abort while opening a document.
packed.try_reserve_exact(entries).ok()?;
for i in 0..entries {
let rgb = match tables {
Some(tables) => [*tables.first()?.get(i)?, *tables.get(1)?.get(i)?, *tables.get(2)?.get(i)?],
None => [identity(i as f32 / (entries - 1) as f32); 3],
};
if !rgb.iter().all(|v| v.is_finite()) {
return None;
}
if let Some(previous) = packed.last()
&& rgb.iter().zip(previous).any(|(next, before)| !(*next - *before).is_finite())
{
return None;
}
packed.push([rgb[0], rgb[1], rgb[2], 1.0]);
}
Some(packed)
}
#[cfg(test)]
mod tests {
use super::*;
const IDENTITY: [[f32; 4]; 3] = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]];
#[test]
fn rejects_invalid_tables_and_nonfinite_values() {
let inputs = vec![vec![0.0; GpuMatrixShaper::INPUT_INTERVALS + 1]; 3];
let outputs = vec![vec![0.0; GpuMatrixShaper::OUTPUT_ENTRIES]; 3];
assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, Some(&outputs)).is_some());
assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs[..2], Some(&outputs)).is_none());
assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, Some(&outputs[..2])).is_none());
assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, Some(&[])).is_none());
let mut invalid = inputs.clone();
invalid[0].pop();
assert!(GpuMatrixShaper::from_parts(IDENTITY, &invalid, Some(&outputs)).is_none());
for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
let mut invalid = inputs.clone();
invalid[1][123] = v;
assert!(GpuMatrixShaper::from_parts(IDENTITY, &invalid, Some(&outputs)).is_none());
let mut invalid = outputs.clone();
invalid[2][321] = v;
assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, Some(&invalid)).is_none());
let mut invalid = IDENTITY;
invalid[1][3] = v;
assert!(GpuMatrixShaper::from_parts(invalid, &inputs, Some(&outputs)).is_none());
}
}
#[test]
fn rejects_finite_data_that_overflows_shader_arithmetic() {
let mut matrix = IDENTITY;
matrix[0] = [f32::MAX; 4];
assert!(GpuMatrixShaper::from_parts(matrix, &[], None).is_none());
let mut inputs = vec![vec![0.0; GpuMatrixShaper::INPUT_INTERVALS + 1]; 3];
inputs[0][100] = -f32::MAX;
inputs[0][101] = f32::MAX;
assert!(GpuMatrixShaper::from_parts(IDENTITY, &inputs, None).is_none());
}
}
+4 -1
View File
@@ -1,4 +1,4 @@
//! Photocraft colour management: a pure-Rust ICC v2/v4 colour management module.
//! PhotoCraft colour management: a pure-Rust ICC v2/v4 colour management module.
//!
//! * [`Profile`] parses ICC profiles (matrix/TRC RGB, gray TRC, `mft1`/`mft2`/`mAB `/`mBA `
//! LUT profiles incl. CMYK↔Lab, `curv`/`para` curves) and writes ICC v4 profiles.
@@ -8,6 +8,7 @@
//! * [`builtin`] ships sRGB, Display P3, Adobe RGB (1998)-compatible, ProPhoto-compatible,
//! linear sRGB, Rec. 2020, Gray 2.2, sGray, Lab D50 and a synthetic coated CMYK profile.
//! * [`Lut3d`] samples a display transform into a 3D texture for the GPU canvas.
//! * [`GpuMatrixShaper`] preserves near-black precision for separable RGB display transforms.
//!
//! Implemented from the public ICC.1:2010 (v4.3) and ICC.1:2001-04 (v2) specifications,
//! CIE 15, and Adobe's published black point compensation paper. This crate depends on no
@@ -19,6 +20,7 @@ pub mod builtin;
pub mod clut;
pub mod curve;
pub mod gamut;
mod gpu_matrix_shaper;
pub mod lut3d;
pub mod lutfile;
pub mod math;
@@ -32,6 +34,7 @@ pub use builtin::Builtin;
pub use clut::Clut;
pub use curve::Curve;
pub use gamut::GamutCheck;
pub use gpu_matrix_shaper::GpuMatrixShaper;
pub use lut3d::Lut3d;
pub use profile::{ColorSpace, Pcs, Profile, ProfileClass};
pub use transform::{SampleKind, Transform, TransformOptions, black_point, cached};
+1 -1
View File
@@ -245,7 +245,7 @@ fn smooth(x: f32) -> f32 {
x * x * (3.0 - 2.0 * x)
}
/// Generated looks shipped with Photocraft (procedural, CC0): `(id, label)`.
/// Generated looks shipped with PhotoCraft (procedural, CC0): `(id, label)`.
pub const BUILTIN: [(&str, &str); 8] = [
("warm", "Warm Filter"),
("cool", "Cool Filter"),
+1 -1
View File
@@ -2,7 +2,7 @@
//!
//! Adobe's CMYK profiles (U.S. Web Coated SWOP, Coated FOGRA39 …) are proprietary, and the
//! freely downloadable characterisation-based profiles come with redistribution conditions, so
//! Photocraft ships a CMYK profile it generates itself from a documented printer model:
//! PhotoCraft ships a CMYK profile it generates itself from a documented printer model:
//!
//! * **Forward model** (CMYK → XYZ): Yule–Nielsen modified Neugebauer (n = 2) over the 16
//! ink overprints, with Demichel coverage weights and a parabolic tone value increase (dot
+28 -3
View File
@@ -9,7 +9,7 @@ use crate::curve::Curve;
use crate::math;
use crate::pipeline::{Pipeline, Stage};
use crate::profile::{ColorSpace, Pcs, Profile, ProfileClass};
use crate::{CmsError, Intent};
use crate::{CmsError, GpuMatrixShaper, Intent};
/// Sample storage of a byte buffer for [`Transform::convert_bytes_many`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -39,9 +39,9 @@ impl Default for TransformOptions {
/// Grid sizes of the device-link tables built for integer (and fast float) evaluation.
pub const GRID_3D: usize = 33;
pub const GRID_4D: usize = 17;
const TABLE_1D: usize = 4096;
const TABLE_1D: usize = GpuMatrixShaper::INPUT_INTERVALS;
/// Resolution of output shaper tables (indexed by `x^(1/4)`).
const OUT_TABLE: usize = 4096;
const OUT_TABLE: usize = GpuMatrixShaper::OUTPUT_ENTRIES;
/// ICC v4 perceptual reference medium black (ICC.1:2010 §6.3.4.4), XYZ.
const PERCEPTUAL_BLACK: [f64; 3] = [0.00336, 0.0034731, 0.00287];
@@ -226,6 +226,31 @@ impl Transform {
&self.pipeline
}
/// A separable RGB transform for accurate GPU display conversion, when available.
///
/// Reuses the input/matrix/output acceleration of the integer conversion path instead
/// of interpolating final encoded RGB over a coarse 3D grid. Returns `None` for other
/// channel counts, CLUTs, proofing stages, or invalid/unavailable table data, so callers
/// can keep their existing display-LUT fallback. This is an SDR representation; see
/// [`GpuMatrixShaper`] for the precise sampling and clamping contract.
pub fn gpu_matrix_shaper(&self) -> Option<GpuMatrixShaper> {
if self.inputs != 3 || self.outputs != 3 {
return None;
}
let Core::Shaper { curves, matrix, .. } = &self.core else { return None };
let mut rows = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]];
if let Some((nr, nc, coefficients, offsets)) = matrix {
if *nr != 3 || *nc != 3 || coefficients.len() != 9 || offsets.len() != 3 {
return None;
}
for (row, (coefficients, offset)) in rows.iter_mut().zip(coefficients.as_chunks::<3>().0.iter().zip(offsets)) {
let [r, g, b] = coefficients;
*row = [*r, *g, *b, *offset];
}
}
GpuMatrixShaper::from_parts(rows, curves, self.post.as_ref().map(|p| p.tables.as_slice()))
}
/// Exact evaluation of one colour (`inputs` → `outputs` normalised values).
#[inline]
pub fn eval(&self, input: &[f32], out: &mut [f32]) {
Loaded 100 of 649 files, more files were not shown because too many files have changed in this diff. Show more