Compare commits
88 Commits
v0.1.1
...
batch-foll
| Author | SHA1 | Date |
|---|---|---|
|
|
65bc1645b8 | |
|
|
1f2a74518a | |
|
|
08512df255 | |
|
|
ab0fa25897 | |
|
|
60c5df70b5 | |
|
|
3400412616 | |
|
|
def1402401 | |
|
|
bfcbdc0ee8 | |
|
|
2c7b86bc02 | |
|
|
7c53a3c1ad | |
|
|
a0a90c3c15 | |
|
|
f4cf980a37 | |
|
|
fb79564f2a | |
|
|
a65b5ca80f | |
|
|
7100b0d617 | |
|
|
4af95fa807 | |
|
|
d3a6855367 | |
|
|
5ede5f7ba6 | |
|
|
ad86321738 | |
|
|
7e7864afae | |
|
|
8b0ceea374 | |
|
|
d8d648cb72 | |
|
|
2149f7f646 | |
|
|
ff53be714d | |
|
|
f5f0837aab | |
|
|
4f8da9365a | |
|
|
d3b540234c | |
|
|
5716c2f6f1 | |
|
|
d881785fde | |
|
|
abf785f7b1 | |
|
|
37c8f81032 | |
|
|
7a5c1930b3 | |
|
|
d5eb44e9fd | |
|
|
5bd3dac660 | |
|
|
297f7f8521 | |
|
|
b17c239db8 | |
|
|
51b82caf0e | |
|
|
8b1cbb741b | |
|
|
2e80c781ee | |
|
|
ab3ab92b4a | |
|
|
86cc989e72 | |
|
|
6dffa0894c | |
|
|
f8f48db78a | |
|
|
4218d07f40 | |
|
|
91ef877f2f | |
|
|
1d832d3e84 | |
|
|
296ced38d6 | |
|
|
48d1a4f9e5 | |
|
|
69f0e25d5f | |
|
|
1fe689a7bf | |
|
|
29506f3e93 | |
|
|
6f1d0dc080 | |
|
|
66d5a5381e | |
|
|
71272d5cd7 | |
|
|
1c09936154 | |
|
|
574d4fe7da | |
|
|
f7eb6ea9e7 | |
|
|
50a27e6c74 | |
|
|
159c11feb4 | |
|
|
8d21ca349d | |
|
|
11e5af23ad | |
|
|
7ec0b4aa62 | |
|
|
6bdee62e87 | |
|
|
7cb26e4f7f | |
|
|
9905db34ff | |
|
|
cf93edc99c | |
|
|
b70fbd4ff6 | |
|
|
a196adfe93 | |
|
|
35896f056a | |
|
|
ae7d802726 | |
|
|
16ce03877c | |
|
|
34d138c11f | |
|
|
b7cac05f69 | |
|
|
09c987f1a8 | |
|
|
e6e82ca74d | |
|
|
ce13708af3 | |
|
|
8a2f31c2c4 | |
|
|
b6a95b6c8d | |
|
|
bf111b524e | |
|
|
ed0b99964d | |
|
|
5f84a7add8 | |
|
|
c89c33c704 | |
|
|
9dc394dfc8 | |
|
|
bb52e3aa46 | |
|
|
0cc39de41b | |
|
|
a0eff27619 | |
|
|
5442398a91 | |
|
|
b6b8ed21cb |
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -7,3 +7,4 @@ target/
|
|||
/log
|
||||
/secrets
|
||||
/temp
|
||||
/book/output
|
||||
|
|
|
|||
22
AGENTS.md
22
AGENTS.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
25
README.md
25
README.md
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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)
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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).
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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).
|
||||
|
|
@ -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).
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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).
|
||||
|
|
@ -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.
|
||||
|
|
@ -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).
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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).
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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())) {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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, ®ion(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, ®ion(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, ®ion(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, ®ion(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, ®ion(Rect::new(0, 0, 4, 4), 255), f32::NAN, None, None).is_empty());
|
||||
assert!(magic_erase(&mut s, ®ion(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, ®ion(Rect::new(0, 0, 4, 4), 255), 1.0, None, None).is_empty());
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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))));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue