Compare commits

..

88 Commits

Author SHA1 Message Date
Brandon Thomas 65bc1645b8 fix(ui): read the OS clipboard only on an explicit paste
Follow-up to #102. Drop the 4 Hz background mirroring of the OS clipboard
in logic(): reading the whole system clipboard (possibly a large image)
without user action is a privacy problem (macOS warns about it) and wastes
CPU. Edit > Paste / Paste in Place are now enabled when the session
clipboard has content or an OS clipboard service exists (no read); the menu,
shortcut, palette and control paths import the OS image once, on the paste
itself, and keep #102's centring of external images. An empty or non-image
clipboard makes Paste a no-op with a status message instead of an error.

Tests: external_clipboard_enables_and_centres_paste adapted (enabled without
a read, exactly one read per paste, centred), plus an empty-clipboard no-op
test and idle_frames_never_read_the_os_clipboard (20 full app frames, zero
reads).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:03:08 -04:00
Brandon Thomas 1f2a74518a chore: rustfmt and egui 0.36 test port for the community batch
PR #97/#98/#99/#101 code was not rustfmt-clean at max_width 160, and the
liquify/menu regression tests from #97/#98 used Context::run, which egui 0.36
removed: port them to run_ui, clear texture deltas, and let the popup area
lay out for a few frames before hit-testing it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:03:08 -04:00
Kentaro Tsutsumi 08512df255
Japanese support: IME input in the Type tool and CJK glyphs in the UI (#101)
Japanese could not be typed or displayed in the UI.

- Type tool: handle egui IME events (Preedit/Commit). The preedit text is
  written into the layer and replaced on every update, underlined while
  uncommitted, and the caret rect is reported through output.ime so the OS
  enables the IME and positions its candidate window. Without output.ime,
  egui-winit never calls set_ime_allowed, which is why only paste worked.
- UI fonts: append the first installed OS Japanese font (Hiragino, Yu Gothic,
  Meiryo, Noto Sans CJK JP, IPA...) to the end of every egui font family, so
  Latin text keeps Inter / JetBrains Mono and only missing glyphs fall
  through. No font is bundled; a missing font is not an error. Native only.
- Text engine: try Japanese fallback families before the SC ones.

Not covered: the web build has no CJK font, UI strings are not translated,
and text layers do not set a "ja" locale yet.

Tested: IME preedit/commit/cancel/stale-range unit test, font fallback unit
test, ui-egui and text crate tests, clippy -D warnings, cargo xtask layers.
Verified by hand on macOS (UI glyphs, IME composition, candidate window).

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 01:02:52 -04:00
Gh0stly ab0fa25897
security: bound automation previews and reply resources (#93) 2026-10-05 01:02:40 -04:00
sommerper 60c5df70b5
fix(ui): shape tools show the shape while dragging (#87)
The drag drew a 1 px white box (invisible on white canvases). It now
previews the exact path shape.create commits, filled and stroked, with
the accent path outline on top. shape_geometry is shared by the preview
and finish_shape; engine::vector_cmds::shape_path builds the path.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:02:28 -04:00
Nigel Ihza Firdausy 3400412616
PR #96 — Liquify undo (#97)
* fix(ui): route Liquify shortcuts before egui focus gate

* test(ui): cover Liquify undo with focused dialog controls
2026-10-05 01:02:16 -04:00
Nigel Ihza Firdausy def1402401
Menu overlap and overflow #92 (#98)
* fix(ui): ignore menu-title hover through overlapping popups

* test(ui): keep overlap regression warning-free

* fix(ui): keep tall menu levels inside the viewport

* test(ui): inspect popup areas through public memory API
2026-10-05 01:02:04 -04:00
Nigel Ihza Firdausy bfcbdc0ee8
fix(ui): make Move transform controls interactive (#99) 2026-10-05 01:01:49 -04:00
TiredIsa 2c7b86bc02
fix(ui): pasting an image copied in another app never ran (#102)
edit.paste's `enabled` gate checks only the session clipboard, but the
OS clipboard import ran inside run()/invoke -- after the gate. Ctrl+V
was consumed then dropped, and Edit > Paste stayed grey, so images
copied in other apps could never be pasted.

The app now mirrors the OS clipboard at 4 Hz (a read every frame is
too costly), the shortcut handler imports before checking enablement
on paste presses, and a clip_external flag keeps external images
centring instead of pasting at (0,0).

Generated with [Devin](https://devin.ai)

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-05 01:01:36 -04:00
MikeeI 7c53a3c1ad
perf(compose): share bounded pattern preparation across tiles (#104)
Reserve up to 64 MiB of converted pattern pixels and 64 entries for
one render call, including pending initializers and all export bands.
Initialize outside the map lock on the requesting CMYK-scoped worker.

Budget exhaustion retains the existing uncached conversion path;
the retained-payload budget is not a cap on transient fallback buffers.
2026-10-05 01:01:23 -04:00
MikeeI a0a90c3c15
perf(compose): reuse compiled vector masks within each render (#112)
Keep OnceLock geometry initialization sequential so Rayon work stealing
cannot re-enter an unfinished slot. Raster coverage and scratch remain
local to each requested rectangle; export bands share only geometry.
2026-10-05 01:01:10 -04:00
MikeeI f4cf980a37
fix(io): detect merged PSD alpha at the stored sample precision (#111) 2026-10-05 01:00:57 -04:00
MikeeI fb79564f2a
perf(compose): remove redundant effect application halos (#110)
Keep full-region neighbourhood effect maps while rendering content only for the output rectangle. Compare complete tiled output at U8, U16 and F32 depths rather than three sampled pixels.
2026-10-05 01:00:44 -04:00
MikeeI a65b5ca80f
fix(compose): mix group coverage in premultiplied space (#108) 2026-10-05 01:00:31 -04:00
MikeeI 7100b0d617
fix(io): preserve HDR samples when unmatting PSD composites (#106) 2026-10-05 01:00:19 -04:00
MikeeI 4af95fa807
fix(compose): hash surface defaults in effect cache identity (#105) 2026-10-05 01:00:07 -04:00
MikeeI d3a6855367
perf(compose): prepare gradient stops outside pixel loops (#103)
Keep effect and fill sampling separate: empty effects use a gray ramp,
while empty fills stay transparent and interpolate Color.alpha.
Preparation stays inside the active document CMYK scope.

Evidence:
- 24 MP RGB gradient overlay: warm median 362.752 ms to 235.813 ms.
- 24 MP RGB gradient fill: warm median 129.264 ms to 117.642 ms.
- Five release samples, eight Rayon workers, matching pixel digests.
2026-10-05 00:59:53 -04:00
MikeeI 5ede5f7ba6
perf(compose): avoid unused backdrop snapshots (#109)
Pass-through groups snapshot only for mixing or visible clipping. Adjustments retain one independent mutable result and read the untouched original backdrop pixel before replacing it.
2026-10-05 00:59:38 -04:00
Brandon Thomas ad86321738
Release 0.2.0; CI builds with --locked (#113)
Bump the workspace version to 0.2.0 (cargo xtask version set 0.2.0; Cargo.lock changes only
the 26 workspace crates). CI's test and clippy steps now pass --locked, as the release packaging
scripts do, so a stale Cargo.lock fails CI instead of the release build.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 00:21:13 -04:00
Brandon Thomas 7e7864afae
Painting: right-click opens the brush picker (or erases), real Smoothing, eraser-on-Background tests (#86)
* Right-click opens the Brush Preset picker (or erases), eraser-on-Background tests, README comma

- #75: the right mouse button never paints with the tool. With a painting tool it opens the
  Brush Preset picker at the pointer (Photoshop). New preference Preferences › Tools ›
  Right-click with painting tools (tools.rightClickWithPaintingTools: brushPicker | erase,
  serde default brushPicker): with erase, a right drag with the Brush erases with the current
  brush (one paint.stroke, one undo step, pen pressure/tilt as usual). ui.pointer accepts
  "button": "secondary". New module ui-egui/src/paint_mouse.rs; picker body shared with the
  options-bar chip.
- #76: the engine already paints the background colour when erasing a transparency-locked
  layer (the Background); added tests at 8/16/32-bit in RGB/CMYK/Gray, with a selection, on a
  normal layer (erases to transparency), on a transparency-locked layer, and through the UI.
- paint.stroke/pencil refuse coordinates beyond ±1e6 (an f64::MAX point ran out of memory).
- #73: test that the canvas hands the live stroke over to the committed document with no
  frame lacking the stroke, refreshing only the stroke's rect.
- #69: README comma.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Brush smoothing follows the options bar; the live stroke draws the catch-up tail (#73)

- The options-bar Smoothing % (pro and classic bars, Brush and Eraser) edits the session
  brush's smoothing, which the live stroke and the committed paint.stroke both use; the
  canvas no longer forces 0.3. The session brush starts at Photoshop's 10 %.
- With smoothing the brush lags the pointer and catches up when the stroke ends. LiveStroke
  now draws that catch-up tail on every step (StrokeRenderer::tail_preview renders only the
  tiles the tail touches; the previous step's tail is restored first), so the preview at the
  last move equals the commit and nothing pops in on release. At 0 % there is no lag.
- Path › Stroke Path ignores smoothing (it strokes the exact geometry).
- Tests: paint tail_preview = finished stroke; canvas: 0 % preview = commit, 50 % preview =
  commit with the end reaching the pointer, smoothing changes the stroke, commit equals
  paint.stroke with that smoothing, no stale tails after sharp turns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Smoothing is a per-tool option that brush presets keep

- Picking a brush preset (tools.setBrush / paint.* "preset", .abr import with select) keeps the
  current smoothing amount and mode instead of the preset's (BrushSettings::picked_over),
  as Photoshop's options bar does. An explicit smoothing alongside the preset still applies.
- The Brush and the Eraser each remember their smoothing (UiState smoothing_tool /
  tool_smoothing, synced on tool switch before the options bar, the canvas and each stroke);
  a tool seen for the first time starts at 10 %.
- Tests: engine presets keep 10 % / a user value with pulled-string mode; UI per-tool
  smoothing across Brush/Eraser/other tools and on the first stroke after a switch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:31:17 -04:00
Brandon Thomas 8b0ceea374
Adjustment dialogs: GPU live preview through a clipped adjustment layer (#77) (#85)
* Adjustment dialogs: preview through a clipped adjustment layer on the GPU (#77)

While an Image › Adjustments dialog is open on a pixel layer, the canvas shows the
document with a temporary adjustment layer (the dialog's settings, the selection as
its mask) clipped to the target instead of re-running the command on a CPU proxy.
The canvas compositor draws it, so on the GPU canvas a change recomposites only the
target's area within the selection, and of that only what the view shows (the rest
catches up as the view moves); zoomed out on a large document it composites a copy
reduced to about screen resolution, built once per session. The base document and
region are built once per session; a change swaps the layer's adjustment in a
shallow copy. OK still runs the real command (one history step); Cancel drops it.

Kept on the CPU proxy where the layer composite can't match the command: non-pixel
or clipped targets, Blend If, visible untouched area, channel / Quick Mask targets,
CMYK/Lab/other modes, colour-making kinds in Grayscale, and documents the GPU
compositor doesn't cover. The navigator waits for settings to settle.

Tests: preview vs command within 1/255 (worst 0.37/255) for all 13 kinds at
8/16/32-bit, RGB and Gray, with/without selection; nothing changes outside the
region; dialog flow (cache, keys, damage, Cancel untouched, OK one step); GPU
parity of the preview and of the zoomed-out proxy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* interactive_bench: per-change Image › Adjustments dialog preview rows (#77)

Times the old CPU proxy preview against the clipped-layer preview at full
resolution, at 100 % view and zoomed out, with and without a selection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:31:05 -04:00
Brandon Thomas d8d648cb72
GPU canvas: Rgba16Float display texture for 16/32-bit documents (#46) (#84)
* engine: display LUT without the 32-bit preview for the GPU canvas shader; keep values above 1.0 in texture_buffer

ColorState::gpu_canvas_lut leaves View > 32-bit Preview Options out of the display LUT
(the canvas shader applies exposure/gamma before it, on unclipped float texels), and
ColorState::hdr_preview returns the active preview. CanvasDisplay::texture_buffer no
longer clamps linear values to 1.0 before sRGB-encoding them (the 8-bit path clamps
afterwards with the same result).

Refs #46

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* GPU canvas: Rgba16Float display texture for 16/32-bit documents (#46)

16- and 32-bit documents get an Rgba16Float canvas texture when the adapter can
sample, filter, render to and copy it (Rgba8Unorm stays for 8-bit documents, as the
fallback, and for high-bit documents over 100 MP, about 1.07 GB with mips), so smooth
gradients no longer band through 8 bits. Both the wgpu compositor path (encode pass
with 16F pipelines; no upper clamp) and the CPU band/rect upload path (f32 -> half
conversion, reused band buffer) write it. The 32-bit preview (exposure/gamma) moves
into the canvas shader, before the colour-management/proof LUT, so values above 1.0
are exposed into range instead of clipped. GpuCanvas::read_texels / texture_info for
tests and benchmarks; PHOTOCRAFT_CANVAS_F16=0/1 disables the float canvas / ignores
the budget. large_image_bench gains --depth 16|32.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:30:53 -04:00
Brandon Thomas 2149f7f646
raw: Sony cRAW, Panasonic RW2 (RawFormat 5), uncompressed ORF + ORF previews; NEF blocked (clean-room) (#83)
* raw: decode Sony cRAW, Panasonic RW2 (RawFormat 5) and uncompressed Olympus ORF

- sony.rs: compressed ARW (SonyRawFileType 2): 32-pixel runs as two
  16-byte blocks of 11-bit max/min, 4-bit positions and 7-bit deltas,
  then the SonyToneCurve (0x7010) to 14 bits. From Dietz (EI 2016) and
  the RawDigger write-up; bit order and curve scale by observation.
- rw2.rs: RawFormat 5 12/14-bit packing (10/9 pixels per 16-byte LE
  block), stored in 0x4000-byte pages split at 0x1FF8 (observed), with
  PanasonicRaw CFA, black, linearity limit, WB and sensor borders.
  RawFormat 4 and older stay unsupported (preview fallback).
- orf.rs: uncompressed 16-bit ORF (E-1, E-400): left-justified
  samples, EXIF CFAPattern, Olympus ImageProcessing black / WB /
  ValidBits / crop; compressed ORF stays unsupported but its maker-note
  preview (CameraSettings 0x0101/0x0102) is now found.
- testgen: cRAW, RW2 and ORF encoders/builders; vendor_codecs tests
  round-trip each codec; hostile tests fuzz/truncate all three.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* raw: README support matrix for cRAW, RW2, ORF and the clean-room gaps

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:30:41 -04:00
Brandon Thomas ff53be714d
Persist brush presets in a size-aware preset store (#78) (#82)
User and imported .abr brush presets now survive a restart. The new
photocraft_engine::preset_store keeps one JSON file per preset group plus
content-addressed, deflated tip bitmaps (8-bit when the tip came from 8-bit
data) in <config dir>/Presets, and an index with group order and deleted
built-ins. Built-ins are never written; writes are atomic and bounded;
corrupt or oversized files are skipped with a warning and left on disk.

The desktop app loads the store on a background thread and attaches it when
it arrives; sessions without a store (CLI, MCP, web, tests) stay hermetic.
Brush preset mutations bump ToolState::presets_rev and the store syncs after
the command, rewriting only groups whose content changed.

Fixes #78

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:53:24 -04:00
storybored379 f5f0837aab
Select › Color Range: the dialog, Select presets, eyedroppers, Localized Color Clusters, Invert (#80)
* Select › Color Range: Select presets, samples, localized clusters, invert

select.colorRange only did one sampled colour. Add the dialog's Select
presets (Reds … Magentas, Highlights / Midtones / Shadows, Out of
Gamut), several samples (colors / eyedropper points), the Subtract from
Sample eyedropper (subtractPoints), Localized Color Clusters with Range,
tonal Range, and Invert. Bad params are rejected without touching the
selection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Select › Color Range… opens a real dialog

The menu item ran select.colorRange immediately with the foreground
colour. It now opens a Color Range dialog: Select (Sampled Colors, the
hue families, Highlights / Midtones / Shadows, Out Of Gamut), Fuzziness,
Localized Color Clusters with Range, the tonal range sliders, the three
eyedroppers (on the preview; Shift adds, Alt subtracts), a grayscale
selection / image preview, Invert, OK and Cancel. Controls a mode
doesn't use are disabled. The preview and OK run the same engine
command, so the GUI, CLI and MCP select identically; OK is one history
step and Cancel leaves the selection untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Dialogs shrink back when their body gets shorter

The OK / Cancel row was laid out right-to-left with Align::Center, so it
filled the height left over from the previous frame's size and the
dialog could only ever grow. In the Color Range dialog, switching from
Midtones (two extra sliders) to Out Of Gamut or Sampled Colors left an
empty band around the buttons. Aligning the row to the top keeps it
exactly as tall as the buttons, so the dialog fits its content again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: storybored379 <storybored379@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:10:43 -04:00
sommerper 4f8da9365a
fix(ui): brush and eraser show the real stroke while drawing (#64)
* fix(ui): brush and eraser show the real stroke while drawing

While dragging, the Brush and Eraser drew a hard, flat egui polyline at
the brush width; the soft brush only appeared on release.

The engine's new `brush_cmds::LiveStroke` shares `paint.stroke`'s setup
and renders incrementally with `StrokeRenderer` onto a copy of the
document. The canvas shows that copy and recomposites only each step's
damage (preview key = stroke key + step). The commit passes the live
stroke's seed, so its pixels match the preview, and reuses the canvas so
only the stroke rect refreshes.

Downsampled CPU textures (documents over 4096 px, the Navigator) now
update partially too, aligned to the downsample factor, instead of
re-flattening the whole document.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Live stroke on the large-image stack: reduced CPU textures, pen tilt, display key

Adapts #64's live Brush/Eraser stroke to #71 (banded/reduced canvas), #66
(colour-managed display) and #74 (stylus tilt):

- compose: `render_reduced_damage` renders just the output pixels of
  `render_reduced` whose source area meets a damage rect, with bit-identical
  values (same spans, same summation order). The CPU canvas texture of a
  document over 4096 px now updates partially from it (strokes, live steps,
  any damaged edit) instead of #64's factor-aligned `downsample`, which
  didn't match #71's area-reduced texture; uploads still go through
  `display_image` (document -> monitor profile).
- ui-egui: the live stroke feeds each point's pen tilt/rotation (the same
  values `Stylus::stroke_points` gives the commit), so tilt-driven brushes
  preview what they commit; the first points come from the drag after
  `Stylus::begin_stroke`.
- Preview keys keep #66's display key folded in; `damage_since` un-folds
  the cached keys to compare against the live stroke's steps, and the commit
  resets the caches to the document's key (raw 0 ^ display key).
- Stroke counter is masked so live keys stay in their 2^44 band.

6000x4000, 120 px soft brush, release: LiveStroke::push 0.72 ms mean
(p95 1.0), GPU damage refresh 1.6 ms mean (p95 4.7); CPU reduced-texture
rect 0.75 ms vs 135 ms for the full 3000x2000 reduction it replaces.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-04 17:10:23 -04:00
Brandon Thomas d3b540234c
Large images: adapter texture limits, banded compositing/export, proxy thumbnails, history byte budget (#71)
* Compose: render in place and in bands; area-reduced and proxy thumbnails

render_tiled wrote every tile into a Vec and then copied them into the output:
two full-size float copies (6.3 GB for a 14000² document). Tiles are now
written straight into the output band by band.

New streaming APIs so large documents never need a full-size float
composite: render_bands (sink per band of ~8 MP), render_reduced (banded
area average, bit-identical to the old flatten-then-average thumbnail) and
flatten_to_surface (Flatten Image, Merge Visible, Copy Merged and the other
flattened-surface users).

thumbnail() reduces a downsampled proxy (moved from ui-egui to
compose::proxy, now also scaling spot channels) for documents over 16 MP
when that is faithful (no layer effects or vector masks), so navigator,
channel and histogram thumbnails of a 196 MP document cost milliseconds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* GPU canvas: request the adapter's real limits; banded CPU fallback; navigator thumbnail

egui-wgpu asks for an 8192 px texture limit, so the wgpu compositor rejected
every document wider or taller than 8192 px and each refresh fell back to a
full CPU flatten. The desktop and web apps now request the adapter's own
max_texture_dimension_2d and max_buffer_size (gpu_canvas::use_adapter_limits);
the benches use the same limits.

The CPU fallback (documents beyond the GPU limit, or features it lacks) is
GpuCanvas::refresh: it renders and uploads the composite band by band
instead of a full float composite plus a full RGBA8 copy. The CPU canvas
texture is reduced in bands too, and the Navigator on the GPU canvas uses a
cached thumbnail instead of a full-resolution CPU composite per revision.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* IO: banded export and import; no full-size copies in encoders; larger decode limits

- Flat export renders the composite in bands and quantises each band; alpha
  is dropped in place when every pixel is opaque. The single-layer native
  path copies surface rows a band at a time (no f32 read_region and no
  per-pixel Vec). Buffers come from try_reserve: Err, not an abort.
- PSD export encodes the merged image band by band into its planes (no
  flatten, no second plane copy); near-opaque documents are re-encoded
  without the white matte so the output is unchanged. Layer interleaved
  bytes are freed before their planes are compressed.
- Flat import converts in ~32 MB bands (and not at all when already RGBA);
  the PSD merged-image un-matte works per tile row.
- Encoders borrow the image when no conversion is needed (Image::converted)
  instead of cloning it; PNG only copies 16-bit data for the byte swap.
- Decode limits: 2^30 pixels and 8 GiB on 64-bit targets (a 20000² JPEG
  was refused at 2^28).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Filters: write tile results in memory-bounded groups

apply_tiled collected every tile's float result before writing any (3.1 GB
for a 14000² layer). Results are now produced and written in groups of about
256 MB (at least one tile per core).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* History: bound pixel memory by Preferences > Performance > Memory Usage

History::max_bytes caps the current document's tiles plus the tiles only
history holds (layers, masks, alpha channels); History::trim drops the
oldest states beyond it after every recorded step and when preferences
change, always keeping the last step undoable. The engine sets it from
memory_usage_mb, which was unused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Large-image benchmark (14k / 20k): open, refresh, thumbnails, filter, stroke, saves

One operation per run on a cached synthetic photo, with the GPU device created
the way the app creates it, so /usr/bin/time -l reports that operation's peak
memory. Documented in docs/development.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:45:17 -04:00
Gh0stly 5716c2f6f1
security: constrain automation filesystem access (#72)
Rebased onto main (#58/#61/#63/#66/#70/#74) by the maintainers: automation
open/save return and surface import/export warnings (app.open/app.save
reply with `warnings`), automation opens route .abr/.grd to the preset
libraries with script events suppressed, interactive opens keep open_file's
path/recent/notice handling.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:35:06 -04:00
Brandon Thomas d881785fde
Photoshop brushes: .abr import, stylus pressure/tilt, 36 grouped presets, .grd import (#74)
* psd: .abr brush (v1/v2/v6+) and .grd gradient (v5) parsers and writers

Clean-room from public format descriptions: legacy computed/sampled brushes,
8BIM-sectioned samp/patt/desc files, 8/16-bit raw and PackBits tips. Bounded
(MAX_EDGE, MAX_TOTAL_BYTES), damaged brushes are skipped with warnings, hostile
input returns Err. Adds an abr fuzz target.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* paint: 36 grouped built-in brush presets and new procedural tips

General / Dry Media / Wet Media / Special Effects groups (pressure size/opacity
variants, airbrush, pencils, charcoal, pastel, crayon, rake, watercolour, ink,
oil, foliage, grass, stipple, sparkle, halftone…). Leaf, grass, charcoal,
sponge, star and rake tips are generated in code. BrushPreset gains a group.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* io: map .abr brushes onto BrushSettings

Tip shape, shape dynamics, scattering, texture (embedded patt patterns),
dual brush, colour dynamics, transfer, pose, noise, wet edges, build-up and
captured tool options; unmapped settings are reported as warnings. Includes
bench_abr (100-brush import, 5000-dab stroke).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* engine: brush.presets.importAbr and gradient.presets.importGrd

Path or base64 data, group per file (re-import replaces), unique names next to
the built-ins; graceful-failure tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ui: real stylus pressure/tilt, grouped Brushes panel, Import Brushes

Pen force from egui Touch events (Windows WM_POINTER via winit), Pointer Events
pressure/tilt/twist on the web, tilt via ui.pointer; mouse stays 1.0. .abr/.grd
open from File > Open, drag-and-drop, Brushes panel and Preset Manager. Preset
previews keyed on a cheap signature instead of the full brush JSON.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:26:16 -04:00
Brandon Thomas abf785f7b1
Magic Eraser and Background Eraser tools; canvas rotation/flip/size move vector geometry (#70)
* Magic Eraser and Background Eraser engine commands

paint.magicEraser erases the wand region (tolerance, anti-alias,
contiguous, sample all layers, opacity) to transparency, tile by tile in
parallel; paint.backgroundEraser erases colours similar to the one under
the brush hotspot (continuous / once / background swatch sampling,
discontiguous / contiguous / find-edges limits, tolerance, protect
foreground colour). Both compare and erase in the layer's own channels at
any depth, respect the selection, turn the Background into Layer 0 like
Photoshop, and are one undo step. The Magic Eraser paints the background
colour on layers with locked transparency.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Magic Eraser and Background Eraser tools in the Eraser group

Shift+E / E cycles Eraser, Background Eraser and Magic Eraser; the
toolbar flyout lists all three. Options bars: Magic Eraser tolerance,
anti-alias, contiguous, sample all layers and opacity; Background Eraser
sampling toggles, limits, tolerance and protect foreground colour. The
Background Eraser cursor always shows its sampling hotspot. Icons are
derived from Lucide's eraser (ISC), recorded in ATTRIBUTION.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Image Rotation, Canvas Size, Crop, Trim and Image Size move vector geometry

Canvas-wide operations remapped only pixels: type, shape and smart-object
transforms, vector masks, saved/work paths, guides, slices, notes and
Count/Ruler marks stayed put, so they jumped back on the next re-render.

New engine module canvas_geom applies the canvas map to all non-pixel
geometry. 90°/180°/flip turns remap every pixel surface exactly (incl.
type/shape/smart/fill caches and smart filter masks) and move geometry
through the matching affine; guides swap orientation under 90° turns;
rect/ellipse/line live shapes stay live; gradient fill angles follow.
Canvas Size/Crop/Trim translate geometry (shapes re-render for the new
canvas), Image Size scales it and re-renders vectors, Arbitrary rotation
also moves unlinked vector masks and document marks. Layer effect angles
stay (Global Light), as in Photoshop.

Tests compare the CPU composite of a rotated document (type, shapes,
vector mask, smart object) against the rotated flattened original, also
after re-rendering everything from geometry, at 8/16/32-bit RGB, CMYK
and Gray.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Magic Eraser: edit encoded tile bytes in parallel (24 MP: 1260 -> 375 ms)

Tiles are copied out as encoded bytes and only the touched samples are
decoded, so the parallel workers do all the work and the serial write-back
is a memcpy. The ignored bench prints a phase breakdown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:17:39 -04:00
Brandon Thomas 37c8f81032
Image › Adjustments overhaul: full params, one editor per kind, live-preview dialogs, Curves point deletion (#68)
* Adjustments: full parameter sets for every kind, Hue/Sat ranges, CMYK/Lab tone channels

- doc: Hue/Saturation gains six colour ranges (HueRange), Gradient Map a dither flag,
  Levels/Curves a ToneSpace (RGB, CMYK inks + black, Lab) for mode-native channels.
- engine: new adjust_params module (from_params over a base / to_params) used by
  image.adjustments.*, layer.newAdjustmentLayer.* and layer.setAdjustment. Curves,
  Color Balance, Channel Mixer, Black & White, Gradient Map and Photo Filter now honour
  their params; bad params are BadParams errors; setAdjustment merges (empty = reset).
- compose/gpu: hue-range tables, CMYK/Lab tone channels (CPU; GPU falls back), dithered
  Gradient Map, Black & White from grey/secondary/primary parts; GPU parity extended.
- io: hue2 ranges, grdm dither, CMYK/Lab Levels/Curves records round-trip.
- destructive adjustments use the document's tone transfer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Adjustments UI: one editor per kind for Properties and Image › Adjustments dialogs

- adjust_editors: Brightness/Contrast (legacy), Levels and Curves (per channel in RGB, Gray,
  CMYK and Lab, histogram behind), Exposure, Vibrance, Hue/Saturation (master + six ranges with
  draggable range sliders, colorize, before/after spectrum), Color Balance (tones, preserve
  luminosity), Black & White (six weights, tint colour), Photo Filter (presets or colour,
  density), Channel Mixer (outputs, constant, monochrome, total), Posterize, Threshold
  (histogram), Gradient Map (stops, presets, reverse, dither). Replaces the M7 placeholder.
- Properties previews live (GPU) while a control moves and commits one setAdjustment per gesture.
- adjust_dialog: Image › Adjustments opens the same editor modally; the canvas previews the
  real command on the proxy, Preview checkbox, OK = one history step, Cancel leaves the doc.
- Curves (#43): hit-test at the press origin, Delete/Backspace removes the selected point when
  the graph has focus, Cmd/Ctrl-click removes, dragging off removes (and back on restores),
  arrows nudge; endpoints and the last two points stay; pressing the gradient bars does nothing.
- tone: histograms per channel space, of the image below a layer or of the target layer alone.
- kittests: every adjustment layer kind edited through the Properties panel with real input at
  8/16/32-bit RGB, Gray, CMYK and Lab (#12); Curves point interactions; dialog preview/OK/Cancel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* io tests: adjustment round trip covers the new Levels/Curves/Hue-Sat/Gradient Map fields

#60's round-trip tests predate #68's doc-model additions (tone space and black channel,
Hue/Saturation ranges, Gradient Map dither); fill them with their defaults.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:08:40 -04:00
Brandon Thomas 7a5c1930b3
Sandboxed WebAssembly plug-ins: photocraft-plugins (wasmi), plugin.* commands, Filter › Plug-ins (#67)
* photocraft-plugins: sandboxed WebAssembly filter plug-ins (wasmi host, ABI v1)

A new L4 crate runs plug-ins in wasmi (pure Rust, MIT/Apache) with no host
imports, a fuel budget metered in slices with a wall-clock deadline, a memory
cap, a recursion cap and wasmi's strict module limits. Every band gets a fresh
instance; traps, budgets, bad manifests and bad pointers are all Errors.
Pixels go over as interleaved normalised f32 in tile-aligned bands, in
parallel on native, blended by the selection. Includes the example Invert
plug-in (no_std Rust, examples/plugins/invert-rs) built into a test fixture,
hostile-module tests, and docs/plugins.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Engine: plugin.* commands (install, list, run, remove, reload)

plugin.run filters the active pixel layer, a targeted channel or a smart
object (recorded as a smart filter) as one undo step; Preferences >
Plug-ins gains Use Additional Plug-ins Folder, loaded when prefs apply.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* UI: Filter > Plug-ins lists installed plug-ins with generated dialogs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Plug-ins: bands never outgrow the memory cap; regenerate parity

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Bench: example plug-in Invert vs built-in Invert on 24 MP

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:08:28 -04:00
Brandon Thomas d5eb44e9fd
Colour-managed canvas: document profile -> monitor (P3/Adobe RGB/ProPhoto/Gray/CMYK), linear EXR, embedded CMYK (#66)
* CMYK documents display and export through their embedded CMYK profile

A thread-local CmykSpace (photocraft_color::convert::with_cmyk_space) replaces the built-in
coated CMYK while the CPU compositor, the GPU compositor's uploads and the PSD merged
composite convert a document's CMYK pixels; GPU residents track the profile they were
converted with.

Refs #46

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* EXR/HDR are linear: tag imports linear sRGB, linearise exports

Refs #46

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Colour-managed canvas: document profile -> monitor profile on GPU and CPU paths

* engine display_color: CanvasDisplay per (document profile, mode, monitor), identity fast
  path when they match (sRGB on sRGB costs nothing); linear composites are stored
  sRGB-encoded in the 8-bit texture. canvas_lut now always carries the CM transform, with
  Proof Colors / Gamut Warning / 32-bit preview on top.
* GPU canvas applies the display LUT whenever the transform isn't the identity (also for
  filter/proxy previews); the CPU canvas and Navigator transform the 8-bit composite.
* Edit > Color Settings > Monitor Profile (auto|built-in|.icc). macOS 'auto' reads the main
  display's ICC profile at launch via osascript/AppKit (no unsafe); elsewhere sRGB.
* Tests: P3 (hand-computed), Adobe RGB, ProPhoto, Gray Gamma 2.2, synthetic CMYK, linear,
  monitor setting; GPU+CPU canvas test via kittest (skips without Rgba32Float).

Fixes #46

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* io: tag EXR/HDR linear in import_flat (adapt to the shared image_to_document)

#65 moved flat decoding into image_to_document(name, img) so camera raws can reuse it; the
linear-EXR tagging needs the file bytes for format detection, so it now runs in import_flat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:44:13 -04:00
Brandon Thomas 5bd3dac660
Camera raw: clean-room pure-Rust decoder (DNG, CR2, TIFF/EP raws) with develop pipeline (#65)
* raw: clean-room camera raw decoder and developer (photocraft-raw)

A new standalone crate (no workspace deps, wasm-safe, no unsafe) written
from TIFF 6.0, TIFF/EP, the DNG 1.7 spec, ITU-T T.81 Annex H and published
demosaicing papers:

- Lossless JPEG (LJ92) decoder: predictors 1-7, point transform, restart
  intervals, combined Huffman+extra-bits fast table.
- DNG: uncompressed (8-16 bit, packed) and LJ92, strips and tiles, CFA and
  LinearRaw, linearization, black level (repeat, deltas), white level,
  active area, default crop, ColorMatrix/CameraCalibration/ForwardMatrix
  interpolation by CCT, AnalogBalance, AsShotNeutral/WhiteXY,
  BaselineExposure, orientation.
- CR2 (LJ92 slices, SensorInfo borders, ColorData as-shot WB, measured
  black) and TIFF/EP raws (NEF/ARW/PEF uncompressed or LJ92, incl. Sony
  lossless ARW tiles, Nikon/Sony documented black and WB tags).
- Develop: black/white scaling, WB with highlight clipping, bilinear /
  Malvar-He-Cutler / AHD demosaic (rayon bands, sequential on wasm),
  camera -> XYZ D50 -> ProPhoto, exposure, gamma 1.8, 16-bit, orientation.
- Embedded JPEG preview finder; clear Unsupported errors for CR3, RAF,
  ORF, RW2, vendor-compressed NEF/ARW/PEF, lossy/JXL/float DNG, X-Trans.
- Limits checked before every allocation; testgen feature with an LJ92
  encoder and DNG/CR2/TIFF-EP writers; round-trip, colour-reference,
  truncation and corruption tests.

Registered as a standalone crate in xtask layers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* io: open camera raws through photocraft-raw (16-bit ProPhoto), preview fallback

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* raw: DNG GainMap opcode, fuzz targets, stress tools; open raws from the apps

- Apply DNG OpcodeList2 GainMap (lens shading, e.g. phone DNGs); report other opcodes.
- cargo-fuzz targets (develop, ljpeg) and a mutation stress example.
- Raw extensions in the desktop/web open dialogs and the batch folder list.
- file.open returns the import notes (how a raw was developed, preview fallback).
- codecs: the camera raw refusal points at photocraft-raw.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* raw: clippy 1.99 (is_multiple_of, clamp)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:43:47 -04:00
Brandon Thomas 297f7f8521
macOS: open files from Finder/Open With/Dock; file path, recent, warnings and .pcraft fixes (#63)
* File open/save: set the path everywhere, Open Recent, show errors and import/export warnings

File › Open, drag-and-drop onto the window and launch paths now share
PhotocraftApp::open_file: the document is named after the file (not the full
path), keeps its path so File › Save writes back, and goes to Open Recent.
Failures are shown as errors (status bar + a non-blocking notice).

Import/export warnings from photocraft-io are no longer thrown away: they show
in the status bar and in a dismissable notice (native and web), and the
control protocol returns them from app.open/app.save/file.open/file.save/
file.saveAs/file.saveACopy. .pcraft is in the Open and Save filters, and
File › Save writes .pcraft documents in place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* macOS: open files from Finder, Open With and the Dock (Apple events)

Finder double-click, Open With, drops on the Dock icon and open -a deliver
files as kAEOpenDocuments Apple events, not argv, and winit 0.30 ignores them.
Register an NSAppleEventManager handler through the small fmv-macos-events
crate (MIT OR Apache-2.0, 168 lines, audited, pinned =0.1.0; it keeps the
Objective-C unsafe out of our forbid(unsafe_code) workspace) before the event
loop starts, so both the launching event and later ones reach the UI as
OsEvent::Open; the window is focused. The quit Apple event goes through the
normal close path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:18:03 -04:00
Brandon Thomas b17c239db8
UI input: Ctrl+'+' zooms the canvas, dialogs don't cancel on outside click, menu hover-switch (#62)
* Shortcuts match like Photoshop: Ctrl/Cmd + '+' zooms, exact modifiers, clipboard keys

- Cmd/Ctrl+'=' also matches '+' (Shift+= on US layouts, numpad +, the '+' key on
  Nordic/German layouts); Delete also matches Backspace (the Mac delete key).
- Modifiers match exactly (Cmd+Alt+I no longer runs Cmd+I); Shift shortcuts on
  punctuation match the shifted character (Cmd+Shift+; arrives as Cmd+':').
- Zoom In/Out, Fit on Screen and 100% stay live while a dialog or a non-text
  widget has focus.
- clipboard_keys: egui-winit turns Cmd+C/X/V into Copy/Cut/Paste events (and
  drops Cmd+V when the clipboard holds an image); outside text fields they
  become key presses again so Edit > Copy/Cut/Paste/Copy Merged run.
- Snapping: Ctrl (Windows) / Control (Mac) disables snapping only while held
  during the drag, not at the press (Ctrl-click belongs to the tool).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Dialogs: no cancel on outside click; canvas pans and zooms under them

- Disable egui's keyboard UI zoom: Cmd/Ctrl +/-/0 never scale the interface.
- A click outside a dialog no longer silently cancels it (Esc still does).
- With a dialog open the canvas still scrolls, pinch/Cmd-scroll zooms and
  Space/middle/Hand-drag pans, like Photoshop; everything else stays modal.
- Clipboard key presses restored in raw_input_hook.
- Studio Properties card: Order::Middle (menus, popups, dialogs stay above it)
  and draggable by its title off the area being worked on.
- Magic Wand options (tolerance, anti-alias, contiguous, sample all layers) in
  the Studio themes too.
- egui_kittest tests: Ctrl+Plus zooms the canvas and leaves zoom_factor at 1,
  zoom works with a dialog open, outside clicks keep the dialog, scroll and
  space-drag pan under it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Menu bar: hovering another title switches the open menu

Like Windows and macOS menu bars: with a menu open, moving to another top-level
title opens that menu. Tests for the switch and dropdown row spacing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:56:12 -04:00
Brandon Thomas 51b82caf0e
PSD round trip: read/write Vibrance, Color Balance, B&W, Photo Filter, Channel Mixer; corpus floors (#60)
* PSD: read and write vibA, blnc, blwh, phfl, mixr adjustment layers

Vibrance, Color Balance, Black & White, Photo Filter and Channel Mixer
layers made in PhotoCraft were dropped on PSD save (Photoshop saw an empty
pixel layer), and the same blocks rendered as no-ops on import. They are
now parsed into their Adjustment variants and written per the Adobe spec
(descriptor blocks with version 16; phfl version 2/3; mixr version 1).
A Color Lookup without a table is written as an identity cube and a
Gradient Map with fewer than two stops as the equivalent two-stop
gradient, so every evaluated kind has an encoding.

Tests: golden hand-built blocks for each new key, malformed and fuzzed
input, and a document -> PSD -> document round trip of every adjustment
kind (8 and 16 bit, plain / masked + blended / clipped) and every blend
mode (pixel, clipped, group, pass-through, adjustment layers), asserting
model equality where lossless and composite equality within 1/255.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Corpus: export round-trip step and pass floors; fill pixels in the mask frame

corpus.rs now re-imports every exported file and compares its flatten
with the first import (169/169 render the same). With PHOTOCRAFT_CORPUS
set (corpus dir, or 1 for the default location) it asserts the oracle
pass floor (113) and the round-trip floor (169).

The new step found gradient fills without Photoshop pixels exported in
the canvas frame instead of the frame their masks give them, so they
re-imported shifted (gradient-fill.psd/.psb, 0.26 max error). Export now
renders the fill like the compositor (render_fill_content).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:25:00 -04:00
Gh0stly 8b1cbb741b
security: authenticate control channels and bound transport resources (#61)
* security: authenticate and bound control transports

* fix: keep connection limiter compatible with current stable
2026-10-04 13:58:59 -04:00
Brandon Thomas 2e80c781ee
Never crash: ops (history) denies panicking shortcuts (#59)
ops had no panic sites; the attribute keeps it that way.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:40:43 -04:00
Brandon Thomas ab3ab92b4a
Never crash: last-resort guard for commands and file import/export (#58)
- Session::execute wraps a command's run in catch_unwind: a panic that
  escapes anyway becomes an EngineError ("`id` failed with an internal
  error (logged); the document is unchanged"). Session::edit only commits
  after its closure returns, so documents stay intact. This covers the
  UI, CLI, MCP and control channels.
- apps/photocraft: a new crash_guard module installs a logging panic hook
  (keeping the default one) and guards the native import/export services,
  so a panic while opening or exporting a file is reported as an error
  and the open documents stay. Wired in with two lines in main.rs.
- services::import_flat returns an error instead of unwrap; photocraft
  and photocraft-web deny unwrap/expect/panic/unimplemented/todo/
  unreachable.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:40:30 -04:00
Brandon Thomas 86cc989e72
Never crash: ui-egui is panic-free (Rule 9) (#57)
- canvas: partial redraws read last_damage with unwrap_or(EMPTY), and the
  GPU cache is looked up with let-else after the upload.
- icons: a missing icon (and missing fallback) yields empty bytes, which
  egui shows as a broken image, instead of expect.
- type tool: the caret update is an if-let instead of expect("editing").
- Variables dialog: pages return early if their JSON state is malformed
  instead of unwrap.
- parity: the same hunk as #40.
- ui-egui denies unwrap/expect/panic/unimplemented/todo/unreachable
  (a one-line attribute in lib.rs).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:40:17 -04:00
Brandon Thomas 6dffa0894c
Never crash: algo is panic-free (Rule 9) (#56)
- apply_tiled reads the shared image for a global filter instead of
  unreachable! if it were missing.
- panorama: bundle-adjustment params come from the placed transforms via
  filter_map; the pyramid size loop and the collapse use let-else instead
  of expect.
- inpaint/pyramid: the same while-let/let-else hunks as #40, so the two
  PRs merge in either order.
- quick selection's constant broad model keeps one documented narrow
  expect (eight fixed corners always fit).
- algo denies unwrap/expect/panic/unimplemented/todo/unreachable.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:40:02 -04:00
Brandon Thomas f8f48db78a
Never crash: the GPU compositor is panic-free (Rule 9) (#55)
- A failed readback mapping (e.g. device lost) now returns Unsupported,
  so the caller falls back to the CPU compositor, instead of
  expect("mapped readback buffer").
- Pipeline creation skips kernels without an entry point instead of
  expect; resident/effect entries are looked up with let-else after the
  insert; effect-program targets are Option instead of indexing +
  expect; copies without a source are skipped; the planner returns
  Unsupported instead of unreachable! for an adjustment layer.
- gpu denies unwrap/expect/panic/unimplemented/todo/unreachable.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:39:48 -04:00
Brandon Thomas 4218d07f40
Never crash: the engine is panic-free (Rule 9) (#54)
Every unwrap/expect/unreachable in non-test engine code now returns an
EngineError (or a predicate's Err string) or uses a real fallback:
- enabled predicates read the active layer through active_layer_of()
  instead of unwrap() after has_layer();
- new raster layers get their pixels through pixels_mut()? instead of
  surface_mut().expect("raster");
- layer paths use split_last().ok_or(..)? (Merge Down, Arrange, Ungroup,
  Release Clipping), Auto-Align/Auto-Blend/Photomerge carry placements
  instead of re-unwrapping them, and "next free name" searches are bounded;
- web export builds its options map directly, quantize_rows has a wasm
  tail, parse_script rejects non-array/object JSON, the shape-layer edit
  returns an error instead of unreachable!, and the gray composite
  profile falls back to sRGB.
The engine denies unwrap/expect/panic/unimplemented/todo/unreachable.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:26:26 -04:00
Brandon Thomas 91ef877f2f
Never crash: algo pyramids and ui-egui parity without expect (#40)
Three remaining expect() sites in files no open PR touches:
algo::inpaint::complete and algo::pyramid::gaussian loop with while-let
instead of levels.last().expect(), and ui-egui parity::compute indexes
the menu it just pushed instead of last_mut().expect().

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:26:13 -04:00
Brandon Thomas 1d832d3e84
Never crash: automation (MCP/RPC) and photocraft-cli are panic-free (#38)
- rpc::respond (the JSON-lines server behind `photocraft-cli run`/TCP)
  catches a panicking request and answers it with an error, and keeps
  using a poisoned session lock (as the MCP server already does) instead
  of failing every later call with "session lock poisoned". Regression
  test poisoned_session_keeps_serving.
- MCP server: the eight bridge_client().expect("bridge") and the
  headless_op(..).expect("headless") calls return a tool error instead.
- bridge: the connection is taken with let-else, not expect.
- automation and photocraft-cli deny unwrap/expect/panic/unimplemented/
  todo/unreachable.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:25:58 -04:00
Brandon Thomas 296ced38d6
Never crash: text, color, doc, paint, geom, raster, compose, vector (#39)
- Real crash: build_engine_data took its template from the PSD's text
  engine data and did expect("EngineDict"), so a text layer whose engine
  data lacked EngineDict (or wasn't a dictionary) crashed PSD export.
  The template is now repaired; regression test
  engine_data_template_without_engine_dict.
- color: the built-in CMYK<->sRGB transforms fall back to the naive
  formulas instead of expect if they ever fail to link.
- doc: insert_above falls back to the top of the root instead of
  expect("valid path"); next_layer_name searches a bounded range.
- paint: Mips::new loops with while-let instead of last().expect.
- All eight crates deny unwrap/expect/panic/unimplemented/todo/unreachable.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:25:44 -04:00
Brandon Thomas 48d1a4f9e5
Never crash: cms is panic-free (Rule 9) (#36)
- Real crash: a gray ICC profile whose A2B0 tag is stored as "mBA "
  parsed fine, but re-encoding its RGB view (gray_as_rgb, used for gray
  documents' composites) hit panic!("unsupported lutAtoB stage layout").
  The ICC writer now leaves out a LUT its tag type can't store, and
  gray_as_rgb drops the gray LUTs (one input channel) so the RGB view is
  the matrix/TRC model it builds. Regression test
  gray_profile_with_reversed_lut_type_reencodes.
- math::rgb_to_xyz_matrix returns None for dependent primaries.
- Builtin::profile indexes its cache by discriminant (tested); the shipped
  CMYK profile falls back to synth::coated_cmyk() instead of expect.
- Two constant-only expects (Bradford inverse, built-in primaries) use the
  documented narrow allow.
- cms denies unwrap/expect/panic/unimplemented/todo/unreachable.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:25:31 -04:00
Brandon Thomas 69f0e25d5f
Never crash: psd, codecs, format, io are panic-free (Rule 9) (#34)
- codecs: bump tiff 0.10 -> 0.11. tiff 0.10 indexed an empty SampleFormat
  list and panicked on a crafted TIFF (found by mutation fuzzing);
  regression test tiff_empty_sample_format_is_an_error.
- codecs: Image::new returns Result instead of panicking on size overflow.
- format: par_compress returns an error if a compression worker panics.
- io: par_map joins every worker before relaying a worker panic, so the
  thread scope can't panic on an unjoined thread.
- All four crates now deny unwrap/expect/panic/unimplemented/todo/
  unreachable outside tests.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:25:17 -04:00
Gh0stly 1fe689a7bf
docs: add mdBook and security documentation foundation (#35)
* docs: add mdBook and security documentation foundation

* docs: point book links at storytold/photocraft; scope the credits entry

The book's repository/edit URLs and source links pointed at the contributor's
fork. The credits entry now describes what this PR contributed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:31:18 -04:00
Torstein 29506f3e93
feat(ui): confirm before discarding unsaved changes (#31)
Close, Close All, Close Others, Revert, Exit and the window close button
now ask Save / Don't Save / Cancel when a document has unsaved changes.
The action is parked until each dirty document has been answered, then
re-run. Window close requests are cancelled while the prompt is open.

Closes #9
2026-10-04 12:21:25 -04:00
Brandon Thomas 6f1d0dc080
Merge pull request #37 from storytold/ci/green
CI: green on all platforms (rustfmt config, clippy 1.99, FXC-safe adjust shader)
2026-10-04 11:56:34 -04:00
Brandon Thomas 66d5a5381e GPU: adapters without float render targets keep the GPU path for effect-free documents
Once Format stopped failing first, the Linux runner reached the GPU tests
for the first time: its software adapter (GL) can't render Rgba32Float or
R32Float. The app already picks Rgba16Float there, but the effect-map
pipelines (R32Float/R16Float) still panicked. They are now built inside an
error scope; where they can't be, documents with layer effects use the CPU
compositor and everything else stays on the GPU.

The exact-parity tests skip on adapters without 32-bit float targets (the
app uses the CPU compositor there), like they already skip without an
adapter; the fallback-path tests build with try_new_with_format.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:55:25 -04:00
Brandon Thomas 71272d5cd7 GPU: fs_adjust compiles under FXC (D3D12); a pipeline build failure falls back to the CPU
On D3D12 without DXC, wgpu compiles HLSL with FXC, which aborted on
fs_adjust ("internal error: compilation aborted unexpectedly"). wgpu turns
that into a panic, so every GPU test failed on the Windows runner, and the
app would crash where the same path is taken. A bisect on the Windows
runner pinned it to two cases of adjust()'s switch, Exposure and Selective
Color: both looped over dynamically indexed vector components. They are
now written per channel / per range with the same math (GPU-vs-CPU parity
is unchanged and passes on Metal and on the Windows runner).

Compositor::try_new_with_format catches validation and internal errors
while building the pipelines and returns Err; the canvas then uses the CPU
compositor (once, remembered) instead of crashing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:33:15 -04:00
Brandon Thomas 1c09936154 Clippy 1.99: chunks_exact with a constant size -> as_chunks; drop a no-op Option filter
Rust 1.99's clippy adds chunks_exact_to_as_chunks and some_filter, which
broke the Clippy step on every CI runner (it uses the latest stable). All
sites are mechanical: as_chunks drops the remainder exactly like
chunks_exact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:33:14 -04:00
Brandon Thomas 574d4fe7da Ignore the workspace reformat in git blame
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:33:14 -04:00
Brandon Thomas f7eb6ea9e7 Format: adopt the craft-family rustfmt.toml and format the workspace
CI's `cargo fmt --all -- --check` has failed on every run because the
repo had no rustfmt config and was never formatted. Use the same settings
as EffectCraft/FilmCraft (max_width 160, use_small_heuristics Max), which
also keeps the churn small. Formatting only; no code changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:31:17 -04:00
Brandon Thomas 50a27e6c74
Never crash: steering rules, clippy.toml, forbid unsafe (#33)
AGENTS.md gets a prominent "Never crash" section at the top of the golden
rules (Rule 9 now points to it), docs/contributing.md a short paragraph,
a root clippy.toml lets tests use unwrap/expect/panic/indexing, and the
workspace moves unsafe_code from "deny" to "forbid" (no unsafe exists in
the workspace).

Standard: craftrules/standards/never-crash.md

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:14:55 -04:00
sommerper 159c11feb4
Layer Style dialog previews effects live on the canvas (#30)
The canvas only showed effects after OK. While the dialog is open, the
canvas now renders the document with the dialog's style applied: the same
commands OK runs, replayed on a scratch session (no history), cached by
document revision + style fields so switching pages doesn't re-render.
Cancel drops the preview. Works on both GPU and CPU canvas paths
(display_doc). Tests cover the preview, re-render on edit, and cancel.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:13:36 -04:00
sommerper 8d21ca349d
Dialogs can be moved by dragging the title bar (#29)
All dialogs (Layer Style, New Document, filters, ...) were pinned to the
centre. Dragging the title row now moves them, clamped so the dialog stays
on screen. The title text is no longer selectable, so dragging it moves
the dialog instead of selecting text.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:13:31 -04:00
storybored379 11e5af23ad
Layer Style › Blending Options › Blend If (#26)
Model Blend If (This Layer / Underlying Layer ranges, split points) as
Layer::blend_if, read and write it as the PSD layer-record blending
ranges (previously parsed but dropped on export), save it in .pcraft,
render it in the CPU compositor (premultiplied mix = compositing at
k × alpha), fall back to the CPU on the GPU path, and drive it from
layer.layerStyle.blendingOptions {"blendIf": …}; document.inspect
reports it.

Co-authored-by: storybored379 <storybored379@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:13:27 -04:00
storybored379 7ec0b4aa62
Filter › Other › Minimum / Maximum: Preserve Roundness (#27)
Photoshop's Minimum and Maximum have Preserve: Squareness | Roundness;
only the square element existed. Roundness takes the extreme over a
disc (dx² + dy² ≤ r²), one van Herk / Gil-Werman running min/max per
disc row, so cost stays O(r) per pixel. filter.other.minimum/maximum
take "preserve" (default squareness; older params deserialize as
Squareness).

Co-authored-by: storybored379 <storybored379@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:13:23 -04:00
storybored379 6bdee62e87
Codecs: camera raw files fail with a clear "not supported" error (#25)
Camera raw files are TIFF-structured, so they reached the TIFF decoder.
A raw image with the TIFF/EP CFA or DNG LinearRaw photometric
interpretation failed with "Tiff: unknown photometric interpretation",
a CR2 with "colour type YCbCr(8)", and a DNG or a raw whose sensor data
sits in a SubIFD behind an RGB IFD0 quietly opened that small preview
as if it were the photo.

The TIFF decoder now checks the structure first (CR2 signature,
DNGVersion tag, CFA / LinearRaw image in IFD0, its SubIFDs or the next
IFDs; bounds-checked header reads only) and returns Unsupported:
"camera raw files (such as CR2, NEF, ARW or DNG) are not supported".
Ordinary TIFFs decode as before. Tests use tiny synthetic TIFFs with
those markers, including truncations.

Co-authored-by: storybored379 <storybored379@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:13:19 -04:00
storybored379 7cb26e4f7f
cms tests: system_profiles skips malformed named-colour profiles (#24)
On Debian/Ubuntu with colord installed, system_profiles panics on
/usr/share/color/icc/colord/x11-colors.icc ("unsupported ICC feature:
curve type \0\1\0\0"). That profile is a named-colour (nmcl) profile
whose B2A0 lutBToAType is spec-invalid: inputs=3, but the M-curve
offset (32) leaves room for one curve before the matrix offset (48),
and the 48-byte matrix overlaps the B curves at 64. The parser reads
the second M curve at offset 48 and correctly rejects it. Named-colour
profiles are never used for conversions (the test already skips them
after parsing), so skip them when they fail to parse instead of failing
the suite on a host file.

Co-authored-by: storybored379 <storybored379@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:13:14 -04:00
Brandon Thomas 9905db34ff
Merge pull request #32 from storytold/pr/license-standard
Licensing: MIT OR Apache-2.0 files, NOTICE, ArtCraft brand terms, attribution
2026-10-04 10:08:51 -04:00
Brandon Thomas cf93edc99c Licensing: MIT OR Apache-2.0 files, NOTICE, ArtCraft brand terms, attribution
Adopt the Crafting Apps licensing standard (craftrules standards/licensing.md):
- LICENSE-MIT and LICENSE-APACHE (the repo had neither), NOTICE with the
  third-party list, docs/brand/LICENSE-brand.txt for the ArtCraft logos.
- ATTRIBUTION.md: every bundled asset with path, title, author, source and
  license (Inter, JetBrains Mono, Lucide icons, SCOWL, the CMYK ICC profile,
  the app icon, the original slice-knife glyph, brand files, screenshots).
- README: the standard "License and credits" section; badge alt text.
- Fuzz crates (outside the workspace) declare license = "MIT OR Apache-2.0".
- AGENTS.md and docs/contributing.md point new assets at ATTRIBUTION.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:06:17 -04:00
Brandon Thomas b70fbd4ff6 Fix Transform Selection hang on far-off-canvas transforms (Rule 9)
warp_gray cleared the old + warped region as one dense buffer spanning their
union, so moving a selection/mask/channel far away (e.g. dx=1e5) allocated a
~1e5x1e5 buffer and hung the app. Write the two regions separately instead —
Surface is tile-sparse, so a distant region costs only its own tiles. Found by
the panic_hunt fuzz (now also catches hangs via a per-command thread timeout).
Regression test covers a far transform.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-04 08:34:23 -04:00
Brandon Thomas a196adfe93 Harden Image>Apply Image/Calculations against an empty source (Rule 9)
source() could in principle return no channel planes, and apply_image/
calculations then panicked via src[0] / .remove(0). Guard source() to return
Err when it yields no planes, protecting every caller. Graceful-failure test
covers adversarial params. AGENTS.md: add the panic_hunt fuzz to the finish
checklist and restate the no-panic rule.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-04 08:19:26 -04:00
Brandon Thomas 35896f056a
Add File > Open Recent (fixes #5) (#23)
Tracks opened file paths (de-duplicated, newest first, capped at 10) in UI
state, and shows them as a dynamic File > Open Recent submenu after Open As…,
plus Clear Recent Files. Clicking an entry reopens that path; a stale index
errors gracefully. Recorded on both dialog opens and path opens (file
associations / control channel). Tests cover tracking/dedupe/cap/listing/clear
and a real open → recent → reopen round-trip.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-04 02:39:58 -04:00
Brandon Thomas ae7d802726
GPU: fall back to Rgba16Float when Rgba32Float isn't renderable (#7, #4) (#22)
Some drivers (e.g. Intel Vulkan on Linux/Windows) can't render to Rgba32Float,
so creating the 'fs_content' pipeline panicked with 'Format Rgba32Float is not
renderable' as soon as a document/image was opened. The accumulation format is
now chosen per adapter: Compositor::preferred_acc_format() keeps Rgba32Float
where it's a valid render target and falls back to Rgba16Float otherwise. The
compositor carries the format (pipelines, accumulation texture, byte size and
the readback half-decode all follow it); the app picks it from the eframe
adapter. Capable GPUs are unchanged (parity still exact, 18/18). A new test
forces the Rgba16Float path on any GPU and confirms it renders within display
tolerance (blend + adjustment). wasm green.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-04 02:27:54 -04:00
Brandon Thomas 16ce03877c
Test: Image Rotation rotates every layer, not just the active one (re #1) (#21)
Guards the whole-canvas rotation behaviour (the reported symptom was rotation
affecting only the selected layer, on 0.1.1). remap_document already rotates
all raster layers, masks, channels and the selection; this locks it in.
Non-raster layers (text/shape/smart) rotating under canvas rotation remains a
tracked follow-up.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-04 01:39:38 -04:00
Brandon Thomas 34d138c11f
Links open in the system browser via a platform service (#14) (#20)
ctx.open_url is unreliable on native (notably Windows), so the Help menu,
the Discord buttons, and the start-page links did nothing. Added an open_url
Service hook wired to the `open` crate on native (ShellExecute on Windows);
links::open prefers it and falls back to ctx.open_url (web). link_row now
routes clicks through it instead of ui.hyperlink_to. Test drives every Help
link id + the open() helper through a mock service.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-04 01:34:18 -04:00
Brandon Thomas b7cac05f69
Eraser on a locked-transparency layer paints the background colour (#15) (#19)
The Background layer has locked transparency, so the eraser couldn't remove
opacity and appeared to do nothing. Like Photoshop, erasing a locked-
transparency layer now paints the background colour instead. New test.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-04 01:25:23 -04:00
Torstein 09c987f1a8
fix(ui): give menu bar labels more breathing room (#8)
Preserve menu label spacing after egui installs its default menu-bar style.

- Apply six pixels of horizontal padding per side.
- Cover label padding, overlap, and File-menu interaction across themes.
2026-10-04 01:06:35 -04:00
Torstein e6e82ca74d
feat(ui): add Exit to the File menu (#10)
Closes #3
2026-10-04 01:03:49 -04:00
Multipad-cyber ce13708af3
Layers: Add Layer Mask uses the active selection (#11)
Like Photoshop, the Add a mask button in the Layers panel and the
layer context menu's Add Layer Mask now turn an active selection into
the mask (Reveal Selection) and fall back to Reveal All without one.
Option-click on the button inverts it (Hide Selection / Hide All).

The choice lives in layer_menu_ui::add_mask_command so the button and
the menu agree; it only picks between the existing layer.layerMask.*
commands. Tests cover the mapping, the menu entries with and without a
selection, and that the new mask matches a rectangular selection.

Co-authored-by: Multipad-cyber <246280291+Multipad-cyber@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:02:49 -04:00
storybored379 8a2f31c2c4
MCP: a panicking command no longer wedges the headless session (#16)
headless_op ran every tool under one Mutex<Headless> and mapped a
poisoned lock to "session lock poisoned". So after any command panicked
(e.g. a bug in a filter), every later tool call (doc_inspect, doc_new,
doc_save...) failed for the rest of the MCP session, losing unsaved
work. Edits run on a copy of the document (Session::edit), so the
session is still consistent after a panic: recover the guard with
PoisonError::into_inner and keep serving. The panicking call itself
still returns an error. 1 test (automation server).

Co-authored-by: storybored379 <storybored379@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 01:01:16 -04:00
storybored379 b6a95b6c8d
Indexed Color: no panic when forced colours fill the palette (#17)
Image > Mode > Indexed Color with Colors 2 + Forced Black and White
(or 8 + Primaries, or <=216 + Web) leaves no room for computed entries.
median_cut(.., 0) returns no centres and kmeans then indexed into the
empty list ("index out of bounds: the len is 0 but the index is 0"),
taking the command down with a panic. kmeans now returns early when
there are no centres, so the palette is just the forced colours.
1 test (algo quantize).

Co-authored-by: storybored379 <storybored379@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 00:57:38 -04:00
Brandon Thomas bf111b524e Fix panics on malformed params; add panic-fuzz harness
Rule 9 (no panic on input). Fixes found via fuzzing:
- edit.paste: a short/non-array "center" (e.g. [5], []) indexed a[1] and
  panicked; now requires len>=2, else falls back to the canvas centre.
- edit.transform.again: a quad row with <2 columns indexed [1] and panicked;
  now rejected with the other shape checks.
Tests: paste_with_malformed_center_does_not_panic (fast regression) plus
tests/panic_hunt.rs, an opt-in (#[ignore], run --ignored) fuzz that dispatches
every command with adversarial params and asserts none panic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-04 00:24:01 -04:00
Brandon Thomas ed0b99964d AGENTS.md: add rule 9 — never panic on input (commands return Err)
Commands' run closures and anything they call must return Err, never panic,
for any params/state: no unwrap/expect/panic on param- or data-derived values,
no unchecked indexing or division, validate sizes before allocating. The
panic_hunt integration test enforces this.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-04 00:10:26 -04:00
Brandon Thomas 5f84a7add8 Gradient dithering (reduces 8-bit banding), wired in the UI
paint_gradient gains a dither flag: one quantisation step of monochromatic
position-hash noise, breaking banding without colour speckle. paint.gradient
takes "dither":bool (default true, like Photoshop); the options-bar Dither
checkbox now persists (gradient_dither) and is sent. New gradient_dither_
breaks_banding test; clippy + wasm green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-03 18:26:30 -04:00
Brandon Thomas c89c33c704 Brush: paint with a blend mode (options-bar Mode), wired in the UI
paint.stroke/paint.pencil now honour a "mode" param (normal|multiply|screen|…),
and the options-bar Mode dropdown sets the session brush's blend mode (it was
decorative). The Eraser is forced to Normal (no blend mode in Photoshop). New
brush_blend_mode_multiply test; clippy + wasm green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-03 13:19:21 -04:00
Brandon Thomas 9dc394dfc8 UI: wire Paint Bucket Fill dropdown (Foreground/Pattern) to the engine
The options-bar Fill dropdown was decorative. It now persists in tool_options
(bucket_fill_pattern) and the click sends contents=foreground|pattern, so
Pattern actually flood-fills with the current pattern. ui tests + wasm green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-03 13:12:50 -04:00
Brandon Thomas bb52e3aa46 Paint Bucket: fill with a pattern (not just a colour)
The Paint Bucket tool only filled the foreground colour; Photoshop's Fill
dropdown also offers Pattern. paint.bucket now takes contents="pattern"
(+ pattern/scale/angle), flood-filling the matched region with the pattern.
algo gains bucket_fill_src (fill colour from a per-pixel source closure);
bucket_fill delegates to it. pattern_cmds resolve_param/placement are now
pub(crate) for reuse. New bucket_fills_with_a_pattern test; clippy + wasm green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-03 13:07:52 -04:00
Brandon Thomas 0cc39de41b Align a single layer to the canvas (Photoshop parity)
A lone selected layer with no active selection was disabled for Align; in
Photoshop it aligns/centres to the canvas. can_align now enables one layer,
and auto-align targets the canvas bounds for a single layer (instead of the
layer aligning to itself). Selection- and multi-layer behaviour unchanged.
New align_single_layer_to_canvas test; the Align buttons light up in the UI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-03 13:02:23 -04:00
Brandon Thomas a0eff27619 Fix test-module attributes from prior insertion (dup #[test]; restore cut_clears)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-03 10:32:40 -04:00
Brandon Thomas 5442398a91 Tests: copy/paste between tabs (incl. cross-mode) and transform scale
User-reported features verified working; added the missing regression coverage.
- copy_paste_between_documents: copy in one tab, paste into another (shared
  clipboard targets the active document).
- paste_between_different_color_modes: RGB clipboard pastes into a Grayscale
  doc, converting to the target format (no panic).
- transform_scale_grows_content: edit.transform 2x scales layer content.
Cross-document paste had no test before. All green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-03 10:30:58 -04:00
Brandon Thomas b6b8ed21cb Levels: Photoshop's gamma>1 shadow toe (bounded slope 2^gamma)
Verified our levels() against Adobe Photoshop 2026 (adjustLevels on a 0..255
ramp): exact for gamma<=1 and all input/output ranges, but a pure u^(1/gamma)
has infinite slope at black while Photoshop bounds the shadow toe to initial
slope 2^gamma. Added a soft-min (p-norm) of the power curve with that line,
applied only for gamma>1; reduces to the exact power curve in the body, so
highlights/midtones and gamma<=1 are unchanged. GPU Levels LUT is built from
this fn, so GPU stays in sync (gpu parity 18/18).

Corpus oracle: levels_grayscale.psd max error 0.043 -> 0.0078 (bad px
4.1% -> 0.1%); 113 pass / 30 differ unchanged (no regressions). Golden test
pins the curve to captured Photoshop samples (gamma 2 toe within 4/255).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-03 09:58:31 -04:00
545 changed files with 45667 additions and 13305 deletions

3
.git-blame-ignore-revs Normal file
View File

@ -0,0 +1,3 @@
# Bulk formatting commits, skipped by `git blame` (git config blame.ignoreRevsFile .git-blame-ignore-revs).
# Adopt rustfmt.toml and format the workspace.
f7eb6ea9e724dbcf97af2d8162785c8063089edc

View File

@ -52,10 +52,10 @@ jobs:
run: cargo fmt --all -- --check
- name: Test
run: cargo test --workspace
run: cargo test --workspace --locked
- name: Clippy
run: cargo clippy --workspace --all-targets -- -D warnings
run: cargo clippy --workspace --all-targets --locked -- -D warnings
- name: Dependency layering
run: cargo xtask layers

38
.github/workflows/docs.yml vendored Normal file
View File

@ -0,0 +1,38 @@
name: Documentation
on:
push:
branches: [main]
paths:
- "book/**"
- "docs/**"
- "README.md"
- "SECURITY.md"
- ".github/workflows/docs.yml"
pull_request:
paths:
- "book/**"
- "docs/**"
- "README.md"
- "SECURITY.md"
- ".github/workflows/docs.yml"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: docs-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
name: mdBook build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Install mdBook
run: cargo install mdbook --version 0.4.52 --locked
- name: Build documentation
run: mdbook build book

1
.gitignore vendored
View File

@ -7,3 +7,4 @@ target/
/log
/secrets
/temp
/book/output

View File

@ -24,7 +24,7 @@ crates/
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
io L4 document <-> PSD / flat formats
io plugins L4 document <-> PSD / flat formats; sandboxed WebAssembly plug-ins
engine L5 Session + command registry (every action is a command)
ui-egui automation L6 egui shell (thin: all actions go through the engine); MCP server
testkit test helpers
@ -39,15 +39,30 @@ xtask/ cargo xtask layers | wasm | ci | stats | corpus | p
## 3. Golden rules
### Never crash (outranks feature work)
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"`.
- **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.
- **Last-resort guard.** The app shell must catch an escaped panic around command dispatch and file import/export, reports it as an error and keeps the document. It's a safety net, not a licence to panic. Keep `panic = "unwind"`.
- **Prove it.** Every crash fix comes with a small synthetic regression test that panicked before the fix.
- **Enforced by clippy.** `clippy.toml` allows `unwrap`/`expect`/`panic`/indexing in tests only. Clean crates carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`; new crates start with it.
1. **Everything is a command.** New user-visible behaviour = a command in the engine (`crates/engine/src/*_cmds.rs`, registered in `commands.rs`) with id, label, menu path, shortcut, params doc, `enabled` and `run`, plus tests. The UI, CLI, control channel and MCP all dispatch commands by id. Use the **exact id from `crates/ui-egui/src/menu_catalog.rs`** and the menu item goes live automatically. Only pure view/window state (zoom, panels, screen mode) belongs to the shell (`menus.rs` `UI_COMMANDS`).
2. **No format or colour assumptions.** Bit depth (8/16/32f) and colour model (RGB/Gray/CMYK/Lab…) are runtime data. Never introduce a `u8`-only pixel path in public APIs. Never assume sRGB: colour conversions go through `photocraft-cms` (`Transform`, `transform::cached`). Test at several depths.
3. **Clean-room.** We studied Photoshop and other proprietary editors for *behaviour and look only*. Never copy their code, shaders, profiles or assets. Implement from public specs (Adobe PSD spec, ICC, ISO 32000 blend modes, papers) and observation. Third-party assets must be permissively licensed and recorded (see `assets/`, `docs/images/SOURCES.md`).
3. **Clean-room.** We studied Photoshop and other proprietary editors for *behaviour and look only*. Never copy their code, shaders, profiles or assets. Implement from public specs (Adobe PSD spec, ICC, ISO 32000 blend modes, papers) and observation. Third-party assets must be permissively licensed, keep their license file next to them, and get a row in `ATTRIBUTION.md` (path, title, author, source, license) in the same change; so do original assets. The ArtCraft logos in `docs/brand/` are not open source (`docs/brand/LICENSE-brand.txt`).
4. **Tests are the gate.** Every change comes with tests. Format crates use round-trip, synthetic-generator, oracle and fuzz tests. Keep the PSD corpus results and the parity floor (`crates/ui-egui/src/parity.rs`) from regressing.
5. **The UI is thin and data-driven.** UI state lives in `ui-egui/src/state.rs` (serde), so the control channel can read and drive it. Colours and radii come from `theme::Tokens`, never hard-coded.
6. **Verify UI changes visually.** Render offscreen with `cargo run -p photocraft-ui-egui --example snapshot` (no window, no focus stealing), or launch with `--control` and take `ui.screenshot`. Look at the PNG. Demo images must be public-domain art, never personal photos. When fetching assets, never put a person's name, email or other personal details in requests (User-Agent, headers, URLs); use a generic `Photocraft-dev` User-Agent.
7. **Never break wasm.** L0–L6 must `cargo check --target wasm32-unknown-unknown` (run `cargo xtask wasm`). File-system code is `cfg(not(target_arch = "wasm32"))` or goes through the platform services.
8. **Performance is a feature.** Benchmark heavy operations on a 24–36 MP image in release. Work per tile in parallel (rayon), skip empty tiles, never scan a full surface per frame (cache per revision), and record before/after timings in the dev log.
9. **Never panic on input** (see *Never crash* above). A command's `run` closure and anything it calls must return `Err`, never panic, for *any* params or document state: validate params, check bounds before indexing or dividing, and reject absurd sizes before allocating. The `panic_hunt` integration test fuzzes every command with adversarial params and must stay green.
## 4. Picking work
Priorities: important infrastructure first, then low-hanging parity, then the long tail.
@ -66,8 +81,11 @@ cargo clippy -p <crates> --all-targets -- -D warnings
cargo xtask layers
cargo xtask wasm # if you touched L0–L6
cargo xtask parity # if you added commands; commit the regenerated docs/parity.md
cargo test -p photocraft-engine --test panic_hunt -- --ignored # if you added/changed commands: no panic on adversarial input (Rule 9)
```
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).
Then append a terse entry to `log/devlog.md` (what landed, numbers, what's still open). Sessions can end abruptly (crashes, context limits), so the dev log plus a green tree is how the next agent picks up. Keep the tree building at every step.
## 6. Parallel agents

53
ATTRIBUTION.md Normal file
View File

@ -0,0 +1,53 @@
# Attribution
Every non-code asset in this repository, with its author, source and license. PhotoCraft's own
code and original assets are MIT OR Apache-2.0 (see [`LICENSE-MIT`](LICENSE-MIT),
[`LICENSE-APACHE`](LICENSE-APACHE) and [`NOTICE`](NOTICE)). When you add an asset, add a row here
in the same change; third-party assets must be permissively licensed and keep their license file
next to them.
## Bundled in the app
| Path | Title | Author | Source | License |
|---|---|---|---|---|
| `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`, `eraser-background.svg` and `eraser-magic.svg` (108 files) | 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-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,lock,minus,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/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/dict/en_US-scowl-50.txt.gz` | English word list for Check Spelling (SCOWL 2020.12.07, size 50, en_US; 72,403 words) | Kevin Atkinson and the SCOWL contributors | <http://wordlist.aspell.net/> | SCOWL license (permissive, keep the notice), [`assets/dict/LICENSE-SCOWL.txt`](assets/dict/LICENSE-SCOWL.txt), which also has the build recipe |
| `crates/cms/profiles/photocraft-coated-cmyk.icc` | PhotoCraft Coated CMYK (synthetic) ICC profile | PhotoCraft contributors (generated by `photocraft-cms`, `src/synth.rs`) | Original work, see [`crates/cms/README.md`](crates/cms/README.md) | CC0-1.0 |
| `assets/app-icon/` (all files) | PhotoCraft app icon (nine-tailed kitsune) | The project owner (drawn in ArtCraft, vectorised) | Original work, see [`assets/app-icon/README.md`](assets/app-icon/README.md) | MIT OR Apache-2.0, [`assets/app-icon/LICENSE.txt`](assets/app-icon/LICENSE.txt) |
The other built-in ICC profiles and the generated LUT looks are produced by code at runtime
(`crates/cms/src/builtin.rs`, `crates/cms/src/lutfile.rs`) and dedicated to the public domain
(CC0-1.0); they are not separate files.
## Documentation and README
| Path | Title | Author | Source | License |
|---|---|---|---|---|
| `docs/brand/` (all files) | ArtCraft name, wordmark and mark | ArtCraft Team | getartcraft.com | Not open source; trademarks of the ArtCraft Team, [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) |
| `docs/images/photocraft-*.jpg` | PhotoCraft screenshots | PhotoCraft contributors (UI) | Rendered offscreen with the `snapshot` example | MIT OR Apache-2.0 (UI); the artwork in each is public domain, listed below |
Artwork shown in the screenshots (all public domain, via Wikimedia Commons; details in
[`docs/images/SOURCES.md`](docs/images/SOURCES.md)):
| Screenshot | Artwork | Source |
|---|---|---|
| `photocraft-demo.jpg` | *The Great Wave off Kanagawa*, Katsushika Hokusai, c. 1831 | [Commons](https://commons.wikimedia.org/wiki/File:Tsunami_by_hokusai_19th_century.jpg) |
| `photocraft-layer-styles.jpg` | *Earthrise*, William Anders, Apollo 8, 1968 (NASA) | [Commons](https://commons.wikimedia.org/wiki/File:NASA-Apollo8-Dec24-Earthrise.jpg) |
| `photocraft-masks.jpg` | *Girl with a Pearl Earring*, Johannes Vermeer, c. 1665 | [Commons](https://commons.wikimedia.org/wiki/File:1665_Girl_with_a_Pearl_Earring.jpg) |
| `photocraft-vector.jpg` | *Water Lilies*, Claude Monet, 1906 | [Commons](https://commons.wikimedia.org/wiki/File:Claude_Monet_-_Water_Lilies_-_1906,_Ryerson.jpg) |
| `photocraft-filters.jpg` | *The Starry Night*, Vincent van Gogh, 1889 | [Commons](https://commons.wikimedia.org/wiki/File:Van_Gogh_-_Starry_Night_-_Google_Art_Project.jpg) |
| `photocraft-adjustments.jpg` | *Impression, Sunrise*, Claude Monet, 1872 | [Commons](https://commons.wikimedia.org/wiki/File:Monet_-_Impression,_Sunrise.jpg) |
| `photocraft-transform.jpg` | *The Tetons and the Snake River*, Ansel Adams, 1942 (U.S. National Archives) | [Commons](https://commons.wikimedia.org/wiki/File:Adams_The_Tetons_and_the_Snake_River.jpg) |
| `photocraft-type.jpg` | *Among the Sierra Nevada, California*, Albert Bierstadt, 1868 | [Commons](https://commons.wikimedia.org/wiki/File:Albert_Bierstadt_-_Among_the_Sierra_Nevada,_California_-_Google_Art_Project.jpg) |
| `photocraft-export-light.jpg` | *The Kiss*, Gustav Klimt, 1907–1908 | [Commons](https://commons.wikimedia.org/wiki/File:Gustav_Klimt_016.jpg) |
## Test data (not committed, not shipped)
`corpus/` is gitignored. `corpus/psd` holds MIT-licensed test PSDs (ag-psd, psd-tools), listed in
`corpus/psd/SOURCES.md`; `cargo xtask corpus --download` fetches PngSuite (public domain). Files
copied in by hand must be MIT, BSD or CC0. No test fixtures are committed to the repository.

350
Cargo.lock generated
View File

@ -167,6 +167,12 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "ambient-authority"
version = "0.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b"
[[package]]
name = "android-activity"
version = "0.6.1"
@ -717,6 +723,36 @@ dependencies = [
"wayland-client",
]
[[package]]
name = "cap-primitives"
version = "4.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b5f74729fd2f44701d1a8eb47e906cdb3ccd9ec0f02baad85a744b791940b18"
dependencies = [
"ambient-authority",
"fs-set-times",
"io-extras",
"io-lifetimes 3.0.1",
"ipnet",
"maybe-owned",
"rustix 1.1.5",
"rustix-linux-procfs",
"windows-sys 0.61.2",
"winx",
]
[[package]]
name = "cap-std"
version = "4.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1ec78e242cfa2cfe276807ac2ecc00315a6c97786977414bcd1c3963b6c91b8"
dependencies = [
"cap-primitives",
"io-extras",
"io-lifetimes 3.0.1",
"rustix 1.1.5",
]
[[package]]
name = "cast"
version = "0.3.0"
@ -1537,12 +1573,29 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"
[[package]]
name = "fmv-macos-events"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90642cf66dde7f205dc8b42dfe57b47d0f3a29f571b832742883ef512e23fe55"
dependencies = [
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
]
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foldhash"
version = "0.2.0"
@ -1599,6 +1652,17 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
[[package]]
name = "fs-set-times"
version = "0.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a"
dependencies = [
"io-lifetimes 2.0.4",
"rustix 1.1.5",
"windows-sys 0.59.0",
]
[[package]]
name = "futures"
version = "0.3.34"
@ -1770,7 +1834,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a"
dependencies = [
"bytemuck",
"foldhash",
"foldhash 0.2.0",
"hashbrown 0.17.1",
"log",
"peniko",
@ -1904,6 +1968,15 @@ dependencies = [
"smallvec",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash 0.1.5",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
@ -1912,7 +1985,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
"foldhash 0.2.0",
]
[[package]]
@ -1921,7 +1994,7 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
"foldhash 0.2.0",
]
[[package]]
@ -2110,7 +2183,7 @@ dependencies = [
"qoi",
"ravif",
"rgb",
"tiff 0.11.3",
"tiff",
"zune-core",
"zune-jpeg",
]
@ -2160,6 +2233,34 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "io-extras"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f"
dependencies = [
"io-lifetimes 3.0.1",
"windows-sys 0.60.2",
]
[[package]]
name = "io-lifetimes"
version = "2.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983"
[[package]]
name = "io-lifetimes"
version = "3.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f0fb0570afe1fed943c5c3d4102d5358592d8625fda6a0007fdbe65a92fba96"
[[package]]
name = "ipnet"
version = "2.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
[[package]]
name = "is-docker"
version = "0.2.0"
@ -2364,6 +2465,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "lebe"
version = "0.5.3"
@ -2468,6 +2575,12 @@ dependencies = [
"imgref",
]
[[package]]
name = "maybe-owned"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4"
[[package]]
name = "maybe-rayon"
version = "0.1.1"
@ -3235,12 +3348,15 @@ dependencies = [
[[package]]
name = "photocraft"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"arboard",
"eframe",
"egui",
"fmv-macos-events",
"log",
"open",
"photocraft-automation",
"photocraft-codecs",
"photocraft-color",
"photocraft-compose",
@ -3258,7 +3374,7 @@ dependencies = [
[[package]]
name = "photocraft-algo"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@ -3270,9 +3386,11 @@ dependencies = [
[[package]]
name = "photocraft-automation"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"base64",
"cap-std",
"getrandom 0.3.4",
"photocraft-codecs",
"photocraft-compose",
"photocraft-doc",
@ -3290,7 +3408,7 @@ dependencies = [
[[package]]
name = "photocraft-cli"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"photocraft-automation",
"photocraft-codecs",
@ -3303,7 +3421,7 @@ dependencies = [
[[package]]
name = "photocraft-cms"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"moxcms",
"rayon",
@ -3311,7 +3429,7 @@ dependencies = [
[[package]]
name = "photocraft-codecs"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"exr",
"flate2",
@ -3322,14 +3440,14 @@ dependencies = [
"png 0.18.1",
"proptest",
"thiserror 2.0.21",
"tiff 0.10.3",
"tiff",
"zune-core",
"zune-jpeg",
]
[[package]]
name = "photocraft-color"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"photocraft-cms",
"serde",
@ -3337,7 +3455,7 @@ dependencies = [
[[package]]
name = "photocraft-compose"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"criterion",
"photocraft-color",
@ -3352,7 +3470,7 @@ dependencies = [
[[package]]
name = "photocraft-doc"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@ -3363,7 +3481,7 @@ dependencies = [
[[package]]
name = "photocraft-engine"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"blake3",
"flate2",
@ -3378,18 +3496,22 @@ dependencies = [
"photocraft-io",
"photocraft-ops",
"photocraft-paint",
"photocraft-plugins",
"photocraft-psd",
"photocraft-raster",
"photocraft-raw",
"photocraft-text",
"photocraft-vector",
"rayon",
"serde",
"serde_json",
"thiserror 2.0.21",
"wat",
]
[[package]]
name = "photocraft-format"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"blake3",
"flate2",
@ -3407,15 +3529,16 @@ dependencies = [
[[package]]
name = "photocraft-geom"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"serde",
]
[[package]]
name = "photocraft-gpu"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"half",
"log",
"photocraft-color",
"photocraft-compose",
@ -3430,7 +3553,7 @@ dependencies = [
[[package]]
name = "photocraft-io"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"photocraft-cms",
"photocraft-codecs",
@ -3439,8 +3562,10 @@ dependencies = [
"photocraft-doc",
"photocraft-format",
"photocraft-geom",
"photocraft-paint",
"photocraft-psd",
"photocraft-raster",
"photocraft-raw",
"photocraft-text",
"photocraft-vector",
"proptest",
@ -3449,14 +3574,14 @@ dependencies = [
[[package]]
name = "photocraft-ops"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"photocraft-doc",
]
[[package]]
name = "photocraft-paint"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@ -3465,9 +3590,24 @@ dependencies = [
"serde_json",
]
[[package]]
name = "photocraft-plugins"
version = "0.2.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
"photocraft-raster",
"rayon",
"serde",
"serde_json",
"thiserror 2.0.21",
"wasmi",
"wat",
]
[[package]]
name = "photocraft-psd"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"flate2",
"photocraft-psd",
@ -3478,7 +3618,7 @@ dependencies = [
[[package]]
name = "photocraft-raster"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@ -3486,9 +3626,18 @@ dependencies = [
"serde",
]
[[package]]
name = "photocraft-raw"
version = "0.2.0"
dependencies = [
"photocraft-raw",
"png 0.18.1",
"rayon",
]
[[package]]
name = "photocraft-testkit"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@ -3497,7 +3646,7 @@ dependencies = [
[[package]]
name = "photocraft-text"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"flate2",
"parley",
@ -3511,8 +3660,9 @@ dependencies = [
[[package]]
name = "photocraft-ui-egui"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"base64",
"eframe",
"egui",
"egui_extras",
@ -3528,6 +3678,7 @@ dependencies = [
"photocraft-geom",
"photocraft-gpu",
"photocraft-io",
"photocraft-plugins",
"photocraft-raster",
"photocraft-text",
"rayon",
@ -3537,7 +3688,7 @@ dependencies = [
[[package]]
name = "photocraft-vector"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"photocraft-color",
"photocraft-doc",
@ -3548,7 +3699,7 @@ dependencies = [
[[package]]
name = "photocraft-web"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"eframe",
"egui",
@ -4236,6 +4387,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustix-linux-procfs"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056"
dependencies = [
"once_cell",
"rustix 1.1.5",
]
[[package]]
name = "rustversion"
version = "1.0.23"
@ -4571,6 +4732,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "spin"
version = "0.9.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
[[package]]
name = "spirv"
version = "0.4.0+sdk-1.4.341.0"
@ -4601,6 +4768,16 @@ dependencies = [
"float-cmp",
]
[[package]]
name = "string-interner"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23de088478b31c349c9ba67816fa55d9355232d63c3afea8bf513e31f0f1d2c0"
dependencies = [
"hashbrown 0.15.5",
"serde",
]
[[package]]
name = "strsim"
version = "0.11.1"
@ -4712,18 +4889,6 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "tiff"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f"
dependencies = [
"flate2",
"half",
"quick-error 2.0.1",
"weezl",
]
[[package]]
name = "tiff"
version = "0.11.3"
@ -5149,6 +5314,99 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-encoder"
version = "0.261.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2608e8bb6d67fd68f5a8d0eb1363d6e7bcbc1f8ded5a0bd3a1e382462b876b22"
dependencies = [
"leb128fmt",
"wasmparser 0.261.0",
]
[[package]]
name = "wasmi"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78693fcdd618e0fc34af59c6b8efa9ac5d58c68df940beff4bedddb6acfe7c27"
dependencies = [
"spin",
"wasmi_collections",
"wasmi_core",
"wasmi_ir",
"wasmparser 0.228.0",
]
[[package]]
name = "wasmi_collections"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a8be2aa467cf2d29e96ff759472c36eeb44a3c81c67fc9cb76c9a24c519c557"
dependencies = [
"string-interner",
]
[[package]]
name = "wasmi_core"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69372d5fda3ea3d1e0aa6603c7888110e0187e88ea17cd8fc2e2df0a0e1f37fa"
dependencies = [
"libm",
]
[[package]]
name = "wasmi_ir"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f17b774caa13c618c7244f1ee51fe23c5e7b8538a471fa46d9949779758aed6"
dependencies = [
"wasmi_core",
]
[[package]]
name = "wasmparser"
version = "0.228.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4abf1132c1fdf747d56bbc1bb52152400c70f336870f968b85e89ea422198ae3"
dependencies = [
"bitflags 2.13.2",
"indexmap",
]
[[package]]
name = "wasmparser"
version = "0.261.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f20f20e44f7e8aeb6744823ea9d869ede51e51be4fdaedede2852282e54d2d8"
dependencies = [
"bitflags 2.13.2",
"indexmap",
"semver",
]
[[package]]
name = "wast"
version = "261.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "776443145731a4062e5b0d392892a2005909b6ab72d9fdc3cad53dd1a714e44a"
dependencies = [
"bumpalo",
"leb128fmt",
"memchr",
"unicode-width",
"wasm-encoder",
]
[[package]]
name = "wat"
version = "1.261.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7b4d1a49ea73a8f3326e74e3a05db667001b16bd1035ed3356fc1a0ed05ca7f"
dependencies = [
"wast",
]
[[package]]
name = "wayland-backend"
version = "0.3.17"
@ -5847,6 +6105,16 @@ dependencies = [
"version_check",
]
[[package]]
name = "winx"
version = "0.36.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d"
dependencies = [
"bitflags 2.13.2",
"windows-sys 0.59.0",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
@ -5930,7 +6198,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
[[package]]
name = "xtask"
version = "0.1.1"
version = "0.2.0"
dependencies = [
"serde_json",
]

View File

@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/photocraft-cli", "xtask"]
[workspace.package]
version = "0.1.1"
version = "0.2.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
@ -19,6 +19,7 @@ photocraft-doc = { path = "crates/doc" }
photocraft-ops = { path = "crates/ops" }
photocraft-psd = { path = "crates/psd" }
photocraft-codecs = { path = "crates/codecs" }
photocraft-raw = { path = "crates/raw" }
photocraft-compose = { path = "crates/compose" }
photocraft-gpu = { path = "crates/gpu" }
photocraft-paint = { path = "crates/paint" }
@ -28,6 +29,7 @@ photocraft-text = { path = "crates/text" }
photocraft-vector = { path = "crates/vector" }
photocraft-format = { path = "crates/format" }
photocraft-automation = { path = "crates/automation" }
photocraft-plugins = { path = "crates/plugins" }
photocraft-engine = { path = "crates/engine" }
photocraft-ui-egui = { path = "crates/ui-egui" }
photocraft-testkit = { path = "crates/testkit" }
@ -48,9 +50,10 @@ rfd = "0.17"
log = "0.4"
rayon = "1"
criterion = { version = "0.5", default-features = false }
getrandom = "0.3"
[workspace.lints.rust]
unsafe_code = "deny"
unsafe_code = "forbid"
[workspace.lints.clippy]
dbg_macro = "warn"

177
LICENSE-APACHE Normal file
View File

@ -0,0 +1,177 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS

21
LICENSE-MIT Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 ArtCraft Team and the PhotoCraft contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

18
NOTICE Normal file
View File

@ -0,0 +1,18 @@
PhotoCraft
Copyright (c) 2026 ArtCraft Team and the PhotoCraft contributors.
Licensed under the MIT License or the Apache License, Version 2.0, at your option
(LICENSE-MIT, LICENSE-APACHE).
The ArtCraft name, wordmark and logos in docs/brand/ are trademarks of the ArtCraft Team. They are
not open source and are not covered by the license above; their terms are in
docs/brand/LICENSE-brand.txt.
Third-party material
This product bundles third-party material under its own licenses, listed below. The per-file
list (author, source, license) is in ATTRIBUTION.md.
Inter font (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt.
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.

View File

@ -18,7 +18,7 @@
<p align="center">
<img alt="100% Rust" src="https://img.shields.io/badge/100%25-Rust-b7410e?style=flat-square&logo=rust">
<img alt="macOS · Windows · Linux · Web" src="https://img.shields.io/badge/macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%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="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">
</p>
@ -235,7 +235,7 @@ photocraft-cli batch --actions grade.json --in ./raw --out ./graded
photocraft-cli mcp
```
The desktop app also listens on a local control channel (`photocraft --control`) for inspecting UI state, driving tools with pointer events, and taking offscreen screenshots. Every image in this README was rendered that way. See [`docs/control-protocol.md`](docs/control-protocol.md).
The desktop app also offers an authenticated, loopback-only control channel (`photocraft --control`) for inspecting UI state, driving tools with pointer events, and taking offscreen screenshots. Every image in this README was rendered that way. See [`docs/control-protocol.md`](docs/control-protocol.md).
## Under the hood
@ -260,7 +260,15 @@ New contributors and AI agents: start with [`AGENTS.md`](AGENTS.md), then [`docs
Installers for macOS, Windows, Linux and the web are attached to each [GitHub release](https://github.com/storytold/photocraft/releases). Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are built, signed and published.
> [!IMPORTANT]
> **Status:** PhotoCraft is in early alpha. The core editing workflow is here, and we're working toward full Photoshop parity milestone by milestone (see [`docs/roadmap.md`](docs/roadmap.md)). Progress is measured, not guessed: `cargo xtask parity` checks every item in Photoshop's menu tree against the live command registry and writes [`docs/parity.md`](docs/parity.md). Expect rough edges, and please file issues. You can also tell us what broke on [Discord](https://discord.gg/artcraft).
> **Status:** PhotoCraft is in early alpha. The core editing workflow is here, and we're working toward full Photoshop parity, milestone by milestone (see [`docs/roadmap.md`](docs/roadmap.md)). Progress is measured, not guessed: `cargo xtask parity` checks every item in Photoshop's menu tree against the live command registry and writes [`docs/parity.md`](docs/parity.md). Expect rough edges, and please file issues. You can also tell us what broke on [Discord](https://discord.gg/artcraft).
## Documentation
Developer, architecture, automation, format, and security documentation is maintained in the [PhotoCraft documentation book](book/).
## Security
Security architecture, threat modeling, parser hardening, fuzzing, and vulnerability reporting are covered in the [security documentation](book/src/security/) and the repository [security policy](SECURITY.md).
## The Crafting Apps
@ -308,10 +316,19 @@ The Crafting Apps share the same conventions: clean-room and pure Rust, native o
## License and credits
PhotoCraft is licensed under MIT or Apache-2.0.
PhotoCraft is dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE), at your option.
Copyright (c) 2026 ArtCraft Team and the PhotoCraft contributors. Required notices are in [NOTICE](NOTICE).
Bundled fonts, icons, images and other assets keep their own open licenses; each one is listed
with its author, source and license in [ATTRIBUTION.md](ATTRIBUTION.md).
Every artwork shown is in the public domain (Wikimedia Commons, NASA, U.S. National Archives); sources are listed in [`docs/images/SOURCES.md`](docs/images/SOURCES.md).
The ArtCraft name, wordmark and logos in [`docs/brand/`](docs/brand/) are trademarks of the
ArtCraft Team and are not covered by this license. They may be used only unmodified, and only as
part of this repository and PhotoCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
Forks and modified versions must remove them.
<sub>Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. PhotoCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
<p align="center">

52
SECURITY.md Normal file
View File

@ -0,0 +1,52 @@
# Security Policy
PhotoCraft processes complex document and image formats and exposes CLI, JSON control, and MCP automation. Treat files, paths, metadata, automation requests, and command parameters as potentially malicious.
## Reporting a vulnerability
Please give the repository maintainers a reasonable opportunity to investigate and coordinate a fix before publishing exploitable details, proof-of-concept code, or weaponized files.
This repository does not currently document a dedicated private security email or private reporting channel. Contact the repository maintainers through the repository owner/maintainer channels already listed in the project, provide only a minimal non-exploitable summary in public, and ask for an appropriate private transfer method before sharing sensitive details. Do not open a public issue containing a working exploit, secret, sensitive crash dump, or malicious attachment.
If no private channel can be established, withhold weaponized material and report the smallest safe description that allows maintainers to make contact.
## What to include
A useful report includes:
- affected PhotoCraft version, commit, or release artifact;
- operating system, architecture, build profile, and relevant feature flags;
- the affected component or path;
- security impact and required attacker access;
- exact reproduction steps;
- expected behavior and actual behavior;
- crash logs, backtraces, sanitizer output, or resource measurements when applicable;
- whether the problem reproduces from a clean checkout;
- any known workaround or containment.
Remove credentials, personal paths, private document content, and unrelated system information from logs.
## Malicious test files
Do not attach a potentially harmful or non-redistributable file to a public issue. Initially provide its format, size, cryptographic hash, observed effect, provenance category (for example, synthetic or fuzz-generated), and the command needed to reproduce the problem. Coordinate a private transfer method with maintainers before sending the sample.
Test files accepted into the repository must be minimized, free of personal or proprietary data, legally redistributable, and documented with the expected failure behavior. A fixed parser defect should receive a permanent regression test whenever safe and practical.
## Scope
Security reports may cover:
- PSD/PSB, raster, `.pcraft`, ICC, LUT, pattern, and embedded-object parsing;
- decompression bombs, oversized dimensions, integer overflow, memory exhaustion, recursion, or hangs;
- import/export and filesystem path handling, including traversal, symlinks, and unsafe overwrite behavior;
- engine command validation and serialization/deserialization;
- the desktop control server, headless JSON server, MCP server, and MCP-to-GUI bridge;
- authentication, authorization/capabilities, request limits, batch limits, and connection exhaustion;
- GPU resource validation or driver-facing input handling;
- dependencies, CI, packaging, signing, checksums, and release artifact integrity.
General bugs without a security impact should use the project's normal issue and contribution process.
## Current limitations
TCP control connections require a 256-bit bearer token before method dispatch and enforce request-line, active-connection, and batch-step limits. These protections do not provide a capability model: an authenticated client still receives the complete exposed control surface. Automation file operations are not restricted to configured workspace roots, and JSON-depth, render, document-memory, command-duration, client-identity, encryption, and audit controls are not yet implemented. See the [security documentation](book/src/security/overview.md) for the source-backed status and hardening roadmap.

View File

@ -1,10 +1,12 @@
//! 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)]
use std::io::Write;
use std::path::{Path, PathBuf};
use photocraft_automation::{Headless, PhotocraftMcp, files};
use photocraft_automation::{AuthorizedWorkspace, Headless, PhotocraftMcp, files, security};
use photocraft_io::ExportOptions;
use serde_json::{Value, json};
@ -25,9 +27,11 @@ USAGE:
Run a droplet (File › Automate › Create Droplet) on images and folders.
photocraft-cli commands [--json] [--filter <text>]
List the engine command registry.
photocraft-cli mcp [--bridge <127.0.0.1:port>]
photocraft-cli mcp [--bridge <127.0.0.1:port>] [--control-token <64-hex> | --control-token-file <path>]
[--automation-read-root <dir>] [--automation-write-root <dir>]
Run the MCP server on stdio (headless engine, or bridge to a running `photocraft --control <port>`).
photocraft-cli serve [--port <port>]
photocraft-cli serve [--port <port>] [--control-token <64-hex> | --control-token-file <path>]
[--automation-read-root <dir>] [--automation-write-root <dir>]
Keep one headless session open and answer JSON lines ({\"id\",\"method\",\"params\"}) on stdio,
or on 127.0.0.1:<port>. Methods: engine.execute, engine.commands, doc.open/new/save/inspect/render/
select/close, session.list, batch, methods (docs/control-protocol.md#headless-server).
@ -50,22 +54,21 @@ const VALUE_FLAGS: &[&str] = &[
"--filter",
"--bridge",
"--port",
"--control-token",
"--control-token-file",
"--automation-read-root",
"--automation-write-root",
];
fn parse(args: &[String]) -> Result<Args, String> {
let mut a = Args {
positional: Vec::new(),
flags: Vec::new(),
};
let mut a = Args { positional: Vec::new(), flags: Vec::new() };
let mut i = 0;
while i < args.len() {
let s = &args[i];
if let Some((k, v)) = s.split_once('=').filter(|(k, _)| k.starts_with("--")) {
a.flags.push((k.to_owned(), Some(v.to_owned())));
} else if VALUE_FLAGS.contains(&s.as_str()) {
let v = args
.get(i + 1)
.ok_or_else(|| format!("{s} needs a value"))?;
let v = args.get(i + 1).ok_or_else(|| format!("{s} needs a value"))?;
a.flags.push((s.clone(), Some(v.clone())));
i += 1;
} else if s.starts_with("--") {
@ -80,11 +83,7 @@ fn parse(args: &[String]) -> Result<Args, String> {
impl Args {
fn get(&self, k: &str) -> Option<&str> {
self.flags
.iter()
.rev()
.find(|(f, _)| f == k)
.and_then(|(_, v)| v.as_deref())
self.flags.iter().rev().find(|(f, _)| f == k).and_then(|(_, v)| v.as_deref())
}
fn has(&self, k: &str) -> bool {
self.flags.iter().any(|(f, _)| f == k)
@ -120,11 +119,7 @@ pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 {
return 0;
}
"--version" | "version" => {
let _ = writeln!(
out,
"photocraft-cli {}",
photocraft_engine::build_info::long_version()
);
let _ = writeln!(out, "photocraft-cli {}", photocraft_engine::build_info::long_version());
return 0;
}
other => {
@ -156,31 +151,24 @@ fn warn_all(err: &mut dyn Write, ws: &[String]) {
}
}
fn automation_workspace(args: &Args) -> Result<AuthorizedWorkspace, String> {
AuthorizedWorkspace::new(args.get("--automation-read-root").map(Path::new), args.get("--automation-write-root").map(Path::new))
.map_err(|error| error.to_string())
}
fn convert(a: &Args, _out: &mut dyn Write, err: &mut dyn Write) -> R {
let [input, output] = a.positional.as_slice() else {
return Err("convert needs <in> <out>".into());
};
let o = files::open(Path::new(input)).map_err(|e| e.to_string())?;
warn_all(err, &o.warnings);
let ws = files::save(
&o.document,
Path::new(output),
a.get("--format"),
&export_opts(a)?,
None,
)
.map_err(|e| e.to_string())?;
let ws = files::save(&o.document, Path::new(output), a.get("--format"), &export_opts(a)?, None).map_err(|e| e.to_string())?;
warn_all(err, &ws);
Ok(())
}
fn print_json(out: &mut dyn Write, v: &Value, compact: bool) -> R {
let s = if compact {
serde_json::to_string(v)
} else {
serde_json::to_string_pretty(v)
}
.map_err(|e| e.to_string())?;
let s = if compact { serde_json::to_string(v) } else { serde_json::to_string_pretty(v) }.map_err(|e| e.to_string())?;
writeln!(out, "{s}").map_err(|e| e.to_string())
}
@ -188,7 +176,7 @@ fn info(a: &Args, out: &mut dyn Write) -> R {
let [file] = a.positional.as_slice() else {
return Err("info needs <file>".into());
};
let mut h = Headless::new();
let mut h = Headless::trusted_local();
let opened = h.open(Path::new(file)).map_err(|e| e.to_string())?;
let mut doc = h.inspect(None).map_err(|e| e.to_string())?;
doc["warnings"] = opened["warnings"].clone();
@ -210,8 +198,7 @@ fn command_list(a: &Args) -> Result<Vec<(String, Value)>, String> {
("--cmd", Some(id)) => cmds.push((id.clone(), json!({}))),
("--params", Some(p)) => {
let last = cmds.last_mut().ok_or("--params must follow a --cmd")?;
last.1 = serde_json::from_str(p)
.map_err(|e| format!("bad --params JSON for `{}`: {e}", last.0))?;
last.1 = serde_json::from_str(p).map_err(|e| format!("bad --params JSON for `{}`: {e}", last.0))?;
}
_ => {}
}
@ -220,7 +207,7 @@ fn command_list(a: &Args) -> Result<Vec<(String, Value)>, String> {
}
fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
let mut h = Headless::new();
let mut h = Headless::trusted_local();
match (a.positional.as_slice(), a.get("--new")) {
([file], None) => {
let o = h.open(Path::new(file)).map_err(|e| e.to_string())?;
@ -238,20 +225,11 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
return Err("run needs at least one --cmd (or --out)".into());
}
for (id, params) in cmds {
let r = h
.command_run(&id, params)
.map_err(|e| format!("`{id}`: {e}"))?;
let r = h.command_run(&id, params).map_err(|e| format!("`{id}`: {e}"))?;
print_json(out, &json!({"command": id, "result": r}), true)?;
}
if let Some(o) = a.get("--out") {
let r = h
.save(
None,
Some(Path::new(o)),
a.get("--format"),
&export_opts(a)?,
)
.map_err(|e| e.to_string())?;
let r = h.save(None, Some(Path::new(o)), a.get("--format"), &export_opts(a)?).map_err(|e| e.to_string())?;
let ws: Vec<String> = serde_json::from_value(r["warnings"].clone()).unwrap_or_default();
warn_all(err, &ws);
}
@ -264,37 +242,21 @@ pub fn parse_actions(text: &str) -> Result<Vec<(String, Value)>, String> {
let v: Value = serde_json::from_str(text).map_err(|e| format!("actions JSON: {e}"))?;
let list = match &v {
Value::Array(a) => a.clone(),
Value::Object(m) => m
.get("actions")
.and_then(Value::as_array)
.cloned()
.ok_or("actions JSON: expected an array or {\"actions\": [...]}")?,
Value::Object(m) => m.get("actions").and_then(Value::as_array).cloned().ok_or("actions JSON: expected an array or {\"actions\": [...]}")?,
_ => return Err("actions JSON: expected an array".into()),
};
list.into_iter()
.enumerate()
.map(|(i, a)| {
let id = a
.get("command")
.or_else(|| a.get("id"))
.and_then(Value::as_str)
.ok_or_else(|| format!("action {i}: missing \"command\""))?;
Ok((
id.to_owned(),
a.get("params").cloned().unwrap_or_else(|| json!({})),
))
let id = a.get("command").or_else(|| a.get("id")).and_then(Value::as_str).ok_or_else(|| format!("action {i}: missing \"command\""))?;
Ok((id.to_owned(), a.get("params").cloned().unwrap_or_else(|| json!({}))))
})
.collect()
}
fn is_input(p: &Path) -> bool {
let known = |e: &str| {
matches!(e, "pcraft" | "psd" | "psb")
|| photocraft_codecs::from_extension(e).is_some_and(|f| photocraft_codecs::caps(f).read)
};
p.is_file()
&& p.extension()
.is_some_and(|e| known(&e.to_string_lossy().to_ascii_lowercase()))
let known = |e: &str| matches!(e, "pcraft" | "psd" | "psb") || photocraft_codecs::from_extension(e).is_some_and(|f| photocraft_codecs::caps(f).read);
p.is_file() && p.extension().is_some_and(|e| known(&e.to_string_lossy().to_ascii_lowercase()))
}
fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
@ -305,35 +267,21 @@ fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
let actions = parse_actions(&text)?;
let opts = export_opts(a)?;
std::fs::create_dir_all(&out_dir).map_err(|e| e.to_string())?;
let mut inputs: Vec<PathBuf> = std::fs::read_dir(&in_dir)
.map_err(|e| format!("{}: {e}", in_dir.display()))?
.flatten()
.map(|e| e.path())
.filter(|p| is_input(p))
.collect();
let mut inputs: Vec<PathBuf> =
std::fs::read_dir(&in_dir).map_err(|e| format!("{}: {e}", in_dir.display()))?.flatten().map(|e| e.path()).filter(|p| is_input(p)).collect();
inputs.sort();
let (mut ok, mut failed) = (0, 0);
for input in &inputs {
let ext = a
.get("--format")
.map(str::to_owned)
.or_else(|| input.extension().map(|e| e.to_string_lossy().into_owned()))
.unwrap_or_else(|| "png".into());
let stem = input
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let ext = a.get("--format").map(str::to_owned).or_else(|| input.extension().map(|e| e.to_string_lossy().into_owned())).unwrap_or_else(|| "png".into());
let stem = input.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
let target = out_dir.join(format!("{stem}.{ext}"));
let r = (|| -> Result<Vec<String>, String> {
let mut h = Headless::new();
let mut h = Headless::trusted_local();
h.open(input).map_err(|e| e.to_string())?;
for (id, p) in &actions {
h.command_run(id, p.clone())
.map_err(|e| format!("`{id}`: {e}"))?;
h.command_run(id, p.clone()).map_err(|e| format!("`{id}`: {e}"))?;
}
let r = h
.save(None, Some(&target), Some(&ext), &opts)
.map_err(|e| e.to_string())?;
let r = h.save(None, Some(&target), Some(&ext), &opts).map_err(|e| e.to_string())?;
Ok(serde_json::from_value(r["warnings"].clone()).unwrap_or_default())
})();
match r {
@ -349,18 +297,11 @@ fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
}
}
let _ = writeln!(out, "{ok} succeeded, {failed} failed");
if failed > 0 {
Err(format!("{failed} file(s) failed"))
} else {
Ok(())
}
if failed > 0 { Err(format!("{failed} file(s) failed")) } else { Ok(()) }
}
fn droplet(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
let (file, inputs) = a
.positional
.split_first()
.ok_or("droplet needs <file.pcdroplet> and inputs")?;
let (file, inputs) = a.positional.split_first().ok_or("droplet needs <file.pcdroplet> and inputs")?;
if inputs.is_empty() {
return Err("droplet needs at least one input file or folder".into());
}
@ -369,26 +310,15 @@ fn droplet(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
p["output"] = json!(o);
}
let mut s = photocraft_engine::Session::new();
let r = s
.execute("file.automate.runDroplet", p)
.map_err(|e| e.to_string())?;
let r = s.execute("file.automate.runDroplet", p).map_err(|e| e.to_string())?;
for f in r["files"].as_array().into_iter().flatten() {
let _ = writeln!(out, "ok {}", f.as_str().unwrap_or_default());
}
let errors = r["errors"].as_array().cloned().unwrap_or_default();
for e in &errors {
let _ = writeln!(
err,
"FAIL {}: {}",
e["file"].as_str().unwrap_or_default(),
e["error"].as_str().unwrap_or_default()
);
}
if errors.is_empty() {
Ok(())
} else {
Err(format!("{} file(s) failed", errors.len()))
let _ = writeln!(err, "FAIL {}: {}", e["file"].as_str().unwrap_or_default(), e["error"].as_str().unwrap_or_default());
}
if errors.is_empty() { Ok(()) } else { Err(format!("{} file(s) failed", errors.len())) }
}
fn commands(a: &Args, out: &mut dyn Write) -> R {
@ -401,74 +331,54 @@ fn commands(a: &Args, out: &mut dyn Write) -> R {
.unwrap_or_default()
.into_iter()
.filter(|c| {
needle.as_ref().is_none_or(|n| {
format!(
"{} {}",
c["id"].as_str().unwrap_or(""),
c["label"].as_str().unwrap_or("")
)
.to_lowercase()
.contains(n)
})
needle.as_ref().is_none_or(|n| format!("{} {}", c["id"].as_str().unwrap_or(""), c["label"].as_str().unwrap_or("")).to_lowercase().contains(n))
})
.collect();
if a.has("--json") {
return print_json(
out,
&Value::Array(items.into_iter().cloned().collect()),
false,
);
return print_json(out, &Value::Array(items.into_iter().cloned().collect()), false);
}
for c in items {
let menu = c["menu"]
.as_array()
.map(|m| {
m.iter()
.filter_map(Value::as_str)
.collect::<Vec<_>>()
.join(" > ")
})
.unwrap_or_default();
writeln!(
out,
"{:<44} {:<32} {}",
c["id"].as_str().unwrap_or(""),
c["label"].as_str().unwrap_or(""),
menu
)
.map_err(|e| e.to_string())?;
let menu = c["menu"].as_array().map(|m| m.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(" > ")).unwrap_or_default();
writeln!(out, "{:<44} {:<32} {}", c["id"].as_str().unwrap_or(""), c["label"].as_str().unwrap_or(""), menu).map_err(|e| e.to_string())?;
}
Ok(())
}
fn serve(a: &Args, err: &mut dyn Write) -> R {
use std::sync::{Arc, Mutex};
let h = Arc::new(Mutex::new(Headless::new()));
let h = Arc::new(Mutex::new(Headless::with_workspace(automation_workspace(a)?)));
match a.get("--port") {
Some(port) => {
let port: u16 = port.parse().map_err(|_| format!("bad --port `{port}`"))?;
let addr = format!("127.0.0.1:{port}");
photocraft_automation::rpc::serve_tcp(&addr, h, |local| {
let (supplied, token_file) = security::token_inputs(a.get("--control-token").map(str::to_owned), a.get("--control-token-file").map(PathBuf::from));
let token = security::server_token(supplied.as_deref(), token_file.as_deref()).map_err(|e| e.to_string())?;
if let Some(path) = &token_file {
let _ = writeln!(err, "photocraft-cli control token file: {}", path.display());
} else if supplied.is_none() {
let _ = writeln!(err, "photocraft-cli control token: {token}");
}
photocraft_automation::rpc::serve_tcp(&addr, h, token, |local| {
let _ = writeln!(err, "photocraft-cli serving on {local}");
})
.map_err(|e| e.to_string())
}
None => {
let stdin = std::io::stdin();
photocraft_automation::rpc::serve_lines(&h, stdin.lock(), std::io::stdout())
.map_err(|e| e.to_string())
photocraft_automation::rpc::serve_lines(&h, stdin.lock(), std::io::stdout()).map_err(|e| e.to_string())
}
}
}
fn mcp(a: &Args) -> R {
let server = match a.get("--bridge") {
Some(addr) => PhotocraftMcp::bridge(addr).map_err(|e| e.to_string())?,
None => PhotocraftMcp::headless(),
Some(addr) => {
let (supplied, token_file) = security::token_inputs(a.get("--control-token").map(str::to_owned), a.get("--control-token-file").map(PathBuf::from));
let token = security::client_token(supplied.as_deref(), token_file.as_deref()).map_err(|e| e.to_string())?;
PhotocraftMcp::bridge(addr, &token).map_err(|e| e.to_string())?
}
None => PhotocraftMcp::headless_with_workspace(automation_workspace(a)?),
};
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.map_err(|e| e.to_string())?;
let rt = tokio::runtime::Builder::new_multi_thread().enable_all().build().map_err(|e| e.to_string())?;
rt.block_on(server.serve_stdio()).map_err(|e| e.to_string())
}

View File

@ -1,3 +1,5 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let code = photocraft_cli::run(&args, &mut std::io::stdout(), &mut std::io::stderr());

View File

@ -18,30 +18,15 @@ fn tmp(name: &str) -> PathBuf {
}
fn write_png(path: &Path, w: u32, h: u32, seed: u8) {
let data: Vec<u8> = (0..w * h * 4)
.map(|i| (i as u8).wrapping_mul(seed).wrapping_add(seed))
.collect();
let img = photocraft_codecs::Image::from_u8(w, h, photocraft_codecs::ChannelLayout::Rgba, data)
.unwrap();
std::fs::write(
path,
photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default())
.unwrap(),
)
.unwrap();
let data: Vec<u8> = (0..w * h * 4).map(|i| (i as u8).wrapping_mul(seed).wrapping_add(seed)).collect();
let img = photocraft_codecs::Image::from_u8(w, h, photocraft_codecs::ChannelLayout::Rgba, data).unwrap();
std::fs::write(path, photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap();
}
fn ok(cmd: &mut Command) -> (String, String) {
let o = cmd.output().unwrap();
let (out, err) = (
String::from_utf8_lossy(&o.stdout).into_owned(),
String::from_utf8_lossy(&o.stderr).into_owned(),
);
assert!(
o.status.success(),
"exit {:?}\nstdout: {out}\nstderr: {err}",
o.status
);
let (out, err) = (String::from_utf8_lossy(&o.stdout).into_owned(), String::from_utf8_lossy(&o.stderr).into_owned());
assert!(o.status.success(), "exit {:?}\nstdout: {out}\nstderr: {err}", o.status);
(out, err)
}
@ -70,26 +55,15 @@ fn convert_png_pcraft_png_is_lossless() {
let y = photocraft_codecs::decode(&std::fs::read(&c).unwrap()).unwrap();
assert_eq!(x.to_rgba8(), y.to_rgba8());
// Format override and lossy warning path.
ok(bin()
.args(["convert"])
.arg(&a)
.arg(d.join("out.bin"))
.args(["--format", "jpg", "--quality", "60"]));
assert!(
photocraft_codecs::detect(&std::fs::read(d.join("out.bin")).unwrap())
== Some(photocraft_codecs::Format::Jpeg)
);
ok(bin().args(["convert"]).arg(&a).arg(d.join("out.bin")).args(["--format", "jpg", "--quality", "60"]));
assert!(photocraft_codecs::detect(&std::fs::read(d.join("out.bin")).unwrap()) == Some(photocraft_codecs::Format::Jpeg));
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn convert_errors() {
let d = tmp("converr");
let o = bin()
.args(["convert", "/missing/in.png"])
.arg(d.join("x.png"))
.output()
.unwrap();
let o = bin().args(["convert", "/missing/in.png"]).arg(d.join("x.png")).output().unwrap();
assert_eq!(o.status.code(), Some(1));
let o = bin().args(["convert", "only-one"]).output().unwrap();
assert_eq!(o.status.code(), Some(1));
@ -119,65 +93,33 @@ fn run_commands_and_save() {
let (out, _) = ok(bin()
.args(["run", "--new", r#"{"width":40,"height":30,"name":"Run"}"#])
.args(["--cmd", "layer.new.layer", "--params", r#"{"name":"Ink"}"#])
.args([
"--cmd",
"paint.stroke",
"--params",
r##"{"points":[[2,2,1],[30,20,1]],"size":5,"color":"#00ff00"}"##,
])
.args(["--cmd", "paint.stroke", "--params", r##"{"points":[[2,2,1],[30,20,1]],"size":5,"color":"#00ff00"}"##])
.args(["--cmd", "document.inspect"])
.arg("--out")
.arg(&out_pc));
let lines: Vec<Value> = out
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect();
let lines: Vec<Value> = out.lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert_eq!(lines.len(), 3);
assert_eq!(lines[2]["command"], "document.inspect");
let (info, _) = ok(bin().arg("info").arg(&out_pc));
let v: Value = serde_json::from_str(&info).unwrap();
assert_eq!(v["name"], "Run");
let names: Vec<&str> = v["layers"]
.as_array()
.unwrap()
.iter()
.filter_map(|l| l["name"].as_str())
.collect();
let names: Vec<&str> = v["layers"].as_array().unwrap().iter().filter_map(|l| l["name"].as_str()).collect();
assert_eq!(names, ["Ink", "Background"]);
// Continue from the saved file and export PNG.
let png = d.join("r.png");
ok(bin()
.arg("run")
.arg(&out_pc)
.args(["--cmd", "layer.new.layer"])
.arg("--out")
.arg(&png));
assert_eq!(
photocraft_codecs::decode(&std::fs::read(&png).unwrap())
.unwrap()
.dimensions(),
(40, 30)
);
ok(bin().arg("run").arg(&out_pc).args(["--cmd", "layer.new.layer"]).arg("--out").arg(&png));
assert_eq!(photocraft_codecs::decode(&std::fs::read(&png).unwrap()).unwrap().dimensions(), (40, 30));
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn run_errors() {
let o = bin()
.args(["run", "--new", "{}", "--cmd", "no.such"])
.output()
.unwrap();
let o = bin().args(["run", "--new", "{}", "--cmd", "no.such"]).output().unwrap();
assert_eq!(o.status.code(), Some(1));
assert!(String::from_utf8_lossy(&o.stderr).contains("unknown command"));
let o = bin()
.args(["run", "--new", "{}", "--params", "{}"])
.output()
.unwrap();
let o = bin().args(["run", "--new", "{}", "--params", "{}"]).output().unwrap();
assert_eq!(o.status.code(), Some(1));
let o = bin()
.args(["run", "--new", "{", "--cmd", "x"])
.output()
.unwrap();
let o = bin().args(["run", "--new", "{", "--cmd", "x"]).output().unwrap();
assert_eq!(o.status.code(), Some(1));
}
@ -190,19 +132,8 @@ fn batch_applies_actions_to_directory() {
write_png(&inp.join("two.png"), 9, 9, 2);
std::fs::write(inp.join("notes.txt"), "ignored").unwrap();
let actions = d.join("actions.json");
std::fs::write(
&actions,
json!([{"command": "layer.new.layer", "params": {"name": "Batch"}}]).to_string(),
)
.unwrap();
let (out, _) = ok(bin()
.args(["batch", "--actions"])
.arg(&actions)
.arg("--in")
.arg(&inp)
.arg("--out")
.arg(&outp)
.args(["--format", "pcraft"]));
std::fs::write(&actions, json!([{"command": "layer.new.layer", "params": {"name": "Batch"}}]).to_string()).unwrap();
let (out, _) = ok(bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&inp).arg("--out").arg(&outp).args(["--format", "pcraft"]));
assert!(out.contains("2 succeeded, 0 failed"), "{out}");
for n in ["one", "two"] {
let (info, _) = ok(bin().arg("info").arg(outp.join(format!("{n}.pcraft"))));
@ -211,15 +142,7 @@ fn batch_applies_actions_to_directory() {
}
// A failing action reports and exits 1.
std::fs::write(&actions, r#"{"actions":[{"id":"no.such.command"}]}"#).unwrap();
let o = bin()
.args(["batch", "--actions"])
.arg(&actions)
.arg("--in")
.arg(&inp)
.arg("--out")
.arg(&outp)
.output()
.unwrap();
let o = bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&inp).arg("--out").arg(&outp).output().unwrap();
assert_eq!(o.status.code(), Some(1));
assert!(String::from_utf8_lossy(&o.stderr).contains("FAIL"));
std::fs::remove_dir_all(d).unwrap();
@ -227,21 +150,9 @@ fn batch_applies_actions_to_directory() {
#[test]
fn parse_actions_formats() {
let a =
photocraft_cli::parse_actions(r#"[{"command":"a","params":{"x":1}},{"id":"b"}]"#).unwrap();
assert_eq!(
a,
vec![
("a".to_string(), json!({"x":1})),
("b".to_string(), json!({}))
]
);
assert!(
photocraft_cli::parse_actions(r#"{"actions":[{"command":"c"}]}"#)
.unwrap()
.len()
== 1
);
let a = photocraft_cli::parse_actions(r#"[{"command":"a","params":{"x":1}},{"id":"b"}]"#).unwrap();
assert_eq!(a, vec![("a".to_string(), json!({"x":1})), ("b".to_string(), json!({}))]);
assert!(photocraft_cli::parse_actions(r#"{"actions":[{"command":"c"}]}"#).unwrap().len() == 1);
assert!(photocraft_cli::parse_actions(r#"[{"params":{}}]"#).is_err());
assert!(photocraft_cli::parse_actions("7").is_err());
}
@ -252,38 +163,26 @@ fn commands_listing() {
assert!(out.contains("file.new"));
let (out, _) = ok(bin().args(["commands", "--json", "--filter", "layer.new"]));
let v: Value = serde_json::from_str(&out).unwrap();
assert!(v.as_array().unwrap().iter().all(|c| {
c["id"].as_str().unwrap().contains("layer.new")
|| c["label"]
.as_str()
.unwrap()
.to_lowercase()
.contains("layer.new")
}));
assert!(
v.as_array()
.unwrap()
.iter()
.all(|c| { c["id"].as_str().unwrap().contains("layer.new") || c["label"].as_str().unwrap().to_lowercase().contains("layer.new") })
);
}
#[test]
fn in_process_run_matches_binary() {
let mut out = Vec::new();
let mut err = Vec::new();
let code = photocraft_cli::run(
&["commands".into(), "--filter".into(), "file.new".into()],
&mut out,
&mut err,
);
let code = photocraft_cli::run(&["commands".into(), "--filter".into(), "file.new".into()], &mut out, &mut err);
assert_eq!(code, 0);
assert!(String::from_utf8(out).unwrap().contains("file.new"));
}
#[test]
fn mcp_stdio_handshake_and_tool_call() {
let mut child = bin()
.arg("mcp")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
let mut child = bin().arg("mcp").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap();
let mut stdin = child.stdin.take().unwrap();
let mut stdout = BufReader::new(child.stdout.take().unwrap());
let mut send = |v: Value| {
@ -300,33 +199,19 @@ fn mcp_stdio_handshake_and_tool_call() {
}
}
};
send(
json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}),
);
send(json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}));
let init = recv(1);
assert_eq!(init["result"]["serverInfo"]["name"], "photocraft", "{init}");
send(json!({"jsonrpc":"2.0","method":"notifications/initialized"}));
send(json!({"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}));
let tools = recv(2);
let names: Vec<&str> = tools["result"]["tools"]
.as_array()
.unwrap()
.iter()
.filter_map(|t| t["name"].as_str())
.collect();
assert!(
names.contains(&"command_run") && names.contains(&"doc_render_preview"),
"{names:?}"
);
send(
json!({"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"doc_new","arguments":{"width":8,"height":8}}}),
);
let names: Vec<&str> = tools["result"]["tools"].as_array().unwrap().iter().filter_map(|t| t["name"].as_str()).collect();
assert!(names.contains(&"command_run") && names.contains(&"doc_render_preview"), "{names:?}");
send(json!({"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"doc_new","arguments":{"width":8,"height":8}}}));
let r = recv(3);
assert_ne!(r["result"]["isError"], true, "{r}");
send(
json!({"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"doc_render_preview","arguments":{}}}),
);
send(json!({"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"doc_render_preview","arguments":{}}}));
let r = recv(4);
assert_eq!(r["result"]["content"][0]["type"], "image", "{r}");
let _ = child.kill();
@ -345,25 +230,10 @@ fn droplet_runs_an_action_on_files() {
)
.unwrap();
let out_dir = d.join("out");
let (out, _) = ok(bin()
.arg("droplet")
.arg(&droplet)
.arg(d.join("a.png"))
.arg(d.join("b.png"))
.arg("--out")
.arg(&out_dir));
assert_eq!(
out.lines().filter(|l| l.starts_with("ok")).count(),
2,
"{out}"
);
let (out, _) = ok(bin().arg("droplet").arg(&droplet).arg(d.join("a.png")).arg(d.join("b.png")).arg("--out").arg(&out_dir));
assert_eq!(out.lines().filter(|l| l.starts_with("ok")).count(), 2, "{out}");
let img = photocraft_codecs::decode(&std::fs::read(out_dir.join("a.png")).unwrap()).unwrap();
assert_eq!(img.dimensions(), (4, 8));
let o = bin()
.arg("droplet")
.arg(d.join("a.png"))
.arg(d.join("b.png"))
.output()
.unwrap();
let o = bin().arg("droplet").arg(d.join("a.png")).arg(d.join("b.png")).output().unwrap();
assert!(!o.status.success(), "not a droplet");
}

View File

@ -29,4 +29,4 @@ 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"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent"] }

View File

@ -14,6 +14,8 @@
//! URL query flags: `?cpu` forces the CPU canvas path (same as `PHOTOCRAFT_CPU_CANVAS=1`);
//! `?webgl` forces the WebGL2 backend instead of WebGPU.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(target_arch = "wasm32")]
mod web;
@ -24,7 +26,5 @@ fn main() {
#[cfg(not(target_arch = "wasm32"))]
fn main() {
eprintln!(
"photocraft-web only runs in the browser: build it with `trunk build --release` in apps/photocraft-web"
);
eprintln!("photocraft-web only runs in the browser: build it with `trunk build --release` in apps/photocraft-web");
}

View File

@ -11,9 +11,11 @@ use wasm_bindgen::JsCast as _;
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
const IMAGE_EXTS: &[&str] = &[
"psd", "psb", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi",
"exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm",
/// Everything File › Open reads: PhotoCraft and Photoshop documents, flat images, and Photoshop
/// brushes (.abr) and gradients (.grd), which go to the preset libraries.
const OPEN_EXTS: &[&str] = &[
"pcraft", "psd", "psb", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm",
"dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd",
];
const CANVAS_ID: &str = "photocraft_canvas";
@ -24,22 +26,20 @@ pub fn start() {
log::error!("no document");
return;
};
let Some(canvas) = document
.get_element_by_id(CANVAS_ID)
.and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok())
else {
let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()) else {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
return;
};
let q = query();
let force_cpu = q.contains("cpu");
let mut options = eframe::WebOptions::default();
photocraft_ui_egui::gpu_canvas::use_adapter_limits(&mut options.wgpu_options.wgpu_setup);
if q.contains("webgl")
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) =
&mut options.wgpu_options.wgpu_setup
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
{
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
}
let pen_target = canvas.clone();
let result = eframe::WebRunner::new()
.start(
canvas,
@ -47,18 +47,13 @@ pub fn start() {
Box::new(move |cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, ThemeKind::Pro);
let inbox: Inbox = Arc::default();
let mut app = PhotocraftApp::new(
Session::new(),
services(inbox.clone(), cc.egui_ctx.clone()),
);
let mut app = PhotocraftApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone()));
listen_pen(&pen_target, app.stylus.feed.clone());
app.set_theme(&cc.egui_ctx, ThemeKind::Pro);
if let Some(rs) = cc.wgpu_render_state.clone()
&& !force_cpu
{
log::info!(
"photocraft-web: wgpu backend {:?}",
rs.adapter.get_info().backend
);
log::info!("photocraft-web: wgpu backend {:?}", rs.adapter.get_info().backend);
app.set_wgpu(rs);
}
Ok(Box::new(WebShell { app, inbox }))
@ -74,10 +69,30 @@ pub fn start() {
});
}
/// Pen pressure, tilt and twist from Pointer Events (eframe forwards none of them for pens) into
/// the app's stylus feed. The sample is kept through `pointerup` so the stroke's last points keep
/// their pressure; hovering, a mouse, or leaving the canvas clears it.
fn listen_pen(target: &web_sys::HtmlCanvasElement, feed: photocraft_ui_egui::stylus::StylusFeed) {
use photocraft_ui_egui::stylus::PenSample;
use wasm_bindgen::closure::Closure;
for kind in ["pointerdown", "pointermove", "pointerup", "pointercancel", "pointerleave"] {
let feed = feed.clone();
let cb = Closure::<dyn FnMut(web_sys::PointerEvent)>::new(move |e: web_sys::PointerEvent| {
let ty = e.type_();
if ty == "pointerup" && e.pointer_type() == "pen" {
return;
}
let pen = e.pointer_type() == "pen" && e.buttons() != 0 && ty != "pointercancel" && ty != "pointerleave";
feed.set(pen.then(|| PenSample { pressure: e.pressure(), tilt_x: e.tilt_x() as f32, tilt_y: e.tilt_y() as f32, rotation: e.twist() as f32 }));
});
if target.add_event_listener_with_callback(kind, cb.as_ref().unchecked_ref()).is_ok() {
cb.forget();
}
}
}
fn query() -> String {
web_sys::window()
.and_then(|w| w.location().search().ok())
.unwrap_or_default()
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
}
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously,
@ -94,17 +109,10 @@ impl eframe::App for WebShell {
let inbox = self.inbox.clone();
let ctx = ctx.clone();
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());
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));
inbox.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}"),
@ -122,68 +130,41 @@ impl eframe::App for WebShell {
fn services(inbox: Inbox, ctx: egui::Context) -> Services {
let open_inbox = inbox.clone();
Services {
import: Some(Box::new(|name: &str, bytes: &[u8]| {
photocraft_io::import(name, bytes)
.map(|r| r.document)
.map_err(|e| e.to_string())
})),
import: Some(Box::new(|name: &str, bytes: &[u8]| photocraft_io::import(name, bytes).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()))),
export: Some(Box::new(|doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings| {
let mut opts = photocraft_io::ExportOptions::default();
if let Some(q) = settings.jpeg_quality {
opts.encode.jpeg_quality = q;
}
photocraft_io::export(doc, path, &opts)
.map(|r| r.bytes)
.map_err(|e| e.to_string())
photocraft_io::export(doc, path, &opts).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string())
})),
pick_open: Some(Box::new(move || {
let inbox = open_inbox.clone();
let ctx = ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let Some(file) = rfd::AsyncFileDialog::new()
.add_filter("Images", IMAGE_EXTS)
.pick_file()
.await
else {
let Some(file) = rfd::AsyncFileDialog::new().add_filter("All Formats", OPEN_EXTS).pick_file().await else {
return;
};
let bytes = file.read().await;
inbox
.lock()
.unwrap_or_else(|e| e.into_inner())
.push((file.file_name(), bytes));
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((file.file_name(), bytes));
ctx.request_repaint();
});
None
})),
// No save dialog on the web: the suggested name becomes the download name.
pick_save: Some(Box::new(|suggested: &str| {
let name = std::path::Path::new(suggested)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| suggested.to_string());
let name = std::path::Path::new(suggested).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| suggested.to_string());
Some(name)
})),
write: Some(Box::new(|path: &str, bytes: &[u8]| download(path, bytes))),
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())
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),
// 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:?}"))
})),
save_prefs: Some(Box::new(|text: &str| local_storage().ok_or("no localStorage")?.set_item(PREFS_KEY, text).map_err(|e| format!("{e:?}")))),
..Default::default()
}
}
@ -197,10 +178,7 @@ fn local_storage() -> Option<web_sys::Storage> {
/// Trigger a browser download of `bytes` named after the last component of `path`.
fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
let name = std::path::Path::new(path)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "photocraft".into());
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "photocraft".into());
let window = web_sys::window().ok_or("no window")?;
let document = window.document().ok_or("no document")?;
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
@ -208,11 +186,7 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
opts.set_type(mime_for(&name));
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?;
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?;
let a: web_sys::HtmlAnchorElement = document
.create_element("a")
.map_err(js)?
.dyn_into()
.map_err(|_| "not an anchor")?;
let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(js)?.dyn_into().map_err(|_| "not an anchor")?;
a.set_href(&url);
a.set_download(&name);
a.style().set_property("display", "none").map_err(js)?;
@ -224,19 +198,12 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
let revoke = wasm_bindgen::closure::Closure::once_into_js(move || {
web_sys::Url::revoke_object_url(&url).ok();
});
window
.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000)
.map_err(js)?;
window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000).map_err(js)?;
Ok(())
}
fn mime_for(name: &str) -> &'static str {
match name
.rsplit('.')
.next()
.map(str::to_ascii_lowercase)
.as_deref()
{
match name.rsplit('.').next().map(str::to_ascii_lowercase).as_deref() {
Some("png") => "image/png",
Some("jpg" | "jpeg") => "image/jpeg",
Some("tif" | "tiff") => "image/tiff",

View File

@ -20,6 +20,7 @@ photocraft-engine = { workspace = true }
photocraft-codecs = { workspace = true }
photocraft-io = { workspace = true }
photocraft-format = { workspace = true }
photocraft-automation = { workspace = true }
photocraft-ui-egui = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
egui = { workspace = true }
@ -29,6 +30,13 @@ log = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd = { workspace = true }
arboard = "3"
open = "5"
[target.'cfg(target_os = "macos")'.dependencies]
# Finder double-click / Open With / Dock drops arrive as Apple events; this small audited crate
# registers the handler (Objective-C interop needs unsafe, which our workspace forbids) behind a
# safe API. Pinned exactly: review the diff before bumping.
fmv-macos-events = "=0.1.0"
[build-dependencies]
# Icon + VERSIONINFO for photocraft.exe; build.rs only uses it when targeting Windows.

View File

@ -0,0 +1,45 @@
//! macOS open-documents and quit Apple events.
//!
//! Finder double-clicks, Open With, drops on the Dock icon and `open -a PhotoCraft file.psd` don't
//! pass paths on the command line: LaunchServices sends the running (or just-launched) app a
//! `kAEOpenDocuments` ('odoc') Apple event. winit 0.30 doesn't handle it and owns the
//! `NSApplicationDelegate`, and handling it ourselves needs Objective-C class declarations, i.e.
//! `unsafe`, which this workspace forbids. The audited `fmv-macos-events` crate wraps exactly that
//! (an `NSAppleEventManager` handler registered before Finder's launch event, leaving winit's
//! delegate alone) behind a safe main-thread API; this module adapts it to [`OsEvent`]s that the UI
//! polls every frame.
use fmv_macos_events::{Event, Inbox, Registration};
use photocraft_ui_egui::OsEvent;
/// Keeps the Apple-event handlers registered; hold it until the event loop returns.
pub struct AppleEvents {
_registration: Registration,
inbox: Inbox,
}
impl AppleEvents {
/// Register the handlers. Call on the main thread before the event loop starts, so the event
/// that launched the app (Finder double-click) is caught too.
pub fn install() -> Self {
let (registration, inbox) = Registration::install();
Self { _registration: registration, inbox }
}
/// The queue the UI drains (see `Services::os_events`); events arriving later wake `ctx`.
pub fn connect(&self, ctx: &egui::Context) -> photocraft_ui_egui::OsEventsFn {
let ctx = ctx.clone();
self.inbox.set_wake(move || ctx.request_repaint());
let inbox = self.inbox.clone();
Box::new(move || {
inbox
.drain()
.into_iter()
.map(|e| match e {
Event::Open(paths) => OsEvent::Open(paths.into_iter().map(|p| p.to_string_lossy().into_owned()).collect()),
Event::Quit => OsEvent::Quit,
})
.collect()
})
}
}

View File

@ -1,15 +1,20 @@
//! Localhost JSON-lines control server (one request per line, one reply per line).
//! Loopback only. This is the transport the MCP server will wrap.
use std::io::{BufRead, BufReader, Write};
use std::io::{BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::Duration;
use photocraft_automation::budgets::write_reply;
use photocraft_automation::security::{
ConnectionLimiter, LineRead, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, read_bounded_line,
};
use photocraft_ui_egui::ControlRequest;
use serde_json::{Value, json};
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
pub fn start(port: u16, token: String, ctx: egui::Context) -> Receiver<ControlRequest> {
let (tx, rx) = channel::<ControlRequest>();
let listener = match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => l,
@ -20,21 +25,57 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
};
eprintln!("photocraft: control server listening on 127.0.0.1:{port}");
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let limiter = ConnectionLimiter::new(MAX_CONNECTIONS);
let token = Arc::new(token);
for mut stream in listener.incoming().flatten() {
let Some(permit) = limiter.try_acquire() else {
let _ = configure_stream(&stream);
let _ = writeln!(stream, "{}", json!({"id": null, "ok": false, "error": "connection limit reached"}));
continue;
};
let tx = tx.clone();
let ctx = ctx.clone();
std::thread::spawn(move || serve(stream, tx, ctx));
let token = Arc::clone(&token);
std::thread::spawn(move || {
let _permit = permit;
serve(stream, &token, tx, ctx);
});
}
});
rx
}
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context) {
if configure_stream(&stream).is_err() {
return;
}
let Ok(read) = stream.try_clone() else { return };
let mut reader = BufReader::new(read);
let mut out = stream;
for line in BufReader::new(read).lines() {
let Ok(line) = line else { break };
if line.trim().is_empty() {
let mut line = String::new();
let mut authenticated = false;
loop {
match read_bounded_line(&mut reader, &mut line) {
Ok(LineRead::Eof) | Err(_) => break,
Ok(LineRead::TooLong) => {
let reply = json!({
"id": null,
"ok": false,
"error": format!("request exceeds {MAX_REQUEST_BYTES} bytes"),
});
let _ = write_reply(&mut out, &reply);
let _ = out.flush();
break;
}
Ok(LineRead::Line) if line.trim().is_empty() => continue,
Ok(LineRead::Line) => {}
}
if !authenticated {
let (reply, ok) = authentication_reply(&line, token);
authenticated = ok;
if write_reply(&mut out, &reply).is_err() || out.flush().is_err() || !authenticated {
break;
}
continue;
}
let reply = match serde_json::from_str::<Value>(&line) {
@ -55,8 +96,62 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
}
Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
};
if writeln!(out, "{reply}").is_err() {
if write_reply(&mut out, &reply).is_err() {
break;
}
if out.flush().is_err() {
break;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::BufRead;
#[test]
fn oversized_reply_preserves_framing_id_and_the_next_control_request() {
use photocraft_automation::budgets::MAX_RESPONSE_BYTES;
const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = channel::<ControlRequest>();
let server = std::thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
serve(stream, TOKEN, tx, egui::Context::default());
});
let handler = std::thread::spawn(move || {
let first = rx.recv_timeout(Duration::from_secs(5)).unwrap();
assert_eq!(first.method, "test.large");
first.reply.send(json!({"ok": true, "result": "x".repeat(MAX_RESPONSE_BYTES)})).unwrap();
let second = rx.recv_timeout(Duration::from_secs(5)).unwrap();
assert_eq!(second.method, "test.small");
second.reply.send(json!({"ok": true, "result": "still serving"})).unwrap();
});
let mut stream = TcpStream::connect(addr).unwrap();
stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
let mut reader = BufReader::new(stream.try_clone().unwrap());
writeln!(stream, "{}", json!({"id": 1, "method": "auth", "params": {"token": TOKEN}})).unwrap();
let mut line = String::new();
reader.read_line(&mut line).unwrap();
assert_eq!(serde_json::from_str::<Value>(&line).unwrap()["ok"], true);
writeln!(stream, "{}", json!({"id": 2, "method": "test.large"})).unwrap();
line.clear();
reader.read_line(&mut line).unwrap();
let rejected: Value = serde_json::from_str(&line).unwrap();
assert_eq!(rejected["id"], 2);
assert_eq!(rejected["ok"], false);
assert!(rejected["error"].as_str().unwrap().contains("operation may have completed"));
writeln!(stream, "{}", json!({"id": 3, "method": "test.small"})).unwrap();
line.clear();
reader.read_line(&mut line).unwrap();
let accepted: Value = serde_json::from_str(&line).unwrap();
assert_eq!(accepted["id"], 3);
assert_eq!(accepted["result"], "still serving");
drop(reader);
drop(stream);
handler.join().unwrap();
server.join().unwrap();
}
}

View File

@ -0,0 +1,35 @@
//! Last-resort crash guard (AGENTS.md, Never crash).
//!
//! Code must return errors, never panic. This is the safety net for a panic that escapes anyway:
//! the hook logs it, and [`guard`] turns a panic during file import/export into an error the UI
//! shows, so the open documents stay in place. Commands get the same treatment in
//! `Session::execute`.
use std::panic::{AssertUnwindSafe, catch_unwind};
/// Log every panic (with its location) before the default hook prints it.
pub fn install_hook() {
let default = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
log::error!("PhotoCraft internal error: {info}");
default(info);
}));
}
/// Run `f`; a panic inside it becomes an `Err` naming `what`.
pub fn guard<T>(what: &str, f: impl FnOnce() -> Result<T, String>) -> Result<T, String> {
catch_unwind(AssertUnwindSafe(f)).unwrap_or_else(|_| Err(format!("{what} failed with an internal error (logged); your open documents are unchanged")))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn guard_turns_a_panic_into_an_error() {
let r: Result<(), String> = guard("Open", || panic!("boom"));
assert!(r.unwrap_err().contains("Open failed"));
assert_eq!(guard("Open", || Ok(3)), Ok(3));
assert_eq!(guard::<()>("Open", || Err("bad file".into())), Err("bad file".into()));
}
}

View File

@ -1,16 +1,23 @@
//! Photocraft desktop app.
//!
//! Usage: `photocraft [--control <port>] [files…]`
//! Usage: `photocraft [--control <port>] [--control-token <64-hex> |
//! --control-token-file <path>] [--automation-read-root <dir>]
//! [--automation-write-root <dir>] [files…]`
//!
//! `--control <port>` (or `PHOTOCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server.
//! Each line `{"id":1,"method":"ui.inspect","params":{}}` gets a reply line
//! The first line must authenticate; subsequent request lines get reply lines.
//! `{"id":1,"ok":true,"result":…}`. See `photocraft_ui_egui::control` for the methods.
// Release builds on Windows are GUI-subsystem apps, so launching from the Start Menu or Explorer
// doesn't open a console window. (`--version` output then only shows when redirected.)
#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(target_os = "macos")]
mod apple_events;
mod control_server;
mod crash_guard;
mod monitor_profile;
mod services;
use photocraft_engine::Session;
@ -30,12 +37,21 @@ fn app_icon() -> egui::IconData {
}
fn main() -> eframe::Result {
crash_guard::install_hook();
let mut control_port: Option<u16> = std::env::var("PHOTOCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
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();
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
"--control-token" => control_token = args.next(),
"--control-token-file" => control_token_file = args.next().map(std::path::PathBuf::from),
"--automation-read-root" => automation_read_root = args.next().map(std::path::PathBuf::from),
"--automation-write-root" => automation_write_root = args.next().map(std::path::PathBuf::from),
"--version" => {
println!("photocraft {}", photocraft_engine::build_info::long_version());
return Ok(());
@ -46,19 +62,71 @@ fn main() -> eframe::Result {
}
}
let options = eframe::NativeOptions {
let control = if let Some(port) = control_port {
let (supplied, token_file) = photocraft_automation::security::token_inputs(control_token, control_token_file);
let token = match photocraft_automation::security::server_token(supplied.as_deref(), token_file.as_deref()) {
Ok(token) => token,
Err(e) => {
eprintln!("photocraft: cannot configure control authentication: {e}");
return Ok(());
}
};
if let Some(path) = token_file {
eprintln!("photocraft: control token file: {}", path.display());
} else if supplied.is_none() {
eprintln!("photocraft: control token: {token}");
} else {
eprintln!("photocraft: using supplied control token");
}
let workspace = match photocraft_automation::AuthorizedWorkspace::new(automation_read_root.as_deref(), automation_write_root.as_deref()) {
Ok(workspace) => workspace,
Err(error) => {
eprintln!("photocraft: cannot configure automation workspace: {error}");
return Ok(());
}
};
Some((port, token, workspace))
} else {
None
};
// Finder / Dock / Open With deliver files as Apple events, not arguments; catch the one that
// launched us as well as later ones. Lives until the event loop returns.
#[cfg(target_os = "macos")]
let apple_events = apple_events::AppleEvents::install();
#[cfg(target_os = "macos")]
let apple_events = &apple_events;
// Read the main display's ICC profile while the window opens (colour-managed canvas).
let monitor = monitor_profile::detect_async();
// Brush presets load in the background; the app attaches them when they arrive.
let presets = services::presets_dir().map(photocraft_engine::preset_store::open_dir_async);
let mut options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_icon(app_icon())
.with_app_id(APP_ID)
.with_title("PhotoCraft").with_inner_size([1440.0, 900.0]).with_min_inner_size([760.0, 480.0]).with_drag_and_drop(true).with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false),
.with_title("PhotoCraft")
.with_inner_size([1440.0, 900.0])
.with_min_inner_size([760.0, 480.0])
.with_drag_and_drop(true)
.with_fullsize_content_view(true)
.with_titlebar_shown(false)
.with_title_shown(false),
..Default::default()
};
// The adapter's real texture limits (egui asks for 8192 px), so big documents stay on the GPU.
photocraft_ui_egui::gpu_canvas::use_adapter_limits(&mut options.wgpu_options.wgpu_setup);
eframe::run_native(
"Photocraft",
options,
Box::new(move |cc| {
let mut app = PhotocraftApp::new(Session::new(), services::native());
let automation = control.as_ref().map(|(_, _, workspace)| workspace.clone());
let mut services = services::native(automation);
services.preset_store = presets;
let mut app = PhotocraftApp::new(Session::new(), services);
app.integrated_titlebar = cfg!(target_os = "macos");
if let Ok(Some(icc)) = monitor.recv_timeout(std::time::Duration::from_secs(2)) {
app.session.color.monitor_profile = Some(std::sync::Arc::new(icc));
}
// Preferences › Performance › Use Graphics Processor.
if let Some(rs) = cc.wgpu_render_state.clone()
&& std::env::var_os("PHOTOCRAFT_CPU_CANVAS").is_none()
@ -66,23 +134,16 @@ fn main() -> eframe::Result {
{
app.set_wgpu(rs);
}
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
if let Some((port, token, _)) = control {
let rx = control_server::start(port, token, cc.egui_ctx.clone());
app = app.with_control(rx);
}
for f in files {
match std::fs::read(&f) {
Ok(bytes) => {
let name = std::path::Path::new(&f).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(f.clone());
if let Err(e) = app.open_bytes(&name, &bytes) {
eprintln!("photocraft: {f}: {e}");
} else if let Some(st) = app.session.active_mut() {
st.path = Some(f.clone());
}
}
Err(e) => eprintln!("photocraft: {f}: {e}"),
}
#[cfg(target_os = "macos")]
{
app.services.os_events = Some(apple_events.connect(&cc.egui_ctx));
}
// Paths on the command line (Linux/Windows file associations, `photocraft a.psd`).
app.open_paths(&files);
Ok(Box::new(app))
}),
)

View File

@ -0,0 +1,98 @@
//! The main display's ICC profile, for the colour-managed canvas (Edit › Color Settings ›
//! Monitor Profile = `auto`).
//!
//! macOS: `NSScreen.mainScreen.colorSpace.ICCProfileData`, a documented AppKit API, read through
//! `osascript` (AppleScriptObjC) so the app needs no `unsafe` FFI. The query runs on a background
//! thread at launch (about 0.4 s) and the profile is applied when it arrives. Other platforms
//! return `None` (sRGB, or the profile chosen in Color Settings).
use std::sync::mpsc::Receiver;
/// Starts reading the main display's profile in the background.
pub fn detect_async() -> Receiver<Option<Vec<u8>>> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(detect());
});
rx
}
#[cfg(target_os = "macos")]
fn detect() -> Option<Vec<u8>> {
let out = std::process::Command::new("/usr/bin/osascript")
.args([
"-e",
"use framework \"AppKit\"",
"-e",
"return ((current application's NSScreen's mainScreen()'s colorSpace()'s ICCProfileData()'s base64EncodedStringWithOptions:0) as text)",
])
.stderr(std::process::Stdio::null())
.output()
.ok()?;
if !out.status.success() {
return None;
}
let bytes = base64_decode(std::str::from_utf8(&out.stdout).ok()?.trim())?;
// An ICC profile starts with its size and carries `acsp` at offset 36.
(bytes.len() >= 132 && bytes.get(36..40) == Some(b"acsp")).then_some(bytes)
}
#[cfg(not(target_os = "macos"))]
fn detect() -> Option<Vec<u8>> {
None
}
/// Standard base64 (RFC 4648, with padding) → bytes; `None` on any invalid character.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
fn base64_decode(s: &str) -> Option<Vec<u8>> {
let val = |c: u8| -> Option<u32> {
Some(match c {
b'A'..=b'Z' => c - b'A',
b'a'..=b'z' => c - b'a' + 26,
b'0'..=b'9' => c - b'0' + 52,
b'+' => 62,
b'/' => 63,
_ => return None,
} as u32)
};
let s = s.trim_end_matches('=').as_bytes();
let mut out = Vec::with_capacity(s.len() * 3 / 4);
for chunk in s.chunks(4) {
let mut acc = 0u32;
for (i, c) in chunk.iter().enumerate() {
acc |= val(*c)? << (18 - 6 * i);
}
let n = match chunk.len() {
4 => 3,
3 => 2,
2 => 1,
_ => return None,
};
out.extend_from_slice(&acc.to_be_bytes()[1..1 + n]);
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn base64() {
assert_eq!(base64_decode("aGVsbG8=").as_deref(), Some(&b"hello"[..]));
assert_eq!(base64_decode("aGVsbG8h").as_deref(), Some(&b"hello!"[..]));
assert_eq!(base64_decode("aGk=").as_deref(), Some(&b"hi"[..]));
assert_eq!(base64_decode("").as_deref(), Some(&b""[..]));
assert!(base64_decode("a").is_none());
assert!(base64_decode("a$==").is_none());
}
/// The detected profile (when there is one) parses as an RGB profile.
#[test]
fn detected_profile_parses() {
if let Some(bytes) = detect() {
let p = photocraft_engine::color_cmds::profile_from_bytes(&std::sync::Arc::new(bytes)).expect("parses");
assert_eq!(format!("{:?}", p.color_space), "Rgb", "{}", p.description);
}
}
}

View File

@ -3,8 +3,8 @@
use photocraft_codecs::{ChannelLayout, EncodeOptions, Image, SampleType as CS};
use photocraft_color::{ColorMode, SampleType};
use photocraft_doc::{Document, Layer, Size};
use photocraft_geom::Rect;
use photocraft_format::Autosaver;
use photocraft_geom::Rect;
use photocraft_ui_egui::Services;
use std::cell::RefCell;
use std::collections::HashMap;
@ -12,7 +12,29 @@ use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::sync::Arc;
const IMAGE_EXTS: &[&str] = &["psd", "psb", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm"];
/// Everything File › Open reads: PhotoCraft and Photoshop documents, flat images, and Photoshop
/// brushes (.abr) and gradients (.grd), which go to the preset libraries.
const OPEN_EXTS: &[&str] = &[
"pcraft", "psd", "psb", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm",
"dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd",
];
/// File › Save As formats: (filter name, extensions). The filter matching the suggested name's
/// extension comes first, so a .pcraft document saves as .pcraft by default and everything else
/// keeps defaulting to Photoshop.
const SAVE_FILTERS: &[(&str, &[&str])] =
&[("Photoshop", &["psd", "psb"]), ("PhotoCraft", &["pcraft"]), ("PNG", &["png"]), ("JPEG", &["jpg"]), ("TIFF", &["tif"]), ("OpenEXR", &["exr"])];
/// [`SAVE_FILTERS`] with the one for `suggested`'s extension first.
fn save_filters(suggested: &str) -> Vec<(&'static str, &'static [&'static str])> {
let ext = Path::new(suggested).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
let mut v = SAVE_FILTERS.to_vec();
if let Some(i) = v.iter().position(|(_, exts)| exts.contains(&ext.as_str())) {
let f = v.remove(i);
v.insert(0, f);
}
v
}
/// Per-user settings directory: `PHOTOCRAFT_CONFIG_DIR`, else the platform convention
/// (macOS `~/Library/Application Support/Photocraft`, Windows `%APPDATA%\Photocraft`, Linux
@ -35,6 +57,12 @@ fn prefs_file() -> Option<PathBuf> {
config_dir().map(|d| d.join("preferences.json"))
}
/// The brush preset store (one file per preset group plus tip bitmaps; see
/// `photocraft_engine::preset_store`).
pub fn presets_dir() -> Option<PathBuf> {
config_dir().map(|d| d.join("Presets"))
}
fn recovery_dir() -> Option<PathBuf> {
config_dir().map(|d| d.join("Recovery"))
}
@ -49,37 +77,61 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> {
std::fs::rename(&tmp, path).map_err(|e| e.to_string())
}
pub fn native() -> Services {
pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) -> Services {
let savers: Rc<RefCell<HashMap<u64, Autosaver>>> = Rc::default();
let savers2 = savers.clone();
let automation_read = automation.clone().map(|workspace| {
Box::new(move |path: &str| {
let bytes = workspace.read(path).map_err(|error| error.to_string())?;
let name = Path::new(path).file_name().and_then(|name| name.to_str()).unwrap_or(path).to_string();
Ok((name, bytes))
}) as photocraft_ui_egui::AutomationReadFn
});
let automation_write = automation.clone().map(|workspace| {
Box::new(move |path: &str, bytes: &[u8]| workspace.write(path, bytes).map_err(|error| error.to_string())) as photocraft_ui_egui::AutomationWriteFn
});
let automation_command = automation.map(|_| {
Box::new(|id: &str, params: &serde_json::Value| {
photocraft_automation::workspace::authorize_desktop_engine_command(id, params).map_err(|error| error.to_string())
}) as photocraft_ui_egui::AutomationCommandFn
});
Services {
import: Some(Box::new(|name: &str, bytes: &[u8]| photocraft_io::import(name, bytes).map(|r| r.document).map_err(|e| e.to_string()))),
import: Some(Box::new(|name: &str, bytes: &[u8]| {
crate::crash_guard::guard("Open", || photocraft_io::import(name, bytes).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()))
})),
export: Some(Box::new(|doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings| {
let mut opts = photocraft_io::ExportOptions::default();
if let Some(q) = settings.jpeg_quality {
opts.encode.jpeg_quality = q;
}
photocraft_io::export(doc, path, &opts).map(|r| r.bytes).map_err(|e| e.to_string())
crate::crash_guard::guard("Export", || photocraft_io::export(doc, path, &opts).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string()))
})),
pick_open: Some(Box::new(|| {
let path = rfd::FileDialog::new().add_filter("Images", IMAGE_EXTS).pick_file()?;
let path = rfd::FileDialog::new().add_filter("All Formats", OPEN_EXTS).add_filter("PhotoCraft", &["pcraft"]).pick_file()?;
let bytes = std::fs::read(&path).ok()?;
Some((path.to_string_lossy().to_string(), bytes))
})),
pick_save: Some(Box::new(|suggested: &str| {
let p = std::path::Path::new(suggested);
let mut d = rfd::FileDialog::new().add_filter("Photoshop", &["psd", "psb"]).add_filter("PNG", &["png"]).add_filter("JPEG", &["jpg"]).add_filter("TIFF", &["tif"]).add_filter("OpenEXR", &["exr"]);
let mut d = rfd::FileDialog::new();
for (name, exts) in save_filters(suggested) {
d = d.add_filter(name, exts);
}
if let Some(name) = p.file_name() {
d = d.set_file_name(name.to_string_lossy());
}
Some(d.save_file()?.to_string_lossy().to_string())
})),
write: Some(Box::new(|path: &str, bytes: &[u8]| std::fs::write(path, bytes).map_err(|e| e.to_string()))),
automation_read,
automation_write,
automation_command,
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: None,
open_url: Some(Box::new(|url: &str| open::that(url).map_err(|e| e.to_string()))),
clipboard_set_image: Some(Box::new(|w: u32, h: u32, px: &[u8]| {
let mut cb = arboard::Clipboard::new().map_err(|e| e.to_string())?;
cb.set_image(arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(px) }).map_err(|e| e.to_string())
@ -120,6 +172,10 @@ pub fn native() -> Services {
let mut f = std::fs::OpenOptions::new().create(true).append(true).open(path).map_err(|e| e.to_string())?;
f.write_all(text.as_bytes()).map_err(|e| e.to_string())
})),
// Set by main once the Apple-event handlers are connected (macOS).
os_events: None,
// Set by main, which starts loading the store before the window opens.
preset_store: None,
}
}
@ -135,7 +191,13 @@ pub fn import_flat(name: &str, bytes: &[u8]) -> Result<Document, String> {
};
let gray = matches!(img.layout(), ChannelLayout::Gray | ChannelLayout::GrayA);
let cmyk = matches!(img.layout(), ChannelLayout::Cmyk | ChannelLayout::CmykA);
let mode = if gray { ColorMode::Grayscale } else if cmyk { ColorMode::Cmyk } else { ColorMode::Rgb };
let mode = if gray {
ColorMode::Grayscale
} else if cmyk {
ColorMode::Cmyk
} else {
ColorMode::Rgb
};
let target = match mode {
ColorMode::Grayscale => ChannelLayout::GrayA,
ColorMode::Cmyk => ChannelLayout::CmykA,
@ -155,7 +217,7 @@ pub fn import_flat(name: &str, bytes: &[u8]) -> Result<Document, String> {
}
let mut layer = Layer::raster("Background", doc.pixel_format());
let data = conv.to_normalized();
layer.surface_mut().unwrap().write_region(Rect::from_xywh(0, 0, w, h), &data);
layer.surface_mut().ok_or("new raster layer has no pixels")?.write_region(Rect::from_xywh(0, 0, w, h), &data);
doc.layers.push(layer);
Ok(doc)
}

View File

@ -0,0 +1,18 @@
<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"
>
<path d="M21 21H8a2 2 0 0 1-1.42-.587l-3.994-3.999a2 2 0 0 1 0-2.828l10-10a2 2 0 0 1 2.829 0l5.999 6a2 2 0 0 1 0 2.828L12.834 21" />
<path d="m5.082 11.09 8.828 8.828" />
<path d="M2 2h1" />
<path d="M6 2h1" />
<path d="M2 6h1" />
<path d="M4 4h1" />
</svg>

After

Width:  |  Height:  |  Size: 471 B

View File

@ -0,0 +1,16 @@
<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"
>
<path d="M21 21H8a2 2 0 0 1-1.42-.587l-3.994-3.999a2 2 0 0 1 0-2.828l10-10a2 2 0 0 1 2.829 0l5.999 6a2 2 0 0 1 0 2.828L12.834 21" />
<path d="m5.082 11.09 8.828 8.828" />
<path d="M5 1v5" />
<path d="M7.5 3.5h-5" />
</svg>

After

Width:  |  Height:  |  Size: 432 B

31
book/book.toml Normal file
View File

@ -0,0 +1,31 @@
[book]
authors = ["PhotoCraft contributors"]
language = "en"
multilingual = false
src = "src"
title = "PhotoCraft Documentation"
[build]
build-dir = "output"
create-missing = false
[output.html]
git-repository-icon = "fa-github"
git-repository-url = "https://github.com/storytold/photocraft"
edit-url-template = "https://github.com/storytold/photocraft/edit/main/book/{path}"
preferred-dark-theme = "navy"
smart-punctuation = true
[output.html.fold]
enable = true
level = 1
[output.html.search]
enable = true
limit-results = 30
use-boolean-and = true
boost-title = 2
boost-hierarchy = 1
boost-paragraph = 1
expand = true
heading-split-level = 3

34
book/src/README.md Normal file
View File

@ -0,0 +1,34 @@
# PhotoCraft Documentation
PhotoCraft is a native image editor written in Rust. This book is the maintained entry point for installation, architecture, formats, automation, development, and security documentation.
The repository's existing `docs/` directory remains authoritative for detailed design notes, parity reports, UI conventions, release procedures, and the complete control-protocol method reference. The book links to those documents instead of copying them.
## Start here
- Users and evaluators: [Installation](getting-started/installation.md)
- Contributors: [Building](getting-started/building.md) and [Contributing](getting-started/contributing.md)
- Architecture work: [Architecture overview](architecture/overview.md)
- Agents and automation clients: [Automation overview](automation/overview.md)
- Security-sensitive work: [Security overview](security/overview.md) and [Threat model](security/threat-model.md)
## Documentation for coding agents
Coding agents should read the following in order:
1. [`AGENTS.md`](https://github.com/storytold/photocraft/blob/main/AGENTS.md) for repository rules and verification gates.
2. [`README.md`](https://github.com/storytold/photocraft/blob/main/README.md) for product scope and current entry points.
3. [`book/src/architecture/`](architecture/overview.md) for crate boundaries and command flow.
4. [`book/src/automation/`](automation/overview.md) before driving the CLI, control protocol, or MCP server.
5. [`book/src/security/`](security/overview.md) before changing a parser, file I/O, automation, MCP, the control server, command execution, or serialization/deserialization.
Agents must treat document bytes, paths, command parameters, and automation messages as untrusted input. A page that describes a proposed control does not mean that control is implemented.
## Status language
Security pages use four labels consistently:
- **Implemented:** behavior confirmed in current source or tests.
- **Known limitation:** an exposed risk or missing control in current source.
- **Proposed:** a design direction that is not enforced by the current build.
- **Future hardening:** useful follow-up work without an accepted implementation yet.

56
book/src/SUMMARY.md Normal file
View File

@ -0,0 +1,56 @@
# Summary
[PhotoCraft Documentation](README.md)
# Getting started
- [Installation](getting-started/installation.md)
- [Building](getting-started/building.md)
- [Contributing](getting-started/contributing.md)
# Architecture
- [Overview](architecture/overview.md)
- [Crates and dependency layers](architecture/crates.md)
- [Command engine](architecture/command-engine.md)
- [Rendering](architecture/rendering.md)
- [Automation architecture](architecture/automation.md)
# Formats
- [Overview](formats/overview.md)
- [PSD and PSB](formats/psd-psb.md)
- [Raster formats](formats/raster-formats.md)
- [The `.pcraft` format](formats/pcraft.md)
# Automation
- [Overview](automation/overview.md)
- [Command-line interface](automation/cli.md)
- [Control protocol](automation/control-protocol.md)
- [MCP](automation/mcp.md)
- [Automation security model](automation/security-model.md)
# Security
- [Overview](security/overview.md)
- [Threat model](security/threat-model.md)
- [Trust boundaries](security/trust-boundaries.md)
- [Secure file handling](security/secure-file-handling.md)
- [Parser hardening](security/parser-hardening.md)
- [Fuzzing and regression testing](security/fuzzing.md)
- [Automation security](security/automation-security.md)
- [Vulnerability reporting](security/vulnerability-reporting.md)
# Development
- [Testing](development/testing.md)
- [Fuzzing](development/fuzzing.md)
- [Dependencies](development/dependencies.md)
- [Release security](development/release-security.md)
# Project
- [Roadmap](project/roadmap.md)
- [Credits](project/credits.md)
- [Licenses](project/licenses.md)

View File

@ -0,0 +1,26 @@
# Automation architecture
PhotoCraft exposes the same engine through several transports:
```text
photocraft-cli commands/run/batch
|
MCP stdio ---+--> photocraft-automation::Headless --> engine Session
MCP bridge --> BridgeClient --> 127.0.0.1 control server --> UI ControlRequest
JSON-lines stdio/TCP --> rpc::respond --> Headless::handle --> engine Session
```
Headless file operations are implemented in `crates/automation/src/files.rs`; RPC dispatch is in `crates/automation/src/rpc.rs`; MCP tools are registered in `crates/automation/src/server.rs`; and the live client is in `crates/automation/src/bridge.rs`. The desktop TCP listener is `apps/photocraft/src/control_server.rs`.
The control and headless TCP listeners are restricted to loopback addresses. Stdio MCP avoids a listening network socket, but the MCP process still acts with the filesystem permissions of the user who launched it.
Loopback is not authentication. TCP transports require a bearer token and enforce request-byte,
batch-step, and active-connection limits. Remote file methods use an `AuthorizedWorkspace` with
independent read and write directory capabilities; paths are relative to a configured root and
fail closed when the applicable root is absent. General per-method scopes, client identity,
encryption, and operation budgets are not implemented. See the
[Automation security model](../automation/security-model.md) and
[Automation security](../security/automation-security.md) before exposing or extending these
surfaces.

View File

@ -0,0 +1,33 @@
# Command engine
Every user-visible editing action is represented by a command specification in `crates/engine/src/*_cmds.rs` and registered by `crates/engine/src/commands.rs`. A specification supplies an ID, label, menu path, shortcut, parameter documentation, enabled predicate, and `run` implementation.
```text
caller
|
| command id + JSON parameters
v
command registry -- enabled/parameter checks --> command run closure
|
v
Session + one history step
```
The desktop UI, CLI, control protocol, and MCP tools all dispatch into this registry. Pure view/window state is the exception and is handled by the UI shell's command table.
## Input contract
Command parameters and current document state are untrusted at the command boundary. Implementations must return `Err` for missing, wrong-type, out-of-range, or contradictory parameters. Values derived from input must not reach unchecked indexing, division, allocation sizing, or panic paths.
The ignored `crates/engine/tests/panic_hunt.rs` integration test exercises commands with adversarial parameters. A changed command also needs focused graceful-failure tests and should be verified with:
```sh
cargo test -p photocraft-engine
cargo test -p photocraft-engine --test panic_hunt -- --ignored
```
The exact command-creation checklist is maintained in the existing [contribution guide](https://github.com/storytold/photocraft/blob/main/docs/contributing.md).
## Security boundary note
The registry validates command behavior, but current automation transports do not provide per-command capabilities. An MCP or control client that reaches command dispatch can request any exposed command whose runtime `enabled` predicate allows it. Capability enforcement is [proposed automation hardening](../security/automation-security.md), not an implemented control.

View File

@ -0,0 +1,25 @@
# Crates and dependency layers
The layer numbers below describe allowed dependency direction, not runtime privilege. A crate may depend only on lower layers. Standalone format crates remain independent of other workspace crates where specified.
| Layer | Crates | Responsibility |
|---|---|---|
| L0 | `geom`, `cms`, `color`, `raster` | Geometry, ICC processing, color/blend math, pixel formats, and copy-on-write tiles |
| L0 standalone | `psd`, `codecs` | PSD/PSB and flat raster format parsing/encoding |
| L1 | `doc` | Pure-data documents, layers, masks, effects, adjustments, text, vectors, and smart objects |
| L2 | `ops`, `paint`, `algo`, `text`, `vector` | History operations, painting, imaging algorithms, type, paths, and shapes |
| L3 | `compose`, `gpu`, `format` | CPU composition, wgpu composition, and `.pcraft` persistence |
| L4 | `io` | Import/export between the document model and external formats |
| L5 | `engine` | `Session`, command registry, validation, history integration, and inspection |
| L6 | `ui-egui`, `automation` | egui shell, headless automation, MCP, and the live bridge |
| Test support | `testkit` | Shared test helpers |
Workspace applications are:
| Application | Path | Purpose |
|---|---|---|
| PhotoCraft desktop | `apps/photocraft` | eframe/wgpu application and loopback control server |
| PhotoCraft CLI | `apps/photocraft-cli` | Convert, inspect, run, batch, JSON-lines serve, and MCP |
| PhotoCraft web | `apps/photocraft-web` | The UI and engine compiled for WebAssembly |
New crates must be registered in `xtask/src/layers.rs`. Nothing below `ui-egui` may depend on egui, eframe, winit, or rfd. The `psd`, `codecs`, and `cms` crates have additional independence requirements documented in [`AGENTS.md`](https://github.com/storytold/photocraft/blob/main/AGENTS.md).

View File

@ -0,0 +1,33 @@
# Architecture overview
PhotoCraft separates the document engine from presentation and transport. The desktop UI, CLI, JSON control channel, and MCP server reach editing behavior through the command registry rather than maintaining separate implementations.
```text
desktop UI CLI JSON control MCP
| | | |
+-------------+--------------+--------------+
|
command registry
|
engine Session
|
document model + operations + compositors
|
PSD / raster / .pcraft I/O
```
The enforced dependency direction runs from format-independent foundation crates through the document and operation layers to composition, I/O, the engine, and finally UI/automation. `cargo xtask layers` checks this rule.
The complete design narrative and dependency diagram remain in [`docs/architecture.md`](https://github.com/storytold/photocraft/blob/main/docs/architecture.md). That document contains historical design material as well as implemented architecture; confirm behavior in current source when security or compatibility depends on it.
## Core properties
- `photocraft-doc` holds the pure-data document model.
- `photocraft-engine::Session` owns open documents, active state, history, and command dispatch.
- `photocraft-compose` is the CPU reference compositor; `photocraft-gpu` accelerates supported interactive paths.
- `photocraft-io` maps between documents and PSD or flat formats.
- `photocraft-format` reads and writes the native `.pcraft` bundle.
- `photocraft-ui-egui` is a thin shell over engine commands.
- `photocraft-automation` exposes the headless engine and the live-GUI bridge.
See [Crates and dependency layers](crates.md) for the complete workspace map.

View File

@ -0,0 +1,23 @@
# Rendering
PhotoCraft has two compositors with distinct roles:
- `photocraft-compose` is the CPU compositor and correctness oracle used by export and tests.
- `photocraft-gpu` plans and executes supported canvas composition through wgpu.
The interactive canvas uses the GPU path when the planner can represent the document. Unsupported cases fall back to CPU composition. Current documentation identifies Multichannel documents and regions above the GPU texture limit as fallback cases.
Pixel storage in `photocraft-raster` supports runtime sample depth rather than a public 8-bit-only path. Color mode and profiles are document data; color conversion belongs in `photocraft-cms` rather than being treated as implicit sRGB.
## Verification
GPU changes require parity checks against the CPU oracle. UI changes require an actual rendered image, using either the offscreen snapshot example or a control-channel screenshot:
```sh
cargo run -p photocraft-ui-egui --example snapshot
cargo test -p photocraft-gpu
```
Performance-sensitive work should avoid full-surface work per frame, operate per tile, skip empty tiles, and cache by revision. The current commands and benchmark entry points are listed in [`docs/development.md`](https://github.com/storytold/photocraft/blob/main/docs/development.md).
GPU processing is not a security sandbox. Dimensions, decoded buffers, shaders, and command parameters must be validated before resource creation or dispatch.

View File

@ -0,0 +1,26 @@
# Command-line interface
`apps/photocraft-cli` provides the following current subcommands:
| Command | Purpose |
|---|---|
| `convert` | Open one document and export it to another format |
| `info` | Print document size, mode, depth, and layer information as JSON |
| `run` | Open or create a document, execute engine commands, and optionally save |
| `batch` | Apply an action list to files in one input directory |
| `droplet` | Run a PhotoCraft droplet against files or folders |
| `commands` | List command IDs and parameter documentation |
| `mcp` | Start the MCP server on stdio, optionally bridged to the desktop app |
| `serve` | Start the JSON-lines headless server on stdio or loopback TCP |
Use `photocraft-cli --help` as the executable source of truth. A typical bounded command run is:
```sh
photocraft-cli run input.psd \
--cmd image.adjustments.invert \
--out output.png
```
Paths supplied to the current CLI are ordinary operating-system paths. The CLI does not restrict them to a workspace root. Batch output creates the requested output directory, and save operations may create parent directories. Run untrusted action files only in an appropriately isolated account or environment.
CLI parsing reports malformed flags and JSON as errors, but it is not a security policy layer. Engine commands remain responsible for validating their own parameters.

View File

@ -0,0 +1,39 @@
# Control protocol
The desktop control protocol is newline-delimited JSON over a TCP listener bound to `127.0.0.1`. The first frame on every connection must be `auth` with the configured 256-bit bearer token. After authentication, each request carries `id`, `method`, and optional `params`; each reply includes the matching `id` and either a result or error.
```json
{"id":"auth","method":"auth","params":{"token":"<64-hex>"}}
{"id":1,"method":"ui.inspect","params":{}}
```
The current method catalog includes engine execution and discovery, application open/save operations, UI inspection and input, screenshots, and document/control helpers. The authoritative method and parameter tables are maintained in [`docs/control-protocol.md`](https://github.com/storytold/photocraft/blob/main/docs/control-protocol.md).
Implementation is split across:
- `apps/photocraft/src/control_server.rs`: loopback TCP transport;
- `crates/ui-egui/src/control.rs`: live application handlers;
- `crates/automation/src/bridge.rs`: MCP-to-GUI client.
## Current security posture
**Implemented:** the desktop server binds to IPv4 loopback; every TCP connection authenticates
before dispatch; the bridge accepts only loopback-style addresses and authenticates on
connection; encoded request lines are limited to 1 MiB; active connections are limited to 16;
socket I/O and handler waits use timeouts; MCP and headless batches are limited to 256 steps.
JSON-lines replies and encoded MCP tool results are capped at 8 MiB, with aggregate batch reply
budgets. Headless previews have source-pixel, requested-edge, and encoded-PNG ceilings. The
bridge bounds incoming reply frames and screenshot decoding.
Automation file methods require separately configured read and write roots. Request paths are
relative, and absolute, traversal, alternate-separator, device-name, and link-escape paths are
rejected before file effects.
**Known limitations:** the token is a bearer credential and grants the complete non-filesystem
control surface. There is no encryption, client identity, general per-method capability check,
explicit JSON-depth policy, aggregate session-memory accounting, or command-duration/cancellation
budget. Desktop capture/encoding and import/export still need their own operation budgets.
The listener creates one thread per authenticated
active connection, within the connection cap. Trusted one-shot CLI operations and interactive
desktop file pickers retain the launching user's normal filesystem authority.
Use a private token file, enable `--control` only for a trusted local automation session, and never proxy the unencrypted protocol beyond loopback. See [Automation security](../security/automation-security.md) for the remaining gateway work.

View File

@ -0,0 +1,32 @@
# MCP
`photocraft-automation` implements an MCP server over stdio with two backends:
- **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>`.
Start the headless server with:
```sh
photocraft-cli mcp --automation-read-root /work/project --automation-write-root /work/project
```
Bridge to a running desktop application with the same private token file used by the app:
```sh
photocraft-cli mcp --bridge 127.0.0.1:7878 --control-token-file /private/path/control.token
```
Tools cover session/document operations, command discovery and execution, batching, and—when bridged—UI inspection and control. Tool schemas improve correctness but are not authorization boundaries.
## Security notes
Stdio MCP does not open a network listener. Its client receives only the read and write roots
explicitly granted at launch; omitting a root omits that authority. Bridge mode authenticates its
loopback control connection with a bearer token before invoking any control method and uses the
roots configured on the desktop process.
The current server does not issue scoped sessions or distinguish trusted tools from high-impact tools. Authentication proves possession of the token; it does not restrict an authenticated client to document-only access. Do not expose either transport through an untrusted broker, shell, proxy, or remote service without adding an external policy boundary.
Filesystem capabilities are implemented. General tool and method capabilities remain
[proposed](../security/automation-security.md).

View File

@ -0,0 +1,21 @@
# Automation overview
PhotoCraft automation has three entry styles:
1. `photocraft-cli` runs finite convert, inspect, command, batch, and droplet jobs.
2. `photocraft-cli serve` keeps a headless session alive over JSON lines on stdio or loopback TCP.
3. `photocraft-cli mcp` exposes MCP tools over stdio, either against an in-process headless session or through a bridge to the desktop control server.
The desktop application can start an authenticated loopback JSON control server with `photocraft --control <port>`. It exposes engine and UI operations, including pointer/type input and screenshots. Each TCP connection must present the configured bearer token before any operation is dispatched.
All editing routes eventually reach the command registry. Transport behavior is not equivalent, however: only the live bridge can use UI-specific methods, and file operations execute in the process that owns the selected backend.
## Safe operating assumptions
- Prefer one-shot CLI commands or stdio MCP when a TCP listener is unnecessary.
- Run automation with the least-privileged operating-system account practical.
- Do not enable the desktop control port persistently.
- Do not send secrets in command parameters, filenames, document metadata, or logs.
- Treat tool descriptions and document content as untrusted; they do not grant authority.
Current security limitations are summarized in [Automation security model](security-model.md).

View File

@ -0,0 +1,43 @@
# Automation security model
Automation requests are untrusted input with potential effects beyond the active document. Opening, saving, rendering to a path, preferences changes, application control, pointer/type injection, and arbitrary engine commands have different risk levels but currently share broad transport access.
| Control | Current status |
|---|---|
| Loopback-only TCP binding | **Implemented** for desktop control and headless TCP serve |
| Stdio transport | **Implemented** for MCP and headless JSON lines |
| Command-level error handling | **Implemented**, with command-specific tests and `panic_hunt` coverage |
| Control-channel authentication | **Implemented:** 256-bit bearer token required before TCP method dispatch |
| MCP capability scopes | **Partial:** filesystem read/write capabilities only; general tool scopes are absent |
| Allowed read/write roots | **Implemented:** separate launch-time roots, with absent authority failing closed |
| Symlink-safe capability filesystem | **Implemented:** relative operations use held directory capabilities and reject link escapes |
| Request-byte and JSON-depth limits | **Partial:** 1 MiB request-line limit; no explicit JSON-depth policy |
| Batch-step limit | **Implemented:** 256 steps for headless and MCP batches |
| Reply-size limit | **Implemented:** 8 MiB encoded JSON/MCP result; aggregate batch reply budget |
| Headless preview budgets | **Implemented:** 2048-pixel requested edge, 67,108,864 source pixels, 5 MiB encoded PNG |
| Connection/worker limit | **Implemented:** 16 active TCP connections; one worker thread per accepted active connection |
| Security audit events | **Proposed** |
## Gateway status and remaining work
```text
MCP or control client
|
authenticated session
v
security gateway
- general method capabilities (proposed)
- path handles/read-write roots (implemented)
- request budgets
- command policy
- audit events
|
v
command engine / UI shell
```
The current token proves possession of a secret but does not provide general method authorization.
Filesystem handles and a defensive command-path policy are implemented; explicit non-filesystem
capabilities, session-memory/command-duration budgets, and audit events remain. Preview and
reply ceilings do not bound all document operations or desktop screenshot capture. Private token handling, process
isolation, and least-privileged execution are still practical containment measures.

View File

@ -0,0 +1,18 @@
# Dependencies
Cargo dependencies are pinned through `Cargo.lock` and exercised by the normal workspace build, test, clippy, and WebAssembly jobs. Parser and GPU dependencies deserve additional review because they process attacker-controlled data or interface with native drivers.
## Current repository state
No `cargo-deny` policy file, `cargo-audit` job, or equivalent dependency-advisory workflow is present in the current tree. Do not describe dependency auditing as implemented until configuration and CI evidence land.
## Proposed checks
```sh
cargo audit
cargo deny check
```
A future `deny.toml` should be reviewed by maintainers and encode accepted licenses, advisory policy, source rules, and duplicate-version policy. Advisory exceptions need an owner, rationale, compensating control, and expiration/review date. CI tools and GitHub Actions should also be version-pinned and reviewed as dependencies.
Dependency changes should document why a crate is needed, whether it processes untrusted input, unsafe/native code exposure, supported targets, maintenance status, and license compatibility. Avoid replacing source review with a scanner result.

View File

@ -0,0 +1,16 @@
# Fuzzing
Fuzzing procedures, current targets, security properties, and corpus rules are maintained in [Fuzzing and regression testing](../security/fuzzing.md).
The existing fuzz workspaces are intentionally separate from the main Cargo workspace because `cargo-fuzz` uses nightly Rust and sanitizer instrumentation. Example:
```sh
rustup toolchain install nightly
cargo install cargo-fuzz --locked
cd crates/codecs
cargo +nightly fuzz run decode_any
```
Reproduce a crash, minimize it, add a deterministic regression test, and then fix it. A time-limited fuzz run with no findings is evidence of that run only; it is not proof that a parser is secure.
The main GitHub Actions workflow currently runs fuzzing only when manually dispatched with the `fuzz` input. Scheduled or hosted continuous fuzzing remains future work.

View File

@ -0,0 +1,24 @@
# Release security
PhotoCraft's release workflow builds platform packages and a draft GitHub Release from the `release` branch. The authoritative process is [`docs/releasing.md`](https://github.com/storytold/photocraft/blob/main/docs/releasing.md).
## Implemented workflow elements
- platform-specific signing/notarization steps exist for macOS and Windows;
- signing secrets are scoped through the GitHub `release` environment;
- release artifacts include `SHA256SUMS.txt`;
- the final release job receives `contents: write`, while build jobs use narrower access;
- artifacts remain in a draft release until a maintainer publishes them.
Signing is conditional. The workflow can produce unsigned artifacts when signing material is unavailable and emits warnings. Consumers and maintainers must verify the job summary and artifact signature rather than assuming every release is signed.
## Future hardening
- generate a machine-readable SBOM for each release;
- publish provenance/attestations tied to the source commit and workflow;
- make signature state explicit in release metadata;
- verify checksums, signatures, notarization, and installer behavior before publication;
- review action pinning and release-tool provenance;
- document key rotation and compromise response.
Building, signing, drafting, publishing, and deploying are separate states. A successful build must not be reported as a published release.

View File

@ -0,0 +1,27 @@
# Testing
Tests are the gate for source changes. Choose the narrowest relevant package tests during development, then run the repository gates appropriate to the touched layers.
```sh
cargo test -p <package>
cargo clippy -p <package> --all-targets -- -D warnings
cargo xtask layers
```
For changes below or at L6, also run `cargo xtask wasm`. Command changes require the ignored adversarial test:
```sh
cargo test -p photocraft-engine --test panic_hunt -- --ignored
```
Format changes should include valid round trips, exact boundary values, truncation, inconsistent lengths, malformed structures, and allocation/decompression limit cases. Pixel behavior should be exercised at applicable 8-bit, 16-bit, and 32-bit depths.
UI changes need rendered evidence from the offscreen snapshot example or a control-channel screenshot. A successful build or HTTP response is not visual verification.
Documentation changes are verified with:
```sh
mdbook build book
```
Internal Markdown links and every `SUMMARY.md` destination should also be checked. The documentation workflow performs a clean mdBook build in CI.

View File

@ -0,0 +1,13 @@
# Format overview
External bytes enter PhotoCraft through three main paths:
- `photocraft-psd` parses and writes PSD and PSB structures.
- `photocraft-codecs` decodes and encodes flat raster formats.
- `photocraft-format` loads and saves the native `.pcraft` bundle.
`photocraft-io` maps PSD and flat images into the document model and exports documents back to those formats. It is the integration layer; parsing limits remain the responsibility of the underlying format crates.
Formats are a primary security boundary. File extension is not a trust signal, metadata can be attacker-controlled, and small compressed inputs can declare very large decoded outputs. Callers should use the bounded default APIs and preserve errors rather than retrying with unlimited limits.
See [PSD and PSB](psd-psb.md), [Raster formats](raster-formats.md), and [The `.pcraft` format](pcraft.md). Cross-format hardening guidance is in [Parser hardening](../security/parser-hardening.md).

View File

@ -0,0 +1,29 @@
# The `.pcraft` format
`photocraft-format` stores the native lossless document as a ZIP archive or directory bundle. Its main entries are:
```text
manifest.json
tiles/<blake3>.zst
blobs/<blake3>.zst
thumb.png
composite/preview.png
```
The manifest is versioned and describes the document tree. Tiles and blobs are content-addressed. Loaders verify ZIP entry bounds and CRCs; document content also uses stored hashes to detect mismatched or corrupted tile/blob content. Unsupported newer format versions fail explicitly.
## Implemented load limits
Default `LoadOptions` apply these maximums:
| Limit | Default |
|---|---:|
| `manifest.json` | 256 MiB |
| One decompressed blob | 1 GiB |
| Total decompressed tiles and blobs | 16 GiB |
The ZIP reader bounds decompression by the caller-supplied maximum, rejects encrypted entries and unsupported compression methods, validates offsets with checked arithmetic, and rejects CRC or declared-size mismatches. Corruption and property tests cover truncation, byte mutation, missing content, invalid manifests, excessive sizes, and random bytes.
These defaults are intentionally finite but can still be expensive on constrained systems. Future hardening should add context-sensitive budgets and explicit limits for entry count, document complexity, and cumulative work, then test directory bundles against symlink and replacement races.
When fields are added to `photocraft-doc`, `photocraft-format` intentionally requires corresponding manifest and conversion updates so native saves do not silently drop new state.

View File

@ -0,0 +1,23 @@
# PSD and PSB
`photocraft-psd` is a standalone clean-room reader and writer based on Adobe's public PSD specification. It supports PSD version 1 and PSB version 2, preserves unknown blocks for round-trip fidelity, and decodes channel data lazily.
The parser accepts all four PSD compression methods handled by the crate and represents format-level structures before `photocraft-io` maps them to `photocraft-doc`. The current public API and fidelity behavior are documented in [`crates/psd/README.md`](https://github.com/storytold/photocraft/blob/main/crates/psd/README.md).
## Implemented parser protections
Current source enforces several explicit limits:
- document width and height: at most 300,000 pixels each;
- document channels: at most 56;
- layer channels: at most 64;
- decoded channel data: at most 2 GiB for one decode layout;
- ActionDescriptor nesting: at most 64 levels;
- pattern edge: at most 30,000 pixels;
- arithmetic for row counts and decoded lengths uses checked operations.
Malformed-input tests, property tests, and `cargo-fuzz` targets cover the main parser and ActionDescriptor parser. These controls reduce risk but do not prove the parser safe for every hostile file or bound total work across every embedded object.
## Remaining hardening
Priorities include broader PSB and embedded-object fuzz corpora, measuring aggregate allocations and parse time, ensuring every nested length is budgeted, and retaining minimized crashing inputs as regression fixtures. See [Fuzzing and regression testing](../security/fuzzing.md).

View File

@ -0,0 +1,22 @@
# Raster formats
`photocraft-codecs` provides format detection, capability declarations, decode limits, metadata handling, and encoding for flat images. The default build declares read/write support for PNG, JPEG, TIFF, WebP, GIF, BMP, TGA, ICO, Netpbm/PFM, QOI, OpenEXR, and Radiance HDR. AVIF encoding is optional; AVIF decoding is not implemented in the current crate.
Capability details such as sample depth, channel layout, alpha, ICC, EXIF, XMP, DPI, and animation behavior are defined by `crates/codecs/src/format.rs`. Do not infer fidelity from the filename extension alone.
## Implemented decode limits
`DecodeOptions::default()` applies `Limits` before decoded pixel allocation:
| Limit | Default |
|---|---:|
| Maximum width | 262,144 pixels |
| Maximum height | 262,144 pixels |
| Maximum pixel count | 268,435,456 pixels |
| Maximum decoded allocation | 2 GiB |
Format adapters pass compatible allocation limits into PNG, TIFF, WebP, and `image`-based decoders, while other implementations perform explicit checked dimension/buffer validation. Tests cover bomb-like headers, truncation, bit flips, header overwrites, and random data.
`Limits::none()` exists for callers with a deliberate reason to relax policy. It remains bounded by representable memory sizes, but it removes the default resource policy and should not be used for untrusted input.
Animated containers are represented as a single decoded frame by the current codec API. Refer to [`crates/codecs/README.md`](https://github.com/storytold/photocraft/blob/main/crates/codecs/README.md) and source capability declarations for current behavior.

View File

@ -0,0 +1,32 @@
# Building
PhotoCraft uses the Rust workspace at the repository root. Install the stable Rust toolchain, clone the repository, and run commands from that root.
```sh
cargo build --workspace
cargo run --release -p photocraft -- image.psd
cargo run -p photocraft-cli -- --help
```
The standard repository gates are:
```sh
cargo fmt --all -- --check
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
cargo xtask layers
cargo xtask wasm
```
`cargo xtask wasm` requires the `wasm32-unknown-unknown` target. UI work should also be checked using the offscreen snapshot example described in the [development guide](https://github.com/storytold/photocraft/blob/main/docs/development.md).
## Build the documentation
Install mdBook, then build from the repository root:
```sh
cargo install mdbook --locked
mdbook build book
```
The generated site is written to `book/output/` and is not source documentation.

View File

@ -0,0 +1,18 @@
# Contributing
Read [`AGENTS.md`](https://github.com/storytold/photocraft/blob/main/AGENTS.md) and the existing [contribution guide](https://github.com/storytold/photocraft/blob/main/docs/contributing.md) before editing source. Those files define the clean-room policy, crate layering, command checklist, testing requirements, naming, and visual verification rules.
Important repository invariants include:
- user-visible behavior is implemented as an engine command unless it is pure shell/view state;
- public pixel paths cannot assume 8-bit data or sRGB;
- command execution must return errors for adversarial input rather than panic;
- format work includes round-trip, malformed-input, and limit tests;
- L0-L6 changes must continue to compile for WebAssembly;
- third-party assets need a permissive license, a neighboring license file, and an `ATTRIBUTION.md` entry.
## Security-sensitive contributions
Before changing parsers, file I/O, serialization, automation, MCP, the control server, or command dispatch, read the [threat model](../security/threat-model.md) and [trust boundaries](../security/trust-boundaries.md). State whether a change adds an implemented control, documents a limitation, or proposes later hardening. Add a regression test for every fixed security defect when a safe test is possible.
Do not include live exploit payloads, secrets, personal files, or unlicensed samples in issues, commits, fixtures, or fuzz corpora. Follow [Vulnerability reporting](../security/vulnerability-reporting.md) for undisclosed vulnerabilities.

View File

@ -0,0 +1,15 @@
# Installation
PhotoCraft is currently an early-alpha project. Release artifacts, when available, are published through the repository's GitHub Releases page. Verify the release notes and checksums before running downloaded artifacts.
For source builds, continue to [Building](building.md). The desktop application, headless CLI, and web target are separate workspace applications:
- `apps/photocraft`: native desktop application
- `apps/photocraft-cli`: headless CLI, JSON-lines server, and MCP server
- `apps/photocraft-web`: WebAssembly application built with Trunk
Platform packaging and signing details are maintained in the existing [release documentation](https://github.com/storytold/photocraft/blob/main/docs/releasing.md). Signing is conditional on the configured release secrets; the existence of an artifact alone is not proof that it was signed.
## Trusting input files
Treat PSD, PSB, `.pcraft`, raster images, profiles, LUTs, patterns, and embedded document content as potentially malicious. Review [Secure file handling](../security/secure-file-handling.md) before using unknown files in privileged or sensitive environments.

View File

@ -0,0 +1,7 @@
# Credits
PhotoCraft is created and maintained by the ArtCraft team and PhotoCraft contributors. Project ownership, copyright, and licensing remain as stated in the repository's `README.md`, `NOTICE`, and license files. Every merged contribution is credited in the repository history and release notes.
## Documentation
- [Gh0stlyKn1ght](https://github.com/Gh0stlyKn1ght): the initial documentation book and its security documentation (threat model, trust boundaries, parser hardening and fuzzing notes, vulnerability reporting guidance).

View File

@ -0,0 +1,12 @@
# Licenses
PhotoCraft source is dual-licensed under MIT or Apache-2.0 at the user's option. The authoritative terms are the repository files:
- [`LICENSE-MIT`](https://github.com/storytold/photocraft/blob/main/LICENSE-MIT)
- [`LICENSE-APACHE`](https://github.com/storytold/photocraft/blob/main/LICENSE-APACHE)
- [`NOTICE`](https://github.com/storytold/photocraft/blob/main/NOTICE)
- [`ATTRIBUTION.md`](https://github.com/storytold/photocraft/blob/main/ATTRIBUTION.md)
Bundled third-party assets retain their own licenses and attribution requirements. The ArtCraft name, wordmark, and logos in `docs/brand/` are subject to [`docs/brand/LICENSE-brand.txt`](https://github.com/storytold/photocraft/blob/main/docs/brand/LICENSE-brand.txt) and are not covered by the source-code license.
This page summarizes repository locations and does not alter any ownership or license term.

View File

@ -0,0 +1,15 @@
# Roadmap
The live product roadmap, milestone definitions, parity measurements, and current focus are maintained in [`docs/roadmap.md`](https://github.com/storytold/photocraft/blob/main/docs/roadmap.md). Menu coverage is generated into [`docs/parity.md`](https://github.com/storytold/photocraft/blob/main/docs/parity.md) by `cargo xtask parity`.
Security hardening is cross-cutting rather than a replacement for those product milestones. The current security sequence is:
1. threat model and reporting policy;
2. authenticated control sessions;
3. capability-scoped MCP/control access;
4. workspace-rooted filesystem handles;
5. request, batch, render, and connection budgets;
6. broader parser fuzzing and malicious-input regression corpora;
7. dependency policy and release SBOM/provenance.
Only the documentation foundation in item 1 is established by this book. Later controls remain proposed until implemented and tested.

View File

@ -0,0 +1,85 @@
# Automation security
Automation is a privilege boundary because requests can cause filesystem access, application control, UI input, preferences changes, rendering, and command execution.
## Current implementation
- Desktop control binds to `127.0.0.1` and uses one JSON request/reply per line.
- Headless TCP refuses a successfully bound non-loopback address.
- Every desktop-control and headless-TCP connection must authenticate with a 256-bit bearer token before method dispatch.
- Encoded request lines are limited to 1 MiB, active TCP connections to 16, and headless/MCP batches to 256 steps.
- JSON-lines replies and encoded MCP tool results are limited to 8 MiB; retained batch replies
have an aggregate budget and stop later steps when exhausted. The headless stdio JSON-lines
transport enforces the same request/reply ceilings as TCP.
- Headless previews reject requested edges above 2048 pixels and source documents above
67,108,864 pixels before compositing. Full-size requests must fit the edge ceiling. Rendered
PNGs are limited to 5 MiB before base64 encoding or file writes. Trusted-local CLI rendering
retains its existing behavior.
- The MCP bridge bounds incoming replies and does not retry an operation after an oversized
reply. Screenshot decoding has separate dimension, pixel, and allocation ceilings.
- MCP normally uses stdio and can optionally bridge to loopback control TCP.
- The bridge and UI request paths use timeouts.
- Engine commands are expected to reject invalid parameters without panicking.
- Remote filesystem methods use separately granted read and write directory capabilities.
- Automation paths must be relative beneath the applicable root. Absolute paths, parent
traversal, alternate separators, Windows device names, malformed components, and link escapes
are rejected before file effects.
- Engine commands that still use ambient paths fail closed at the automation boundary. Synthetic
UI input reaches the same command policy, and automation-triggered file hooks do not run
user-configured script-event paths.
## Known limitations
- no per-client or per-tool capabilities;
- no general per-client or per-tool capability model beyond filesystem read/write authority;
- no explicit JSON-depth policy, aggregate document/session-memory accounting, compositor
scratch-space accounting, or command-duration/cancellation budget;
- one thread per accepted TCP connection;
- no structured security audit event stream;
- desktop screenshot capture/encoding and document import/export have no automation-specific
operation budgets; the desktop transport limits replies after their creation;
- expensive engine commands are not charged to a session budget.
Reply rejection can happen after a command has changed state. Such errors report that the
operation may have completed. Exhausting a batch reply budget stops subsequent steps, including
when normal command errors would allow the batch to continue; it does not roll back prior edits.
See [Control protocol](../automation/control-protocol.md) for the authoritative protocol reference.
## Proposed session model
The transport now establishes an authenticated connection from a cryptographically random credential before method discovery. A future security gateway should turn that authenticated connection into a short-lived, capability-scoped session.
Remaining capabilities should be explicit and composable, for example:
```text
DocumentRead
DocumentWrite
UiInspect
UiControl
FilesystemRead (implemented as a root capability)
FilesystemWrite (implemented as a separate root capability)
PreferencesWrite
ApplicationControl
```
Names and granularity remain a design proposal. The important property is that a client granted document inspection is not implicitly granted arbitrary file writes, pointer injection, preferences changes, or application shutdown.
## Proposed request budgets
The gateway should enforce and test finite values for:
- request bytes and JSON nesting;
- batch steps and nested/recursive calls;
- concurrent connections and per-client in-flight requests;
- render dimensions and returned payload bytes;
- document pixels, decoded bytes, and aggregate session memory;
- command duration or cancellation where the engine supports it.
Limits should fail with stable errors, be applied before expensive work, and be configurable only inside safe global ceilings. Audit events should capture session identity, capability decision, method/command ID, outcome, duration, and redacted path handle—not file contents, tokens, or secrets.
## Deployment guidance today
Prefer stdio, start control TCP only when needed, grant the narrowest read and write roots, use a
private token file, keep the listener on loopback, use a least-privileged OS account, and isolate
untrusted agents from sensitive files. Do not commit or log bearer tokens. Do not tunnel or proxy
the unencrypted loopback protocol to another host.

View File

@ -0,0 +1,43 @@
# Fuzzing and regression testing
PhotoCraft already has `cargo-fuzz` workspaces next to its primary untrusted-format crates:
| Crate | Targets |
|---|---|
| `crates/psd` | `parse`, `descriptor` |
| `crates/codecs` | `decode_any`, `decode_png`, `decode_jpeg`, `decode_tiff`, `decode_exr` |
| `crates/format` | `load` |
The main CI workflow can run these targets through a manually dispatched fuzz job. It is not currently scheduled continuous fuzzing.
Run a target from its crate directory with nightly Rust and `cargo-fuzz` installed:
```sh
cd crates/psd
cargo fuzz run parse
```
The normal test suites complement libFuzzer with property tests, structured malformed cases, truncation sweeps, bit mutation, bomb-like headers, archive corruption, and command `panic_hunt` coverage.
## Security properties
A useful fuzz target should detect more than memory-safety crashes. Relevant failures include:
- panic or abort;
- integer overflow or out-of-bounds access;
- unreasonable allocation or decompression expansion;
- timeout, infinite loop, or algorithmic complexity spike;
- recursion/stack exhaustion;
- malformed input accepted into an invalid internal state;
- inconsistent results between detection and explicit-format decode;
- corrupted `.pcraft` content accepted despite hash/CRC mismatch.
## Corpus policy
Every confirmed defect should produce a minimized, permanent regression test when disclosure and licensing allow it. Store only synthetic or redistributable inputs. Do not commit personal documents, customer data, credentials, proprietary assets, or an undisclosed weaponized sample.
A regression entry should record the affected parser, expected error class, resource budget, and issue/advisory reference after disclosure. Fuzz crashes must be reproduced under a normal test before the fix is considered durable.
## Future program
Priority additions are PSB-specific structures, WebP, native directory bundles, ICC/LUT/pattern parsers, and embedded smart objects. Continuous scheduled fuzzing and artifact triage remain future work.

View File

@ -0,0 +1,29 @@
# Security overview
PhotoCraft processes complex attacker-controlled files and exposes automation that can act with the user's filesystem permissions. Security is therefore part of parser, engine, I/O, automation, release, and dependency design.
## Current posture
| Area | Implemented protections | Known limitations / future work |
|---|---|---|
| PSD/PSB | Header, channel, decoded-byte, nesting, and pattern limits; malformed/property tests; fuzz targets | Broader hostile corpus, aggregate work budgets, embedded-object coverage |
| Raster codecs | Default dimension/pixel/allocation limits; decoder-specific budgets; malformed/property tests; fuzz targets | Continuous fuzzing and more format-specific semantic cases |
| `.pcraft` | Manifest/blob/total-byte limits; bounded decompression; CRC/hash and version checks; fuzz target | Tighter complexity/entry limits and directory-bundle path-race analysis |
| Engine commands | Error-return contract, focused bad-parameter tests, adversarial `panic_hunt` test | Continue eliminating input-derived panic and exhaustion paths |
| Control TCP | Loopback binding; bearer-token authentication; rooted read/write authority; 1 MiB request lines; 8 MiB replies; 16 active connections; I/O timeouts | No general method capabilities, encryption, client identity, explicit JSON-depth policy, aggregate session-memory/command-duration budgets, or audit trail |
| MCP | Stdio; typed tools; authenticated and bounded bridge; rooted read/write authority; 256-step batches with aggregate reply budgets; headless preview/output ceilings | No general tool authorization scopes, aggregate session-memory accounting, or command cancellation; transport reply caps do not bound desktop screenshot capture |
| Dependencies | Cargo lockfile and normal CI build/test/clippy | No `cargo-audit` or `cargo-deny` job/configuration confirmed in current tree |
| Releases | Checksums and platform signing/notarization paths when secrets are configured | Signing can be absent; no SBOM generation confirmed in current tree |
The table is a snapshot of the current repository, not a guarantee about deployed artifacts. Verify source and release evidence for high-risk use.
## Security priorities
1. Add capability-scoped automation sessions on top of the authenticated transport.
2. Maintain the implemented workspace-handle boundary as file-bearing commands evolve.
3. Extend the preview/reply budgets to JSON complexity, aggregate document/session memory,
desktop capture/import/export, and command duration/cancellation.
4. Expand PSD/PSB, codec, and `.pcraft` fuzzing with regression corpora.
5. Add dependency advisories/license policy checks and release SBOMs.
Start with the [Threat model](threat-model.md), then use the focused pages for file handling, parsers, fuzzing, and automation.

View File

@ -0,0 +1,38 @@
# Parser hardening
Parser safety is broader than “does not crash.” A parser must also bound memory, CPU time, recursion, output expansion, and the number of objects it creates.
## Implemented controls
### PSD/PSB
`photocraft-psd` enforces maximum document dimensions and channel counts, bounds decoded channel data, limits ActionDescriptor nesting and pattern edges, and uses checked arithmetic for key decoded-size calculations. Tests cover malformed headers, truncation, property-generated inputs, and dedicated fuzz targets.
### Raster codecs
`photocraft-codecs` has configurable `Limits` for width, height, pixel count, and decoded allocation. The default policy is applied across enabled decoders and tested with oversized PNG, PNM, JPEG, and TIFF headers plus random/mutated inputs.
### Native format
`photocraft-format` bounds manifest, blob, and total decompressed bytes; validates ZIP structure and decompressed size; verifies CRCs and content hashes; rejects unsupported versions; and property-tests random bundles/manifests.
## Review checklist
For every attacker-controlled count, offset, dimension, or length:
1. Parse into a type that can represent the file field without truncation.
2. Validate semantic limits before allocation.
3. Use `checked_add`, `checked_mul`, or a deliberately safe saturating comparison.
4. Verify a range lies inside the input before slicing.
5. Charge decoded bytes, objects, and recursion against a budget.
6. Return a format-specific error; never panic or loop indefinitely.
7. Test zero, maximum, maximum-plus-one, truncation, and inconsistent declarations.
## Future hardening
- define aggregate parse budgets shared across nested PSD resources and embedded objects;
- add time/work counters for highly fragmented but size-bounded files;
- exercise stack depth beyond ActionDescriptors, including recursive document relationships;
- add format-specific decompression ratio and object-count regression cases;
- benchmark worst-case accepted inputs on constrained machines;
- retain minimized fuzz artifacts with a non-sensitive provenance note.

View File

@ -0,0 +1,57 @@
# Secure file handling
PhotoCraft reads and writes documents in the desktop app, CLI, headless RPC server, and MCP server.
Remote automation paths are untrusted relative names resolved beneath explicitly granted roots.
## Implemented
- Format loaders return structured errors for I/O and malformed data.
- `.pcraft` ZIP loading validates archive offsets, entry sizes, CRCs, content hashes, and configured decompressed-size budgets.
- Raster and PSD loaders apply parser/decode limits before major pixel allocation.
- Native `.pcraft` saves use temporary-file/rename patterns in relevant store paths.
- `AuthorizedWorkspace` holds independent read and write directory capabilities for remote
automation.
- Absolute paths, parent traversal, alternate separators, drive/device/stream prefixes, reserved
Windows device names, empty or dot components, and link escapes are rejected before file
effects.
- Non-existent output files are supported when their parent already exists beneath the write
root. Automation does not create caller-selected directory trees.
- Filesystem-bearing engine commands that have not migrated to capability I/O fail closed for
automation. Trusted local CLI subcommands and user-driven desktop file pickers keep their
intended ambient authority.
## Known limitations
- Directory `.pcraft` bundles have not been documented as resistant to link replacement or time-of-check/time-of-use races.
- Root grants are process launch configuration, not per-document grants.
- General method capabilities and structured filesystem audit events are not implemented.
- Existing hard links inside a granted root are trusted root contents; the path policy cannot
infer how they were created.
## Capability-based design
Prefer an opened directory capability or workspace handle over repeated string sanitization:
```text
AuthorizedWorkspace
- open_document(relative_path)
- save_document(relative_path)
- export_render(relative_path)
```
The handle is created from an explicit launch-time grant, preserves separate read/write
permissions, rejects absolute and escaping paths, and performs operations relative to the held
directory capability. New files require an existing in-root parent because a non-existent target
cannot be canonicalized safely.
## Design requirements
- Canonicalization alone is insufficient: links can change after validation.
- Policy should operate on handles and relative components where platform APIs allow it.
- Reads and writes should have distinct capabilities.
- Replace, create-new, truncate, and directory creation semantics must be explicit.
- Logs should avoid file contents and should minimize disclosure of full private paths.
- Tests should cover `..`, absolute paths, mixed separators, case behavior, symlinks/junctions, non-existent targets, and replacement races on supported platforms.
OS-account, VM, container, or sandbox isolation remains appropriate defense in depth for
untrusted automation and for controls not yet covered by general method capabilities.

View File

@ -0,0 +1,68 @@
# Threat model
This threat model covers the source repository's desktop, CLI, WebAssembly, format, and automation surfaces. It assumes files, paths, JSON, MCP messages, agent decisions, and embedded metadata may be malicious.
## Assets
- confidentiality of user documents, metadata, credentials, and unrelated local files;
- integrity of documents, preferences, filesystem contents, and application state;
- availability of PhotoCraft and the host system;
- integrity and provenance of release artifacts and dependencies;
- predictable command execution and document fidelity.
## Threat actors
- an author of a malicious image, PSD/PSB, `.pcraft`, profile, LUT, pattern, or embedded object;
- a malicious or compromised local process connecting to a loopback server;
- a malicious, compromised, or over-privileged MCP client or automation agent;
- a project/action file that induces unsafe paths or expensive commands;
- a compromised dependency or build/release environment.
## Entry points
```text
untrusted files and bytes
PSD / PSB / raster / .pcraft / ICC / LUT / pattern / embedded content
untrusted control data
CLI arguments / filesystem paths / JSON-lines / MCP requests / agent commands
untrusted build inputs
dependencies / CI actions / release secrets / packaging tools
```
## Primary threat scenarios
### Hostile document parsing
A compact file declares oversized dimensions, excessive channels, nested descriptors, corrupted offsets, or highly expanding compressed data. Desired outcome: reject it before unreasonable allocation or work, return an error, and retain a minimized regression test.
### Filesystem authority abuse
An automation client supplies an absolute path, traversal sequence, symlink, junction, or replaced path to read or overwrite data outside the intended project. Current automation uses caller-supplied OS paths and has no workspace policy. This scenario is a known limitation.
### Local control takeover
Another process on the same host connects to the loopback control server, discovers methods, drives UI input, executes commands, or opens/saves files. Loopback reduces network exposure but does not authenticate the client.
### Resource exhaustion
A client opens many TCP connections, sends an extremely long JSON line, creates a huge batch, requests full-size rendering, or repeatedly invokes expensive commands. Current automation transports lack explicit request, batch, and connection budgets.
### Supply-chain compromise
A vulnerable or malicious crate, action, installer tool, or signing environment changes build behavior or artifacts. Lockfiles and release checksums help reproducibility and verification, but advisory/license policy checks and SBOM output are future work.
## Security objectives
- reject malformed and oversized data before allocation where possible;
- use checked arithmetic and bounded recursion at parser/command boundaries;
- make automation authority explicit, narrow, and revocable;
- keep file access inside authorized roots despite traversal and link tricks;
- bound work per request and per session;
- preserve sufficient non-secret audit data to investigate automation actions;
- turn every fixed security bug into a permanent regression test.
## Non-goals and limits
PhotoCraft is not currently a sandbox for hostile automation or hostile native code. Loopback transport, Rust memory safety, typed schemas, and file-format checks do not replace authentication, authorization, OS isolation, or operational least privilege. This document does not claim those missing controls are present.

View File

@ -0,0 +1,48 @@
# Trust boundaries
The central boundary is crossed when untrusted bytes or requests become internal document state or authorized side effects.
```text
UNTRUSTED
PSD / PSB
PNG / JPEG / TIFF / WebP / EXR and other raster files
.pcraft files and directory bundles
ICC profiles / LUT files / pattern files / embedded objects
CLI arguments / filesystem paths
JSON control messages / MCP requests / agent commands
|
v
SECURITY BOUNDARY
format parsers and decode budgets
automation transports and request validation
filesystem access layer
command registry, enabled checks, and parameter validation
serialization/deserialization
|
v
TRUSTED INTERNAL STATE
document model
engine Session and history
CPU/GPU composition plans
user-authorized filesystem effects
```
“Trusted internal state” means code may rely on validated invariants; it does not mean the data is secret-free or harmless to display. Metadata and text originating in a document remain tainted for logs, UI, filenames, and external commands.
## Boundary ownership
| Boundary | Primary code | Required checks |
|---|---|---|
| PSD/PSB bytes to model | `crates/psd`, then `crates/io` | lengths, dimensions, channels, nesting, decompression, offsets |
| Raster bytes to image | `crates/codecs` | dimensions, pixels, decoded allocation, format-specific limits |
| `.pcraft` to document | `crates/format` | archive bounds, decompressed totals, hashes, schema/version validation |
| JSON to command | `crates/automation`, `crates/engine` | request budget, method/capability, parameter type/range, enabled state |
| Path to filesystem effect | `crates/automation/src/files.rs`, app services | authorized root/handle, link policy, create/replace semantics |
| Document to GPU work | `crates/gpu`, UI planner | texture limits, buffer sizes, supported-plan validation |
Authentication and filesystem read/write capabilities are implemented at these boundaries.
General method capabilities, structured audit events, and broader operation budgets remain open.
## Agent guidance
Agents must review this page before modifying any parser, file I/O, automation, MCP, control server, command execution, or serialization/deserialization path. A caller-provided path or tool request is data, not proof of user authorization.

View File

@ -0,0 +1,9 @@
# Vulnerability reporting
The repository-level [`SECURITY.md`](https://github.com/storytold/photocraft/blob/main/SECURITY.md) is the authoritative reporting policy.
Do not publish exploit details, weaponized files, secrets, or sensitive crash data before maintainers have had a reasonable opportunity to review and coordinate a fix. The repository currently documents no dedicated private security email or private reporting address.
A useful report identifies affected versions or commits, platform/build configuration, impact, exact reproduction steps, expected versus actual behavior, and relevant logs or backtraces. For a malicious test file, first provide a minimal description, hash, format, and safe transfer requirements; do not attach it publicly when it could harm users or contains non-redistributable data.
Security scope includes parsers, import/export, `.pcraft`, profiles/LUTs/patterns, command validation, automation, MCP, the control server, path handling, GPU resource validation, dependencies, CI, packaging, updates, and artifact verification.

4
clippy.toml Normal file
View File

@ -0,0 +1,4 @@
allow-unwrap-in-tests = true
allow-expect-in-tests = true
allow-panic-in-tests = true
allow-indexing-slicing-in-tests = true

View File

@ -11,9 +11,7 @@ fn main() {
let r = Rect::new(0, 0, w, h);
let mut s = Surface::new(PixelFormat::RGBA8);
// Busy deterministic content (gradients plus texture) so edge-aware filters do real work.
let bytes: Vec<u8> = (0..h)
.flat_map(|y| (0..w).flat_map(move |x| [(x % 256) as u8, ((x * 3 + y) / 11 % 256) as u8, ((x ^ y) % 256) as u8, 255]))
.collect();
let bytes: Vec<u8> = (0..h).flat_map(|y| (0..w).flat_map(move |x| [(x % 256) as u8, ((x * 3 + y) / 11 % 256) as u8, ((x ^ y) % 256) as u8, 255])).collect();
s.write_interleaved(r, &bytes);
let cases = vec![
FilterParams::OilPaint { stylization: 4.0, cleanliness: 5.0, scale: 1.0, bristle_detail: 5.0, lighting: true, angle: -60.0, shine: 1.0 },
@ -43,7 +41,16 @@ fn main() {
FilterParams::PathBlur { paths: vec![BlurPath::default()] },
FilterParams::Crystallize { cell_size: 10.0, seed: 0 },
FilterParams::ColorHalftone { max_radius: 8.0, angles: [108.0, 162.0, 90.0, 45.0] },
FilterParams::LightingEffects { lights: vec![Light::default()], gloss: 0.0, metallic: 0.0, exposure: 0.0, ambience: 8.0, texture: TextureChannel::Luminance, height: 50.0, white_is_high: true },
FilterParams::LightingEffects {
lights: vec![Light::default()],
gloss: 0.0,
metallic: 0.0,
exposure: 0.0,
ambience: 8.0,
texture: TextureChannel::Luminance,
height: 50.0,
white_is_high: true,
},
];
for p in cases {
if only.as_deref().is_some_and(|o| !p.label().to_lowercase().contains(&o.to_lowercase())) {

View File

@ -93,14 +93,24 @@ fn main() {
// Puppet warp on a 24 MP layer.
let pw = PuppetWarp {
pins: vec![PuppetPin { src: [1000.0, 2000.0], dst: [1000.0, 2000.0], rotate: None, depth: 0 }, PuppetPin { src: [5000.0, 2000.0], dst: [4800.0, 1200.0], rotate: None, depth: 0 }, PuppetPin { src: [3000.0, 3500.0], dst: [3100.0, 3500.0], rotate: None, depth: 0 }],
pins: vec![
PuppetPin { src: [1000.0, 2000.0], dst: [1000.0, 2000.0], rotate: None, depth: 0 },
PuppetPin { src: [5000.0, 2000.0], dst: [4800.0, 1200.0], rotate: None, depth: 0 },
PuppetPin { src: [3000.0, 3500.0], dst: [3100.0, 3500.0], rotate: None, depth: 0 },
],
mode: PuppetMode::Normal,
density: PuppetDensity::Normal,
expansion: 2.0,
};
let t = Instant::now();
let (solver, v, order) = deform(&s, b, &pw, photocraft_algo::puppet::ITERATIONS);
println!("puppet mesh {} verts / {} tris + ARAP ({} rounds): {:.1} ms", solver.mesh.verts.len(), solver.mesh.tris.len(), photocraft_algo::puppet::ITERATIONS, t.elapsed().as_secs_f64() * 1e3);
println!(
"puppet mesh {} verts / {} tris + ARAP ({} rounds): {:.1} ms",
solver.mesh.verts.len(),
solver.mesh.tris.len(),
photocraft_algo::puppet::ITERATIONS,
t.elapsed().as_secs_f64() * 1e3
);
let dst: Vec<[f64; 2]> = pw.pins.iter().map(|p| p.dst).collect();
let t = Instant::now();
let _ = solver.solve(&dst, &[None; 3], PuppetMode::Normal, 4, Some(&v));

View File

@ -46,7 +46,23 @@ fn main() {
time("camera raw: basic (wb, exposure, tone)", &only, || {
let mut a = px.clone();
develop(&mut a, w, h, &CameraRaw { temperature: 20.0, exposure: 0.3, contrast: 20.0, highlights: -40.0, shadows: 40.0, whites: 10.0, blacks: -10.0, vibrance: 20.0, ..Default::default() }, false);
develop(
&mut a,
w,
h,
&CameraRaw {
temperature: 20.0,
exposure: 0.3,
contrast: 20.0,
highlights: -40.0,
shadows: 40.0,
whites: 10.0,
blacks: -10.0,
vibrance: 20.0,
..Default::default()
},
false,
);
});
time("camera raw: clarity + texture + dehaze", &only, || {
let mut a = px.clone();
@ -56,26 +72,59 @@ fn main() {
let mut a = px.clone();
let mut hs = [0.0; 8];
hs[2] = 30.0;
develop(&mut a, w, h, &CameraRaw { hsl_sat: hs, grade_shadows: Wheel { hue: 220.0, sat: 30.0, lum: 0.0 }, point_curve: vec![[0.0, 10.0], [128.0, 140.0], [255.0, 250.0]], ..Default::default() }, false);
develop(
&mut a,
w,
h,
&CameraRaw {
hsl_sat: hs,
grade_shadows: Wheel { hue: 220.0, sat: 30.0, lum: 0.0 },
point_curve: vec![[0.0, 10.0], [128.0, 140.0], [255.0, 250.0]],
..Default::default()
},
false,
);
});
time("camera raw: sharpen + NR + grain + vignette", &only, || {
let mut a = px.clone();
develop(&mut a, w, h, &CameraRaw { sharpen_amount: 40.0, noise_luminance: 30.0, noise_color: 25.0, grain_amount: 20.0, vignette_amount: -30.0, ..Default::default() }, false);
develop(
&mut a,
w,
h,
&CameraRaw { sharpen_amount: 40.0, noise_luminance: 30.0, noise_color: 25.0, grain_amount: 20.0, vignette_amount: -30.0, ..Default::default() },
false,
);
});
time("lens correction (distortion, CA, vignette, persp)", &only, || {
lens::correct(&s, r, &LensCorrection { profile: Some(lens::generic_profile(24.0)), distortion: 10.0, red_cyan: 20.0, vignette_amount: 30.0, vertical: -20.0, angle: 1.5, ..Default::default() })
lens::correct(
&s,
r,
&LensCorrection {
profile: Some(lens::generic_profile(24.0)),
distortion: 10.0,
red_cyan: 20.0,
vignette_amount: 30.0,
vertical: -20.0,
angle: 1.5,
..Default::default()
},
)
});
time("adaptive wide angle (fisheye, 2 constraints)", &only, || {
let p = WideAngle {
model: WideModel::Fisheye,
focal_length: 12.0,
constraints: vec![Constraint { a: [500.0, 600.0], b: [5500.0, 600.0], orientation: Orientation::Horizontal }, Constraint { a: [700.0, 500.0], b: [700.0, 3500.0], orientation: Orientation::Vertical }],
constraints: vec![
Constraint { a: [500.0, 600.0], b: [5500.0, 600.0], orientation: Orientation::Horizontal },
Constraint { a: [700.0, 500.0], b: [700.0, 3500.0], orientation: Orientation::Vertical },
],
..Default::default()
};
wideangle::apply(&s, r, &p, Interp::Bicubic)
});
time("hdr: merge 3 exposures + local adaptation", &only, || {
let shots: Vec<Vec<[f32; 4]>> = [0.5f32, 1.0, 2.0].iter().map(|k| px.iter().map(|q| [(q[0] * k).min(1.0), (q[1] * k).min(1.0), (q[2] * k).min(1.0), 1.0]).collect()).collect();
let shots: Vec<Vec<[f32; 4]>> =
[0.5f32, 1.0, 2.0].iter().map(|k| px.iter().map(|q| [(q[0] * k).min(1.0), (q[1] * k).min(1.0), (q[2] * k).min(1.0), 1.0]).collect()).collect();
let refs: Vec<&[[f32; 4]]> = shots.iter().map(Vec::as_slice).collect();
let t0 = Instant::now();
let mut m = hdr::merge(w, h, &refs, &MergeOptions { exposures: vec![0.5, 1.0, 2.0], remove_ghosts: true, ghost_base: None, response: None });
@ -85,16 +134,43 @@ fn main() {
time("crop and straighten: detection", &only, || photocraft_algo::scancrop::find_photos(w, h, &px));
time("panorama: 4 × 2 MP register + blend", &only, || {
let (sw, sh) = (1700usize, 1200usize);
let views: Vec<Vec<f32>> = (0..4).map(|k| (0..sw * sh).map(|i| { let q = px[(i / sw + 300) * w + i % sw + 1100 * k]; 0.3 * q[0] + 0.6 * q[1] + 0.1 * q[2] }).collect()).collect();
let views: Vec<Vec<f32>> = (0..4)
.map(|k| {
(0..sw * sh)
.map(|i| {
let q = px[(i / sw + 300) * w + i % sw + 1100 * k];
0.3 * q[0] + 0.6 * q[1] + 0.1 * q[2]
})
.collect()
})
.collect();
let prepared: Vec<panorama::PreparedImage> = views.iter().map(|v| (sw, sh, v.clone(), None)).collect();
let t0 = Instant::now();
let feats = panorama::detect_many(&prepared, 800);
let matches = panorama::match_pairs(&feats);
let al = panorama::align(&feats, &matches, &AlignOptions { layout: Layout::Reposition, focal: None, reference: None, geometric: false });
println!(" register {:.1} ms ({} pairs, placed {})", t0.elapsed().as_secs_f64() * 1000.0, matches.len(), al.as_ref().map_or(0, |a| a.placements.iter().flatten().count()));
let rois: Vec<RoiImage> = views.iter().enumerate().map(|(k, v)| RoiImage { x0: 1100 * k as i32, y0: 0, w: sw, h: sh, ch: 1, px: v.clone(), alpha: vec![1.0; sw * sh] }).collect();
println!(
" register {:.1} ms ({} pairs, placed {})",
t0.elapsed().as_secs_f64() * 1000.0,
matches.len(),
al.as_ref().map_or(0, |a| a.placements.iter().flatten().count())
);
let rois: Vec<RoiImage> = views
.iter()
.enumerate()
.map(|(k, v)| RoiImage { x0: 1100 * k as i32, y0: 0, w: sw, h: sh, ch: 1, px: v.clone(), alpha: vec![1.0; sw * sh] })
.collect();
let cw = 1100 * 3 + sw;
let wts: Vec<Vec<f32>> = (0..4i32).map(|k| (0..sw * sh).map(|i| { let x = (i % sw) as i32 + 1100 * k; if (x / 1100).min(3) == k { 1.0 } else { 0.0 } }).collect()).collect();
let wts: Vec<Vec<f32>> = (0..4i32)
.map(|k| {
(0..sw * sh)
.map(|i| {
let x = (i % sw) as i32 + 1100 * k;
if (x / 1100).min(3) == k { 1.0 } else { 0.0 }
})
.collect()
})
.collect();
let t1 = Instant::now();
let _ = panorama::multiband(cw, sh, &rois, &wts, panorama::blend_levels(cw, sh));
println!(" multiband {:.1} ms", t1.elapsed().as_secs_f64() * 1000.0);

View File

@ -9,11 +9,13 @@ fn main() {
for d in [50usize, 100, 200, 300] {
let n = d + 6;
let (w, h, ch) = (n, n, 4);
let img: Vec<f32> = (0..w * h).flat_map(|i| {
let (x, y) = ((i % w) as f32, (i / w) as f32);
let v = 0.5 + 0.2 * (x * 0.1).sin() * (y * 0.13).cos();
[v, v * 0.8, 0.3, 1.0]
}).collect();
let img: Vec<f32> = (0..w * h)
.flat_map(|i| {
let (x, y) = ((i % w) as f32, (i / w) as f32);
let v = 0.5 + 0.2 * (x * 0.1).sin() * (y * 0.13).cos();
[v, v * 0.8, 0.3, 1.0]
})
.collect();
let src: Vec<f32> = img.iter().map(|v| v * 0.7).collect();
let r = d as f32 / 2.0;
let mask: Vec<bool> = (0..w * h).map(|i| ((i % w) as f32 - n as f32 / 2.0).hypot((i / w) as f32 - n as f32 / 2.0) < r).collect();
@ -23,11 +25,13 @@ fn main() {
// Completion on a region with a margin, like Spot Healing.
let m = d + 16;
let (cw, chh) = (d + 2 * m, d + 2 * m);
let cimg: Vec<f32> = (0..cw * chh).flat_map(|i| {
let (x, y) = ((i % cw) as f32, (i / cw) as f32);
let v = 0.5 + 0.2 * (x * 0.1).sin() * (y * 0.13).cos();
[v, v * 0.8, 0.3, 1.0]
}).collect();
let cimg: Vec<f32> = (0..cw * chh)
.flat_map(|i| {
let (x, y) = ((i % cw) as f32, (i / cw) as f32);
let v = 0.5 + 0.2 * (x * 0.1).sin() * (y * 0.13).cos();
[v, v * 0.8, 0.3, 1.0]
})
.collect();
let c = cw as f32 / 2.0;
let hole: Vec<bool> = (0..cw * chh).map(|i| ((i % cw) as f32 - c).hypot((i / cw) as f32 - c) < r + 2.0).collect();
let t = Instant::now();

View File

@ -97,7 +97,11 @@ pub const GALLERY_CATEGORIES: [&str; 6] = ["Artistic", "Brush Strokes", "Distort
/// One parameter of a gallery filter.
#[derive(Clone, Debug, PartialEq)]
pub enum GalleryParamKind {
Range { min: f32, max: f32, default: f32 },
Range {
min: f32,
max: f32,
default: f32,
},
/// Choices; the first is the default. Stored as the index.
Choice(Vec<&'static str>),
Bool(bool),

View File

@ -673,7 +673,8 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) {
let eb = g("edgeBrightness") / 50.0;
for i in 0..win.c.len() {
let k = clamp01(edge[i] * 6.0);
win.c[i] = if eb >= 0.5 { map(win.c[i], |v| v + (1.0 - v) * k * (eb - 0.5) * 2.0) } else { map(win.c[i], |v| v * (1.0 - k * (0.5 - eb) * 2.0)) };
win.c[i] =
if eb >= 0.5 { map(win.c[i], |v| v + (1.0 - v) * k * (eb - 0.5) * 2.0) } else { map(win.c[i], |v| v * (1.0 - k * (0.5 - eb) * 2.0)) };
}
}
F::AngledStrokes | F::Crosshatch => {
@ -863,10 +864,12 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) {
let r = (g("strokeLength") / 2.0) as usize;
let bal = g("lightDarkBalance") / 100.0;
let l = win.lum();
let mut n: Vec<f32> = (0..win.c.len()).map(|i| {
let (x, y) = win.xy(i);
hash01(x, y, 0, 151)
}).collect();
let mut n: Vec<f32> = (0..win.c.len())
.map(|i| {
let (x, y) = win.xy(i);
hash01(x, y, 0, 151)
})
.collect();
line_blur(&mut n, 1, w, h, direction(e.choice("strokeDirection")), r);
let sd = 0.2887 / ((2 * r + 1) as f32).sqrt();
for i in 0..win.c.len() {
@ -1034,7 +1037,11 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) {
let q = match ty {
"soft" => [p[0] + hc(0) * it * 0.4, p[1] + hc(1) * it * 0.4, p[2] + hc(2) * it * 0.4],
"sprinkles" => {
if hash01(x, y, 3, 211) < it * 0.15 { bg } else { p }
if hash01(x, y, 3, 211) < it * 0.15 {
bg
} else {
p
}
}
"clumped" => map(p, |v| v + (vnoise(x as f32 / 2.0, y as f32 / 2.0, 212) - 0.5) * it * 1.2),
"contrasty" => map(p, |v| (v - 0.5) * (1.0 + it * 2.0) + 0.5 + hc(4) * it * 0.5),
@ -1049,7 +1056,11 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) {
"horizontal" => map(p, |v| v + (vnoise(x as f32 / 25.0, y as f32, 216) - 0.5) * it),
"vertical" => map(p, |v| v + (vnoise(x as f32, y as f32 / 25.0, 217) - 0.5) * it),
"speckle" => {
if hash01(x, y, 6, 211) < it * 0.1 { mix(p, fg, 0.85) } else { p }
if hash01(x, y, 6, 211) < it * 0.1 {
mix(p, fg, 0.85)
} else {
p
}
}
_ => [p[0] + hc(0) * it * 0.8, p[1] + hc(1) * it * 0.8, p[2] + hc(2) * it * 0.8],
};
@ -1191,7 +1202,14 @@ mod tests {
s.fill_rect(Rect::new(0, y, w, y + 3), &[0.9, 0.8, 0.2, 1.0]);
}
let t = std::time::Instant::now();
std::hint::black_box(crate::apply_in(&s, &FilterParams::AddNoise { amount: 10.0, distribution: crate::Distribution::Uniform, monochromatic: true, seed: 1 }, b, b, None, b));
std::hint::black_box(crate::apply_in(
&s,
&FilterParams::AddNoise { amount: 10.0, distribution: crate::Distribution::Uniform, monochromatic: true, seed: 1 },
b,
b,
None,
b,
));
println!("{:>18}: {:>7.0} ms", "(addNoise baseline)", t.elapsed().as_secs_f64() * 1000.0);
let mut total = 0.0;
for f in crate::GalleryFilter::ALL {

View File

@ -779,11 +779,11 @@ fn detail(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, ps: f32) {
let s = gauss(&y, w, h, 1.0 * ps.max(0.5));
let t = p.sharpen_masking / 100.0 * 0.08;
crate::photo_util::par_map(w * h, |i| {
let (x, yy) = (i % w, i / w);
let gx = s[yy * w + (x + 1).min(w - 1)] - s[yy * w + x.saturating_sub(1)];
let gy = s[(yy + 1).min(h - 1) * w + x] - s[yy.saturating_sub(1) * w + x];
smoothstep(t * 0.5, t + 1e-4, gx.hypot(gy))
})
let (x, yy) = (i % w, i / w);
let gx = s[yy * w + (x + 1).min(w - 1)] - s[yy * w + x.saturating_sub(1)];
let gy = s[(yy + 1).min(h - 1) * w + x] - s[yy.saturating_sub(1) * w + x];
smoothstep(t * 0.5, t + 1e-4, gx.hypot(gy))
})
});
let amt = p.sharpen_amount / 100.0 * 1.2;
let det = p.sharpen_detail / 100.0;
@ -916,7 +916,13 @@ mod tests {
let vib = run(CameraRaw { vibrance: 100.0, ..Default::default() });
let spread = |q: [f32; 4]| q[0].max(q[1]).max(q[2]) - q[0].min(q[1]).min(q[2]);
assert!(spread(vib[w * h / 2]) > spread(base[w * h / 2]));
for p in [CameraRaw { clarity: 80.0, ..Default::default() }, CameraRaw { texture: 80.0, ..Default::default() }, CameraRaw { dehaze: 60.0, ..Default::default() }, CameraRaw { dehaze: -60.0, ..Default::default() }, CameraRaw { whites: 50.0, blacks: -50.0, ..Default::default() }] {
for p in [
CameraRaw { clarity: 80.0, ..Default::default() },
CameraRaw { texture: 80.0, ..Default::default() },
CameraRaw { dehaze: 60.0, ..Default::default() },
CameraRaw { dehaze: -60.0, ..Default::default() },
CameraRaw { whites: 50.0, blacks: -50.0, ..Default::default() },
] {
let out = run(p.clone());
assert!(out.iter().all(|q| q[..3].iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v))), "{p:?}");
assert_ne!(out, base);
@ -954,8 +960,16 @@ mod tests {
develop(&mut c, w, h, &CameraRaw { point_curve: vec![[0.0, 30.0], [255.0, 255.0]], curve_shadows: 50.0, ..Default::default() }, false);
assert!(c.iter().all(|q| q[1] >= 30.0 / 255.0 - 1e-4));
// Noise reduction reduces variance; sharpening increases it; grain is deterministic.
let noisy: Vec<[f32; 4]> = (0..w * h).map(|i| { let n = hash01(i as i64, 0, 3) * 0.2; [0.4 + n, 0.4 + n, 0.4 + n, 1.0] }).collect();
let var = |a: &[[f32; 4]]| { let m = mean(a, 1); a.iter().map(|q| (q[1] - m).powi(2)).sum::<f32>() / a.len() as f32 };
let noisy: Vec<[f32; 4]> = (0..w * h)
.map(|i| {
let n = hash01(i as i64, 0, 3) * 0.2;
[0.4 + n, 0.4 + n, 0.4 + n, 1.0]
})
.collect();
let var = |a: &[[f32; 4]]| {
let m = mean(a, 1);
a.iter().map(|q| (q[1] - m).powi(2)).sum::<f32>() / a.len() as f32
};
let mut nr = noisy.clone();
develop(&mut nr, w, h, &CameraRaw { noise_luminance: 80.0, ..Default::default() }, false);
assert!(var(&nr) < var(&noisy) * 0.5);

164
crates/algo/src/erase.rs Normal file
View File

@ -0,0 +1,164 @@
//! Magic Eraser: erase a wand [`Region`] to transparency (or, on a layer with locked transparency,
//! to a colour), tile by tile.
//!
//! Works in the surface's own channels at any depth and in any colour model: only the alpha
//! channel changes (or, under a transparency lock, the colour channels move toward `lock_color`).
use photocraft_color::{read_sample, write_sample};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use crate::selection::Region;
/// Erases `region` from `target` at `amount` (0–1, the tool's opacity), weighted by the selection's
/// coverage. With `lock_color` (native colour channels, e.g. the background colour converted to the
/// surface's model) the pixels are painted toward that colour instead and alpha is kept, as
/// Photoshop does on layers with locked transparency. Returns the damaged rectangle.
pub fn magic_erase(target: &mut Surface, region: &Region, amount: f32, selection: Option<&Surface>, lock_color: Option<&[f32]>) -> Rect {
let amount = if amount.is_finite() { amount.clamp(0.0, 1.0) } else { 0.0 };
let bbox = region.bbox;
let bw = bbox.width() as usize;
if bbox.is_empty() || amount <= 0.0 || region.mask.len() != bw * bbox.height() as usize {
return Rect::EMPTY;
}
let fmt = target.format();
let nc = fmt.mode.color_channels();
let alpha = fmt.alpha.then_some(nc);
// Without alpha and without a lock colour there is nothing an eraser can do.
if alpha.is_none() && lock_color.is_none() {
return Rect::EMPTY;
}
let lock: Option<Vec<f32>> = lock_color.map(|c| (0..nc).map(|i| c.get(i).copied().unwrap_or(0.0)).collect());
let rects: Vec<Rect> = bbox.tiles().map(|tc| tc.rect().intersect(&bbox)).filter(|r| !r.is_empty()).collect();
let src: &Surface = target;
let (sample, bpp) = (fmt.sample, fmt.bytes_per_pixel());
// Each tile is edited as encoded bytes (only the touched samples are decoded), in parallel;
// the results are copied back serially.
let work = |r: Rect| -> Option<(Rect, Vec<u8>)> {
let w = r.width() as usize;
let cov = |x: i32, y: i32| region.mask.get((y - bbox.y0) as usize * bw + (x - bbox.x0) as usize).copied().unwrap_or(0);
// Skip tiles the region doesn't touch.
if !(r.y0..r.y1).any(|y| (r.x0..r.x1).any(|x| cov(x, y) != 0)) {
return None;
}
let mut bytes = src.to_interleaved(r);
let sel = selection.map(|s| (s.channels(), s.read_region(r)));
for y in r.y0..r.y1 {
for x in r.x0..r.x1 {
let c = cov(x, y);
if c == 0 {
continue;
}
let i = (y - r.y0) as usize * w + (x - r.x0) as usize;
let s = sel.as_ref().map_or(1.0, |(sc, v)| v.get(i * sc).copied().unwrap_or(0.0));
let k = f32::from(c) / 255.0 * amount * s;
if k <= 0.0 {
continue;
}
let Some(px) = bytes.get_mut(i * bpp..(i + 1) * bpp) else { continue };
match (&lock, alpha) {
(Some(col), _) => {
for (ch, t) in col.iter().enumerate() {
let v = read_sample(px, sample, ch);
write_sample(px, sample, ch, v + (t - v) * k);
}
}
(None, Some(a)) => {
let v = read_sample(px, sample, a) * (1.0 - k);
if k >= 1.0 || v <= 0.0 {
// Fully erased pixels become the empty pixel, so tiles can be pruned.
px.fill(0);
} else {
write_sample(px, sample, a, v);
}
}
(None, None) => {}
}
}
}
Some((r, bytes))
};
#[cfg(not(target_arch = "wasm32"))]
let done: Vec<(Rect, Vec<u8>)> = {
use rayon::prelude::*;
rects.into_par_iter().filter_map(work).collect()
};
#[cfg(target_arch = "wasm32")]
let done: Vec<(Rect, Vec<u8>)> = rects.into_iter().filter_map(work).collect();
let mut dmg = Rect::EMPTY;
for (r, bytes) in done {
target.write_interleaved(r, &bytes);
dmg = dmg.union(&r);
}
if lock.is_none() {
target.prune();
}
dmg
}
#[cfg(test)]
mod tests {
use super::*;
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_raster::from_rgba;
fn region(bbox: Rect, v: u8) -> Region {
Region { bbox, mask: vec![v; bbox.width() as usize * bbox.height() as usize] }
}
#[test]
fn erases_alpha_in_every_format() {
for mode in [ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Grayscale, ColorMode::Lab] {
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
let fmt = PixelFormat::new(mode, sample, true);
let mut s = Surface::new(fmt);
s.fill_rect(Rect::new(0, 0, 600, 300), &from_rgba(&fmt, [0.2, 0.5, 0.7, 1.0]));
let before = s.pixel(10, 10);
let d = magic_erase(&mut s, &region(Rect::new(0, 0, 300, 300), 255), 1.0, None, None);
assert_eq!(d, Rect::new(0, 0, 300, 300));
assert_eq!(s.rgba(10, 10)[3], 0.0, "{fmt:?}");
assert_eq!(s.pixel(400, 10), before, "outside the region is untouched {fmt:?}");
// Half opacity halves alpha and keeps colour.
let mut h = Surface::new(fmt);
h.fill_rect(Rect::new(0, 0, 10, 10), &from_rgba(&fmt, [0.2, 0.5, 0.7, 1.0]));
magic_erase(&mut h, &region(Rect::new(0, 0, 10, 10), 255), 0.5, None, None);
let p = h.pixel(5, 5);
let a = fmt.mode.color_channels();
assert!((p[a] - 0.5).abs() < 0.01, "{fmt:?} {p:?}");
assert!((p[0] - before[0]).abs() < 1e-6, "colour kept {fmt:?}");
}
}
}
#[test]
fn respects_selection_and_lock() {
let fmt = PixelFormat::RGBA8;
let mut s = Surface::new(fmt);
s.fill_rect(Rect::new(0, 0, 20, 20), &[1.0, 0.0, 0.0, 1.0]);
let mut sel = Surface::new(PixelFormat::GRAY8);
sel.fill_rect(Rect::new(0, 0, 10, 20), &[1.0]);
magic_erase(&mut s, &region(Rect::new(0, 0, 20, 20), 255), 1.0, Some(&sel), None);
assert_eq!(s.rgba(5, 5)[3], 0.0);
assert_eq!(s.rgba(15, 5)[3], 1.0);
// Locked transparency paints the lock colour, alpha kept.
let mut l = Surface::new(fmt);
l.fill_rect(Rect::new(0, 0, 20, 20), &[1.0, 0.0, 0.0, 1.0]);
magic_erase(&mut l, &region(Rect::new(0, 0, 20, 20), 255), 1.0, None, Some(&[1.0, 1.0, 1.0]));
assert_eq!(l.rgba(5, 5), [1.0, 1.0, 1.0, 1.0]);
}
#[test]
fn degenerate_inputs_do_nothing() {
let mut s = Surface::new(PixelFormat::RGBA8);
s.fill_rect(Rect::new(0, 0, 4, 4), &[1.0, 0.0, 0.0, 1.0]);
let orig = s.clone();
// Mask length mismatch, zero/NaN amount, empty box.
assert!(magic_erase(&mut s, &Region { bbox: Rect::new(0, 0, 4, 4), mask: vec![255; 3] }, 1.0, None, None).is_empty());
assert!(magic_erase(&mut s, &region(Rect::new(0, 0, 4, 4), 255), f32::NAN, None, None).is_empty());
assert!(magic_erase(&mut s, &region(Rect::EMPTY, 255), 1.0, None, None).is_empty());
assert_eq!(s, orig);
// No alpha channel and no lock colour: nothing to erase.
let mut g = Surface::new(PixelFormat::GRAY8);
assert!(magic_erase(&mut g, &region(Rect::new(0, 0, 4, 4), 255), 1.0, None, None).is_empty());
}
}

View File

@ -136,7 +136,8 @@ pub fn harris(w: usize, h: usize, img: &[f32], max: usize, min_dist: f32, valid:
let mut out = Vec::new();
for c in cand {
let (gx, gy) = ((c.x / cell) as usize, (c.y / cell) as usize);
let near = (gy.saturating_sub(1)..=(gy + 1).min(gh - 1)).any(|yy| (gx.saturating_sub(1)..=(gx + 1).min(gw - 1)).any(|xx| grid[yy * gw + xx].iter().any(|&(px, py)| (px - c.x).hypot(py - c.y) < min_dist)));
let near = (gy.saturating_sub(1)..=(gy + 1).min(gh - 1))
.any(|yy| (gx.saturating_sub(1)..=(gx + 1).min(gw - 1)).any(|xx| grid[yy * gw + xx].iter().any(|&(px, py)| (px - c.x).hypot(py - c.y) < min_dist)));
if near {
continue;
}
@ -311,7 +312,8 @@ pub fn fit(model: Model, src: &[[f64; 2]], dst: &[[f64; 2]]) -> Option<Homograph
return None;
}
// x' = a·x − b·y + tx, y' = b·x + a·y + ty.
let rows: Vec<(Vec<f64>, f64)> = src.iter().zip(dst).flat_map(|(s, d)| [(vec![s[0], -s[1], 1.0, 0.0], d[0]), (vec![s[1], s[0], 0.0, 1.0], d[1])]).collect();
let rows: Vec<(Vec<f64>, f64)> =
src.iter().zip(dst).flat_map(|(s, d)| [(vec![s[0], -s[1], 1.0, 0.0], d[0]), (vec![s[1], s[0], 0.0, 1.0], d[1])]).collect();
let v = least_squares(&rows, 4)?;
Some(Homography([v[0], -v[1], v[2], v[1], v[0], v[3], 0.0, 0.0, 1.0]))
}
@ -396,7 +398,15 @@ pub fn ransac(model: Model, src: &[[f64; 2]], dst: &[[f64; 2]], iters: usize, th
/// Register `moving` onto `reference` (both `w × h` luminance; optional validity masks for
/// transparent areas). Returns the model mapping `moving` coordinates to `reference`
/// coordinates and the inlier count.
pub fn register(w: usize, h: usize, reference: &[f32], moving: &[f32], valid_ref: Option<&[bool]>, valid_mov: Option<&[bool]>, model: Model) -> Option<(Homography, usize)> {
pub fn register(
w: usize,
h: usize,
reference: &[f32],
moving: &[f32],
valid_ref: Option<&[bool]>,
valid_mov: Option<&[bool]>,
model: Model,
) -> Option<(Homography, usize)> {
let upright = model == Model::Translation;
let min_dist = (w.max(h) as f32 / 60.0).clamp(4.0, 24.0);
let ca = harris(w, h, reference, 600, min_dist, valid_ref);
@ -479,20 +489,26 @@ mod tests {
fn fits_are_exact_on_clean_data() {
let src = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0], [5.0, 3.0]];
let truth = Homography([1.1, 0.05, 3.0, -0.02, 0.95, -2.0, 0.001, 0.0005, 1.0]);
let dst: Vec<[f64; 2]> = src.iter().map(|p| {
let (x, y) = truth.apply(p[0], p[1]);
[x, y]
}).collect();
let dst: Vec<[f64; 2]> = src
.iter()
.map(|p| {
let (x, y) = truth.apply(p[0], p[1]);
[x, y]
})
.collect();
let h = fit(Model::Homography, &src, &dst).unwrap();
for p in &src {
let (a, b) = (h.apply(p[0], p[1]), truth.apply(p[0], p[1]));
assert!((a.0 - b.0).abs() < 1e-6 && (a.1 - b.1).abs() < 1e-6);
}
let sim = Homography([0.9, -0.2, 4.0, 0.2, 0.9, 1.0, 0.0, 0.0, 1.0]);
let dst: Vec<[f64; 2]> = src.iter().map(|p| {
let (x, y) = sim.apply(p[0], p[1]);
[x, y]
}).collect();
let dst: Vec<[f64; 2]> = src
.iter()
.map(|p| {
let (x, y) = sim.apply(p[0], p[1]);
[x, y]
})
.collect();
let h = fit(Model::Similarity, &src, &dst).unwrap();
assert!(h.0.iter().zip(sim.0).all(|(a, b)| (a - b).abs() < 1e-9));
}

View File

@ -35,10 +35,12 @@ fn variable_blur(src: &Image, out: Rect, ctx: &Ctx, smax: f32, sigma: impl Fn(f3
let (ow, oh) = (out.width() as usize, out.height() as usize);
// Level σs: 0, then smax / 2^(LEVELS-1) … smax.
let lv: Vec<f32> = std::iter::once(0.0).chain((0..LEVELS).map(|k| smax / 2f32.powi((LEVELS - 1 - k) as i32))).collect();
let sig: Vec<f32> = (0..ow * oh).map(|i| {
let (x, y) = xy(out, i);
sigma(x as f32 + 0.5, y as f32 + 0.5).clamp(0.0, smax)
}).collect();
let sig: Vec<f32> = (0..ow * oh)
.map(|i| {
let (x, y) = xy(out, i);
sigma(x as f32 + 0.5, y as f32 + 0.5).clamp(0.0, smax)
})
.collect();
// Hat-function weight of level k for σ s.
let weight = |k: usize, s: f32| -> f32 {
let c = lv[k];
@ -146,7 +148,8 @@ pub(crate) fn field(src: &Image, out: Rect, ctx: &Ctx, pins: &[FieldPin]) -> Vec
return src.crop(out);
}
let smax = pins.iter().map(|p| sigma_of(p.blur)).fold(0.0, f32::max);
let pts: Vec<(f32, f32, f32)> = pins.iter().map(|p| (b.x0 as f32 + p.x * b.width() as f32, b.y0 as f32 + p.y * b.height() as f32, sigma_of(p.blur))).collect();
let pts: Vec<(f32, f32, f32)> =
pins.iter().map(|p| (b.x0 as f32 + p.x * b.width() as f32, b.y0 as f32 + p.y * b.height() as f32, sigma_of(p.blur))).collect();
variable_blur(src, out, ctx, smax, |x, y| {
let (mut ws, mut s) = (0.0f32, 0.0f32);
for &(px, py, sg) in &pts {

View File

@ -277,24 +277,24 @@ pub fn merge(w: usize, h: usize, imgs: &[&[[f32; 4]]], opts: &MergeOptions) -> M
if opts.remove_ghosts && p > 1 {
// Weighted variance of per-exposure log radiance (luminance).
let score: Vec<f32> = crate::photo_util::par_map(w * h, |i| {
let (mut s, mut s2, mut sw) = (0.0f64, 0.0f64, 0.0f64);
for j in 0..p {
let q = imgs[j][i];
let l = luma(q);
let wt = hat(l as f64 * 255.0);
if wt < 20.0 {
continue;
}
let lr = (0..3).map(|c| 0.33 * (g_at(&response[c], q[c]) - log_dt[j])).sum::<f64>();
s += wt * lr;
s2 += wt * lr * lr;
sw += wt;
let (mut s, mut s2, mut sw) = (0.0f64, 0.0f64, 0.0f64);
for j in 0..p {
let q = imgs[j][i];
let l = luma(q);
let wt = hat(l as f64 * 255.0);
if wt < 20.0 {
continue;
}
if sw <= 0.0 {
return 0.0;
}
let m = s / sw;
((s2 / sw - m * m).max(0.0)).sqrt() as f32
let lr = (0..3).map(|c| 0.33 * (g_at(&response[c], q[c]) - log_dt[j])).sum::<f64>();
s += wt * lr;
s2 += wt * lr * lr;
sw += wt;
}
if sw <= 0.0 {
return 0.0;
}
let m = s / sw;
((s2 / sw - m * m).max(0.0)).sqrt() as f32
});
for i in 0..w * h {
ghost[i] = if score[i] > 0.35 { 1.0 } else { 0.0 };
@ -459,7 +459,8 @@ mod tests {
let r = est[k] / est[k - 1];
assert!((2.0..8.0).contains(&r), "ratio {r} ({est:?})");
}
let m = merge(w, h, &refs, &MergeOptions { exposures: dts.iter().map(|d| *d as f64).collect(), remove_ghosts: false, ghost_base: None, response: None });
let m =
merge(w, h, &refs, &MergeOptions { exposures: dts.iter().map(|d| *d as f64).collect(), remove_ghosts: false, ghost_base: None, response: None });
// Radiance proportional to the scene: check log-ratios across the 12-stop ramp.
let probe = |x: usize| m.px[(h / 2) * w + x][1] as f64 / sc[(h / 2) * w + x][1] as f64;
let k0 = probe(w / 2);
@ -498,7 +499,12 @@ mod tests {
#[test]
fn mtb_finds_shifts() {
let (w, h) = (128usize, 96usize);
let img: Vec<f32> = (0..w * h).map(|i| { let (x, y) = ((i % w) as f32, (i / w) as f32); 0.5 + 0.4 * ((x * 0.21).sin() * (y * 0.17).cos()) }).collect();
let img: Vec<f32> = (0..w * h)
.map(|i| {
let (x, y) = ((i % w) as f32, (i / w) as f32);
0.5 + 0.4 * ((x * 0.21).sin() * (y * 0.17).cos())
})
.collect();
let (dx, dy) = (5i32, -3i32);
let shifted: Vec<f32> = (0..w * h)
.map(|i| {
@ -515,7 +521,12 @@ mod tests {
fn tone_mapping_methods_produce_display_range() {
let (w, h) = (96usize, 32usize);
let sc = scene(w, h);
for method in [ToneMethod::LocalAdaptation(HdrToning { radius: 7.0, strength: 0.52, ..Default::default() }), ToneMethod::ExposureGamma { exposure: 0.0, gamma: 1.0 }, ToneMethod::HighlightCompression, ToneMethod::EqualizeHistogram] {
for method in [
ToneMethod::LocalAdaptation(HdrToning { radius: 7.0, strength: 0.52, ..Default::default() }),
ToneMethod::ExposureGamma { exposure: 0.0, gamma: 1.0 },
ToneMethod::HighlightCompression,
ToneMethod::EqualizeHistogram,
] {
let mut px: Vec<[f32; 4]> = sc.iter().map(|r| [r[0], r[1], r[2], 1.0]).collect();
tone_map(&mut px, w, h, &method);
assert!(px.iter().all(|q| q[..3].iter().all(|v| (0.0..=1.0).contains(v))));

View File

@ -358,8 +358,7 @@ pub fn complete(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], p: &C
let mut levels = vec![Level { w, h, img: img.to_vec(), hole: hole.to_vec() }];
let mut ext = (bx1 - bx0).max(by1 - by0);
let mut exts = vec![ext];
loop {
let l = levels.last().expect("non-empty");
while let Some(l) = levels.last() {
if ext <= 2 * psz || l.w / 2 < 3 * psz || l.h / 2 < 3 * psz {
break;
}

View File

@ -234,9 +234,9 @@ pub fn auto_scale(lc: &LensCorrection, frame: Rect) -> f64 {
let (x0, y0, x1, y1) = (frame.x0 as f64, frame.y0 as f64, frame.x1 as f64, frame.y1 as f64);
(0..=40).all(|i| {
let t = i as f64 / 40.0;
[(x0 + t * (x1 - x0), y0), (x0 + t * (x1 - x0), y1), (x0, y0 + t * (y1 - y0)), (x1, y0 + t * (y1 - y0))].iter().all(|&(x, y)| {
m.sample(x, y).is_some_and(|s| s.p[0] >= x0 - 0.5 && s.p[0] <= x1 + 0.5 && s.p[1] >= y0 - 0.5 && s.p[1] <= y1 + 0.5)
})
[(x0 + t * (x1 - x0), y0), (x0 + t * (x1 - x0), y1), (x0, y0 + t * (y1 - y0)), (x1, y0 + t * (y1 - y0))]
.iter()
.all(|&(x, y)| m.sample(x, y).is_some_and(|s| s.p[0] >= x0 - 0.5 && s.p[0] <= x1 + 0.5 && s.p[1] >= y0 - 0.5 && s.p[1] <= y1 + 0.5))
})
};
if covers(lc.scale) {
@ -366,7 +366,13 @@ pub fn remap(src: &Surface, frame: Rect, out_area: Rect, edge: EdgeMode, map: &(
let alpha = if sfmt.alpha { bicubic(n_in - 1, s.p).clamp(0.0, 1.0) } else { 1.0 };
let mut col = [0.0f32; 8];
for (c, v) in col.iter_mut().enumerate().take(cc) {
let p = if rgb && c == 0 { s.pr } else if rgb && c == 2 { s.pb } else { s.p };
let p = if rgb && c == 0 {
s.pr
} else if rgb && c == 2 {
s.pb
} else {
s.p
};
let pm = bicubic(c, p);
let straight = if sfmt.alpha { if alpha > 1e-6 { pm / alpha } else { 0.0 } } else { pm };
let g = s.gain;

View File

@ -16,58 +16,60 @@
//! [`apply`] runs a filter over an area, tile by tile (in parallel with
//! rayon on native targets; single-threaded on wasm).
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod artistic;
mod artistic_fx;
mod blur;
mod blur2;
pub mod camera_raw;
pub mod content_aware;
mod denoise;
mod distort;
mod distort2;
pub mod erase;
pub mod exif;
pub mod features;
mod fxutil;
mod gallery;
pub mod hdr;
mod image;
pub mod inpaint;
pub mod lens;
pub mod liquify;
pub mod matting;
mod noise;
mod oil;
mod other;
mod other2;
pub mod paint;
pub mod panorama;
pub mod perspective;
mod photo_util;
mod pixelate;
pub mod poisson;
pub mod puppet;
pub mod pyramid;
pub mod quantize;
mod render;
pub mod render2;
mod stylize2;
mod video;
mod noise;
mod other;
pub mod paint;
pub mod resample;
pub mod selection;
pub mod segment;
pub mod matting;
pub mod quantize;
pub mod stack;
pub mod tone;
pub mod trap;
pub mod transform;
pub mod warp;
pub mod liquify;
pub mod puppet;
pub mod perspective;
pub mod poisson;
pub mod inpaint;
pub mod retouch;
pub mod seam;
pub mod content_aware;
pub mod features;
pub mod pyramid;
pub mod panorama;
pub mod hdr;
pub mod exif;
pub mod lens;
pub mod wideangle;
pub mod scancrop;
pub mod camera_raw;
pub mod vanishing;
mod photo_util;
pub mod seam;
pub mod segment;
pub mod selection;
mod sharpen;
pub mod stack;
mod stylize;
mod stylize2;
pub mod tone;
pub mod transform;
pub mod trap;
pub mod vanishing;
mod video;
pub mod warp;
pub mod wideangle;
pub use artistic::{GALLERY_CATEGORIES, GalleryEffect, GalleryFilter, GalleryParam, GalleryParamKind};
pub use image::{Edge, Image};
@ -117,6 +119,17 @@ pub enum UndefinedAreas {
Transparent,
}
/// Minimum / Maximum › Preserve: the shape the filter grows or shrinks by.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum Preserve {
/// A square of `2·radius + 1` pixels (keeps corners square).
#[default]
Squareness,
/// A disc of `radius` pixels (rounds corners off evenly).
Roundness,
}
/// Spherize mode.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
@ -162,39 +175,100 @@ pub enum PolarMode {
#[serde(tag = "filter", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum FilterParams {
/// Radius = Gaussian standard deviation in pixels (0.1–1000).
GaussianBlur { radius: f32 },
GaussianBlur {
radius: f32,
},
/// Box of `2·radius + 1` pixels.
BoxBlur { radius: f32 },
MotionBlur { angle: f32, distance: f32 },
BoxBlur {
radius: f32,
},
MotionBlur {
angle: f32,
distance: f32,
},
/// Amount 1–100; centre as a fraction of the bounds (0.5, 0.5 = centre).
RadialBlur { amount: f32, method: RadialMethod, center_x: f32, center_y: f32 },
RadialBlur {
amount: f32,
method: RadialMethod,
center_x: f32,
center_y: f32,
},
/// Radius px, threshold in levels.
SurfaceBlur { radius: f32, threshold: f32 },
SurfaceBlur {
radius: f32,
threshold: f32,
},
/// Amount %, radius px, threshold levels.
UnsharpMask { amount: f32, radius: f32, threshold: f32 },
UnsharpMask {
amount: f32,
radius: f32,
threshold: f32,
},
/// Amount %, radius px, noise reduction % (basic: Gaussian unsharp).
SmartSharpen { amount: f32, radius: f32, reduce_noise: f32 },
HighPass { radius: f32 },
SmartSharpen {
amount: f32,
radius: f32,
reduce_noise: f32,
},
HighPass {
radius: f32,
},
/// Amount 0–400 %.
AddNoise { amount: f32, distribution: Distribution, monochromatic: bool, seed: u32 },
Median { radius: f32 },
DustAndScratches { radius: f32, threshold: f32 },
Minimum { radius: f32 },
Maximum { radius: f32 },
Offset { horizontal: i32, vertical: i32, undefined: UndefinedAreas },
Mosaic { cell_size: f32 },
AddNoise {
amount: f32,
distribution: Distribution,
monochromatic: bool,
seed: u32,
},
Median {
radius: f32,
},
DustAndScratches {
radius: f32,
threshold: f32,
},
/// Radius px; `preserve` is the dialog's Preserve (older params default to Squareness).
Minimum {
radius: f32,
#[serde(default)]
preserve: Preserve,
},
Maximum {
radius: f32,
#[serde(default)]
preserve: Preserve,
},
Offset {
horizontal: i32,
vertical: i32,
undefined: UndefinedAreas,
},
Mosaic {
cell_size: f32,
},
/// Angle degrees, height px, amount %.
Emboss { angle: f32, height: f32, amount: f32 },
Emboss {
angle: f32,
height: f32,
amount: f32,
},
FindEdges,
Solarize,
Invert,
Desaturate,
/// Angle −999…999 degrees.
Twirl { angle: f32 },
Twirl {
angle: f32,
},
/// Amount −100…100 %.
Pinch { amount: f32 },
Pinch {
amount: f32,
},
/// Amount −100…100 %.
Spherize { amount: f32, mode: SpherizeMode },
Spherize {
amount: f32,
mode: SpherizeMode,
},
Wave {
generators: u32,
wavelength_min: f32,
@ -206,32 +280,83 @@ pub enum FilterParams {
seed: u32,
},
/// Amount −999…999 %.
Ripple { amount: f32, size: RippleSize },
PolarCoordinates { mode: PolarMode },
Ripple {
amount: f32,
size: RippleSize,
},
PolarCoordinates {
mode: PolarMode,
},
// ---- Pixelate ----
/// Max dot radius px (4–127); screen angles per channel in degrees.
ColorHalftone { max_radius: f32, angles: [f32; 4] },
ColorHalftone {
max_radius: f32,
angles: [f32; 4],
},
/// Cell size px (3–300).
Crystallize { cell_size: f32, seed: u32 },
Crystallize {
cell_size: f32,
seed: u32,
},
Facet,
Fragment,
Mezzotint { kind: MezzotintType, seed: u32 },
Mezzotint {
kind: MezzotintType,
seed: u32,
},
/// Cell size px (3–300); `background` is straight sRGB RGBA.
Pointillize { cell_size: f32, seed: u32, background: [f32; 4] },
Pointillize {
cell_size: f32,
seed: u32,
background: [f32; 4],
},
// ---- Stylize ----
Diffuse { mode: DiffuseMode, seed: u32 },
Diffuse {
mode: DiffuseMode,
seed: u32,
},
/// Size px (2–255), depth (1–255).
Extrude { kind: ExtrudeType, size: f32, depth: f32, level_based: bool, solid_front: bool, mask_incomplete: bool, seed: u32 },
Extrude {
kind: ExtrudeType,
size: f32,
depth: f32,
level_based: bool,
solid_front: bool,
mask_incomplete: bool,
seed: u32,
},
/// Photoshop's Oil Paint units: stylization 0.1–10, cleanliness 0–10, scale 0.1–10,
/// bristle detail 0–10, lighting angle degrees, shine 0–10.
OilPaint { stylization: f32, cleanliness: f32, scale: f32, bristle_detail: f32, lighting: bool, angle: f32, shine: f32 },
OilPaint {
stylization: f32,
cleanliness: f32,
scale: f32,
bristle_detail: f32,
lighting: bool,
angle: f32,
shine: f32,
},
/// Tiles along the shorter side (1–99), max offset % (1–99).
Tiles { count: u32, max_offset: f32, fill: TileFill, foreground: [f32; 4], background: [f32; 4], seed: u32 },
Tiles {
count: u32,
max_offset: f32,
fill: TileFill,
foreground: [f32; 4],
background: [f32; 4],
seed: u32,
},
/// Level 0–255; `upper` traces the side above the level.
TraceContour { level: f32, upper: bool },
Wind { method: WindMethod, from_right: bool, seed: u32 },
TraceContour {
level: f32,
upper: bool,
},
Wind {
method: WindMethod,
from_right: bool,
seed: u32,
},
// ---- Distort ----
/// Scales in % (−999…999; 100 % = 128 px at full map contrast). The map is
@ -245,25 +370,63 @@ pub enum FilterParams {
map: Option<std::sync::Arc<Image>>,
},
/// Curve points `[t, offset]`: t 0–1 top→bottom, offset −1…1 of half the width.
Shear { points: Vec<[f32; 2]>, undefined: UndefinedAreas },
Shear {
points: Vec<[f32; 2]>,
undefined: UndefinedAreas,
},
/// Amount −100…100, ridges 0–20.
ZigZag { amount: f32, ridges: f32, style: ZigZagStyle },
ZigZag {
amount: f32,
ridges: f32,
style: ZigZagStyle,
},
// ---- Render ----
/// Variance 1–64, strength 1–64; colours straight sRGB RGBA.
Fibers { variance: f32, strength: f32, seed: u32, foreground: [f32; 4], background: [f32; 4] },
Fibers {
variance: f32,
strength: f32,
seed: u32,
foreground: [f32; 4],
background: [f32; 4],
},
/// Brightness 10–300 %, centre as a fraction of the bounds.
LensFlare { brightness: f32, center_x: f32, center_y: f32, lens: LensType },
LensFlare {
brightness: f32,
center_x: f32,
center_y: f32,
lens: LensType,
},
/// Gloss/metallic/exposure/ambience −100…100; bump height 0–100.
LightingEffects { lights: Vec<Light>, gloss: f32, metallic: f32, exposure: f32, ambience: f32, texture: TextureChannel, height: f32, white_is_high: bool },
LightingEffects {
lights: Vec<Light>,
gloss: f32,
metallic: f32,
exposure: f32,
ambience: f32,
texture: TextureChannel,
height: f32,
white_is_high: bool,
},
// ---- Noise ----
/// Strength 0–10, the rest 0–100 %.
ReduceNoise { strength: f32, preserve_details: f32, reduce_color_noise: f32, sharpen_details: f32, remove_jpeg_artifact: bool },
ReduceNoise {
strength: f32,
preserve_details: f32,
reduce_color_noise: f32,
sharpen_details: f32,
remove_jpeg_artifact: bool,
},
// ---- Blur ----
/// Radius 0.1–100 px, threshold 0.1–100 levels.
SmartBlur { radius: f32, threshold: f32, quality: BlurQuality, mode: SmartBlurMode },
SmartBlur {
radius: f32,
threshold: f32,
quality: BlurQuality,
mode: SmartBlurMode,
},
/// Iris radius 0–100 px, `blades` 3–8, curvature 0–100, rotation degrees;
/// focal distance 0–255; specular brightness 0–100 / threshold 0–255; noise 0–100.
LensBlur {
@ -285,27 +448,57 @@ pub enum FilterParams {
depth_map: Option<std::sync::Arc<Image>>,
},
/// Radius 5–1000 px.
ShapeBlur { radius: f32, shape: BlurShape },
ShapeBlur {
radius: f32,
shape: BlurShape,
},
// ---- Blur Gallery ----
/// Centre fractions, angle degrees, focus/transition as fractions of the bounds' shorter side.
TiltShift { blur: f32, center_x: f32, center_y: f32, angle: f32, focus: f32, transition: f32 },
IrisBlur { pins: Vec<IrisPin> },
FieldBlur { pins: Vec<FieldPin> },
SpinBlur { pins: Vec<SpinPin> },
PathBlur { paths: Vec<BlurPath> },
TiltShift {
blur: f32,
center_x: f32,
center_y: f32,
angle: f32,
focus: f32,
transition: f32,
},
IrisBlur {
pins: Vec<IrisPin>,
},
FieldBlur {
pins: Vec<FieldPin>,
},
SpinBlur {
pins: Vec<SpinPin>,
},
PathBlur {
paths: Vec<BlurPath>,
},
// ---- Other ----
/// 5×5 kernel (row-major, centre = index 12), divided by `scale`, plus `offset` levels.
Custom { kernel: Vec<f32>, scale: f32, offset: f32 },
HsbHsl { input: HsbModel, output: HsbModel },
Custom {
kernel: Vec<f32>,
scale: f32,
offset: f32,
},
HsbHsl {
input: HsbModel,
output: HsbModel,
},
// ---- Video ----
/// Removes the even (or odd) lines and rebuilds them by interpolation (or duplication).
DeInterlace { eliminate_even: bool, interpolate: bool },
DeInterlace {
eliminate_even: bool,
interpolate: bool,
},
NtscColors,
/// Filter Gallery: a stack of gallery effects applied in order.
FilterGallery { effects: Vec<GalleryEffect> },
FilterGallery {
effects: Vec<GalleryEffect>,
},
}
impl FilterParams {
@ -319,8 +512,8 @@ impl FilterParams {
| FilterParams::SurfaceBlur { radius, .. }
| FilterParams::Median { radius }
| FilterParams::DustAndScratches { radius, .. }
| FilterParams::Minimum { radius }
| FilterParams::Maximum { radius } => Halo::Radius(radius.max(0.0).ceil() as i32 + 1),
| FilterParams::Minimum { radius, .. }
| FilterParams::Maximum { radius, .. } => Halo::Radius(radius.max(0.0).ceil() as i32 + 1),
FilterParams::MotionBlur { distance, .. } => Halo::Radius((distance.abs() / 2.0).ceil() as i32 + 2),
FilterParams::Mosaic { cell_size } => Halo::Radius(cell_size.max(1.0).ceil() as i32 + 1),
FilterParams::Emboss { height, .. } => Halo::Radius(height.abs().ceil() as i32 + 2),
@ -474,8 +667,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 } => other::min_max(src, out, *radius, false),
FilterParams::Maximum { radius } => other::min_max(src, out, *radius, true),
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::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),
@ -508,37 +701,92 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec<f
FilterParams::Mezzotint { kind, seed } => pixelate::mezzotint(src, out, ctx, *kind, *seed),
FilterParams::Pointillize { cell_size, seed, background } => pixelate::pointillize(src, out, ctx, *cell_size, *seed, *background),
FilterParams::Diffuse { mode, seed } => stylize2::diffuse(src, out, ctx, *mode, *seed),
FilterParams::Extrude { kind, size, depth, level_based, solid_front, mask_incomplete, seed } => {
stylize2::extrude(src, out, ctx, &stylize2::ExtrudeSpec { kind: *kind, size: *size, depth: *depth, level_based: *level_based, solid_front: *solid_front, mask_incomplete: *mask_incomplete, seed: *seed })
}
FilterParams::Extrude { kind, size, depth, level_based, solid_front, mask_incomplete, seed } => stylize2::extrude(
src,
out,
ctx,
&stylize2::ExtrudeSpec {
kind: *kind,
size: *size,
depth: *depth,
level_based: *level_based,
solid_front: *solid_front,
mask_incomplete: *mask_incomplete,
seed: *seed,
},
),
FilterParams::OilPaint { stylization, cleanliness, scale, bristle_detail, lighting, angle, shine } => oil::oil_paint(
src,
out,
ctx,
&oil::OilSpec { stylization: *stylization, cleanliness: *cleanliness, scale: *scale, bristle_detail: *bristle_detail, lighting: *lighting, angle: *angle, shine: *shine },
&oil::OilSpec {
stylization: *stylization,
cleanliness: *cleanliness,
scale: *scale,
bristle_detail: *bristle_detail,
lighting: *lighting,
angle: *angle,
shine: *shine,
},
),
FilterParams::Tiles { count, max_offset, fill, foreground, background, seed } => stylize2::tiles(src, out, ctx, *count, *max_offset, *fill, (*foreground, *background), *seed),
FilterParams::Tiles { count, max_offset, fill, foreground, background, seed } => {
stylize2::tiles(src, out, ctx, *count, *max_offset, *fill, (*foreground, *background), *seed)
}
FilterParams::TraceContour { level, upper } => stylize2::trace_contour(src, out, ctx, *level, *upper),
FilterParams::Wind { method, from_right, seed } => stylize2::wind(src, out, ctx, *method, *from_right, *seed),
FilterParams::Displace { horizontal, vertical, stretch, undefined, map } => distort2::displace(src, out, ctx, (*horizontal, *vertical), *stretch, *undefined, map.as_deref()),
FilterParams::Displace { horizontal, vertical, stretch, undefined, map } => {
distort2::displace(src, out, ctx, (*horizontal, *vertical), *stretch, *undefined, map.as_deref())
}
FilterParams::Shear { points, undefined } => distort2::shear(src, out, ctx, points, *undefined),
FilterParams::ZigZag { amount, ridges, style } => distort2::zigzag(src, out, ctx, *amount, *ridges, *style),
FilterParams::Fibers { variance, strength, seed, foreground, background } => render::fibers(src, out, ctx, *variance, *strength, *seed, (*foreground, *background)),
FilterParams::Fibers { variance, strength, seed, foreground, background } => {
render::fibers(src, out, ctx, *variance, *strength, *seed, (*foreground, *background))
}
FilterParams::LensFlare { brightness, center_x, center_y, lens } => render::lens_flare(src, out, ctx, *brightness, (*center_x, *center_y), *lens),
FilterParams::LightingEffects { lights, gloss, metallic, exposure, ambience, texture, height, white_is_high } => render::lighting(
src,
out,
ctx,
&render::LightingSpec { lights, gloss: *gloss, metallic: *metallic, exposure: *exposure, ambience: *ambience, texture: *texture, height: *height, white_is_high: *white_is_high },
&render::LightingSpec {
lights,
gloss: *gloss,
metallic: *metallic,
exposure: *exposure,
ambience: *ambience,
texture: *texture,
height: *height,
white_is_high: *white_is_high,
},
),
FilterParams::ReduceNoise { strength, preserve_details, reduce_color_noise, sharpen_details, remove_jpeg_artifact } => denoise::reduce_noise(
src,
out,
ctx,
&denoise::DenoiseSpec { strength: *strength, preserve: *preserve_details, color: *reduce_color_noise, sharpen: *sharpen_details, jpeg: *remove_jpeg_artifact },
&denoise::DenoiseSpec {
strength: *strength,
preserve: *preserve_details,
color: *reduce_color_noise,
sharpen: *sharpen_details,
jpeg: *remove_jpeg_artifact,
},
),
FilterParams::SmartBlur { radius, threshold, quality, mode } => blur2::smart_blur(src, out, ctx, *radius, *threshold, *quality, *mode),
FilterParams::LensBlur { radius, blades, curvature, rotation, depth, focal_distance, invert_depth, brightness, threshold, noise, distribution, monochromatic, seed, depth_map } => blur2::lens_blur(
FilterParams::LensBlur {
radius,
blades,
curvature,
rotation,
depth,
focal_distance,
invert_depth,
brightness,
threshold,
noise,
distribution,
monochromatic,
seed,
depth_map,
} => blur2::lens_blur(
src,
out,
ctx,
@ -560,7 +808,9 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec<f
},
),
FilterParams::ShapeBlur { radius, shape } => blur2::shape_blur(src, out, ctx, *radius, *shape),
FilterParams::TiltShift { blur, center_x, center_y, angle, focus, transition } => gallery::tilt_shift(src, out, ctx, *blur, (*center_x, *center_y), *angle, *focus, *transition),
FilterParams::TiltShift { blur, center_x, center_y, angle, focus, transition } => {
gallery::tilt_shift(src, out, ctx, *blur, (*center_x, *center_y), *angle, *focus, *transition)
}
FilterParams::IrisBlur { pins } => gallery::iris(src, out, ctx, pins),
FilterParams::FieldBlur { pins } => gallery::field(src, out, ctx, pins),
FilterParams::SpinBlur { pins } => gallery::spin(src, out, ctx, pins),
@ -620,7 +870,15 @@ fn auto_tile(params: &FilterParams) -> i32 {
}
/// [`apply`] / [`apply_in`] with an explicit tile size (tests use it to check tile independence).
pub fn apply_tiled(surface: &Surface, params: &FilterParams, area: Rect, bounds: Rect, selection: Option<&Surface>, tile: i32, extent: Option<Rect>) -> Surface {
pub fn apply_tiled(
surface: &Surface,
params: &FilterParams,
area: Rect,
bounds: Rect,
selection: Option<&Surface>,
tile: i32,
extent: Option<Rect>,
) -> Surface {
let mut out = surface.clone();
if area.is_empty() {
return out;
@ -650,7 +908,11 @@ pub fn apply_tiled(surface: &Surface, params: &FilterParams, area: Rect, bounds:
};
&owned
}
(None, Halo::Bounds) => unreachable!("shared image exists for global filters"),
// `shared` is always read for global filters; read the same image if it weren't.
(None, Halo::Bounds) => {
owned = Image::read(surface, bounds.union(&area));
&owned
}
};
let mut data = kernel(params, src, *t, &ctx);
if let Some(sel) = selection {
@ -670,20 +932,34 @@ pub fn apply_tiled(surface: &Surface, params: &FilterParams, area: Rect, bounds:
}
(*t, data)
};
// Tiles are filtered in groups of about RESULT_BUDGET bytes (at least one per core) and each
// group is written before the next starts, so a huge layer never holds all its results as
// floats at once.
#[cfg(not(target_arch = "wasm32"))]
let results: Vec<(Rect, Vec<f32>)> = {
use rayon::prelude::*;
tiles.par_iter().map(run).collect()
};
let threads = rayon::current_num_threads();
#[cfg(target_arch = "wasm32")]
let results: Vec<(Rect, Vec<f32>)> = tiles.iter().map(run).collect();
for (t, data) in results {
out.write_region(t, &data);
let threads = 1;
let per_tile = (tile.max(1) as usize).pow(2) * fmt.channels() * std::mem::size_of::<f32>();
let group = (RESULT_BUDGET / per_tile.max(1)).max(threads).max(1);
for chunk in tiles.chunks(group) {
#[cfg(not(target_arch = "wasm32"))]
let results: Vec<(Rect, Vec<f32>)> = {
use rayon::prelude::*;
chunk.par_iter().map(run).collect()
};
#[cfg(target_arch = "wasm32")]
let results: Vec<(Rect, Vec<f32>)> = chunk.iter().map(run).collect();
for (t, data) in results {
out.write_region(t, &data);
}
}
out.prune();
out
}
/// Bytes of float filter results held at once by [`apply_tiled`].
const RESULT_BUDGET: usize = 256 << 20;
#[cfg(test)]
mod tests;
#[cfg(test)]

View File

@ -494,7 +494,8 @@ pub fn apply_liquify(src: &Surface, field: &LiquifyField) -> Surface {
if !moved {
return None;
}
let foot = Rect::new(fx0.floor() as i32 - 3, fy0.floor() as i32 - 3, fx1.ceil() as i32 + 3, fy1.ceil() as i32 + 3).intersect(&t.inflate(maxd)).intersect(&b);
let foot =
Rect::new(fx0.floor() as i32 - 3, fy0.floor() as i32 - 3, fx1.ceil() as i32 + 3, fy1.ceil() as i32 + 3).intersect(&t.inflate(maxd)).intersect(&b);
if foot.is_empty() {
return None;
}
@ -665,7 +666,12 @@ impl ProxyImage {
pub fn proxy_rect(&self, r: Rect) -> [usize; 4] {
let s = self.scale;
let f = |v: i32, o: i32| (f64::from(v - o) / s).max(0.0);
[f(r.x0, self.bounds.x0).floor() as usize, f(r.y0, self.bounds.y0).floor() as usize, (f(r.x1, self.bounds.x0).ceil() as usize + 1).min(self.w), (f(r.y1, self.bounds.y0).ceil() as usize + 1).min(self.h)]
[
f(r.x0, self.bounds.x0).floor() as usize,
f(r.y0, self.bounds.y0).floor() as usize,
(f(r.x1, self.bounds.x0).ceil() as usize + 1).min(self.w),
(f(r.y1, self.bounds.y0).ceil() as usize + 1).min(self.h),
]
}
}

View File

@ -77,7 +77,11 @@ impl RefineParams {
}
/// How far the refined mask can extend beyond the original.
pub fn spread(&self) -> i32 {
(if self.uses_image() { self.radius.ceil() as i32 } else { 0 }) + (3.0 * self.smooth_sigma()).ceil() as i32 + (3.0 * self.feather_sigma()).ceil() as i32 + self.shift_px().max(0) + 1
(if self.uses_image() { self.radius.ceil() as i32 } else { 0 })
+ (3.0 * self.smooth_sigma()).ceil() as i32
+ (3.0 * self.feather_sigma()).ceil() as i32
+ self.shift_px().max(0)
+ 1
}
}
@ -163,7 +167,11 @@ pub fn guided_filter_color(guide: &RgbImage, p: &[f32], r: usize, eps: f32) -> V
let (a0, a1, a2) = if det.abs() < 1e-30 {
(0.0, 0.0, 0.0)
} else {
((i00 * cov[0] + i01 * cov[1] + i02 * cov[2]) / det, (i01 * cov[0] + i11 * cov[1] + i12 * cov[2]) / det, (i02 * cov[0] + i12 * cov[1] + i22 * cov[2]) / det)
(
(i00 * cov[0] + i01 * cov[1] + i02 * cov[2]) / det,
(i01 * cov[0] + i11 * cov[1] + i12 * cov[2]) / det,
(i02 * cov[0] + i12 * cov[1] + i22 * cov[2]) / det,
)
};
av[0][i] = a0 as f32;
av[1][i] = a1 as f32;
@ -708,7 +716,13 @@ mod tests {
s.fill_rect(Rect::new(0, 0, 10, 8), &[1.0, 0.0, 0.0, 1.0]);
s.fill_rect(Rect::new(10, 0, 20, 8), &[0.5, 0.0, 0.5, 1.0]);
s.fill_rect(Rect::new(11, 0, 20, 8), &[0.0, 0.0, 1.0, 1.0]);
let mask: Vec<u8> = (0..20 * 8).map(|i| match i % 20 { x if x < 10 => 255, 10 => 128, _ => 0 }).collect();
let mask: Vec<u8> = (0..20 * 8)
.map(|i| match i % 20 {
x if x < 10 => 255,
10 => 128,
_ => 0,
})
.collect();
let a = Region { bbox: Rect::new(0, 0, 20, 8), mask };
let d = decontaminate(&s, &a, 3.0, 100.0);
let p = d.pixel(10, 4);

View File

@ -65,7 +65,9 @@ pub(crate) fn oil_paint(src: &Image, out: Rect, ctx: &Ctx, spec: &OilSpec) -> Ve
gauss_blur_n(&mut efg, ww, wh, 3, tensor_sigma(spec.scale));
// 4. Stroke direction = minor eigenvector (along edges).
let tangent: Vec<(f32, f32)> = efg
.chunks_exact(3)
.as_chunks::<3>()
.0
.iter()
.map(|t| {
let (e, f, g) = (t[0], t[1], t[2]);
let l1 = 0.5 * (e + g + ((e - g) * (e - g) + 4.0 * f * f).sqrt());

View File

@ -3,7 +3,78 @@
use photocraft_geom::Rect;
use crate::image::{Edge, Image};
use crate::{Ctx, UndefinedAreas};
use crate::{Ctx, Preserve, UndefinedAreas};
/// Minimum or Maximum with the dialog's Preserve option: a square (`min_max`) or a disc
/// (`min_max_round`).
pub(crate) fn min_max_preserve(src: &Image, out: Rect, radius: f32, max: bool, preserve: Preserve) -> Vec<f32> {
match preserve {
Preserve::Squareness => min_max(src, out, radius, max),
Preserve::Roundness => min_max_round(src, out, radius, max),
}
}
/// Minimum or Maximum over a disc (Preserve: Roundness): every offset with
/// `dx² + dy² ≤ radius²`, so corners and straight edges move by the same distance. Each row of
/// the disc is a horizontal window, taken with the van Herk / Gil-Werman running extreme
/// (constant work per pixel whatever the window), so the cost is O(radius) per pixel.
pub(crate) fn min_max_round(src: &Image, out: Rect, radius: f32, max: bool) -> Vec<f32> {
let n = src.ch;
let r = radius.max(0.0);
let ri = r.floor() as i32;
if ri == 0 {
return src.crop(out);
}
let w = out.width() as usize;
let pick = |a: f32, b: f32| if max { a.max(b) } else { a.min(b) };
let mut res = vec![if max { f32::MIN } else { f32::MAX }; w * out.height() as usize * n];
let (mut row, mut ext) = (Vec::new(), Vec::new());
for dy in -ri..=ri {
// Half-width of the disc on this row (the epsilon keeps exact squares, e.g. r 5, dy 3 → 4).
let hw = ((r * r - (dy * dy) as f32).max(0.0).sqrt() + 1e-4).floor() as i32;
for y in out.y0..out.y1 {
let o = (y - out.y0) as usize * w * n;
for c in 0..n {
row.clear();
row.extend((out.x0 - hw..out.x1 + hw).map(|x| src.get(x, y + dy, c)));
running_extreme(&row, (2 * hw + 1) as usize, max, &mut ext);
for (x, v) in ext.iter().enumerate() {
let i = o + x * n + c;
res[i] = pick(res[i], *v);
}
}
}
}
res
}
/// `out[i]` = extreme of `v[i..i + k]` for every full window (van Herk / Gil-Werman: prefix
/// extremes within blocks of `k` plus suffix extremes within blocks, one lookup of each).
pub(crate) fn running_extreme(v: &[f32], k: usize, max: bool, out: &mut Vec<f32>) {
out.clear();
let n = v.len();
if k <= 1 {
out.extend_from_slice(v);
return;
}
if n < k {
return;
}
let pick = |a: f32, b: f32| if max { a.max(b) } else { a.min(b) };
let mut pre = v.to_vec();
let mut suf = v.to_vec();
for i in 1..n {
if i % k != 0 {
pre[i] = pick(pre[i - 1], v[i]);
}
}
for i in (0..n - 1).rev() {
if (i + 1) % k != 0 {
suf[i] = pick(suf[i + 1], v[i]);
}
}
out.extend((0..=n - k).map(|i| pick(suf[i], pre[i + k - 1])));
}
/// Minimum (spreads dark/transparent areas) or Maximum over a square.
pub(crate) fn min_max(src: &Image, out: Rect, radius: f32, max: bool) -> Vec<f32> {

View File

@ -12,7 +12,8 @@ use crate::{Ctx, HsbModel};
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);
let k: Vec<(i32, i32, f32)> = (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 k: Vec<(i32, i32, f32)> =
(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 mut res = src.crop(out);

View File

@ -111,6 +111,16 @@ fn composite_area(
}
/// Gradient tool over `area` (the selection bounds or canvas).
#[allow(clippy::too_many_arguments)]
/// Per-pixel dither noise in [0, 1) from a position hash (decorrelated, no visible pattern).
fn dither_noise(x: i32, y: i32) -> f32 {
let mut h = (x as u32).wrapping_mul(0x9E37_79B1) ^ (y as u32).wrapping_mul(0x85EB_CA77);
h ^= h >> 15;
h = h.wrapping_mul(0x2C1B_3C6D);
h ^= h >> 12;
f32::from((h & 0xFFFF) as u16) / 65535.0
}
#[allow(clippy::too_many_arguments)]
pub fn paint_gradient(
s: &mut Surface,
@ -122,6 +132,7 @@ pub fn paint_gradient(
reverse: bool,
opacity: f32,
blend_mode: BlendMode,
dither: bool,
selection: Option<&Surface>,
) {
composite_area(
@ -131,7 +142,15 @@ pub fn paint_gradient(
|x, y| opacity * selection.map_or(1.0, |m| m.sample_channel(x, y, 0)),
|x, y| {
let t = tool_gradient_t(shape, from, to, x as f32 + 0.5, y as f32 + 0.5);
sample_stops(stops, if reverse { 1.0 - t } else { t })
let mut c = sample_stops(stops, if reverse { 1.0 - t } else { t });
if dither {
// One quantisation step of monochromatic noise breaks 8-bit banding without speckle.
let n = (dither_noise(x, y) - 0.5) / 255.0;
for ch in c.iter_mut().take(3) {
*ch = (*ch + n).clamp(0.0, 1.0);
}
}
c
},
false,
);
@ -150,6 +169,23 @@ pub fn bucket_fill(
color: [f32; 4],
opacity: f32,
selection: Option<&Surface>,
) -> bool {
bucket_fill_src(s, area, seed, tolerance, contiguous, anti_alias, opacity, selection, |_, _| color)
}
/// Like [`bucket_fill`] but the fill colour comes from `src(x, y)` — e.g. a pattern sampled at the
/// canvas position — so the Paint Bucket can fill with a pattern as well as a solid colour.
#[allow(clippy::too_many_arguments)]
pub fn bucket_fill_src(
s: &mut Surface,
area: Rect,
seed: (i32, i32),
tolerance: f32,
contiguous: bool,
anti_alias: bool,
opacity: f32,
selection: Option<&Surface>,
src: impl Fn(i32, i32) -> [f32; 4] + Sync,
) -> bool {
if !area.contains(seed.0, seed.1) {
return false;
@ -159,7 +195,7 @@ pub fn bucket_fill(
drop(img);
// Composite only over the filled region's box.
let b = region.bbox;
composite_area(s, b, BlendMode::Normal, |x, y| region.at(x, y) * opacity * selection.map_or(1.0, |m| m.sample_channel(x, y, 0)), |_, _| color, false);
composite_area(s, b, BlendMode::Normal, |x, y| region.at(x, y) * opacity * selection.map_or(1.0, |m| m.sample_channel(x, y, 0)), src, false);
true
}
@ -185,7 +221,19 @@ mod tests {
let a = Rect::new(0, 0, 11, 2);
let mut sel = Surface::new(PixelFormat::GRAY8);
sel.fill_rect(Rect::new(0, 0, 11, 1), &[1.0]);
paint_gradient(&mut s, a, (0.5, 0.0), (10.5, 0.0), GradientShape::Linear, &[(0.0, [0.0, 0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 1.0, 1.0])], false, 1.0, BlendMode::Normal, Some(&sel));
paint_gradient(
&mut s,
a,
(0.5, 0.0),
(10.5, 0.0),
GradientShape::Linear,
&[(0.0, [0.0, 0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 1.0, 1.0])],
false,
1.0,
BlendMode::Normal,
false,
Some(&sel),
);
assert!(s.pixel(0, 0)[0] < 0.01 && s.pixel(10, 0)[0] > 0.99);
assert!((s.pixel(5, 0)[0] - 0.5).abs() < 0.01);
assert_eq!(s.pixel(5, 1)[3], 0.0, "outside the selection");

View File

@ -313,7 +313,13 @@ impl Placement {
pub fn scaled(&self, k: f64) -> Placement {
let s = Homography([k, 0.0, 0.0, 0.0, k, 0.0, 0.0, 0.0, 1.0]);
let si = Homography([1.0 / k, 0.0, 0.0, 0.0, 1.0 / k, 0.0, 0.0, 0.0, 1.0]);
Placement { projection: self.projection, focal: self.focal * k, center: [self.center[0] * k, self.center[1] * k], k1: self.k1, h: s.mul(&self.h).mul(&si) }
Placement {
projection: self.projection,
focal: self.focal * k,
center: [self.center[0] * k, self.center[1] * k],
k1: self.k1,
h: s.mul(&self.h).mul(&si),
}
}
/// Shifted in the panorama.
@ -610,7 +616,7 @@ fn align_layout(feats: &[ImageFeatures], matches: &[PairMatch], opts: &AlignOpti
let mp = motion.n_params();
let refine_focal = projection != Projection::Plane;
let n_glob = usize::from(opts.geometric) + usize::from(refine_focal);
let mut params: Vec<f64> = movable.iter().flat_map(|&i| motion.to_params(&tf[i].expect("placed"))).collect();
let mut params: Vec<f64> = movable.iter().filter_map(|&i| tf[i].as_ref()).flat_map(|t| motion.to_params(t)).collect();
if opts.geometric {
params.push(0.0);
}
@ -1099,7 +1105,7 @@ pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec<f32>], le
// Level sizes of the panorama.
let mut sizes = vec![(w, h)];
for _ in 1..levels {
let (pw, ph) = *sizes.last().expect("non-empty");
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();
@ -1189,7 +1195,8 @@ pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec<f32>], le
}
}
// Collapse.
let mut cur = acc.pop().expect("levels ≥ 1");
// `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];
@ -1337,7 +1344,8 @@ mod tests {
#[test]
fn placement_round_trips_all_projections() {
for projection in [Projection::Plane, Projection::Cylinder, Projection::Sphere] {
let p = Placement { projection, focal: 400.0, center: [200.0, 150.0], k1: 0.05, h: Homography([0.98, -0.1, 30.0, 0.1, 0.98, -12.0, 0.0, 0.0, 1.0]) };
let p =
Placement { projection, focal: 400.0, center: [200.0, 150.0], k1: 0.05, h: Homography([0.98, -0.1, 30.0, 0.1, 0.98, -12.0, 0.0, 0.0, 1.0]) };
for (x, y) in [(0.0, 0.0), (390.0, 20.0), (123.0, 280.0)] {
let (u, v) = p.forward(x, y);
let (bx, by) = p.inverse(u, v).unwrap();
@ -1365,7 +1373,13 @@ mod tests {
}
}
imgs.push(RoiImage { x0, y0: 0, w, h, ch: 1, px, alpha: vec![1.0; w * h] });
pls.push(Placement { projection: Projection::Plane, focal: 200.0, center: [100.0, 50.0], k1: 0.0, h: Homography([1.0, 0.0, 100.0 + x0 as f64, 0.0, 1.0, 50.0, 0.0, 0.0, 1.0]) });
pls.push(Placement {
projection: Projection::Plane,
focal: 200.0,
center: [100.0, 50.0],
k1: 0.0,
h: Homography([1.0, 0.0, 100.0 + x0 as f64, 0.0, 1.0, 50.0, 0.0, 0.0, 1.0]),
});
let _ = k;
}
let ph = photometric(&imgs, Some((&pls, &[(w, h), (w, h)])), true, false);
@ -1399,7 +1413,16 @@ mod tests {
// Multi-band blend of two flat images at different levels joins smoothly.
let mk = |x0: i32, v: f32| RoiImage { x0, y0: 0, w: 80, h, ch: 1, px: vec![v; 80 * h], alpha: vec![1.0; 80 * h] };
let imgs = [mk(0, 0.3), mk(40, 0.7)];
let wts: Vec<Vec<f32>> = (0..2).map(|k| (0..80 * h).map(|i| { let x = (i % 80) as i32 + imgs[k].x0; if (x < 60) == (k == 0) { 1.0 } else { 0.0 } }).collect()).collect();
let wts: Vec<Vec<f32>> = (0..2)
.map(|k| {
(0..80 * h)
.map(|i| {
let x = (i % 80) as i32 + imgs[k].x0;
if (x < 60) == (k == 0) { 1.0 } else { 0.0 }
})
.collect()
})
.collect();
let (out, cov) = multiband(w, h, &imgs, &wts, 4);
let row: Vec<f32> = (0..w).map(|x| out[20 * w + x]).collect();
assert!((row[2] - 0.3).abs() < 0.02 && (row[w - 3] - 0.7).abs() < 0.02, "{row:?}");

View File

@ -129,7 +129,8 @@ pub fn straighten(planes: &mut [Plane], mode: Straighten) {
let groups = linked_corners(planes);
let group_of = |p: usize, c: usize| groups.iter().position(|g| g.contains(&(p, c))).unwrap_or(0);
let mut pos: Vec<[f64; 2]> = groups.iter().map(|g| planes[g[0].0].dst[g[0].1]).collect();
let edges: Vec<(usize, usize)> = (0..planes.len()).flat_map(|p| (0..4).map(move |k| (p, k))).map(|(p, k)| (group_of(p, k), group_of(p, (k + 1) % 4))).collect();
let edges: Vec<(usize, usize)> =
(0..planes.len()).flat_map(|p| (0..4).map(move |k| (p, k))).map(|(p, k)| (group_of(p, k), group_of(p, (k + 1) % 4))).collect();
for (a, b) in edges {
let (pa, pb) = (pos[a], pos[b]);
let (dx, dy) = ((pb[0] - pa[0]).abs(), (pb[1] - pa[1]).abs());
@ -167,10 +168,17 @@ impl PerspectiveMap {
let mut shared = [None; 4];
for (k, s) in shared.iter_mut().enumerate() {
let (a, b) = (p.src[k], p.src[(k + 1) % 4]);
*s = planes.iter().enumerate().find(|(j, o)| *j != i && (0..4).any(|m| {
let (c, d) = (o.src[m], o.src[(m + 1) % 4]);
(near(a, c) && near(b, d)) || (near(a, d) && near(b, c))
})).map(|(j, _)| j);
*s = planes
.iter()
.enumerate()
.find(|(j, o)| {
*j != i
&& (0..4).any(|m| {
let (c, d) = (o.src[m], o.src[(m + 1) % 4]);
(near(a, c) && near(b, d)) || (near(a, d) && near(b, c))
})
})
.map(|(j, _)| j);
}
maps.push(PlaneMap { src, src_inv, dst, quad: p.src, shared });
}
@ -246,10 +254,12 @@ pub fn unit_corners() -> [[f64; 2]; 4] {
/// A point of plane `p` at plane coordinates `(u, v)`, in source space.
pub fn plane_point(p: &Plane, u: f64, v: f64, dst: bool) -> [f64; 2] {
let q = if dst { &p.dst } else { &p.src };
unit_to_quad(q).map(|h| {
let (x, y) = h.apply(u, v);
[x, y]
}).unwrap_or(q[0])
unit_to_quad(q)
.map(|h| {
let (x, y) = h.apply(u, v);
[x, y]
})
.unwrap_or(q[0])
}
#[cfg(test)]
@ -299,7 +309,8 @@ mod tests {
#[test]
fn straighten_levels_and_plumbs_edges() {
let mut ps = vec![Plane { src: [[0.0, 0.0], [100.0, 0.0], [100.0, 100.0], [0.0, 100.0]], dst: [[0.0, 3.0], [100.0, -2.0], [96.0, 100.0], [4.0, 104.0]] }];
let mut ps =
vec![Plane { src: [[0.0, 0.0], [100.0, 0.0], [100.0, 100.0], [0.0, 100.0]], dst: [[0.0, 3.0], [100.0, -2.0], [96.0, 100.0], [4.0, 104.0]] }];
straighten(&mut ps, Straighten::Vertical);
assert_eq!(ps[0].dst[1][0], ps[0].dst[2][0]);
assert_eq!(ps[0].dst[0][0], ps[0].dst[3][0]);

View File

@ -72,7 +72,11 @@ pub(crate) fn color_halftone(src: &Image, out: Rect, ctx: &Ctx, max_radius: f32,
let ink = if sub && !rgb { raw } else { 1.0 - raw };
// Dot area tracks ink: πr² = ink·cell² until dots touch, then grow to cover the corners.
let touch = std::f32::consts::FRAC_PI_4;
let r = if ink <= touch { cell * (ink / std::f32::consts::PI).sqrt() } else { cell * (0.5 + (ink - touch) / (1.0 - touch) * (std::f32::consts::FRAC_1_SQRT_2 - 0.5)) };
let r = if ink <= touch {
cell * (ink / std::f32::consts::PI).sqrt()
} else {
cell * (0.5 + (ink - touch) / (1.0 - touch) * (std::f32::consts::FRAC_1_SQRT_2 - 0.5))
};
// Tiny dots cannot cover more than their own area.
cov = cov.max((r - d + 0.5).clamp(0.0, 1.0).min(std::f32::consts::PI * r * r));
}
@ -116,9 +120,14 @@ pub(crate) fn crystallize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, see
best.1
};
let mut acc = vec![0.0f32; gw * gh * (n + 1)];
let win = Rect::new(b.x0 + (c0.0 as f32 * cell).floor() as i32, b.y0 + (c0.1 as f32 * cell).floor() as i32, b.x0 + ((c1.0 + 1) as f32 * cell).ceil() as i32, b.y0 + ((c1.1 + 1) as f32 * cell).ceil() as i32)
.intersect(&src.rect)
.intersect(&b);
let win = Rect::new(
b.x0 + (c0.0 as f32 * cell).floor() as i32,
b.y0 + (c0.1 as f32 * cell).floor() as i32,
b.x0 + ((c1.0 + 1) as f32 * cell).ceil() as i32,
b.y0 + ((c1.1 + 1) as f32 * cell).ceil() as i32,
)
.intersect(&src.rect)
.intersect(&b);
let mut owners = vec![(0i32, 0i32); out.width() as usize * out.height() as usize];
for y in win.y0..win.y1 {
for x in win.x0..win.x1 {

View File

@ -272,10 +272,12 @@ mod tests {
// Destination: flat reddish; source: bluish with a stripe texture.
let dst: Vec<f32> = (0..w * h).flat_map(|_| [0.7, 0.3, 0.2]).collect();
let tex = |x: usize| if (x / 4).is_multiple_of(2) { 0.05 } else { -0.05 };
let src: Vec<f32> = (0..w * h).flat_map(|i| {
let t = tex(i % w);
[0.1 + t, 0.2 + t, 0.8 + t]
}).collect();
let src: Vec<f32> = (0..w * h)
.flat_map(|i| {
let t = tex(i % w);
[0.1 + t, 0.2 + t, 0.8 + t]
})
.collect();
let out = seamless_clone(w, h, ch, &src, &dst, &mask);
// Mean colour inside the mask matches the destination.
let inside: Vec<usize> = (0..w * h).filter(|&i| mask[i]).collect();

View File

@ -651,7 +651,14 @@ impl PuppetSolver {
/// Deforms the mesh: `pin_dst` (one per pin given to [`Self::new`]), optional fixed pin
/// rotations (degrees), `iterations` local/global rounds, warm-started from `init` when
/// given. Returns the deformed vertices and the rigid ARAP energy after each round.
pub fn solve(&self, pin_dst: &[[f64; 2]], rotations: &[Option<f64>], mode: PuppetMode, iterations: usize, init: Option<&[[f64; 2]]>) -> (Vec<[f64; 2]>, Vec<f64>) {
pub fn solve(
&self,
pin_dst: &[[f64; 2]],
rotations: &[Option<f64>],
mode: PuppetMode,
iterations: usize,
init: Option<&[[f64; 2]]>,
) -> (Vec<[f64; 2]>, Vec<f64>) {
let nv = self.mesh.verts.len();
let mut v: Vec<[f64; 2]> = match init {
Some(i) if i.len() == nv => i.to_vec(),
@ -667,7 +674,16 @@ impl PuppetSolver {
.bound
.iter()
.zip(rotations)
.filter_map(|(b, r)| r.map(|deg| ((0..3).fold([0.0, 0.0], |a, k| [a[0] + b.bary[k] * self.mesh.verts[b.verts[k]][0], a[1] + b.bary[k] * self.mesh.verts[b.verts[k]][1]]), deg.to_radians())))
.filter_map(|(b, r)| {
r.map(|deg| {
(
(0..3).fold([0.0, 0.0], |a, k| {
[a[0] + b.bary[k] * self.mesh.verts[b.verts[k]][0], a[1] + b.bary[k] * self.mesh.verts[b.verts[k]][1]]
}),
deg.to_radians(),
)
})
})
.collect();
let r: Vec<[f64; 4]> = self
.mesh
@ -781,12 +797,14 @@ mod tests {
let b = s.content_bounds();
let m = build_mesh(&s, b, PuppetDensity::Normal, 2.0);
assert!(m.tris.len() > 20);
let inside = |p: [f64; 2]| m.tris.iter().any(|t| {
let xs = t.map(|i| m.verts[i]);
let (x0, x1) = (xs.iter().map(|v| v[0]).fold(f64::MAX, f64::min), xs.iter().map(|v| v[0]).fold(f64::MIN, f64::max));
let (y0, y1) = (xs.iter().map(|v| v[1]).fold(f64::MAX, f64::min), xs.iter().map(|v| v[1]).fold(f64::MIN, f64::max));
p[0] >= x0 && p[0] <= x1 && p[1] >= y0 && p[1] <= y1
});
let inside = |p: [f64; 2]| {
m.tris.iter().any(|t| {
let xs = t.map(|i| m.verts[i]);
let (x0, x1) = (xs.iter().map(|v| v[0]).fold(f64::MAX, f64::min), xs.iter().map(|v| v[0]).fold(f64::MIN, f64::max));
let (y0, y1) = (xs.iter().map(|v| v[1]).fold(f64::MAX, f64::min), xs.iter().map(|v| v[1]).fold(f64::MIN, f64::max));
p[0] >= x0 && p[0] <= x1 && p[1] >= y0 && p[1] <= y1
})
};
assert!(inside([28.0, 30.0]) && inside([60.0, 62.0]));
assert!(!inside([60.0, 20.0]), "the empty corner of the L has no mesh");
let more = build_mesh(&s, b, PuppetDensity::More, 2.0);
@ -799,7 +817,13 @@ mod tests {
for st in [SampleType::U8, SampleType::U16, SampleType::F32] {
let s = blob(st);
let b = s.content_bounds();
for pins in [vec![], vec![PuppetPin { src: [28.0, 20.0], dst: [28.0, 20.0], rotate: None, depth: 0 }, PuppetPin { src: [60.0, 62.0], dst: [60.0, 62.0], rotate: None, depth: 0 }]] {
for pins in [
vec![],
vec![
PuppetPin { src: [28.0, 20.0], dst: [28.0, 20.0], rotate: None, depth: 0 },
PuppetPin { src: [60.0, 62.0], dst: [60.0, 62.0], rotate: None, depth: 0 },
],
] {
let w = PuppetWarp { pins, mode: PuppetMode::Normal, density: PuppetDensity::Normal, expansion: 2.0 };
let out = puppet_warp(&s, b, &w, Interp::Bicubic);
let worst = s.read_region(b).iter().zip(out.read_region(b)).map(|(a, c)| (a - c).abs()).fold(0.0f32, f32::max);
@ -812,7 +836,12 @@ mod tests {
fn a_single_pin_translates_rigidly() {
let s = blob(SampleType::U8);
let b = s.content_bounds();
let w = PuppetWarp { pins: vec![PuppetPin { src: [28.0, 30.0], dst: [38.0, 25.0], rotate: None, depth: 0 }], mode: PuppetMode::Rigid, density: PuppetDensity::Normal, expansion: 2.0 };
let w = PuppetWarp {
pins: vec![PuppetPin { src: [28.0, 30.0], dst: [38.0, 25.0], rotate: None, depth: 0 }],
mode: PuppetMode::Rigid,
density: PuppetDensity::Normal,
expansion: 2.0,
};
let (solver, v, _) = deform(&s, b, &w, ITERATIONS);
for (d, x) in v.iter().zip(&solver.mesh.verts) {
assert!((d[0] - x[0] - 10.0).abs() < 1e-3 && (d[1] - x[1] + 5.0).abs() < 1e-3, "{d:?} {x:?}");

View File

@ -90,7 +90,7 @@ pub fn expand(l: &Level, ch: usize, w: usize, h: usize) -> Vec<f32> {
pub fn gaussian(w: usize, h: usize, ch: usize, px: &[f32], levels: usize) -> Vec<Level> {
let mut out = vec![Level { w, h, px: px.to_vec() }];
while out.len() < levels.max(1) {
let last = out.last().expect("non-empty");
let Some(last) = out.last() else { break };
if last.w <= 1 && last.h <= 1 {
break;
}

View File

@ -158,16 +158,7 @@ fn forced_colors(f: Forced) -> Vec<Rgb8> {
match f {
Forced::None => Vec::new(),
Forced::BlackWhite => vec![[0, 0, 0], [255, 255, 255]],
Forced::Primaries => vec![
[0, 0, 0],
[255, 255, 255],
[255, 0, 0],
[0, 255, 0],
[0, 0, 255],
[0, 255, 255],
[255, 0, 255],
[255, 255, 0],
],
Forced::Primaries => vec![[0, 0, 0], [255, 255, 255], [255, 0, 0], [0, 255, 0], [0, 0, 255], [0, 255, 255], [255, 0, 255], [255, 255, 0]],
Forced::Web => web_palette(),
}
}
@ -198,9 +189,7 @@ fn median_cut(pts: &[([f32; 3], u32)], n: usize) -> Vec<[f32; 3]> {
.map(|(i, b)| {
let mut best = (0usize, -1.0f32);
for a in 0..3 {
let (lo, hi) = b.iter().fold((f32::MAX, f32::MIN), |(lo, hi), p| {
(lo.min(p.0[a]), hi.max(p.0[a]))
});
let (lo, hi) = b.iter().fold((f32::MAX, f32::MIN), |(lo, hi), p| (lo.min(p.0[a]), hi.max(p.0[a])));
if hi - lo > best.1 {
best = (a, hi - lo);
}
@ -208,10 +197,7 @@ fn median_cut(pts: &[([f32; 3], u32)], n: usize) -> Vec<[f32; 3]> {
let w: f32 = b.iter().map(|p| p.1 as f32).sum();
(i, best.0, best.1 * w.sqrt())
})
.fold(
(usize::MAX, 0, -1.0f32),
|acc, x| if x.2 > acc.2 { x } else { acc },
);
.fold((usize::MAX, 0, -1.0f32), |acc, x| if x.2 > acc.2 { x } else { acc });
if bi == usize::MAX || score <= 0.0 {
break;
}
@ -235,18 +221,17 @@ fn median_cut(pts: &[([f32; 3], u32)], n: usize) -> Vec<[f32; 3]> {
.iter()
.map(|b| {
let w: f64 = b.iter().map(|p| f64::from(p.1)).sum();
std::array::from_fn(|a| {
(b.iter()
.map(|p| f64::from(p.0[a]) * f64::from(p.1))
.sum::<f64>()
/ w) as f32
})
std::array::from_fn(|a| (b.iter().map(|p| f64::from(p.0[a]) * f64::from(p.1)).sum::<f64>() / w) as f32)
})
.collect()
}
/// Lloyd refinement of `centers` over weighted points.
fn kmeans(pts: &[([f32; 3], u32)], centers: &mut [[f32; 3]], iters: usize) {
if centers.is_empty() {
// No room left (the forced colours fill the palette): nothing to refine.
return;
}
for _ in 0..iters {
let mut sum = vec![[0.0f64; 3]; centers.len()];
let mut cnt = vec![0.0f64; centers.len()];
@ -282,12 +267,7 @@ fn lab_to_rgb8(lab: [f32; 3]) -> Rgb8 {
/// Builds the palette for `px`. `colors` is the requested count (2..=256) for the computed
/// kinds; forced colours are included first and count toward it.
pub fn build_palette(
px: &[[f32; 4]],
kind: PaletteKind,
colors: usize,
forced: Forced,
) -> Result<Vec<Rgb8>, String> {
pub fn build_palette(px: &[[f32; 4]], kind: PaletteKind, colors: usize, forced: Forced) -> Result<Vec<Rgb8>, String> {
let colors = colors.clamp(2, 256);
let mut pal = forced_colors(forced);
let room = colors.saturating_sub(pal.len());
@ -295,10 +275,7 @@ pub fn build_palette(
let computed: Vec<Rgb8> = match kind {
PaletteKind::Exact => {
if hist.len() > 256 {
return Err(format!(
"the image has {} colours; Exact needs 256 or fewer",
hist.len()
));
return Err(format!("the image has {} colours; Exact needs 256 or fewer", hist.len()));
}
hist.iter().map(|h| h.0).collect()
}
@ -310,24 +287,17 @@ pub fn build_palette(
if hist.len() <= room {
hist.iter().map(|h| h.0).collect()
} else {
let pts: Vec<([f32; 3], u32)> =
hist.iter().map(|(c, n)| (c.map(f32::from), *n)).collect();
let pts: Vec<([f32; 3], u32)> = hist.iter().map(|(c, n)| (c.map(f32::from), *n)).collect();
let mut centers = median_cut(&pts, room);
kmeans(&pts, &mut centers, 2);
centers
.iter()
.map(|c| c.map(|v| v.round().clamp(0.0, 255.0) as u8))
.collect()
centers.iter().map(|c| c.map(|v| v.round().clamp(0.0, 255.0) as u8)).collect()
}
}
PaletteKind::Perceptual | PaletteKind::Selective => {
if hist.len() <= room {
hist.iter().map(|h| h.0).collect()
} else {
let pts: Vec<([f32; 3], u32)> = hist
.iter()
.map(|(c, n)| (srgb_to_lab(c.map(|v| f32::from(v) / 255.0)), *n))
.collect();
let pts: Vec<([f32; 3], u32)> = hist.iter().map(|(c, n)| (srgb_to_lab(c.map(|v| f32::from(v) / 255.0)), *n)).collect();
let mut centers = median_cut(&pts, room);
kmeans(&pts, &mut centers, 4);
let mut v: Vec<Rgb8> = centers.iter().map(|c| lab_to_rgb8(*c)).collect();
@ -352,13 +322,7 @@ pub fn build_palette(
pal.push(c);
}
}
if matches!(
kind,
PaletteKind::Adaptive
| PaletteKind::Perceptual
| PaletteKind::Selective
| PaletteKind::Uniform
) {
if matches!(kind, PaletteKind::Adaptive | PaletteKind::Perceptual | PaletteKind::Selective | PaletteKind::Uniform) {
pal.truncate(colors.max(forced_colors(forced).len()));
}
if pal.is_empty() {
@ -370,9 +334,7 @@ pub fn build_palette(
fn nearest(pal: &[Rgb8], c: [f32; 3]) -> usize {
let mut best = (f32::MAX, 0);
for (i, e) in pal.iter().enumerate() {
let d: f32 = (0..3)
.map(|k| (c[k] * 255.0 - f32::from(e[k])).powi(2))
.sum();
let d: f32 = (0..3).map(|k| (c[k] * 255.0 - f32::from(e[k])).powi(2)).sum();
if d < best.0 {
best = (d, i);
}
@ -383,17 +345,14 @@ fn nearest(pal: &[Rgb8], c: [f32; 3]) -> usize {
/// 8×8 Bayer threshold in -0.5..0.5.
pub fn bayer8(x: usize, y: usize) -> f32 {
const M: [u8; 64] = [
0, 32, 8, 40, 2, 34, 10, 42, 48, 16, 56, 24, 50, 18, 58, 26, 12, 44, 4, 36, 14, 46, 6, 38,
60, 28, 52, 20, 62, 30, 54, 22, 3, 35, 11, 43, 1, 33, 9, 41, 51, 19, 59, 27, 49, 17, 57,
25, 15, 47, 7, 39, 13, 45, 5, 37, 63, 31, 55, 23, 61, 29, 53, 21,
0, 32, 8, 40, 2, 34, 10, 42, 48, 16, 56, 24, 50, 18, 58, 26, 12, 44, 4, 36, 14, 46, 6, 38, 60, 28, 52, 20, 62, 30, 54, 22, 3, 35, 11, 43, 1, 33, 9, 41,
51, 19, 59, 27, 49, 17, 57, 25, 15, 47, 7, 39, 13, 45, 5, 37, 63, 31, 55, 23, 61, 29, 53, 21,
];
(f32::from(M[(y % 8) * 8 + x % 8]) + 0.5) / 64.0 - 0.5
}
fn hash01(x: usize, y: usize, seed: u32) -> f32 {
let mut h = (x as u32).wrapping_mul(0x8da6_b343)
^ (y as u32).wrapping_mul(0xd816_3841)
^ seed.wrapping_mul(0xcb1a_b31f);
let mut h = (x as u32).wrapping_mul(0x8da6_b343) ^ (y as u32).wrapping_mul(0xd816_3841) ^ seed.wrapping_mul(0xcb1a_b31f);
h ^= h >> 13;
h = h.wrapping_mul(0x5bd1_e995);
h ^= h >> 15;
@ -403,14 +362,7 @@ fn hash01(x: usize, y: usize, seed: u32) -> f32 {
/// Maps every opaque pixel to its palette entry (in place) with the chosen dither; returns the
/// index per pixel. Pixels with alpha < ½ become index `transparent` (and alpha 0) when given;
/// otherwise alpha is kept. `amount` (0..=1) scales diffusion / pattern / noise strength.
pub fn quantize(
px: &mut [[f32; 4]],
w: usize,
pal: &[Rgb8],
dither: Dither,
amount: f32,
transparent: Option<usize>,
) -> Vec<u8> {
pub fn quantize(px: &mut [[f32; 4]], w: usize, pal: &[Rgb8], dither: Dither, amount: f32, transparent: Option<usize>) -> Vec<u8> {
let h = px.len().checked_div(w).unwrap_or(0);
let mut idx = vec![0u8; px.len()];
// Typical palette spacing sets the ordered/noise dither amplitude.
@ -499,12 +451,7 @@ pub fn quantize(
next[x + 2][q] += d[q] / 16.0;
}
}
px[i] = [
e[0],
e[1],
e[2],
if transparent.is_some() { 1.0 } else { p[3] },
];
px[i] = [e[0], e[1], e[2], if transparent.is_some() { 1.0 } else { p[3] }];
}
}
idx
@ -564,16 +511,10 @@ fn spot(shape: HalftoneShape, u: f32, v: f32) -> f32 {
pub fn to_bitmap(gray: &mut [f32], w: usize, method: BitmapMethod) {
let h = gray.len().checked_div(w).unwrap_or(0);
match method {
BitmapMethod::Threshold => gray
.iter_mut()
.for_each(|v| *v = if *v >= 0.5 { 1.0 } else { 0.0 }),
BitmapMethod::Threshold => gray.iter_mut().for_each(|v| *v = if *v >= 0.5 { 1.0 } else { 0.0 }),
BitmapMethod::Pattern => {
for (i, v) in gray.iter_mut().enumerate() {
*v = if *v > bayer8(i % w, i / w) + 0.5 {
1.0
} else {
0.0
};
*v = if *v > bayer8(i % w, i / w) + 0.5 { 1.0 } else { 0.0 };
}
}
BitmapMethod::Diffusion => {
@ -604,15 +545,7 @@ pub fn to_bitmap(gray: &mut [f32], w: usize, method: BitmapMethod) {
let cell = cell.max(2.0);
let (s, c) = angle.to_radians().sin_cos();
// Rank the spot function over the cell so coverage is proportional to the tone.
let mut table: Vec<f32> = (0..32 * 32)
.map(|k| {
spot(
shape,
((k % 32) as f32 + 0.5) / 16.0 - 1.0,
((k / 32) as f32 + 0.5) / 16.0 - 1.0,
)
})
.collect();
let mut table: Vec<f32> = (0..32 * 32).map(|k| spot(shape, ((k % 32) as f32 + 0.5) / 16.0 - 1.0, ((k / 32) as f32 + 0.5) / 16.0 - 1.0)).collect();
table.sort_by(f32::total_cmp);
let cdf = |x: f32| table.partition_point(|t| *t < x) as f32 / table.len() as f32;
for (i, v) in gray.iter_mut().enumerate() {
@ -621,11 +554,7 @@ pub fn to_bitmap(gray: &mut [f32], w: usize, method: BitmapMethod) {
let u = (rx / cell).rem_euclid(1.0) * 2.0 - 1.0;
let vv = (ry / cell).rem_euclid(1.0) * 2.0 - 1.0;
// Ink (black) covers where the spot function exceeds the gray level.
*v = if cdf(spot(shape, u, vv)) >= *v {
0.0
} else {
1.0
};
*v = if cdf(spot(shape, u, vv)) >= *v { 0.0 } else { 1.0 };
}
}
}
@ -636,16 +565,7 @@ mod tests {
use super::*;
fn gradient(w: usize, h: usize) -> Vec<[f32; 4]> {
(0..w * h)
.map(|i| {
[
(i % w) as f32 / (w - 1) as f32,
(i / w) as f32 / (h - 1) as f32,
0.3,
1.0,
]
})
.collect()
(0..w * h).map(|i| [(i % w) as f32 / (w - 1) as f32, (i / w) as f32 / (h - 1) as f32, 0.3, 1.0]).collect()
}
#[test]
@ -662,43 +582,39 @@ mod tests {
#[test]
fn exact_palette_and_failure() {
let px = vec![
[1.0, 0.0, 0.0, 1.0],
[0.0, 0.0, 1.0, 1.0],
[1.0, 0.0, 0.0, 1.0],
];
assert_eq!(
build_palette(&px, PaletteKind::Exact, 256, Forced::None)
.unwrap()
.len(),
2
);
let px = vec![[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0], [1.0, 0.0, 0.0, 1.0]];
assert_eq!(build_palette(&px, PaletteKind::Exact, 256, Forced::None).unwrap().len(), 2);
assert!(build_palette(&gradient(64, 64), PaletteKind::Exact, 256, Forced::None).is_err());
}
#[test]
fn adaptive_and_perceptual_respect_count_and_forced() {
let px = gradient(40, 40);
for kind in [
PaletteKind::Adaptive,
PaletteKind::Perceptual,
PaletteKind::Selective,
] {
for kind in [PaletteKind::Adaptive, PaletteKind::Perceptual, PaletteKind::Selective] {
let pal = build_palette(&px, kind, 16, Forced::BlackWhite).unwrap();
assert!(pal.len() <= 16 && pal.len() >= 8, "{kind:?} {}", pal.len());
assert_eq!(&pal[..2], &[[0, 0, 0], [255, 255, 255]]);
}
}
#[test]
fn forced_colours_filling_the_count_do_not_panic() {
// 2 colours + Black and White (or 8 + Primaries) leaves no room for
// computed entries; the median cut used to return no centres and
// k-means indexed into the empty list.
let px = vec![[1.0, 1.0, 1.0, 1.0]; 16];
for kind in [PaletteKind::Adaptive, PaletteKind::Perceptual, PaletteKind::Selective] {
let pal = build_palette(&px, kind, 2, Forced::BlackWhite).unwrap();
assert_eq!(pal, vec![[0, 0, 0], [255, 255, 255]], "{kind:?}");
let pal = build_palette(&gradient(8, 8), kind, 8, Forced::Primaries).unwrap();
assert_eq!(pal.len(), 8, "{kind:?}");
}
}
#[test]
fn quantize_maps_to_palette_with_each_dither() {
let pal = vec![[0, 0, 0], [255, 255, 255]];
for d in [
Dither::None,
Dither::Diffusion,
Dither::Pattern,
Dither::Noise,
] {
for d in [Dither::None, Dither::Diffusion, Dither::Pattern, Dither::Noise] {
let mut px: Vec<[f32; 4]> = (0..64)
.map(|i| [i as f32 / 63.0; 4])
.map(|mut p| {
@ -726,11 +642,7 @@ mod tests {
BitmapMethod::Threshold,
BitmapMethod::Pattern,
BitmapMethod::Diffusion,
BitmapMethod::Halftone {
cell: 6.0,
angle: 45.0,
shape: HalftoneShape::Round,
},
BitmapMethod::Halftone { cell: 6.0, angle: 45.0, shape: HalftoneShape::Round },
] {
let mut g = vec![0.25f32; 32 * 32];
to_bitmap(&mut g, 32, m);

View File

@ -487,7 +487,8 @@ fn flame_colour(t: f32, custom: Option<[f32; 4]>) -> [f32; 4] {
[rgb[0], rgb[1], rgb[2], a * c[3]]
}
None => {
const STOPS: [(f32, [f32; 3]); 5] = [(0.0, [1.0, 0.98, 0.85]), (0.2, [1.0, 0.86, 0.35]), (0.45, [1.0, 0.55, 0.08]), (0.75, [0.9, 0.25, 0.02]), (1.0, [0.6, 0.08, 0.0])];
const STOPS: [(f32, [f32; 3]); 5] =
[(0.0, [1.0, 0.98, 0.85]), (0.2, [1.0, 0.86, 0.35]), (0.45, [1.0, 0.55, 0.08]), (0.75, [0.9, 0.25, 0.02]), (1.0, [0.6, 0.08, 0.0])];
let k = t.clamp(0.0, 1.0);
let mut rgb = STOPS[4].1;
for w in STOPS.windows(2) {
@ -539,7 +540,11 @@ fn draw_flame(out: &mut Vec<Prim>, spine: &Spine, width: f32, spec: &FlameSpec,
let o = sway(u);
let c = flame_colour(u, spec.color);
let r = half * (1.25 - u) + 2.0;
out.push(Prim::ellipse((px - dy * o, py + dx * o), r, r * 1.2, dy.atan2(dx), [c[0], c[1], c[2], 0.16 * opacity], [c[0], c[1] * 0.6, c[2] * 0.3, 0.0]).with_soft(r.max(2.0)).additive());
out.push(
Prim::ellipse((px - dy * o, py + dx * o), r, r * 1.2, dy.atan2(dx), [c[0], c[1], c[2], 0.16 * opacity], [c[0], c[1] * 0.6, c[2] * 0.3, 0.0])
.with_soft(r.max(2.0))
.additive(),
);
}
let line_r = (half / lines as f32 * 2.2).clamp(0.8, 10.0);
for li in 0..lines {
@ -572,7 +577,11 @@ fn draw_flame(out: &mut Vec<Prim>, spine: &Spine, width: f32, spec: &FlameSpec,
if let Some((q, rq, uq)) = prev {
// Fade in over the first tenth (a soft base) and out towards the line's tip.
let f = |c: [f32; 4], uu: f32, t: f32| [c[0], c[1], c[2], c[3] * opacity * (1.0 - t.powi(3)) * ((uu - start) / 0.08).clamp(0.0, 1.0).sqrt()];
out.push(Prim::capsule(q, p, rq, r, f(flame_colour(uq, spec.color), uq, fade_t), f(flame_colour(u, spec.color), u, fade_t)).with_soft(1.0 + lr * 0.6).lighten());
out.push(
Prim::capsule(q, p, rq, r, f(flame_colour(uq, spec.color), uq, fade_t), f(flame_colour(u, spec.color), u, fade_t))
.with_soft(1.0 + lr * 0.6)
.lighten(),
);
}
prev = Some((p, r, u));
}
@ -586,7 +595,8 @@ pub fn flame(spec: &FlameSpec, paths: &[Vec<(f32, f32)>]) -> Vec<Prim> {
let length = spec.length.clamp(1.0, 1000.0);
let width = spec.width.clamp(1.0, 1000.0);
let interval = spec.interval.clamp(1.0, 1000.0);
let pick_len = |rng: &mut Rng, var: f32| if spec.randomize_length || var > 0.0 { length * rng.range(1.0 - var.max(0.4), 1.0 + var.max(0.4) * 0.5) } else { length };
let pick_len =
|rng: &mut Rng, var: f32| if spec.randomize_length || var > 0.0 { length * rng.range(1.0 - var.max(0.4), 1.0 + var.max(0.4) * 0.5) } else { length };
for path in paths.iter().filter(|p| !p.is_empty()) {
let spine = Spine::new(path.clone());
let total = spine.len();
@ -600,14 +610,29 @@ pub fn flame(spec: &FlameSpec, paths: &[Vec<(f32, f32)>]) -> Vec<Prim> {
let c = FlameSpec { flame_shape: FlameShape::Oval, turbulent: spec.turbulent * 0.3, jag: spec.jag * 0.2, ..spec.clone() };
draw_flame(&mut out, &candle, (width * 0.6).max(4.0), &c, &mut rng);
let core = flame_colour(0.05, spec.color);
out.push(Prim::ellipse((base.0, base.1 - h * 0.28), (width * 0.12).max(1.5), h * 0.22, 0.0, [core[0], core[1], core[2], 0.9 * spec.opacity.clamp(0.0, 100.0) / 100.0], [core[0], core[1], core[2], 0.0]).with_soft(2.0).additive());
out.push(
Prim::ellipse(
(base.0, base.1 - h * 0.28),
(width * 0.12).max(1.5),
h * 0.22,
0.0,
[core[0], core[1], core[2], 0.9 * spec.opacity.clamp(0.0, 100.0) / 100.0],
[core[0], core[1], core[2], 0.0],
)
.with_soft(2.0)
.additive(),
);
}
_ => {
let closed = path.len() > 2 && {
let (a, b) = (path[0], path[path.len() - 1]);
(a.0 - b.0).abs() < 1.0 && (a.1 - b.1).abs() < 1.0
};
let count = if spec.adjust_interval_for_loops && closed { (total / interval).round().max(1.0) as usize } else { (total / interval).floor() as usize + 1 };
let count = if spec.adjust_interval_for_loops && closed {
(total / interval).round().max(1.0) as usize
} else {
(total / interval).floor() as usize + 1
};
let step = if spec.adjust_interval_for_loops && closed { total / count as f32 } else { interval };
for k in 0..count {
let s = k as f32 * step;
@ -699,7 +724,22 @@ struct TreeKind {
}
#[allow(clippy::too_many_arguments)]
const fn tk(name: &'static str, habit: Habit, levels: u32, children: u32, spread: f32, ratio: f32, trunk: f32, droop: f32, wiggle: f32, thick: f32, leaf: Leaf, leaf_col: [f32; 3], bark: [f32; 3], accent: Option<[f32; 3]>) -> TreeKind {
const fn tk(
name: &'static str,
habit: Habit,
levels: u32,
children: u32,
spread: f32,
ratio: f32,
trunk: f32,
droop: f32,
wiggle: f32,
thick: f32,
leaf: Leaf,
leaf_col: [f32; 3],
bark: [f32; 3],
accent: Option<[f32; 3]>,
) -> TreeKind {
TreeKind { name, habit, levels, children, spread, ratio, trunk, droop, wiggle, thick, leaf, leaf_col, bark, accent }
}
@ -1023,7 +1063,8 @@ pub fn tree(spec: &TreeSpec, canvas: Rect) -> Vec<Prim> {
ang += 0.06 * d.sin().atan2(d.cos());
let nq = (q.0 + ang.cos() * flen / segs as f32, q.1 + ang.sin() * flen / segs as f32);
let k2 = t.light_at(nq);
t.leaves.push((k2 - 0.1, Prim::capsule(q, nq, (r0 * 0.15).max(0.6), (r0 * 0.1).max(0.4), shade(t.leaf, k2 * 0.8), shade(t.leaf, k2 * 0.8))));
t.leaves
.push((k2 - 0.1, Prim::capsule(q, nq, (r0 * 0.15).max(0.6), (r0 * 0.1).max(0.4), shade(t.leaf, k2 * 0.8), shade(t.leaf, k2 * 0.8))));
for j in 0..leaflets {
let along = j as f32 / leaflets as f32;
let at = (q.0 + (nq.0 - q.0) * along, q.1 + (nq.1 - q.1) * along);
@ -1201,7 +1242,12 @@ pub fn picture_frame(spec: &FrameSpec, canvas: Rect) -> Vec<Prim> {
let thick = (unit * 0.04 + short * 0.00012 * spec.thickness.clamp(1.0, 100.0)).max(0.6);
let spacing = unit * (4.0 - 3.2 * spec.arrangement.clamp(1.0, 100.0) / 100.0);
let inset = margin + unit;
let b = Border { x0: canvas.x0 as f32 + inset, y0: canvas.y0 as f32 + inset, w: (canvas.width() as f32 - 2.0 * inset).max(1.0), h: (canvas.height() as f32 - 2.0 * inset).max(1.0) };
let b = Border {
x0: canvas.x0 as f32 + inset,
y0: canvas.y0 as f32 + inset,
w: (canvas.width() as f32 - 2.0 * inset).max(1.0),
h: (canvas.height() as f32 - 2.0 * inset).max(1.0),
};
let total = b.len();
let count = (total / spacing).round().max(4.0) as usize;
let step = total / count as f32;
@ -1333,7 +1379,14 @@ pub fn picture_frame(spec: &FrameSpec, canvas: Rect) -> Vec<Prim> {
if style == "rope" {
if k % 2 == 0 {
let (_, t, _) = b.at(s);
out.push(Prim::ellipse(q, unit * 0.45, unit * 0.2, t.1.atan2(t.0) + 0.7, shade([vine[0], vine[1], vine[2]], 1.1), shade([vine[0], vine[1], vine[2]], 0.65)));
out.push(Prim::ellipse(
q,
unit * 0.45,
unit * 0.2,
t.1.atan2(t.0) + 0.7,
shade([vine[0], vine[1], vine[2]], 1.1),
shade([vine[0], vine[1], vine[2]], 0.65),
));
}
} else if let Some(pp) = prev {
out.push(Prim::capsule(pp, q, thick, thick, vine, vine));
@ -1342,7 +1395,12 @@ pub fn picture_frame(spec: &FrameSpec, canvas: Rect) -> Vec<Prim> {
}
if style == "doubleLine" {
// A second rule inside each line and corner squares.
let inner = Border { x0: b.x0 + unit * 0.6 + off, y0: b.y0 + unit * 0.6 + off, w: b.w - 2.0 * (unit * 0.6 + off), h: b.h - 2.0 * (unit * 0.6 + off) };
let inner = Border {
x0: b.x0 + unit * 0.6 + off,
y0: b.y0 + unit * 0.6 + off,
w: b.w - 2.0 * (unit * 0.6 + off),
h: b.h - 2.0 * (unit * 0.6 + off),
};
for e in 0..4 {
let (p, _, _) = inner.at([0.0, inner.w, inner.w + inner.h, 2.0 * inner.w + inner.h][e]);
let (q, _, _) = inner.at([inner.w, inner.w + inner.h, 2.0 * inner.w + inner.h, inner.len() - 0.001][e]);
@ -1458,7 +1516,14 @@ mod tests {
assert!(!picture_frame(&FrameSpec { frame: k, ..Default::default() }, canvas).is_empty(), "frame {k}");
}
let path = default_flame_path(canvas);
for t in [FlameType::OneFlameAlongPath, FlameType::MultipleFlamesAlongPath, FlameType::MultipleFlamesPathDirections, FlameType::MultipleFlamesVariousLength, FlameType::CandleLight, FlameType::MultipleFlamesOneDirection] {
for t in [
FlameType::OneFlameAlongPath,
FlameType::MultipleFlamesAlongPath,
FlameType::MultipleFlamesPathDirections,
FlameType::MultipleFlamesVariousLength,
FlameType::CandleLight,
FlameType::MultipleFlamesOneDirection,
] {
assert!(!flame(&FlameSpec { flame_type: t, ..Default::default() }, std::slice::from_ref(&path)).is_empty(), "{t:?}");
}
}

View File

@ -178,7 +178,16 @@ pub fn local_blur(data: &[f32], w: usize, h: usize, ch: usize, alpha: Option<usi
/// With `protect_detail`, each result is clamped to the 3×3 neighbourhood's range, which stops
/// halos and noise from being amplified into new extremes. Alpha is left unchanged.
#[allow(clippy::too_many_arguments)]
pub fn local_sharpen(data: &[f32], w: usize, h: usize, ch: usize, alpha: Option<usize>, win: (usize, usize, usize, usize), amount: f32, protect_detail: bool) -> Vec<f32> {
pub fn local_sharpen(
data: &[f32],
w: usize,
h: usize,
ch: usize,
alpha: Option<usize>,
win: (usize, usize, usize, usize),
amount: f32,
protect_detail: bool,
) -> Vec<f32> {
let (x0, y0, x1, y1) = win;
let ow = x1 - x0;
let blur = local_blur(data, w, h, ch, alpha, win, 1.0);
@ -289,7 +298,7 @@ mod tests {
let (w, h, ch) = (8, 1, 4);
let data: Vec<f32> = (0..w).flat_map(|x| if x < 4 { [1.0, 0.0, 0.0, 1.0] } else { [0.0, 1.0, 0.0, 0.0] }).collect();
let b = local_blur(&data, w, h, ch, Some(3), (2, 0, 6, 1), 1.0);
for p in b.chunks_exact(4) {
for p in b.as_chunks::<4>().0 {
if p[3] > 0.01 {
assert!(p[0] > 0.99 && p[1] < 0.01, "{p:?}");
}

View File

@ -24,7 +24,8 @@ pub fn sharpness(img: &super::RgbImage, noise: f32) -> Vec<f32> {
for yy in 0..h {
for x in 0..w {
let c = at(x, yy);
let l = (2.0 * c - at(x.saturating_sub(1), yy) - at((x + 1).min(w - 1), yy)).abs() + (2.0 * c - at(x, yy.saturating_sub(1)) - at(x, (yy + 1).min(h - 1))).abs();
let l = (2.0 * c - at(x.saturating_sub(1), yy) - at((x + 1).min(w - 1), yy)).abs()
+ (2.0 * c - at(x, yy.saturating_sub(1)) - at(x, (yy + 1).min(h - 1))).abs();
lap[yy * w + x] = l;
}
}

View File

@ -24,7 +24,8 @@ impl Component {
for (i, row) in c.iter_mut().enumerate() {
row[i] += reg;
}
let det = c[0][0] * (c[1][1] * c[2][2] - c[1][2] * c[2][1]) - c[0][1] * (c[1][0] * c[2][2] - c[1][2] * c[2][0]) + c[0][2] * (c[1][0] * c[2][1] - c[1][1] * c[2][0]);
let det = c[0][0] * (c[1][1] * c[2][2] - c[1][2] * c[2][1]) - c[0][1] * (c[1][0] * c[2][2] - c[1][2] * c[2][0])
+ c[0][2] * (c[1][0] * c[2][1] - c[1][1] * c[2][0]);
if det <= 1e-30 || weight <= 0.0 {
return None;
}
@ -34,13 +35,7 @@ impl Component {
[(c[1][0] * c[2][1] - c[1][1] * c[2][0]) / det, (c[0][1] * c[2][0] - c[0][0] * c[2][1]) / det, (c[0][0] * c[1][1] - c[0][1] * c[1][0]) / det],
];
let log_norm = weight.ln() - 0.5 * det.ln() - 1.5 * (2.0 * std::f64::consts::PI).ln();
Some(Component {
weight: weight as f32,
mean: mean.map(|v| v as f32),
cov: c.map(|r| r.map(|v| v as f32)),
inv,
log_norm,
})
Some(Component { weight: weight as f32, mean: mean.map(|v| v as f32), cov: c.map(|r| r.map(|v| v as f32)), inv, log_norm })
}
/// `ln(weight · N(z; μ, Σ))`.
@ -48,7 +43,9 @@ impl Component {
pub fn log_weighted(&self, z: [f32; 3]) -> f64 {
let d = [(z[0] - self.mean[0]) as f64, (z[1] - self.mean[1]) as f64, (z[2] - self.mean[2]) as f64];
let m = &self.inv;
let q = d[0] * (m[0][0] * d[0] + m[0][1] * d[1] + m[0][2] * d[2]) + d[1] * (m[1][0] * d[0] + m[1][1] * d[1] + m[1][2] * d[2]) + d[2] * (m[2][0] * d[0] + m[2][1] * d[1] + m[2][2] * d[2]);
let q = d[0] * (m[0][0] * d[0] + m[0][1] * d[1] + m[0][2] * d[2])
+ d[1] * (m[1][0] * d[0] + m[1][1] * d[1] + m[1][2] * d[2])
+ d[2] * (m[2][0] * d[0] + m[2][1] * d[1] + m[2][2] * d[2]);
self.log_norm - 0.5 * q
}
}

View File

@ -9,7 +9,7 @@
use photocraft_geom::Rect;
use super::gmm::{DEFAULT_REG, Gmm};
use super::{HARD_BG, HARD_FG, FREE, Region, RgbImage, Sampler, contrast_beta, data_costs, grid_cut, subsample};
use super::{FREE, HARD_BG, HARD_FG, Region, RgbImage, Sampler, contrast_beta, data_costs, grid_cut, subsample};
/// Definite background.
pub const BG: u8 = 0;

View File

@ -30,8 +30,8 @@ pub mod gmm;
pub mod grabcut;
pub mod maxflow;
pub mod quick;
pub mod slic;
pub mod sky;
pub mod slic;
pub mod subject;
use photocraft_geom::Rect;

View File

@ -20,7 +20,7 @@
use photocraft_geom::Rect;
use super::gmm::Gmm;
use super::{HARD_FG, FREE, Region, RgbImage, Sampler, contrast_beta, grid_cut, keep_seeded, subsample};
use super::{FREE, HARD_FG, Region, RgbImage, Sampler, contrast_beta, grid_cut, keep_seeded, subsample};
/// Covariance ridge for the (often tiny) stroke sample: ~6 levels of standard deviation.
const QREG: f32 = 6e-4;
@ -108,6 +108,8 @@ pub fn segment(img: &RgbImage, seeds: &[bool]) -> (Gmm, Gmm, Vec<bool>) {
/// A near-uniform density over the RGB cube.
pub fn broad_model() -> Gmm {
let corners: Vec<[f32; 3]> = (0..8).map(|i| [(i & 1) as f32, ((i >> 1) & 1) as f32, ((i >> 2) & 1) as f32]).collect();
// Eight fixed, distinct samples: the fit cannot fail.
#[allow(clippy::expect_used)]
Gmm::fit(&corners, 1, 0.0).expect("non-degenerate")
}

View File

@ -319,11 +319,8 @@ pub fn combine_region(old: Option<&Surface>, new: Option<&Region>, mode: Selecti
return nonempty(o.clone());
}
let p = prev_u8(o);
let out: Vec<f32> = p
.iter()
.zip(&new.mask)
.map(|(a, n)| if mode == SelectionMode::Add { *a.max(n) } else { a.saturating_sub(*n) } as f32 / 255.0)
.collect();
let out: Vec<f32> =
p.iter().zip(&new.mask).map(|(a, n)| if mode == SelectionMode::Add { *a.max(n) } else { a.saturating_sub(*n) } as f32 / 255.0).collect();
let mut s = o.clone();
s.write_region(b, &out);
s.prune();
@ -373,6 +370,100 @@ pub fn color_range(px: &[[f32; 4]], color: [f32; 3], fuzziness: f32) -> Vec<f32>
.collect()
}
/// One Color Range sample (Sampled Colors): an eyedropper colour, and where it was picked
/// (needed for Localized Color Clusters; area-local pixel coordinates).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct RangeSample {
pub color: [f32; 3],
pub at: Option<(f32, f32)>,
}
/// Color Range › Sampled Colors with several samples (the dialog's "Add to Sample" eyedropper):
/// each pixel takes its best match over the samples, each judged like [`color_range`]. With
/// `localized` (Localized Color Clusters) a sample also fades with distance from where it was
/// picked, reaching 0 at `localized` pixels, so only nearby matching colours are selected;
/// samples without a position aren't localized. `w` is the row width of `px`.
pub fn color_range_samples(px: &[[f32; 4]], w: usize, samples: &[RangeSample], fuzziness: f32, localized: Option<f32>) -> Vec<f32> {
let f = fuzziness.max(0.0) + 1.0;
px.iter()
.enumerate()
.map(|(i, &[r, g, b, a])| {
if a <= 0.0 {
return 0.0;
}
let (x, y) = ((i % w.max(1)) as f32, (i / w.max(1)) as f32);
samples.iter().fold(0.0f32, |best, s| {
let d = [r, g, b].iter().zip(&s.color).map(|(v, c)| (v - c).abs() * 255.0).fold(0.0f32, f32::max);
let mut k = (1.0 - d / f).clamp(0.0, 1.0);
if let (Some(radius), Some((sx, sy))) = (localized, s.at) {
let dist = ((x - sx).powi(2) + (y - sy).powi(2)).sqrt();
k *= (1.0 - dist / radius.max(1.0)).clamp(0.0, 1.0);
}
best.max(k)
})
})
.collect()
}
/// Hue (degrees, 0..360) and chroma (max − min) of an RGB colour.
fn hue_chroma([r, g, b]: [f32; 3]) -> (f32, f32) {
let mx = r.max(g).max(b);
let mn = r.min(g).min(b);
let d = mx - mn;
if d <= 0.0 {
return (0.0, 0.0);
}
let h = if mx == r {
((g - b) / d).rem_euclid(6.0)
} else if mx == g {
(b - r) / d + 2.0
} else {
(r - g) / d + 4.0
};
(h * 60.0, d)
}
/// Color Range › Select: Reds, Yellows, Greens, Cyans, Blues or Magentas (`center` = 0, 60,
/// 120, 180, 240, 300°). A pixel belongs to a family by how close its hue is (full at the
/// family's hue, none 60° away, so an orange is half red, half yellow) times its chroma, so
/// neutrals are never selected and muted colours only partly.
pub fn hue_range(px: &[[f32; 4]], center: f32) -> Vec<f32> {
px.iter()
.map(|&[r, g, b, a]| {
if a <= 0.0 {
return 0.0;
}
let (h, chroma) = hue_chroma([r, g, b].map(|v| v.clamp(0.0, 1.0)));
let dh = (h - center).rem_euclid(360.0);
let dh = dh.min(360.0 - dh);
((1.0 - dh / 60.0).clamp(0.0, 1.0) * chroma).clamp(0.0, 1.0)
})
.collect()
}
/// Color Range › Select: Highlights, Midtones or Shadows. Pixels whose luminance (Rec. 601
/// weights, 0..255) lies in `lo..=hi` are fully selected; outside, selection falls off linearly
/// to 0 over `falloff` levels (the dialog's Fuzziness).
pub fn tone_range(px: &[[f32; 4]], lo: f32, hi: f32, falloff: f32) -> Vec<f32> {
let ramp = falloff.max(1e-3);
px.iter()
.map(|&[r, g, b, a]| {
if a <= 0.0 {
return 0.0;
}
let l = (0.299 * r + 0.587 * g + 0.114 * b).clamp(0.0, 1.0) * 255.0;
let out = if l < lo {
lo - l
} else if l > hi {
l - hi
} else {
0.0
};
(1.0 - out / ramp).clamp(0.0, 1.0)
})
.collect()
}
/// 1D squared distance transform (Felzenszwalb & Huttenlocher).
fn dt1(f: &[f32], out: &mut [f32], v: &mut [usize], z: &mut [f32]) {
let n = f.len();
@ -550,14 +641,16 @@ pub fn polygon(points: &[(f32, f32)], area: Rect, anti_alias: bool) -> Vec<f32>
}
}
xs.sort_by(|a, b| a.total_cmp(b));
for span in xs.chunks_exact(2) {
for span in xs.as_chunks::<2>().0 {
let (x0, x1) = ((span[0] - area.x0 as f32).max(0.0), (span[1] - area.x0 as f32).min(w as f32));
if x1 <= x0 {
continue;
}
let (c0, c1) = (x0.floor() as usize, (x1.ceil() as usize).min(w));
for cx in c0..c1 {
let cov = if anti_alias { ((cx + 1) as f32).min(x1) - (cx as f32).max(x0) } else {
let cov = if anti_alias {
((cx + 1) as f32).min(x1) - (cx as f32).max(x0)
} else {
let c = cx as f32 + 0.5;
if c >= x0 && c < x1 { 1.0 } else { 0.0 }
};
@ -611,6 +704,69 @@ mod tests {
assert_eq!(m[3], 0.0);
}
#[test]
fn color_range_several_samples_and_localized() {
let red = [1.0, 0.0, 0.0, 1.0];
let blue = [0.0, 0.0, 1.0, 1.0];
let green = [0.0, 1.0, 0.0, 1.0];
// 4 × 1: red, blue, green, red.
let px = vec![red, blue, green, red];
let s = |c: [f32; 4], at: Option<(f32, f32)>| RangeSample { color: [c[0], c[1], c[2]], at };
let m = color_range_samples(&px, 4, &[s(red, None), s(blue, None)], 10.0, None);
assert_eq!(m, vec![1.0, 1.0, 0.0, 1.0]);
// One sample is exactly the single-colour Color Range.
assert_eq!(color_range_samples(&px, 4, &[s(red, None)], 40.0, None), color_range(&px, [1.0, 0.0, 0.0], 40.0));
// Localized at x = 0 with a 2 px range: the far red pixel (x = 3) drops out, x = 1 would be half.
let m = color_range_samples(&px, 4, &[s(red, Some((0.0, 0.0)))], 10.0, Some(2.0));
assert_eq!(m, vec![1.0, 0.0, 0.0, 0.0]);
let reds = vec![red; 4];
let m = color_range_samples(&reds, 4, &[s(red, Some((0.0, 0.0)))], 10.0, Some(2.0));
assert_eq!(m, vec![1.0, 0.5, 0.0, 0.0]);
// No samples: nothing selected.
assert!(color_range_samples(&px, 4, &[], 10.0, None).iter().all(|v| *v == 0.0));
}
#[test]
fn hue_families() {
let px = vec![
[1.0, 0.0, 0.0, 1.0], // red
[1.0, 0.5, 0.0, 1.0], // orange: between red and yellow
[0.5, 0.5, 0.5, 1.0], // grey
[1.0, 0.5, 0.5, 1.0], // pink: a muted red
[0.0, 0.0, 1.0, 1.0], // blue
[1.0, 0.0, 0.0, 0.0], // transparent
];
let reds = hue_range(&px, 0.0);
assert_eq!(reds[0], 1.0);
assert!((reds[1] - 0.5).abs() < 1e-5);
assert_eq!(reds[2], 0.0);
assert!((reds[3] - 0.5).abs() < 1e-5);
assert_eq!(reds[4], 0.0);
assert_eq!(reds[5], 0.0);
let yellows = hue_range(&px, 60.0);
assert!((yellows[1] - 0.5).abs() < 1e-5);
assert_eq!(hue_range(&px, 240.0)[4], 1.0);
// Magentas wrap around 360°.
assert_eq!(hue_range(&[[1.0, 0.0, 1.0, 1.0]], 300.0)[0], 1.0);
assert!((hue_range(&[[1.0, 0.0, 0.5, 1.0]], 300.0)[0] - 0.5).abs() < 1e-5);
}
#[test]
fn tonal_ranges() {
let g = |v: f32| [v / 255.0, v / 255.0, v / 255.0, 1.0];
let px = vec![g(0.0), g(60.0), g(75.0), g(128.0), g(200.0), g(255.0)];
let near = |a: &[f32], b: &[f32]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-4);
// Shadows up to 65, fading over 20 levels.
let sh = tone_range(&px, 0.0, 65.0, 20.0);
assert!(near(&sh, &[1.0, 1.0, 0.5, 0.0, 0.0, 0.0]), "{sh:?}");
// Highlights from 190.
let h = tone_range(&px, 190.0, 255.0, 20.0);
assert!(near(&h, &[0.0, 0.0, 0.0, 0.0, 1.0, 1.0]), "{h:?}");
// Midtones 105..150.
let m = tone_range(&px, 105.0, 150.0, 20.0);
assert!(near(&m, &[0.0, 0.0, 0.0, 1.0, 0.0, 0.0]), "{m:?}");
}
fn square(w: usize, h: usize) -> Vec<f32> {
(0..w * h).map(|i| if (5..15).contains(&(i % w)) && (5..15).contains(&(i / w)) { 1.0 } else { 0.0 }).collect()
}
@ -689,7 +845,13 @@ mod tests {
let area = Rect::new(3, -2, 3 + w, -2 + h);
let img = blobs(w, h);
let f: Vec<[f32; 4]> = img.iter().map(|p| p.map(|v| v as f32 / 255.0)).collect();
for (seed, tol, contiguous, aa) in [((10, 5), 10.0, true, true), ((10, 5), 10.0, true, false), ((20, 20), 40.0, false, true), ((4, -2), 0.0, true, true), ((30, 10), 255.0, true, true)] {
for (seed, tol, contiguous, aa) in [
((10, 5), 10.0, true, true),
((10, 5), 10.0, true, false),
((20, 20), 40.0, false, true),
((4, -2), 0.0, true, true),
((30, 10), 255.0, true, true),
] {
let reference = magic_wand(&f, area, seed, tol, contiguous, aa);
let r = wand_region(&img, area, seed, tol, contiguous, aa).unwrap();
for y in area.y0..area.y1 {

View File

@ -35,37 +35,23 @@ fn stat(v: &mut [f32], s: Stat) -> f32 {
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::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
}
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) / sd.powi(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)
}
if var < 1e-12 { 0.0 } else { (m(4) / (var * var) / 10.0).clamp(0.0, 1.0) }
}
Stat::Entropy => {
if v.len() < 2 {
@ -73,9 +59,7 @@ fn stat(v: &mut [f32], s: Stat) -> f32 {
}
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;
*counts.entry((x.clamp(0.0, 1.0) * 255.0).round() as u8).or_default() += 1;
}
let h: f32 = counts
.values()

Some files were not shown because too many files have changed in this diff Show More