Merge main into persistence branch (keep JobPool renderer; wasm inline job timing via now_ms)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
32
AGENTS.md
|
|
@ -9,27 +9,55 @@ LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructi
|
|||
|
||||
`plan/` is gitignored (local-only).
|
||||
|
||||
**Resuming after a crash / another session:** check `git status` on main, `git worktree list`, and each worktree's
|
||||
`git log main..HEAD` + `git status` for unmerged commits or uncommitted work before starting anything new. Commit small
|
||||
and often so a crash loses minutes, not hours.
|
||||
|
||||
## Non-negotiables
|
||||
- **Clean-room.** Never read/disassemble anything inside Adobe app bundles (names/listings only). Never copy Adobe icons, presets, profiles (DCP), lens profiles (LCP), camera matrices, fonts. Observation of the installed Lightroom is read-only (it syncs the user's personal library: never import/edit/rate/delete there). Never copy GPL/LGPL/AGPL code (darktable, RawTherapee, ART, LibRaw, rawspeed, rawloader, rawler, lensfun, dcraw-derived GPL code…).
|
||||
- **Pure Rust** in the product. No C/C++ dependencies.
|
||||
- **Layering** (`plan/architecture.md` §3, enforced by `cargo xtask layers`): nothing below L5 depends on egui/eframe/winit/rfd.
|
||||
- **Everything is a command** (`crates/engine`): id, label, menu path, shortcut, params, enabled(), run(). UI, CLI, control channel and MCP all dispatch by id. Every slider is a `develop` control spec.
|
||||
- **Resolution independence:** settings use normalized image coordinates and relative radii; previews and exports must match.
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, layers, wasm).
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, layers, assets, wasm).
|
||||
- **Commits:** one task id per commit (`M2.3: local Laplacian highlights/shadows`). Only green states. End messages with the attribution line required by the environment.
|
||||
|
||||
## Assets: icons, images, fonts (ABSOLUTE RULE — never violate)
|
||||
- **Never use any iconography, image, artwork, font, sound or other asset from Adobe products** (no Lightroom/Creative Cloud icons, no screenshots, no presets/profiles/LUTs, no UI bitmaps — not even as a temporary placeholder or "reference copy"). Observing Adobe's UI to imitate *layout and behaviour* is allowed; copying or tracing its assets is not.
|
||||
- **This includes Adobe's open-licensed assets**: no Source Sans/Serif/Code or Source Han fonts, no Adobe Fonts, no
|
||||
Adobe-published icon sets, sample photos, colour profiles or LUTs — even when OFL/MIT. The UI font is Inter (OFL).
|
||||
- Every asset in the repository must be one of: **our own original work** (e.g. icons drawn in code as vectors, procedurally generated demo photos), **public domain / CC0**, **Creative Commons** (CC-BY / CC-BY-SA with attribution honoured), **OFL** (fonts), or **permissive open-source** (MIT/Apache-2.0/BSD/ISC) — or contributed by a person who created the asset and licenses it openly.
|
||||
- **Every asset must have an entry in `assets/ATTRIBUTION.md`** (path, title, author/creator, source URL or "original work", licence, date added, modifications) and its licence text when required (e.g. `assets/fonts/OFL-*.txt`). Add the entry in the same commit as the asset. Assets without an attribution entry must not be committed.
|
||||
- Icons drawn in code (e.g. `crates/ui-egui/src/icons.rs`) are original work and are recorded in `assets/ATTRIBUTION.md` as such; do not trace them from Adobe icons.
|
||||
- Demo/test images: generated procedurally by `lightcraft-scenes`, or CC0 downloads kept in the gitignored `corpus/` with their source recorded. Screenshots of Adobe apps live only in the gitignored `plan/` and are never committed or published.
|
||||
- **Enforced:** `cargo xtask assets` (in `ci`) fails when an image/icon/font/sound/video/raw/ICC/XMP file is not matched
|
||||
by a path pattern in the first column of `assets/ATTRIBUTION.md`, when a referenced licence file is missing, when a
|
||||
file name suggests Adobe material, or when an Adobe profile/template format (`.dcp`, `.lcp`, `.lrtemplate`, …) appears.
|
||||
Never weaken this check to make a commit pass — fix the attribution or remove the asset.
|
||||
- When in doubt about an asset's licence: don't use it.
|
||||
|
||||
## Running and looking at the app
|
||||
- `cargo run --release -p lightcraft -- --control 7980` opens the desktop app with the JSON-lines control server (see `docs/control-protocol.md`).
|
||||
- For UI work, **look at the result**: drive via the control channel and take `ui.screenshot`, compare with `plan/lightroom/screenshots/`.
|
||||
- MCP: `lightcraft-cli mcp` (see `docs/mcp.md`).
|
||||
- MCP: `lightcraft-cli mcp` (headless, `--demo` for the procedural library) or `lightcraft-cli mcp --connect` (drives
|
||||
the running app). See `docs/mcp.md`. Quick non-UI checks: `lightcraft-cli render in.jpg -o out.jpg --set light.exposure=1`.
|
||||
- Export goes through `lightcraft_engine::export` (one encoder for app, CLI, MCP and web); UI-only commands live in
|
||||
`crates/ui-egui/src/menus.rs`.
|
||||
- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`.
|
||||
- Parallel agents: separate git worktrees and `CARGO_TARGET_DIR=target/agent-<name>`; keep every `Cargo.toml` valid at all times (the `crates/*` glob means one broken manifest breaks everyone).
|
||||
- Test corpora: `cargo xtask corpus --download` into `corpus/` (gitignored, CC0 only). Never commit media.
|
||||
|
||||
## Testing & performance (do this often)
|
||||
- Unit/property tests next to the code; end-to-end tests drive real binaries (`crates/mcp/tests/e2e.rs`,
|
||||
`apps/lightcraft-cli/tests/`). New features need at least one test that would fail without them.
|
||||
- **Run the app after every user-visible change**: launch with `--control 7980`, drive it with a JSON-lines script
|
||||
(`docs/showcase/run.py file.jsonl`), take `ui.screenshot`, and look at it. Check `ui.inspect` → `perf`.
|
||||
- **Measure, don't guess**: `LIGHTCRAFT_PROFILE=1` prints per-stage pipeline timings to stderr; time CLI renders
|
||||
with `/usr/bin/time`. Record numbers in `plan/STATUS.md` → Metrics. Budgets: slider update ≤ 16 ms (draft) / loupe
|
||||
≤ 60 ms on ~2.5 MP; export ≤ 1 s per 24 MP JPEG.
|
||||
- Never commit media: test images are procedural (`lightcraft-scenes`) or CC0 downloads in the gitignored `corpus/`.
|
||||
|
||||
## Map of the code
|
||||
`geom`, `color`, `raster`, `tiff` (L0) → `raw`, `codecs`, `meta`, `develop` (L1) → `pipeline` → `catalog` → `engine`
|
||||
→ `ui-egui`, `mcp` (L5) → apps `lightcraft` (desktop), `lightcraft-cli` (render/commands/MCP). `scenes` generates demo
|
||||
photos. `xtask` = tooling (`ci`, `layers`, `assets`, `wasm`, `corpus`, `stats`).
|
||||
|
|
|
|||
32
CLAUDE.md
|
|
@ -9,27 +9,55 @@ LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructi
|
|||
|
||||
`plan/` is gitignored (local-only).
|
||||
|
||||
**Resuming after a crash / another session:** check `git status` on main, `git worktree list`, and each worktree's
|
||||
`git log main..HEAD` + `git status` for unmerged commits or uncommitted work before starting anything new. Commit small
|
||||
and often so a crash loses minutes, not hours.
|
||||
|
||||
## Non-negotiables
|
||||
- **Clean-room.** Never read/disassemble anything inside Adobe app bundles (names/listings only). Never copy Adobe icons, presets, profiles (DCP), lens profiles (LCP), camera matrices, fonts. Observation of the installed Lightroom is read-only (it syncs the user's personal library: never import/edit/rate/delete there). Never copy GPL/LGPL/AGPL code (darktable, RawTherapee, ART, LibRaw, rawspeed, rawloader, rawler, lensfun, dcraw-derived GPL code…).
|
||||
- **Pure Rust** in the product. No C/C++ dependencies.
|
||||
- **Layering** (`plan/architecture.md` §3, enforced by `cargo xtask layers`): nothing below L5 depends on egui/eframe/winit/rfd.
|
||||
- **Everything is a command** (`crates/engine`): id, label, menu path, shortcut, params, enabled(), run(). UI, CLI, control channel and MCP all dispatch by id. Every slider is a `develop` control spec.
|
||||
- **Resolution independence:** settings use normalized image coordinates and relative radii; previews and exports must match.
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, layers, wasm).
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, layers, assets, wasm).
|
||||
- **Commits:** one task id per commit (`M2.3: local Laplacian highlights/shadows`). Only green states. End messages with the attribution line required by the environment.
|
||||
|
||||
## Assets: icons, images, fonts (ABSOLUTE RULE — never violate)
|
||||
- **Never use any iconography, image, artwork, font, sound or other asset from Adobe products** (no Lightroom/Creative Cloud icons, no screenshots, no presets/profiles/LUTs, no UI bitmaps — not even as a temporary placeholder or "reference copy"). Observing Adobe's UI to imitate *layout and behaviour* is allowed; copying or tracing its assets is not.
|
||||
- **This includes Adobe's open-licensed assets**: no Source Sans/Serif/Code or Source Han fonts, no Adobe Fonts, no
|
||||
Adobe-published icon sets, sample photos, colour profiles or LUTs — even when OFL/MIT. The UI font is Inter (OFL).
|
||||
- Every asset in the repository must be one of: **our own original work** (e.g. icons drawn in code as vectors, procedurally generated demo photos), **public domain / CC0**, **Creative Commons** (CC-BY / CC-BY-SA with attribution honoured), **OFL** (fonts), or **permissive open-source** (MIT/Apache-2.0/BSD/ISC) — or contributed by a person who created the asset and licenses it openly.
|
||||
- **Every asset must have an entry in `assets/ATTRIBUTION.md`** (path, title, author/creator, source URL or "original work", licence, date added, modifications) and its licence text when required (e.g. `assets/fonts/OFL-*.txt`). Add the entry in the same commit as the asset. Assets without an attribution entry must not be committed.
|
||||
- Icons drawn in code (e.g. `crates/ui-egui/src/icons.rs`) are original work and are recorded in `assets/ATTRIBUTION.md` as such; do not trace them from Adobe icons.
|
||||
- Demo/test images: generated procedurally by `lightcraft-scenes`, or CC0 downloads kept in the gitignored `corpus/` with their source recorded. Screenshots of Adobe apps live only in the gitignored `plan/` and are never committed or published.
|
||||
- **Enforced:** `cargo xtask assets` (in `ci`) fails when an image/icon/font/sound/video/raw/ICC/XMP file is not matched
|
||||
by a path pattern in the first column of `assets/ATTRIBUTION.md`, when a referenced licence file is missing, when a
|
||||
file name suggests Adobe material, or when an Adobe profile/template format (`.dcp`, `.lcp`, `.lrtemplate`, …) appears.
|
||||
Never weaken this check to make a commit pass — fix the attribution or remove the asset.
|
||||
- When in doubt about an asset's licence: don't use it.
|
||||
|
||||
## Running and looking at the app
|
||||
- `cargo run --release -p lightcraft -- --control 7980` opens the desktop app with the JSON-lines control server (see `docs/control-protocol.md`).
|
||||
- For UI work, **look at the result**: drive via the control channel and take `ui.screenshot`, compare with `plan/lightroom/screenshots/`.
|
||||
- MCP: `lightcraft-cli mcp` (see `docs/mcp.md`).
|
||||
- MCP: `lightcraft-cli mcp` (headless, `--demo` for the procedural library) or `lightcraft-cli mcp --connect` (drives
|
||||
the running app). See `docs/mcp.md`. Quick non-UI checks: `lightcraft-cli render in.jpg -o out.jpg --set light.exposure=1`.
|
||||
- Export goes through `lightcraft_engine::export` (one encoder for app, CLI, MCP and web); UI-only commands live in
|
||||
`crates/ui-egui/src/menus.rs`.
|
||||
- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`.
|
||||
- Parallel agents: separate git worktrees and `CARGO_TARGET_DIR=target/agent-<name>`; keep every `Cargo.toml` valid at all times (the `crates/*` glob means one broken manifest breaks everyone).
|
||||
- Test corpora: `cargo xtask corpus --download` into `corpus/` (gitignored, CC0 only). Never commit media.
|
||||
|
||||
## Testing & performance (do this often)
|
||||
- Unit/property tests next to the code; end-to-end tests drive real binaries (`crates/mcp/tests/e2e.rs`,
|
||||
`apps/lightcraft-cli/tests/`). New features need at least one test that would fail without them.
|
||||
- **Run the app after every user-visible change**: launch with `--control 7980`, drive it with a JSON-lines script
|
||||
(`docs/showcase/run.py file.jsonl`), take `ui.screenshot`, and look at it. Check `ui.inspect` → `perf`.
|
||||
- **Measure, don't guess**: `LIGHTCRAFT_PROFILE=1` prints per-stage pipeline timings to stderr; time CLI renders
|
||||
with `/usr/bin/time`. Record numbers in `plan/STATUS.md` → Metrics. Budgets: slider update ≤ 16 ms (draft) / loupe
|
||||
≤ 60 ms on ~2.5 MP; export ≤ 1 s per 24 MP JPEG.
|
||||
- Never commit media: test images are procedural (`lightcraft-scenes`) or CC0 downloads in the gitignored `corpus/`.
|
||||
|
||||
## Map of the code
|
||||
`geom`, `color`, `raster`, `tiff` (L0) → `raw`, `codecs`, `meta`, `develop` (L1) → `pipeline` → `catalog` → `engine`
|
||||
→ `ui-egui`, `mcp` (L5) → apps `lightcraft` (desktop), `lightcraft-cli` (render/commands/MCP). `scenes` generates demo
|
||||
photos. `xtask` = tooling (`ci`, `layers`, `assets`, `wasm`, `corpus`, `stats`).
|
||||
|
|
|
|||
|
|
@ -934,6 +934,7 @@ dependencies = [
|
|||
"egui-wgpu",
|
||||
"egui-winit",
|
||||
"egui_glow",
|
||||
"glow",
|
||||
"glutin",
|
||||
"glutin-winit",
|
||||
"image",
|
||||
|
|
@ -2000,6 +2001,17 @@ dependencies = [
|
|||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-cli"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"lightcraft-codecs",
|
||||
"lightcraft-engine",
|
||||
"lightcraft-mcp",
|
||||
"lightcraft-raster",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-codecs"
|
||||
version = "0.1.0"
|
||||
|
|
@ -2074,6 +2086,17 @@ dependencies = [
|
|||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-mcp"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"lightcraft-codecs",
|
||||
"lightcraft-engine",
|
||||
"lightcraft-raster",
|
||||
"log",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-meta"
|
||||
version = "0.1.0"
|
||||
|
|
@ -2176,6 +2199,25 @@ dependencies = [
|
|||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-web"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
"js-sys",
|
||||
"lightcraft-codecs",
|
||||
"lightcraft-engine",
|
||||
"lightcraft-raster",
|
||||
"lightcraft-ui-egui",
|
||||
"log",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2233,6 +2275,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3255,6 +3298,7 @@ dependencies = [
|
|||
"loop9",
|
||||
"quick-error 2.0.1",
|
||||
"rav1e",
|
||||
"rayon",
|
||||
"rgb",
|
||||
]
|
||||
|
||||
|
|
|
|||
15
Cargo.toml
|
|
@ -53,7 +53,7 @@ tiff = "0.11"
|
|||
image-webp = "0.2"
|
||||
moxcms = { version = "0.9", default-features = false, features = ["lut"] }
|
||||
jxl-oxide = { version = "0.12", default-features = false }
|
||||
ravif = { version = "0.13", default-features = false }
|
||||
ravif = { version = "0.13", default-features = false, features = ["threading"] } # no "asm": it needs nasm (non-Rust)
|
||||
|
||||
# ui
|
||||
egui = "0.36"
|
||||
|
|
@ -61,6 +61,13 @@ egui_extras = { version = "0.36", default-features = false }
|
|||
eframe = { version = "0.36", default-features = false }
|
||||
egui_kittest = "0.36"
|
||||
rfd = "0.17"
|
||||
web-time = "1"
|
||||
|
||||
# web (apps/lightcraft-web)
|
||||
wasm-bindgen = "0.2.129"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = "0.3"
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "deny"
|
||||
|
|
@ -87,3 +94,9 @@ inherits = "release"
|
|||
lto = "thin"
|
||||
codegen-units = 1
|
||||
debug = false
|
||||
|
||||
# `cargo xtask web`: the browser build (no debug info; size matters more than build time)
|
||||
[profile.web]
|
||||
inherits = "release"
|
||||
debug = false
|
||||
lto = "thin"
|
||||
|
|
|
|||
48
README.md
|
|
@ -174,12 +174,23 @@ lightcraft --control 7980 ~/Pictures/trip
|
|||
{"method": "ui.screenshot", "params": {"path": "after.png"}}
|
||||
```
|
||||
|
||||
- **90 engine commands** and **48 UI commands** — list them all with `engine.commands`, read every slider's range,
|
||||
- **91 engine commands** and **48 UI commands** — list them all with `engine.commands`, read every slider's range,
|
||||
default and current value with `develop.controls`.
|
||||
- **MCP server** — `lightcraft-cli mcp` gives Claude (or any MCP client) ~100 tools: import, query, develop, mask,
|
||||
render (returned as an image), export — headless, or attached to the running app with screenshots, clicks and
|
||||
gestures. See [docs/mcp.md](docs/mcp.md).
|
||||
|
||||
```sh
|
||||
cargo build --release -p lightcraft-cli
|
||||
claude mcp add lightcraft -- "$PWD/target/release/lightcraft-cli" mcp ~/Pictures/shoot # headless
|
||||
claude mcp add lightcraft-app -- "$PWD/target/release/lightcraft-cli" mcp --connect 127.0.0.1:7980 # live app
|
||||
```
|
||||
- **Scriptable CLI** — `lightcraft-cli render in.dng -o out.jpg --set light.exposure=0.7 --preset …`.
|
||||
- **Undo for everything**, including agent actions: a slider drag (or a scripted burst of updates) is one undo step.
|
||||
- **Every widget is addressable** (`ui.widgets`) and clickable by name, so agents can operate the real UI, not a
|
||||
side door.
|
||||
- The screenshots in this README were produced by [`docs/showcase/`](docs/showcase/) scripts, end to end.
|
||||
Protocol reference: [docs/control-protocol.md](docs/control-protocol.md).
|
||||
|
||||
<br>
|
||||
|
||||
|
|
@ -208,12 +219,15 @@ LightCraft is young and moving fast — see the [roadmap](ROADMAP.md) for estima
|
|||
| Light, Color, Effects, Tone Curve, Color Mixer, Color Grading, B&W | ✅ |
|
||||
| Masking: brush, linear, radial, luminance/colour range, add/subtract/intersect | ✅ (AI subject/sky use classical heuristics for now) |
|
||||
| Crop, straighten, flip, rotate, aspect ratios, overlays | ✅ |
|
||||
| Presets, versions, history, copy/paste/sync settings | ✅ |
|
||||
| Profiles (Color, Neutral, Vivid, Landscape, Portrait, Monochrome — our own looks), presets, versions, history, copy/paste/sync settings | ✅ |
|
||||
| Control channel + every widget addressable | ✅ |
|
||||
| RAW: DNG, CR2 | ✅ · NEF, ARW, CR3, RAF, ORF, RW2… 🚧 |
|
||||
| Detail (sharpening ✅, noise reduction 🚧), Optics, Geometry/Upright | 🚧 |
|
||||
| Remove / Heal / Clone, Red Eye | 🚧 (spots are recorded; rendering lands in M8) |
|
||||
| Export dialog, MCP server, CLI, GPU pipeline, web build | 🚧 |
|
||||
| RAW: DNG, CR2, ARW, NEF (uncompressed) | ✅ · compressed NEF, CR3, RAF, ORF, RW2, PEF… 🚧 |
|
||||
| Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise, Super Resolution ⬜ |
|
||||
| Remove / Heal / Clone spots (auto source) | ✅ · content-aware fill (PatchMatch), Red Eye 🚧 |
|
||||
| Export: JPEG / PNG / TIFF / WebP / AVIF, sizing, file-size limit, output sharpening, naming, batch | ✅ · metadata policy, watermark, DNG ⬜ |
|
||||
| MCP server, CLI, control channel | ✅ |
|
||||
| Optics, Geometry/Upright, GPU pipeline, persistent library | 🚧 |
|
||||
| Web build (same UI in the browser via WASM; in-memory imports, export downloads) | ✅ · persistence, workers 🚧 |
|
||||
|
||||
<br>
|
||||
|
||||
|
|
@ -225,9 +239,14 @@ cargo run --release -p lightcraft # opens your library (~/
|
|||
cargo run --release -p lightcraft -- ~/Pictures/trip # import your photos (folders are scanned, duplicates skipped)
|
||||
cargo run --release -p lightcraft -- --memory # a throwaway in-memory demo session (writes nothing)
|
||||
cargo run --release -p lightcraft -- --control 7980 # with the automation channel
|
||||
cargo xtask web --serve # the same app in the browser: http://127.0.0.1:8080/
|
||||
cargo run --release -p lightcraft-cli -- render photo.jpg -o out.jpg --set light.exposure=0.5
|
||||
cargo xtask ci # fmt, clippy, tests, layering, wasm checks
|
||||
```
|
||||
|
||||
The web build needs the `wasm32-unknown-unknown` target and the matching `wasm-bindgen` CLI
|
||||
(`cargo xtask web` prints the exact install command); see [docs/web.md](docs/web.md).
|
||||
|
||||
Keyboard: <kbd>G</kbd> grid · <kbd>D</kbd> detail · <kbd>E</kbd> edit · <kbd>C</kbd> crop · <kbd>M</kbd> masking ·
|
||||
<kbd>Shift</kbd>+<kbd>P</kbd> presets · <kbd>\\</kbd> original · <kbd>Y</kbd> before/after · <kbd>Z</kbd> zoom ·
|
||||
<kbd>J</kbd> clipping · <kbd>⌘Z</kbd> undo · <kbd>⌘/</kbd> all shortcuts.
|
||||
|
|
@ -236,7 +255,20 @@ Keyboard: <kbd>G</kbd> grid · <kbd>D</kbd> detail · <kbd>E</kbd> edit · <kbd>
|
|||
|
||||
An engine-first Cargo workspace of small, tested crates with enforced layering (`cargo xtask layers`): `geom`,
|
||||
`color`, `raster`, `tiff` → `raw`, `codecs`, `meta`, `develop` → `pipeline` → `catalog` → `engine` → `ui-egui`. The
|
||||
egui frontend is one swappable crate; nothing below it knows a UI exists.
|
||||
egui frontend is one swappable crate; nothing below it knows a UI exists. `mcp` and the apps (`lightcraft`,
|
||||
`lightcraft-cli`) sit on top of `engine`.
|
||||
|
||||
## Contributing
|
||||
|
||||
Humans and agents follow the same rules — read [AGENTS.md](AGENTS.md) first. The short version:
|
||||
|
||||
- **Clean-room.** Never read Adobe binaries or GPL raw/photo code (darktable, RawTherapee, LibRaw, rawspeed, dcraw…);
|
||||
work from public specs and black-box observation.
|
||||
- **No Adobe assets, ever** — no icons, screenshots, presets, profiles, LUTs or fonts from Adobe products. Every
|
||||
image, icon and font in the repo is original, public domain, Creative Commons, OFL or permissively licensed, and has
|
||||
an entry in [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md) added in the same commit.
|
||||
- **Pure Rust**, enforced crate layering, everything is a command, and `cargo xtask ci` green before every commit
|
||||
(one task id per commit).
|
||||
|
||||
<br>
|
||||
|
||||
|
|
@ -259,7 +291,7 @@ Clean-room, pure-Rust creative tools from the same workshop — native on macOS,
|
|||
Showcase photographs are public-domain works, used via Wikimedia Commons: Ansel Adams, *The Tetons and the Snake River*
|
||||
(1942, U.S. National Archives); Dorothea Lange, *Migrant Mother* (1936, Library of Congress); Bill Anders / NASA,
|
||||
*Earthrise* (1968); NASA, *The Blue Marble* (1972). The demo library is procedurally generated by LightCraft. UI font:
|
||||
Source Sans 3 (SIL OFL). All icons are original. See [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md).
|
||||
Inter (SIL OFL). All icons are original. See [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md).
|
||||
|
||||
LightCraft is an independent project and is not affiliated with or endorsed by Adobe. Licence: MIT OR Apache-2.0.
|
||||
By the artcraft team.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
[package]
|
||||
name = "lightcraft-cli"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
description = "LightCraft command-line tool: headless render, command listing and the MCP server"
|
||||
|
||||
[[bin]]
|
||||
name = "lightcraft-cli"
|
||||
path = "src/main.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
lightcraft-engine = { workspace = true }
|
||||
lightcraft-mcp = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
lightcraft-codecs = { workspace = true }
|
||||
lightcraft-raster = { workspace = true }
|
||||
|
|
@ -0,0 +1,232 @@
|
|||
//! `lightcraft-cli`: headless LightCraft.
|
||||
//!
|
||||
//! ```text
|
||||
//! lightcraft-cli mcp [--connect [ADDR]] [--demo] [--compact] [FILES/FOLDERS…]
|
||||
//! lightcraft-cli render <in> -o <out> [--set control=value]… [--settings FILE.json] [--preset ID] [--size N] [--quality Q]
|
||||
//! lightcraft-cli commands [--json]
|
||||
//! lightcraft-cli controls [--json]
|
||||
//! ```
|
||||
|
||||
use std::io::{BufReader, Write};
|
||||
use std::path::Path;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use lightcraft_engine::Session;
|
||||
use lightcraft_mcp::{Backend, DEFAULT_ADDR, Headless, Remote, Server, expand_paths, write_image};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const USAGE: &str = "\
|
||||
lightcraft-cli — headless LightCraft (photo library + raw developer)
|
||||
|
||||
USAGE:
|
||||
lightcraft-cli mcp [OPTIONS] [FILES/FOLDERS…]
|
||||
MCP server (JSON-RPC 2.0 over stdio). Headless by default: an in-process session with the
|
||||
given files imported. Options:
|
||||
--connect [ADDR] drive a running app instead (`lightcraft --control 7980`; default 127.0.0.1:7980)
|
||||
--demo headless: start with the procedurally generated demo library
|
||||
--compact list only the helper tools (every command stays reachable via run_command)
|
||||
lightcraft-cli render <IN> -o <OUT> [OPTIONS]
|
||||
Develop one file and write the result (.png, .jpg, .tif or .webp by extension). Options:
|
||||
--set CONTROL=VALUE set a develop slider, repeatable (e.g. --set light.exposure=0.5)
|
||||
--settings FILE merge a partial develop-settings JSON file
|
||||
--preset ID apply a preset (see `commands`/presets.list)
|
||||
--size N long edge in pixels (default: full size)
|
||||
--quality Q JPEG quality 1..100 (default 92)
|
||||
lightcraft-cli commands [--json] list every command id with its parameters
|
||||
lightcraft-cli controls [--json] list every develop control id with its range
|
||||
lightcraft-cli --version | --help
|
||||
";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let r = match args.first().map(String::as_str) {
|
||||
Some("mcp") => mcp(&args[1..]),
|
||||
Some("render") => render(&args[1..]),
|
||||
Some("commands") => commands(&args[1..]),
|
||||
Some("controls") => controls(&args[1..]),
|
||||
Some("--version" | "-V" | "version") => {
|
||||
println!("lightcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
Ok(())
|
||||
}
|
||||
Some("--help" | "-h" | "help") | None => {
|
||||
print!("{USAGE}");
|
||||
Ok(())
|
||||
}
|
||||
Some(other) => Err(format!("unknown subcommand `{other}`\n\n{USAGE}")),
|
||||
};
|
||||
match r {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("lightcraft-cli: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mcp(args: &[String]) -> Result<(), String> {
|
||||
let mut connect: Option<String> = None;
|
||||
let mut demo = false;
|
||||
let mut compact = false;
|
||||
let mut files = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"--connect" => {
|
||||
// Optional address argument.
|
||||
match args.get(i + 1).filter(|a| !a.starts_with("--") && a.contains(':')) {
|
||||
Some(a) => {
|
||||
connect = Some(a.clone());
|
||||
i += 1;
|
||||
}
|
||||
None => connect = Some(DEFAULT_ADDR.to_string()),
|
||||
}
|
||||
}
|
||||
a if a.starts_with("--connect=") => connect = Some(a["--connect=".len()..].to_string()),
|
||||
"--headless" => connect = None,
|
||||
"--demo" => demo = true,
|
||||
"--compact" => compact = true,
|
||||
a if a.starts_with("--") => return Err(format!("unknown option `{a}`")),
|
||||
f => files.push(f.to_string()),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
let backend: Box<dyn Backend> = match connect {
|
||||
Some(addr) => {
|
||||
if !files.is_empty() || demo {
|
||||
return Err("FILES and --demo apply to headless mode only (import through the `import` tool instead)".into());
|
||||
}
|
||||
match Remote::connect(&addr) {
|
||||
Ok(r) => {
|
||||
eprintln!("lightcraft-cli mcp: connected to LightCraft at {addr}");
|
||||
Box::new(r)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("lightcraft-cli mcp: LightCraft is not reachable at {addr} yet ({e}); will retry on each call");
|
||||
Box::new(Remote::lazy(&addr))
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let mut h = if demo { Headless::demo() } else { Headless::default() };
|
||||
if !files.is_empty() {
|
||||
let paths = expand_paths(&files);
|
||||
let r = h.session.execute("library.import", &json!({"paths": paths})).map_err(|e| e.to_string())?;
|
||||
eprintln!("lightcraft-cli mcp: imported {} photo(s)", r["imported"].as_array().map_or(0, Vec::len));
|
||||
}
|
||||
Box::new(h)
|
||||
}
|
||||
};
|
||||
eprintln!("lightcraft-cli mcp: serving MCP on stdio ({})", backend.describe());
|
||||
let mut server = Server::new(backend).with_command_tools(!compact);
|
||||
let stdin = std::io::stdin();
|
||||
server.serve(BufReader::new(stdin.lock()), std::io::stdout().lock()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn take_value<'a>(args: &'a [String], i: &mut usize, flag: &str) -> Result<&'a str, String> {
|
||||
*i += 1;
|
||||
args.get(*i).map(String::as_str).ok_or_else(|| format!("{flag} needs a value"))
|
||||
}
|
||||
|
||||
fn render(args: &[String]) -> Result<(), String> {
|
||||
let mut input = None;
|
||||
let mut output = None;
|
||||
let mut values = serde_json::Map::new();
|
||||
let mut settings: Option<Value> = None;
|
||||
let mut preset = None;
|
||||
let mut size: Option<u64> = None;
|
||||
let mut quality = 92u8;
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"-o" | "--output" => output = Some(take_value(args, &mut i, "-o")?.to_string()),
|
||||
"--set" => {
|
||||
let kv = take_value(args, &mut i, "--set")?;
|
||||
let (k, v) = kv.split_once('=').ok_or_else(|| format!("--set expects control=value, got `{kv}`"))?;
|
||||
let v: f64 = v.trim().parse().map_err(|_| format!("--set {k}: `{v}` is not a number"))?;
|
||||
values.insert(k.trim().to_string(), json!(v));
|
||||
}
|
||||
"--settings" => {
|
||||
let p = take_value(args, &mut i, "--settings")?;
|
||||
let text = std::fs::read_to_string(p).map_err(|e| format!("{p}: {e}"))?;
|
||||
settings = Some(serde_json::from_str(&text).map_err(|e| format!("{p}: {e}"))?);
|
||||
}
|
||||
"--preset" => preset = Some(take_value(args, &mut i, "--preset")?.to_string()),
|
||||
"--size" => size = Some(take_value(args, &mut i, "--size")?.parse().map_err(|_| "--size expects a number")?),
|
||||
"--quality" => quality = take_value(args, &mut i, "--quality")?.parse().map_err(|_| "--quality expects 1..100")?,
|
||||
a if a.starts_with('-') => return Err(format!("unknown option `{a}`")),
|
||||
f if input.is_none() => input = Some(f.to_string()),
|
||||
f => return Err(format!("unexpected argument `{f}`")),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
let input = input.ok_or("render: missing input file")?;
|
||||
let output = output.ok_or("render: missing -o OUTPUT")?;
|
||||
let mut s = Session::new().with_fs();
|
||||
let abs = expand_paths(std::slice::from_ref(&input));
|
||||
let r = s.execute("library.import", &json!({"paths": abs})).map_err(|e| e.to_string())?;
|
||||
let id = r["imported"][0].as_u64().ok_or_else(|| format!("{input}: not a readable photo"))?;
|
||||
let run = |s: &mut Session, cmd: &str, p: Value| s.execute(cmd, &p).map(|_| ()).map_err(|e| e.to_string());
|
||||
run(&mut s, "library.select", json!({"ids": [id], "active": id}))?;
|
||||
if let Some(p) = preset {
|
||||
run(&mut s, "preset.apply", json!({"id": p}))?;
|
||||
}
|
||||
if let Some(st) = settings {
|
||||
run(&mut s, "develop.merge", json!({"settings": st}))?;
|
||||
}
|
||||
if !values.is_empty() {
|
||||
run(&mut s, "develop.set", json!({"values": values}))?;
|
||||
}
|
||||
let photo = s.catalog.photo(lightcraft_engine::catalog::PhotoId(id)).ok_or("photo vanished")?;
|
||||
let full = photo.width.max(photo.height).max(1) as u64;
|
||||
let edge = size.unwrap_or(full) as usize;
|
||||
let img = s.render_now(lightcraft_engine::catalog::PhotoId(id), edge, edge)?.image;
|
||||
write_image(Path::new(&output), &img, quality)?;
|
||||
eprintln!("lightcraft-cli: wrote {output} ({}×{})", img.width, img.height);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn commands(args: &[String]) -> Result<(), String> {
|
||||
let mut h = Headless::demo();
|
||||
let cmds = h.call("engine.commands", json!({}))?;
|
||||
let mut out = std::io::stdout().lock();
|
||||
if args.iter().any(|a| a == "--json") {
|
||||
writeln!(out, "{}", serde_json::to_string_pretty(&cmds).unwrap_or_default()).map_err(|e| e.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
for c in cmds.as_array().into_iter().flatten() {
|
||||
let sc = c["shortcut"].as_str().map(|s| format!(" [{s}]")).unwrap_or_default();
|
||||
writeln!(
|
||||
out,
|
||||
"{:<28} {}{sc}\n{:<28} params: {}",
|
||||
c["id"].as_str().unwrap_or(""),
|
||||
c["label"].as_str().unwrap_or(""),
|
||||
"",
|
||||
c["params"].as_str().unwrap_or("")
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn controls(args: &[String]) -> Result<(), String> {
|
||||
let mut s = Session::new();
|
||||
let v = s.execute("develop.controls", &json!({})).map_err(|e| e.to_string())?;
|
||||
let mut out = std::io::stdout().lock();
|
||||
if args.iter().any(|a| a == "--json") {
|
||||
writeln!(out, "{}", serde_json::to_string_pretty(&v).unwrap_or_default()).map_err(|e| e.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
for c in v.as_array().into_iter().flatten() {
|
||||
writeln!(
|
||||
out,
|
||||
"{:<28} {:<22} {} .. {} (default {})",
|
||||
c["id"].as_str().unwrap_or(""),
|
||||
c["label"].as_str().unwrap_or(""),
|
||||
c["min"],
|
||||
c["max"],
|
||||
c["default"]
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
//! End-to-end tests of the `lightcraft-cli` binary: `mcp` over real stdio pipes, `render`, `commands`.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_lightcraft-cli");
|
||||
|
||||
fn tmp(name: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("lightcraft-cli-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d.join(name)
|
||||
}
|
||||
|
||||
fn gradient_png(path: &std::path::Path) {
|
||||
let img = lightcraft_raster::Rgba8::from_fn(120, 80, |x, y| [(x * 2) as u8, (y * 3) as u8, 100, 255]);
|
||||
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &Default::default()).unwrap();
|
||||
std::fs::write(path, png).unwrap();
|
||||
}
|
||||
|
||||
fn mean(path: &std::path::Path) -> f64 {
|
||||
let d = lightcraft_codecs::decode(&std::fs::read(path).unwrap(), Default::default()).unwrap();
|
||||
let img = d.to_srgb8();
|
||||
img.data.iter().map(|p| (p[0] as u32 + p[1] as u32 + p[2] as u32) as f64).sum::<f64>() / (3 * img.data.len()) as f64
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_over_stdio_sets_exposure_and_renders() {
|
||||
let input = tmp("in.png");
|
||||
gradient_png(&input);
|
||||
let mut child =
|
||||
Command::new(BIN).args(["mcp", input.to_str().unwrap()]).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 rpc = |id: u64, method: &str, params: Value| -> Value {
|
||||
writeln!(stdin, "{}", json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})).unwrap();
|
||||
stdin.flush().unwrap();
|
||||
let mut line = String::new();
|
||||
stdout.read_line(&mut line).unwrap();
|
||||
let v: Value = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!(v["id"], id);
|
||||
v
|
||||
};
|
||||
let init = rpc(1, "initialize", json!({"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "cli-test", "version": "0"}}));
|
||||
assert_eq!(init["result"]["protocolVersion"], "2025-06-18");
|
||||
let tools = rpc(2, "tools/list", json!({}));
|
||||
assert!(tools["result"]["tools"].as_array().unwrap().len() > 50);
|
||||
let base = tmp("base.png");
|
||||
let bright = tmp("bright.png");
|
||||
let r = rpc(3, "tools/call", json!({"name": "render_photo", "arguments": {"path": base.to_str().unwrap()}}));
|
||||
assert_eq!(r["result"]["isError"], false, "{r}");
|
||||
let r = rpc(4, "tools/call", json!({"name": "set_develop", "arguments": {"values": {"light.exposure": 1.0}}}));
|
||||
assert_eq!(r["result"]["isError"], false, "{r}");
|
||||
let r = rpc(5, "tools/call", json!({"name": "render_photo", "arguments": {"path": bright.to_str().unwrap()}}));
|
||||
assert_eq!(r["result"]["isError"], false, "{r}");
|
||||
assert_eq!(r["result"]["content"][0]["type"], "image");
|
||||
drop(stdin);
|
||||
assert!(child.wait().unwrap().success());
|
||||
let (a, b) = (mean(&base), mean(&bright));
|
||||
println!("cli mcp: mean sRGB exposure 0 → {a:.1}, +1 → {b:.1}");
|
||||
assert!(b > a + 10.0, "{a} → {b}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_subcommand() {
|
||||
let input = tmp("r-in.png");
|
||||
gradient_png(&input);
|
||||
let plain = tmp("r-plain.png");
|
||||
let out = tmp("r-out.jpg");
|
||||
let st = Command::new(BIN).args(["render", input.to_str().unwrap(), "-o", plain.to_str().unwrap()]).status().unwrap();
|
||||
assert!(st.success());
|
||||
let st = Command::new(BIN)
|
||||
.args(["render", input.to_str().unwrap(), "-o", out.to_str().unwrap(), "--set", "light.exposure=1", "--size", "60"])
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(st.success());
|
||||
let d = lightcraft_codecs::decode(&std::fs::read(&out).unwrap(), Default::default()).unwrap();
|
||||
assert_eq!((d.width, d.height), (60, 40));
|
||||
let d = lightcraft_codecs::decode(&std::fs::read(&plain).unwrap(), Default::default()).unwrap();
|
||||
assert_eq!((d.width, d.height), (120, 80));
|
||||
assert!(mean(&out) > mean(&plain) + 10.0);
|
||||
// Bad control id fails cleanly.
|
||||
let o = Command::new(BIN).args(["render", input.to_str().unwrap(), "-o", out.to_str().unwrap(), "--set", "nope=1"]).output().unwrap();
|
||||
assert!(!o.status.success());
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains("unknown control"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_subcommand_lists_registry() {
|
||||
let o = Command::new(BIN).args(["commands", "--json"]).output().unwrap();
|
||||
assert!(o.status.success());
|
||||
let v: Value = serde_json::from_slice(&o.stdout).unwrap();
|
||||
let ids: Vec<&str> = v.as_array().unwrap().iter().filter_map(|c| c["id"].as_str()).collect();
|
||||
for id in ["develop.set", "photo.rate", "library.import", "edit.undo", "app.export"] {
|
||||
assert!(ids.contains(&id), "{id}");
|
||||
}
|
||||
let o = Command::new(BIN).arg("controls").output().unwrap();
|
||||
assert!(String::from_utf8_lossy(&o.stdout).contains("light.exposure"));
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
[package]
|
||||
name = "lightcraft-web"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
description = "LightCraft in the browser: the egui UI compiled to WASM (build with `cargo xtask web`)"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
lightcraft-engine = { workspace = true }
|
||||
lightcraft-ui-egui = { workspace = true }
|
||||
lightcraft-codecs = { workspace = true }
|
||||
lightcraft-raster = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
eframe = { workspace = true, features = ["default_fonts", "glow"] }
|
||||
egui = { workspace = true }
|
||||
wasm-bindgen = { workspace = true }
|
||||
wasm-bindgen-futures = { workspace = true }
|
||||
js-sys = { workspace = true }
|
||||
web-sys = { workspace = true, features = [
|
||||
"Blob",
|
||||
"BlobPropertyBag",
|
||||
"Document",
|
||||
"Element",
|
||||
"File",
|
||||
"FileList",
|
||||
"HtmlAnchorElement",
|
||||
"HtmlCanvasElement",
|
||||
"HtmlElement",
|
||||
"HtmlInputElement",
|
||||
"Location",
|
||||
"Performance",
|
||||
"Url",
|
||||
"Window",
|
||||
] }
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>LightCraft</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<meta name="description" content="LightCraft: open-source photo library and raw developer, running in your browser (WASM).">
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #1c1c1c; }
|
||||
canvas#lightcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
#lightcraft_loading {
|
||||
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
|
||||
color: #b8b8b8; font: 15px/1.4 system-ui, -apple-system, "Segoe UI", sans-serif; pointer-events: none;
|
||||
}
|
||||
#lightcraft_status { position: absolute; left: 0; bottom: 0; width: 1px; height: 1px; overflow: hidden; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="lightcraft_canvas"></canvas>
|
||||
<div id="lightcraft_loading">Loading LightCraft…</div>
|
||||
<div id="lightcraft_status" aria-live="polite"></div>
|
||||
<script type="module">
|
||||
import init from "./lightcraft_web.js";
|
||||
init().catch((e) => {
|
||||
console.error(e);
|
||||
const el = document.getElementById("lightcraft_loading");
|
||||
if (el) el.textContent = "LightCraft failed to load: " + e;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
//! Scripted in-app measurement (`?bench`): open the first photo in Detail, then drive the Exposure
|
||||
//! control exactly like a slider drag (begin interaction → N `develop.set` → end) and time each
|
||||
//! step from the command to the moment the new loupe texture is ready. Results go to the log
|
||||
//! (the browser console on the web) as one `lightcraft-bench {…json…}` line.
|
||||
|
||||
use lightcraft_ui_egui::LightcraftApp;
|
||||
use lightcraft_ui_egui::render::Slot;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const STEPS: usize = 8;
|
||||
|
||||
#[derive(Debug)]
|
||||
enum Phase {
|
||||
/// Wait for the grid's thumbnails to finish.
|
||||
Idle,
|
||||
/// Detail view requested; wait for the loupe.
|
||||
Opening {
|
||||
t0: f64,
|
||||
},
|
||||
/// A step was issued at `t0`; wait for a Main texture with a new key.
|
||||
Step {
|
||||
i: usize,
|
||||
t0: f64,
|
||||
key: u64,
|
||||
},
|
||||
Done,
|
||||
}
|
||||
|
||||
pub struct Bench {
|
||||
phase: Phase,
|
||||
frames: u32,
|
||||
start_ms: f64,
|
||||
first_frame_ms: Option<f64>,
|
||||
thumbs_done_ms: Option<f64>,
|
||||
open_ms: f64,
|
||||
drafts: Vec<f64>,
|
||||
jobs: Vec<f64>,
|
||||
final_ms: f64,
|
||||
}
|
||||
|
||||
impl Bench {
|
||||
/// `start_ms`: the page's time origin in the same clock as [`lightcraft_ui_egui::now_ms`].
|
||||
pub fn new(start_ms: f64) -> Self {
|
||||
Bench {
|
||||
phase: Phase::Idle,
|
||||
frames: 0,
|
||||
start_ms,
|
||||
first_frame_ms: None,
|
||||
thumbs_done_ms: None,
|
||||
open_ms: 0.0,
|
||||
drafts: vec![],
|
||||
jobs: vec![],
|
||||
final_ms: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn main_key(app: &LightcraftApp) -> Option<u64> {
|
||||
app.renderer.textures.get(&Slot::Main).map(|t| t.key)
|
||||
}
|
||||
|
||||
/// Call once per frame after `LightcraftApp::logic`. Returns the report when finished.
|
||||
pub fn step(&mut self, app: &mut LightcraftApp) -> Option<Value> {
|
||||
let now = lightcraft_ui_egui::now_ms();
|
||||
self.frames += 1;
|
||||
if self.first_frame_ms.is_none() && self.frames > 1 {
|
||||
self.first_frame_ms = Some(now - self.start_ms);
|
||||
}
|
||||
let idle = app.renderer.queued() == 0;
|
||||
match self.phase {
|
||||
Phase::Idle => {
|
||||
let thumbs = app.renderer.textures.keys().filter(|s| matches!(s, Slot::Thumb(_))).count();
|
||||
if idle && thumbs > 0 && self.frames > 3 {
|
||||
self.thumbs_done_ms = Some(now - self.start_ms);
|
||||
let first = app.session.visible().first().copied();
|
||||
if let Some(id) = first {
|
||||
let _ = app.run("library.select", json!({"ids": [id.0]}));
|
||||
}
|
||||
let _ = app.run("view.detail", json!({}));
|
||||
self.phase = Phase::Opening { t0: now };
|
||||
}
|
||||
}
|
||||
Phase::Opening { t0 } => {
|
||||
if idle && Self::main_key(app).is_some() && self.frames > 3 {
|
||||
self.open_ms = now - t0;
|
||||
let _ = app.run("develop.beginInteraction", json!({"label": "Exposure"}));
|
||||
self.issue(app, 0, now);
|
||||
}
|
||||
}
|
||||
Phase::Step { i, t0, key } => {
|
||||
if idle && Self::main_key(app).is_some_and(|k| k != key) {
|
||||
let dt = now - t0;
|
||||
if i < STEPS {
|
||||
self.drafts.push(dt);
|
||||
self.jobs.push(app.renderer.last_main_ms);
|
||||
if i + 1 < STEPS {
|
||||
self.issue(app, i + 1, now);
|
||||
} else {
|
||||
// release: the full-resolution render after the drag
|
||||
let key = Self::main_key(app).unwrap_or(0);
|
||||
let _ = app.run("develop.endInteraction", json!({}));
|
||||
let _ = app.run("develop.set", json!({"control": "light.exposure", "value": 0.0}));
|
||||
self.phase = Phase::Step { i: STEPS, t0: now, key };
|
||||
}
|
||||
} else {
|
||||
self.final_ms = dt;
|
||||
self.phase = Phase::Done;
|
||||
return Some(self.report(app));
|
||||
}
|
||||
}
|
||||
}
|
||||
Phase::Done => {}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn issue(&mut self, app: &mut LightcraftApp, i: usize, now: f64) {
|
||||
let key = Self::main_key(app).unwrap_or(0);
|
||||
let v = 0.15 * (i as f64 + 1.0);
|
||||
let _ = app.run("develop.set", json!({"control": "light.exposure", "value": v}));
|
||||
self.phase = Phase::Step { i, t0: now, key };
|
||||
}
|
||||
|
||||
fn report(&self, app: &LightcraftApp) -> Value {
|
||||
let mut d = self.drafts.clone();
|
||||
d.sort_by(f64::total_cmp);
|
||||
let median = d.get(d.len() / 2).copied().unwrap_or(0.0);
|
||||
let mean = if d.is_empty() { 0.0 } else { d.iter().sum::<f64>() / d.len() as f64 };
|
||||
let tex = app.renderer.textures.get(&Slot::Main).map(|t| t.size).unwrap_or([0, 0]);
|
||||
let r = |x: f64| (x * 10.0).round() / 10.0;
|
||||
json!({
|
||||
"first_frame_ms": self.first_frame_ms.map(r),
|
||||
"thumbs_done_ms": self.thumbs_done_ms.map(r),
|
||||
"open_detail_ms": r(self.open_ms),
|
||||
"slider_draft_ms": d.iter().copied().map(r).collect::<Vec<_>>(),
|
||||
"slider_draft_median_ms": r(median),
|
||||
"slider_draft_mean_ms": r(mean),
|
||||
"slider_job_ms": self.jobs.iter().copied().map(r).collect::<Vec<_>>(),
|
||||
"release_full_ms": r(self.final_ms),
|
||||
"loupe_px": tex,
|
||||
"frames": self.frames,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
//! LightCraft in the browser: the same egui UI as the desktop app, compiled to WASM.
|
||||
//!
|
||||
//! Build with `cargo xtask web` (→ `target/web/`), then serve that folder over HTTP.
|
||||
//! Differences from the desktop host:
|
||||
//! - no filesystem: photos picked (File ▸ Add Photos…) or dropped onto the page are held in
|
||||
//! memory ([`store::MemStore`]) and decoded from bytes;
|
||||
//! - export downloads the file through the browser;
|
||||
//! - renders run inline on the main thread (one job per frame) — no worker threads yet;
|
||||
//! - no persistence yet (UI prefs and the catalog reset on reload);
|
||||
//! - the procedural demo library is loaded at start so the page is never empty.
|
||||
//!
|
||||
//! `?bench` in the URL runs a scripted first-paint / slider-latency measurement and logs it to the
|
||||
//! console (see [`bench`]).
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod bench;
|
||||
pub mod store;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
//! In-memory "filesystem" for the web build: files picked or dropped in the browser are kept as
|
||||
//! bytes under synthetic paths (`mem/<n>/<name>`), and the engine reads them through the same
|
||||
//! [`FileLoader`]/[`FileProbe`] hooks the desktop app backs with `std::fs`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use lightcraft_engine::files::{load_bytes, probe_bytes};
|
||||
use lightcraft_engine::media::{FileLoader, FileProbe};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Inner {
|
||||
files: HashMap<String, Arc<[u8]>>,
|
||||
/// Paths added since the last [`MemStore::take_pending`] (to import on the next frame).
|
||||
pending: Vec<String>,
|
||||
next: u64,
|
||||
}
|
||||
|
||||
/// Shared, cloneable byte store. File reads in the browser are async, so additions arrive from
|
||||
/// promise callbacks and are picked up by the app on its next frame.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct MemStore(Arc<Mutex<Inner>>);
|
||||
|
||||
impl MemStore {
|
||||
fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
|
||||
self.0.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Store a file's bytes; returns its synthetic path and queues it for import.
|
||||
pub fn add(&self, name: &str, bytes: Vec<u8>) -> String {
|
||||
let mut g = self.lock();
|
||||
g.next += 1;
|
||||
let name = name.rsplit(['/', '\\']).next().filter(|n| !n.is_empty()).unwrap_or("photo");
|
||||
let path = format!("mem/{}/{name}", g.next);
|
||||
g.files.insert(path.clone(), bytes.into());
|
||||
g.pending.push(path.clone());
|
||||
path
|
||||
}
|
||||
|
||||
pub fn get(&self, path: &str) -> Option<Arc<[u8]>> {
|
||||
self.lock().files.get(path).cloned()
|
||||
}
|
||||
|
||||
/// Paths added since the last call.
|
||||
pub fn take_pending(&self) -> Vec<String> {
|
||||
std::mem::take(&mut self.lock().pending)
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.lock().files.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
|
||||
/// Engine hooks that decode/probe from this store.
|
||||
pub fn hooks(&self) -> (FileLoader, FileProbe) {
|
||||
let s = self.clone();
|
||||
let loader: FileLoader = Arc::new(move |path: &str, max_edge: usize| {
|
||||
let bytes = s.get(path).ok_or_else(|| format!("{path}: not in memory"))?;
|
||||
load_bytes(&bytes, max_edge)
|
||||
});
|
||||
let s = self.clone();
|
||||
let probe: FileProbe = Arc::new(move |path: &str| {
|
||||
let bytes = s.get(path).ok_or_else(|| format!("{path}: not in memory"))?;
|
||||
probe_bytes(path, &bytes)
|
||||
});
|
||||
(loader, probe)
|
||||
}
|
||||
|
||||
/// Install the hooks into a session.
|
||||
pub fn install(&self, session: &mut lightcraft_engine::Session) {
|
||||
let (l, p) = self.hooks();
|
||||
session.media.file_loader = Some(l);
|
||||
session.media.file_probe = Some(p);
|
||||
}
|
||||
}
|
||||
|
||||
/// File name part of an export path (the browser download name).
|
||||
pub fn download_name(path: &str) -> &str {
|
||||
path.rsplit(['/', '\\']).next().filter(|n| !n.is_empty()).unwrap_or("export.png")
|
||||
}
|
||||
|
||||
/// MIME type for a download, by extension.
|
||||
pub fn mime_for(name: &str) -> &'static str {
|
||||
match name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()).as_deref() {
|
||||
Some("jpg" | "jpeg") => "image/jpeg",
|
||||
Some("png") => "image/png",
|
||||
Some("tif" | "tiff") => "image/tiff",
|
||||
Some("webp") => "image/webp",
|
||||
Some("avif") => "image/avif",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn png_bytes(w: usize, h: usize) -> Vec<u8> {
|
||||
let bytes: Vec<u8> = (0..w * h).flat_map(|i| [(i * 7) as u8, 128, 200, 255]).collect();
|
||||
let img = lightcraft_raster::Rgba8::from_bytes(w, h, &bytes).unwrap();
|
||||
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_take_get() {
|
||||
let s = MemStore::default();
|
||||
let a = s.add("C:\\x\\a.png", vec![1, 2]);
|
||||
let b = s.add("a.png", vec![3]);
|
||||
assert_ne!(a, b, "same name twice gets distinct paths");
|
||||
assert!(a.ends_with("/a.png"));
|
||||
assert_eq!(s.take_pending(), vec![a.clone(), b]);
|
||||
assert!(s.take_pending().is_empty());
|
||||
assert_eq!(&*s.get(&a).unwrap(), &[1, 2]);
|
||||
assert_eq!(s.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_and_render_from_memory() {
|
||||
let store = MemStore::default();
|
||||
let mut session = lightcraft_engine::Session::new();
|
||||
store.install(&mut session);
|
||||
let path = store.add("tiny.png", png_bytes(40, 30));
|
||||
let r = session.execute("library.import", &json!({"paths": [path]})).unwrap();
|
||||
let id = lightcraft_engine::catalog::PhotoId(r["imported"][0].as_u64().unwrap());
|
||||
let p = session.catalog.photo(id).unwrap();
|
||||
assert_eq!((p.width, p.height), (40, 30));
|
||||
assert_eq!(p.file_name, "tiny.png");
|
||||
let out = session.render_now(id, 20, 20).unwrap();
|
||||
assert_eq!(out.image.width, 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_names() {
|
||||
assert_eq!(download_name("dir/x-lightcraft.png"), "x-lightcraft.png");
|
||||
assert_eq!(download_name("y.png"), "y.png");
|
||||
assert_eq!(mime_for("a.JPG"), "image/jpeg");
|
||||
assert_eq!(mime_for("a"), "application/octet-stream");
|
||||
assert_eq!(download_name(""), "export.png");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
//! Browser host: starts eframe on `<canvas id="lightcraft_canvas">`, wires the in-memory store to
|
||||
//! the file picker and drag-and-drop, and turns exports into downloads.
|
||||
|
||||
use lightcraft_engine::Session;
|
||||
use lightcraft_ui_egui::{LightcraftApp, Services};
|
||||
use serde_json::json;
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::bench::Bench;
|
||||
use crate::store::{MemStore, download_name};
|
||||
|
||||
const ACCEPT: &str = ".jpg,.jpeg,.png,.tif,.tiff,.webp,.dng,.cr2,.nef,.arw,.psd,.jxl,.gif,.bmp";
|
||||
|
||||
fn window() -> web_sys::Window {
|
||||
web_sys::window().expect("no window")
|
||||
}
|
||||
|
||||
/// Milliseconds since navigation start.
|
||||
fn perf_now() -> f64 {
|
||||
window().performance().map(|p| p.now()).unwrap_or(0.0)
|
||||
}
|
||||
|
||||
fn set_status(text: &str) {
|
||||
if let Some(el) = window().document().and_then(|d| d.get_element_by_id("lightcraft_status")) {
|
||||
el.set_text_content(Some(text));
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a browser `File` into the store.
|
||||
async fn read_file(store: MemStore, file: web_sys::File, ctx: egui::Context) {
|
||||
match wasm_bindgen_futures::JsFuture::from(file.array_buffer()).await {
|
||||
Ok(buf) => {
|
||||
store.add(&file.name(), js_sys::Uint8Array::new(&buf).to_vec());
|
||||
ctx.request_repaint();
|
||||
}
|
||||
Err(e) => log::warn!("reading {}: {e:?}", file.name()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Show the browser's open dialog; picked files land in the store asynchronously.
|
||||
fn open_picker(store: MemStore, ctx: egui::Context) {
|
||||
let Some(doc) = window().document() else { return };
|
||||
let Ok(input) = doc.create_element("input").map(|e| e.unchecked_into::<web_sys::HtmlInputElement>()) else { return };
|
||||
input.set_type("file");
|
||||
input.set_multiple(true);
|
||||
input.set_accept(ACCEPT);
|
||||
let inp = input.clone();
|
||||
let on_change = Closure::<dyn FnMut()>::new(move || {
|
||||
if let Some(files) = inp.files() {
|
||||
for i in 0..files.length() {
|
||||
if let Some(f) = files.get(i) {
|
||||
wasm_bindgen_futures::spawn_local(read_file(store.clone(), f, ctx.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
input.set_onchange(Some(on_change.as_ref().unchecked_ref()));
|
||||
on_change.forget();
|
||||
input.click();
|
||||
}
|
||||
|
||||
/// Offer `bytes` as a download named after `path`.
|
||||
fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
let e = |e: JsValue| format!("download failed: {e:?}");
|
||||
let arr = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
|
||||
let opts = web_sys::BlobPropertyBag::new();
|
||||
let name = download_name(path);
|
||||
opts.set_type(crate::store::mime_for(name));
|
||||
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&arr, &opts).map_err(e)?;
|
||||
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(e)?;
|
||||
let doc = window().document().ok_or("no document")?;
|
||||
let a: web_sys::HtmlAnchorElement = doc.create_element("a").map_err(e)?.unchecked_into();
|
||||
a.set_href(&url);
|
||||
a.set_download(name);
|
||||
a.click();
|
||||
// revoke once the click has been handled
|
||||
let revoke = Closure::once_into_js(move || {
|
||||
let _ = web_sys::Url::revoke_object_url(&url);
|
||||
});
|
||||
let _ = window().set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 5000);
|
||||
log::info!("exported {name} ({} bytes)", bytes.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn services(store: MemStore, ctx: egui::Context) -> Services {
|
||||
Services {
|
||||
pick_files: Some(Box::new(move || {
|
||||
open_picker(store.clone(), ctx.clone());
|
||||
Vec::new() // files arrive asynchronously and are imported on a later frame
|
||||
})),
|
||||
write: Some(Box::new(download)),
|
||||
png: Some(Box::new(|img: &lightcraft_raster::Rgba8| {
|
||||
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default()
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
struct WebApp {
|
||||
app: LightcraftApp,
|
||||
store: MemStore,
|
||||
bench: Option<Bench>,
|
||||
first_frame_logged: bool,
|
||||
}
|
||||
|
||||
impl WebApp {
|
||||
fn new(cc: &eframe::CreationContext<'_>, bench: bool) -> Self {
|
||||
let store = MemStore::default();
|
||||
let t = perf_now();
|
||||
let mut session = Session::with_demo();
|
||||
log::info!("lightcraft: wasm started at {:.0} ms; demo library built in {:.0} ms", t, perf_now() - t);
|
||||
store.install(&mut session);
|
||||
// previews are large (≤ 2560 px, f32): keep few in a 32-bit address space
|
||||
session.media.preview_capacity = 3;
|
||||
let mut app = LightcraftApp::new(session, services(store.clone(), cc.egui_ctx.clone()));
|
||||
app.ui = app.ui.sanitized();
|
||||
let origin = lightcraft_ui_egui::now_ms() - perf_now();
|
||||
WebApp { app, store, bench: bench.then(|| Bench::new(origin)), first_frame_logged: false }
|
||||
}
|
||||
|
||||
fn import_dropped(&mut self, ctx: &egui::Context) {
|
||||
let dropped = ctx.input(|i| i.raw.dropped_files.clone());
|
||||
for f in dropped {
|
||||
let (store, ctx) = (self.store.clone(), ctx.clone());
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let name = f.path().to_string_lossy().to_string();
|
||||
match f.bytes_async().await {
|
||||
Ok(bytes) => {
|
||||
store.add(&name, bytes);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
Err(e) => log::warn!("reading dropped {name}: {e}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
let paths = self.store.take_pending();
|
||||
if !paths.is_empty() {
|
||||
let n = paths.len();
|
||||
match self.app.run("library.import", json!({"paths": paths})) {
|
||||
Ok(_) => self.app.toast(ctx, format!("Added {n} photo{}", if n == 1 { "" } else { "s" })),
|
||||
Err(e) => log::warn!("import: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for WebApp {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.import_dropped(ctx);
|
||||
self.app.logic(ctx);
|
||||
if let Some(b) = self.bench.as_mut()
|
||||
&& let Some(report) = b.step(&mut self.app)
|
||||
{
|
||||
log::info!("lightcraft-bench {report}");
|
||||
set_status(&format!("bench: {report}"));
|
||||
self.bench = None;
|
||||
}
|
||||
if self.bench.is_some() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.app.raw_input_hook(raw);
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.app.ui(ui);
|
||||
if !self.first_frame_logged && !self.app.widgets.is_empty() {
|
||||
self.first_frame_logged = true;
|
||||
log::info!("lightcraft first UI frame at {:.0} ms after navigation start", perf_now());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(start)]
|
||||
pub fn start() {
|
||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||
log::info!("lightcraft: wasm instantiated at {:.0} ms", perf_now());
|
||||
let bench = window().location().search().is_ok_and(|q| q.contains("bench"));
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let Some(canvas) =
|
||||
window().document().and_then(|d| d.get_element_by_id("lightcraft_canvas")).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok())
|
||||
else {
|
||||
log::error!("missing <canvas id=\"lightcraft_canvas\">");
|
||||
return;
|
||||
};
|
||||
let runner = eframe::WebRunner::new();
|
||||
let r = runner.start(canvas, eframe::WebOptions::default(), Box::new(move |cc| Ok(Box::new(WebApp::new(cc, bench))))).await;
|
||||
match r {
|
||||
Ok(()) => {
|
||||
if let Some(el) = window().document().and_then(|d| d.get_element_by_id("lightcraft_loading")) {
|
||||
el.remove();
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("LightCraft failed to start: {e:?}");
|
||||
set_status(&format!("LightCraft failed to start: {e:?}"));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -85,7 +85,12 @@ fn services() -> Services {
|
|||
.map(|p| p.to_string_lossy().to_string())
|
||||
.collect()
|
||||
})),
|
||||
write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))),
|
||||
write: Some(Box::new(|p: &str, b: &[u8]| {
|
||||
if let Some(dir) = std::path::Path::new(p).parent().filter(|d| !d.as_os_str().is_empty()) {
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
std::fs::write(p, b).map_err(|e| e.to_string())
|
||||
})),
|
||||
png: Some(Box::new(|img: &lightcraft_raster::Rgba8| {
|
||||
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default()
|
||||
})),
|
||||
|
|
|
|||
|
|
@ -2,19 +2,21 @@
|
|||
|
||||
Every asset shipped in this repository is listed here (see the asset rules in `AGENTS.md`). No asset may come from
|
||||
Adobe products. Add an entry in the same commit as the asset.
|
||||
`cargo xtask assets` (part of `cargo xtask ci`) fails if any image, icon, font, sound, video, raw file or colour profile
|
||||
in the repository is not matched by a path pattern in the first column (`*` = any characters except `/`; a trailing `/`
|
||||
covers a whole directory), or if a licence file referenced in the Licence column is missing.
|
||||
|
||||
| Path | Asset | Creator | Source | Licence | Added | Modifications |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `assets/fonts/SourceSans3-Regular.ttf`, `-Semibold.ttf`, `-Bold.ttf`, `-It.ttf` | Source Sans 3 | Paul D. Hunt | https://github.com/adobe-fonts/source-sans | SIL Open Font License 1.1 (`assets/fonts/OFL-SourceSans3.txt`) | 2026-09-30 | none |
|
||||
| `assets/fonts/Inter-Regular.ttf`, `-Medium.ttf`, `-SemiBold.ttf` | Inter | Rasmus Andersson | https://github.com/rsms/inter | SIL Open Font License 1.1 (`assets/fonts/OFL-Inter.txt`) | 2026-09-30 | none |
|
||||
| `assets/fonts/Inter-*.ttf` (Regular, Medium, SemiBold) | Inter | Rasmus Andersson | https://github.com/rsms/inter | SIL Open Font License 1.1 (`assets/fonts/OFL-Inter.txt`) | 2026-09-30 | none |
|
||||
| `crates/scenes/` (generated images) | Procedural demo photographs | LightCraft contributors | original work (generated at runtime, no source imagery) | MIT OR Apache-2.0 | 2026-09-30 | n/a |
|
||||
| `crates/ui-egui/src/icons.rs` | UI icons drawn as vector paths in code | LightCraft contributors | original work | MIT OR Apache-2.0 | 2026-09-30 | n/a |
|
||||
| `docs/images/*-tetons.jpg`, `grid-pd.jpg` (screenshots containing it) | "The Tetons and the Snake River" | Ansel Adams (1942), U.S. National Archives | https://commons.wikimedia.org/wiki/File:Adams_The_Tetons_and_the_Snake_River.jpg | Public domain (U.S. federal government work) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
|
||||
| `docs/images/*-migrant-mother.jpg`, `grid-pd.jpg` | "Migrant Mother" | Dorothea Lange (1936), Farm Security Administration / Library of Congress | https://commons.wikimedia.org/wiki/File:Lange-MigrantMother02.jpg | Public domain (U.S. federal government work) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
|
||||
| `docs/images/*-earthrise.jpg`, `grid-pd.jpg` | "Earthrise" (AS08-14-2383) | NASA / Bill Anders, Apollo 8 (1968) | https://commons.wikimedia.org/wiki/File:NASA-Apollo8-Dec24-Earthrise.jpg | Public domain (NASA) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
|
||||
| `docs/images/*-blue-marble.jpg`, `grid-pd.jpg` | "The Blue Marble" (AS17-148-22727) | NASA, Apollo 17 crew (1972) | https://commons.wikimedia.org/wiki/File:The_Earth_seen_from_Apollo_17.jpg | Public domain (NASA) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
|
||||
| `docs/images/*-tetons.jpg`, `docs/images/grid-pd.jpg` (screenshots containing it) | "The Tetons and the Snake River" | Ansel Adams (1942), U.S. National Archives | https://commons.wikimedia.org/wiki/File:Adams_The_Tetons_and_the_Snake_River.jpg | Public domain (U.S. federal government work) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
|
||||
| `docs/images/*-migrant-mother.jpg`, `docs/images/grid-pd.jpg` | "Migrant Mother" | Dorothea Lange (1936), Farm Security Administration / Library of Congress | https://commons.wikimedia.org/wiki/File:Lange-MigrantMother02.jpg | Public domain (U.S. federal government work) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
|
||||
| `docs/images/*-earthrise.jpg`, `docs/images/grid-pd.jpg` | "Earthrise" (AS08-14-2383) | NASA / Bill Anders, Apollo 8 (1968) | https://commons.wikimedia.org/wiki/File:NASA-Apollo8-Dec24-Earthrise.jpg | Public domain (NASA) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
|
||||
| `docs/images/*-blue-marble.jpg`, `docs/images/grid-pd.jpg` | "The Blue Marble" (AS17-148-22727) | NASA, Apollo 17 crew (1972) | https://commons.wikimedia.org/wiki/File:The_Earth_seen_from_Apollo_17.jpg | Public domain (NASA) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
|
||||
| `docs/images/*.jpg` (UI) | LightCraft application screenshots | LightCraft contributors | original work (captured with `docs/showcase/`) | MIT OR Apache-2.0 | 2026-09-30 | n/a |
|
||||
|
||||
Notes
|
||||
- Source Sans 3 is an open-source (OFL) typeface; it is used as a general-purpose UI font. It is distributed under the
|
||||
OFL and is not an asset taken from any Adobe application.
|
||||
- Fonts authored or published by Adobe (Source Sans/Serif/Code, Source Han, …) are not used, even though some are
|
||||
OFL-licensed: the asset rule excludes anything from Adobe. Source Sans 3 was removed on 2026-09-30 and replaced by Inter.
|
||||
|
|
|
|||
|
|
@ -1,93 +0,0 @@
|
|||
Copyright 2010-2024 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
|
||||
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
|
@ -0,0 +1,384 @@
|
|||
//! Export: encode a rendered image to JPEG / PNG / TIFF / WebP / AVIF with an embedded sRGB profile,
|
||||
//! optional output sharpening and a JPEG file-size limit. Pure (bytes in, bytes out) so the desktop
|
||||
//! app, CLI, MCP and the web build share it; writing the file is the caller's job.
|
||||
|
||||
use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, NamedSpace, TiffCompression, encode, icc};
|
||||
use lightcraft_meta::{DateTime, Gps, Metadata};
|
||||
use lightcraft_raster::Rgba8;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ExportFormat {
|
||||
#[default]
|
||||
Jpeg,
|
||||
Png,
|
||||
Tiff,
|
||||
Webp,
|
||||
Avif,
|
||||
}
|
||||
|
||||
impl ExportFormat {
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
Some(match s.to_ascii_lowercase().as_str() {
|
||||
"jpeg" | "jpg" => Self::Jpeg,
|
||||
"png" => Self::Png,
|
||||
"tiff" | "tif" => Self::Tiff,
|
||||
"webp" => Self::Webp,
|
||||
"avif" => Self::Avif,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
pub fn extension(self) -> &'static str {
|
||||
match self {
|
||||
Self::Jpeg => "jpg",
|
||||
Self::Png => "png",
|
||||
Self::Tiff => "tif",
|
||||
Self::Webp => "webp",
|
||||
Self::Avif => "avif",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Output sharpening target (applied after resizing, in display-encoded values).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SharpenFor {
|
||||
#[default]
|
||||
None,
|
||||
Screen,
|
||||
Matte,
|
||||
Glossy,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SharpenAmount {
|
||||
Low,
|
||||
#[default]
|
||||
Standard,
|
||||
High,
|
||||
}
|
||||
|
||||
/// Which metadata is embedded in exported files.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum MetadataPolicy {
|
||||
/// Everything we know: camera, capture settings, lens, location, title, caption, keywords, copyright.
|
||||
#[default]
|
||||
All,
|
||||
/// Everything except camera/lens make, model and capture settings.
|
||||
AllExceptCamera,
|
||||
/// Copyright and creator only.
|
||||
Copyright,
|
||||
/// Nothing (the sRGB profile is still embedded).
|
||||
None,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ExportOptions {
|
||||
pub format: ExportFormat,
|
||||
/// 1–100 (JPEG, AVIF).
|
||||
pub quality: u8,
|
||||
/// Resize so the long edge is at most this many pixels (`None` = full size).
|
||||
pub long_edge: Option<u32>,
|
||||
/// JPEG only: largest quality whose file fits in this many KB.
|
||||
pub limit_kb: Option<u32>,
|
||||
pub sharpen: SharpenFor,
|
||||
pub sharpen_amount: SharpenAmount,
|
||||
/// File name template: `{name}` (original stem), `{seq}` (1-based, zero-padded to 3), `{ext}` is appended.
|
||||
pub naming: String,
|
||||
pub metadata: MetadataPolicy,
|
||||
/// Strip GPS / location even when the policy would include it.
|
||||
pub remove_location: bool,
|
||||
}
|
||||
|
||||
impl Default for ExportOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
format: ExportFormat::Jpeg,
|
||||
quality: 90,
|
||||
long_edge: None,
|
||||
limit_kb: None,
|
||||
sharpen: SharpenFor::None,
|
||||
sharpen_amount: SharpenAmount::Standard,
|
||||
naming: "{name}".into(),
|
||||
metadata: MetadataPolicy::All,
|
||||
remove_location: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ExportOptions {
|
||||
/// Read options from command params (`format`, `quality`, `longEdge`, `limitKb`, `sharpen`, `sharpenAmount`, `naming`).
|
||||
pub fn from_json(p: &serde_json::Value) -> Self {
|
||||
use serde_json::Value;
|
||||
let d = Self::default();
|
||||
let s = |k: &str| p.get(k).and_then(Value::as_str);
|
||||
let u = |k: &str| p.get(k).and_then(Value::as_u64);
|
||||
fn enm<T: serde::de::DeserializeOwned>(p: &Value, k: &str) -> Option<T> {
|
||||
p.get(k).cloned().and_then(|v| serde_json::from_value(v).ok())
|
||||
}
|
||||
Self {
|
||||
format: s("format").and_then(ExportFormat::parse).unwrap_or(d.format),
|
||||
quality: u("quality").map_or(d.quality, |q| q.clamp(1, 100) as u8),
|
||||
long_edge: u("longEdge").filter(|v| *v > 0).map(|v| v.min(65_535) as u32),
|
||||
limit_kb: u("limitKb").filter(|v| *v > 0).map(|v| v.min(u32::MAX as u64) as u32),
|
||||
sharpen: enm(p, "sharpen").unwrap_or(d.sharpen),
|
||||
sharpen_amount: enm(p, "sharpenAmount").unwrap_or(d.sharpen_amount),
|
||||
naming: s("naming").map_or(d.naming, str::to_string),
|
||||
metadata: enm(p, "metadata").unwrap_or(d.metadata),
|
||||
remove_location: p.get("removeLocation").and_then(Value::as_bool).unwrap_or(d.remove_location),
|
||||
}
|
||||
}
|
||||
|
||||
/// Output file name for photo `stem` at 1-based position `seq` in a batch.
|
||||
pub fn file_name(&self, stem: &str, seq: usize) -> String {
|
||||
let base = if self.naming.trim().is_empty() { "{name}" } else { self.naming.as_str() };
|
||||
let name = base.replace("{name}", stem).replace("{seq}", &format!("{seq:03}"));
|
||||
let name: String = name.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '\0') { '_' } else { c }).collect();
|
||||
format!("{name}.{}", self.format.extension())
|
||||
}
|
||||
}
|
||||
|
||||
/// Unsharp mask on an 8-bit image (separable box-blur approximation of a small Gaussian).
|
||||
pub fn output_sharpen(img: &mut Rgba8, target: SharpenFor, amount: SharpenAmount) {
|
||||
let (radius, base) = match target {
|
||||
SharpenFor::None => return,
|
||||
SharpenFor::Screen => (1usize, 0.35f32),
|
||||
SharpenFor::Matte => (2, 0.6),
|
||||
SharpenFor::Glossy => (1, 0.5),
|
||||
};
|
||||
let k = base
|
||||
* match amount {
|
||||
SharpenAmount::Low => 0.6,
|
||||
SharpenAmount::Standard => 1.0,
|
||||
SharpenAmount::High => 1.5,
|
||||
};
|
||||
let (w, h) = (img.width, img.height);
|
||||
if w < 3 || h < 3 {
|
||||
return;
|
||||
}
|
||||
let src: Vec<[f32; 3]> = img.data.iter().map(|p| [p[0] as f32, p[1] as f32, p[2] as f32]).collect();
|
||||
let r = radius as isize;
|
||||
let box_pass = |inp: &[[f32; 3]], horizontal: bool| -> Vec<[f32; 3]> {
|
||||
let mut out = vec![[0.0; 3]; inp.len()];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let mut acc = [0.0f32; 3];
|
||||
for d in -r..=r {
|
||||
let (sx, sy) = if horizontal {
|
||||
((x as isize + d).clamp(0, w as isize - 1) as usize, y)
|
||||
} else {
|
||||
(x, (y as isize + d).clamp(0, h as isize - 1) as usize)
|
||||
};
|
||||
let p = inp[sy * w + sx];
|
||||
acc = [acc[0] + p[0], acc[1] + p[1], acc[2] + p[2]];
|
||||
}
|
||||
let n = (2 * r + 1) as f32;
|
||||
out[y * w + x] = acc.map(|v| v / n);
|
||||
}
|
||||
}
|
||||
out
|
||||
};
|
||||
let blur = box_pass(&box_pass(&src, true), false);
|
||||
for (i, p) in img.data.iter_mut().enumerate() {
|
||||
for c in 0..3 {
|
||||
let v = src[i][c] + (src[i][c] - blur[i][c]) * k;
|
||||
p[c] = v.round().clamp(0.0, 255.0) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode a rendered (display-referred sRGB) image according to `o`. Resizing to `long_edge` is the
|
||||
/// caller's job (render at that size); sharpening is applied here.
|
||||
pub fn encode_image(img: &Rgba8, o: &ExportOptions) -> Result<Vec<u8>, String> {
|
||||
encode_with_metadata(img, o, None)
|
||||
}
|
||||
|
||||
/// Like [`encode_image`], embedding `meta` (already filtered by the policy) as EXIF + XMP.
|
||||
pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metadata>) -> Result<Vec<u8>, String> {
|
||||
let mut img = img.clone();
|
||||
output_sharpen(&mut img, o.sharpen, o.sharpen_amount);
|
||||
let profile = icc::write_named(NamedSpace::Srgb);
|
||||
let exif = meta.map(lightcraft_meta::write_exif);
|
||||
let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None));
|
||||
let meta = EncodeMeta { icc: Some(&profile), exif: exif.as_deref(), xmp: xmp.as_deref() };
|
||||
let e = EncodeImage::rgba8(&img);
|
||||
let r = match o.format {
|
||||
ExportFormat::Jpeg => {
|
||||
// 4:4:4 normally; 4:2:0 when targeting a file size (much smaller at equal visual quality).
|
||||
let sub = if o.limit_kb.is_some() { ChromaSubsampling::S420 } else { ChromaSubsampling::S444 };
|
||||
let jpeg = |q: u8| encode::encode_jpeg(&e, q, sub, &meta);
|
||||
match o.limit_kb {
|
||||
Some(kb) => {
|
||||
let limit = kb as usize * 1024;
|
||||
// Binary search for the highest quality that fits.
|
||||
let (mut lo, mut hi) = (1u8, o.quality);
|
||||
let mut best = None;
|
||||
while lo <= hi {
|
||||
let mid = lo + (hi - lo) / 2;
|
||||
let b = jpeg(mid).map_err(|e| e.to_string())?;
|
||||
if b.len() <= limit {
|
||||
best = Some(b);
|
||||
lo = mid + 1;
|
||||
} else if mid == 1 {
|
||||
break;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
return best.ok_or_else(|| format!("cannot fit within {kb} KB"));
|
||||
}
|
||||
None => jpeg(o.quality),
|
||||
}
|
||||
}
|
||||
ExportFormat::Png => encode::encode_png(&e, &meta),
|
||||
ExportFormat::Tiff => encode::encode_tiff(&e, TiffCompression::Deflate, &meta),
|
||||
ExportFormat::Webp => encode::encode_webp_lossless(&e, &meta),
|
||||
ExportFormat::Avif => encode::encode_avif(&e, o.quality, 8, &meta),
|
||||
};
|
||||
r.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Parse a shutter speed such as `1/250`, `0.5` or `2"` into seconds.
|
||||
fn parse_shutter(s: &str) -> Option<f64> {
|
||||
let s = s.trim().trim_end_matches(['s', '"']).trim();
|
||||
match s.split_once('/') {
|
||||
Some((n, d)) => Some(n.trim().parse::<f64>().ok()? / d.trim().parse::<f64>().ok()?),
|
||||
None => s.parse().ok(),
|
||||
}
|
||||
.filter(|v: &f64| v.is_finite() && *v > 0.0)
|
||||
}
|
||||
|
||||
/// The metadata to embed for `photo` under `o.metadata` / `o.remove_location`. `None` = embed nothing.
|
||||
pub fn export_metadata(photo: &lightcraft_catalog::Photo, o: &ExportOptions) -> Option<Metadata> {
|
||||
let m = &photo.meta;
|
||||
let text = |s: &str| (!s.trim().is_empty()).then(|| s.to_string());
|
||||
let mut out = Metadata { copyright: text(&m.copyright), artist: text(&m.creator), software: Some("LightCraft".into()), ..Default::default() };
|
||||
match o.metadata {
|
||||
MetadataPolicy::None => return None,
|
||||
MetadataPolicy::Copyright => return Some(out),
|
||||
MetadataPolicy::All | MetadataPolicy::AllExceptCamera => {}
|
||||
}
|
||||
out.title = text(&m.title);
|
||||
out.caption = text(&m.caption);
|
||||
out.keywords = m.keywords.clone();
|
||||
out.capture_time = photo.captured.as_deref().and_then(DateTime::parse_iso);
|
||||
out.rating = (photo.rating > 0).then_some(photo.rating as i8);
|
||||
// Pixels are exported upright: orientation is baked in.
|
||||
out.orientation = Some(lightcraft_meta::Orientation::Normal);
|
||||
if !o.remove_location {
|
||||
out.gps = m.gps.map(|(latitude, longitude)| Gps { latitude, longitude, altitude: None });
|
||||
}
|
||||
if o.metadata == MetadataPolicy::All {
|
||||
out.model = text(&m.camera);
|
||||
out.lens_model = text(&m.lens);
|
||||
out.focal_length = m.focal_mm.map(f64::from);
|
||||
out.f_number = m.aperture.map(f64::from);
|
||||
out.exposure_time = parse_shutter(&m.shutter);
|
||||
out.iso = m.iso;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// One exported file.
|
||||
pub struct Exported {
|
||||
pub file_name: String,
|
||||
pub bytes: Vec<u8>,
|
||||
pub width: usize,
|
||||
pub height: usize,
|
||||
}
|
||||
|
||||
/// Render photo `id` at the requested size (full size when `long_edge` is `None`) and encode it.
|
||||
pub fn export_photo(session: &mut crate::Session, id: lightcraft_catalog::PhotoId, o: &ExportOptions, seq: usize) -> Result<Exported, String> {
|
||||
let p = session.catalog.photo(id).ok_or("no such photo")?;
|
||||
let stem = p.file_name.rsplit_once('.').map_or(p.file_name.as_str(), |(a, _)| a).to_string();
|
||||
let full = p.width.max(p.height).max(1) as usize;
|
||||
let size = o.long_edge.map_or(full, |l| l as usize);
|
||||
let meta = export_metadata(p, o);
|
||||
let r = session.render_now(id, size, size)?;
|
||||
let bytes = encode_with_metadata(&r.image, o, meta.as_ref())?;
|
||||
Ok(Exported { file_name: o.file_name(&stem, seq), bytes, width: r.image.width, height: r.image.height })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_image() -> Rgba8 {
|
||||
let mut img = Rgba8::new(96, 64);
|
||||
for (i, p) in img.data.iter_mut().enumerate() {
|
||||
let (x, y) = (i % 96, i / 96);
|
||||
*p = [(x * 2) as u8, (y * 3) as u8, ((x * y) % 255) as u8, 255];
|
||||
}
|
||||
img
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_have_magic() {
|
||||
let img = test_image();
|
||||
for (f, magic) in
|
||||
[(ExportFormat::Jpeg, &b"\xFF\xD8"[..]), (ExportFormat::Png, b"\x89PNG"), (ExportFormat::Tiff, b"II"), (ExportFormat::Webp, b"RIFF")]
|
||||
{
|
||||
let b = encode_image(&img, &ExportOptions { format: f, ..Default::default() }).unwrap();
|
||||
assert!(b.starts_with(magic), "{f:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_limit_respected() {
|
||||
let img = test_image();
|
||||
let full = encode_image(&img, &ExportOptions { quality: 100, ..Default::default() }).unwrap();
|
||||
let kb = (full.len() / 1024 / 2).max(2) as u32;
|
||||
let b = encode_image(&img, &ExportOptions { quality: 100, limit_kb: Some(kb), ..Default::default() }).unwrap();
|
||||
assert!(b.len() <= kb as usize * 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sharpen_increases_edge_contrast() {
|
||||
let mut img = Rgba8::new(8, 8);
|
||||
for (i, p) in img.data.iter_mut().enumerate() {
|
||||
let v = if i % 8 < 4 { 80 } else { 170 };
|
||||
*p = [v, v, v, 255];
|
||||
}
|
||||
output_sharpen(&mut img, SharpenFor::Matte, SharpenAmount::High);
|
||||
assert!(img.data[3][0] < 80 && img.data[4][0] > 170);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_policies() {
|
||||
use lightcraft_catalog::{Photo, PhotoId, Source};
|
||||
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, "a.jpg", "jpeg", 10, 10, "2026-09-30T00:00:00");
|
||||
p.meta.camera = "Synthetic X2".into();
|
||||
p.meta.copyright = "(c) Me".into();
|
||||
p.meta.shutter = "1/250".into();
|
||||
p.meta.gps = Some((43.0, -110.0));
|
||||
let all = export_metadata(&p, &ExportOptions::default()).unwrap();
|
||||
assert_eq!(all.model.as_deref(), Some("Synthetic X2"));
|
||||
assert!((all.exposure_time.unwrap() - 0.004).abs() < 1e-9);
|
||||
assert!(all.gps.is_some());
|
||||
let o = ExportOptions { metadata: MetadataPolicy::AllExceptCamera, remove_location: true, ..Default::default() };
|
||||
let m = export_metadata(&p, &o).unwrap();
|
||||
assert!(m.model.is_none() && m.gps.is_none() && m.copyright.is_some());
|
||||
let c = export_metadata(&p, &ExportOptions { metadata: MetadataPolicy::Copyright, ..Default::default() }).unwrap();
|
||||
assert!(c.model.is_none() && c.gps.is_none() && c.copyright.as_deref() == Some("(c) Me"));
|
||||
assert!(export_metadata(&p, &ExportOptions { metadata: MetadataPolicy::None, ..Default::default() }).is_none());
|
||||
// embedded and readable back from the JPEG
|
||||
let jpg = encode_with_metadata(&test_image(), &ExportOptions::default(), Some(&all)).unwrap();
|
||||
let back = lightcraft_meta::extract(&jpg);
|
||||
assert_eq!(back.model.as_deref(), Some("Synthetic X2"));
|
||||
assert_eq!(back.copyright.as_deref(), Some("(c) Me"));
|
||||
assert!(back.gps.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn naming_and_params() {
|
||||
let o = ExportOptions::from_json(&serde_json::json!({"format": "jpg", "quality": 150, "longEdge": 2048, "naming": "{name}-{seq}"}));
|
||||
assert_eq!(o.format, ExportFormat::Jpeg);
|
||||
assert_eq!(o.quality, 100);
|
||||
assert_eq!(o.long_edge, Some(2048));
|
||||
assert_eq!(o.file_name("IMG/1", 7), "IMG_1-007.jpg");
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@
|
|||
|
||||
pub mod cmd;
|
||||
pub mod demo;
|
||||
pub mod export;
|
||||
pub mod files;
|
||||
pub mod import;
|
||||
pub mod library;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
[package]
|
||||
name = "lightcraft-mcp"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "LightCraft MCP server (L5): JSON-RPC 2.0 over stdio, driving a running app or a headless session"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
lightcraft-engine = { workspace = true }
|
||||
lightcraft-codecs = { workspace = true }
|
||||
lightcraft-raster = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
//! Where MCP tool calls end up: a control-channel method call.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::{TcpStream, ToSocketAddrs};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Something that answers control-channel methods (`engine.execute`, `engine.commands`,
|
||||
/// `ui.render`, `ui.screenshot`, `ui.key`, …). See `docs/control-protocol.md`.
|
||||
pub trait Backend {
|
||||
/// Call one method. `Ok` carries the `result`, `Err` the error message.
|
||||
fn call(&mut self, method: &str, params: Value) -> Result<Value, String>;
|
||||
/// True when a real UI is attached (`ui.inspect`, `ui.clickWidget`, `ui.pointer` … work).
|
||||
fn has_ui(&self) -> bool;
|
||||
/// Short human description ("headless", "remote 127.0.0.1:7980").
|
||||
fn describe(&self) -> String;
|
||||
}
|
||||
|
||||
/// A running LightCraft app, reached through its loopback control port.
|
||||
pub struct Remote {
|
||||
addr: String,
|
||||
conn: Option<(BufReader<TcpStream>, TcpStream)>,
|
||||
next_id: u64,
|
||||
}
|
||||
|
||||
impl Remote {
|
||||
/// Connect to `addr` (e.g. `127.0.0.1:7980`), failing fast when nothing is listening.
|
||||
pub fn connect(addr: &str) -> std::io::Result<Self> {
|
||||
let mut r = Self { addr: addr.to_string(), conn: None, next_id: 1 };
|
||||
r.reconnect()?;
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// A remote that connects on first use (the app may start after the MCP server).
|
||||
pub fn lazy(addr: &str) -> Self {
|
||||
Self { addr: addr.to_string(), conn: None, next_id: 1 }
|
||||
}
|
||||
|
||||
pub fn addr(&self) -> &str {
|
||||
&self.addr
|
||||
}
|
||||
|
||||
fn reconnect(&mut self) -> std::io::Result<()> {
|
||||
self.conn = None;
|
||||
let mut last = std::io::Error::new(std::io::ErrorKind::NotFound, format!("cannot resolve {}", self.addr));
|
||||
for sa in self.addr.to_socket_addrs()? {
|
||||
match TcpStream::connect_timeout(&sa, Duration::from_millis(800)) {
|
||||
Ok(s) => {
|
||||
s.set_nodelay(true).ok();
|
||||
// The app answers within 60 s (its own timeout); leave headroom.
|
||||
s.set_read_timeout(Some(Duration::from_secs(90))).ok();
|
||||
let read = s.try_clone()?;
|
||||
self.conn = Some((BufReader::new(read), s));
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => last = e,
|
||||
}
|
||||
}
|
||||
Err(last)
|
||||
}
|
||||
|
||||
fn roundtrip(&mut self, line: &str) -> std::io::Result<String> {
|
||||
if self.conn.is_none() {
|
||||
self.reconnect()?;
|
||||
}
|
||||
let Some((reader, writer)) = self.conn.as_mut() else {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::NotConnected, "not connected"));
|
||||
};
|
||||
writer.write_all(line.as_bytes())?;
|
||||
writer.write_all(b"\n")?;
|
||||
writer.flush()?;
|
||||
let mut reply = String::new();
|
||||
if reader.read_line(&mut reply)? == 0 {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "control channel closed"));
|
||||
}
|
||||
Ok(reply)
|
||||
}
|
||||
}
|
||||
|
||||
impl Backend for Remote {
|
||||
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
let line = json!({"id": id, "method": method, "params": params}).to_string();
|
||||
// One retry with a fresh connection (the app may have restarted).
|
||||
let reply = match self.roundtrip(&line) {
|
||||
Ok(r) => r,
|
||||
Err(_) => {
|
||||
self.conn = None;
|
||||
self.roundtrip(&line).map_err(|e| {
|
||||
self.conn = None;
|
||||
format!("LightCraft app at {} is not reachable: {e} (start it with `lightcraft --control PORT`)", self.addr)
|
||||
})?
|
||||
}
|
||||
};
|
||||
let v: Value = serde_json::from_str(reply.trim()).map_err(|e| format!("bad reply from app: {e}"))?;
|
||||
if v.get("ok").and_then(Value::as_bool) == Some(true) {
|
||||
Ok(v.get("result").cloned().unwrap_or(Value::Null))
|
||||
} else {
|
||||
Err(v.get("error").and_then(Value::as_str).unwrap_or("unknown error").to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn has_ui(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn describe(&self) -> String {
|
||||
format!("remote {}", self.addr)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
//! Standard base64 (RFC 4648, padded) for MCP image content.
|
||||
|
||||
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
/// Encode bytes as padded standard base64.
|
||||
pub fn base64_encode(bytes: &[u8]) -> String {
|
||||
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
|
||||
for chunk in bytes.chunks(3) {
|
||||
let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];
|
||||
let n = (b[0] as u32) << 16 | (b[1] as u32) << 8 | b[2] as u32;
|
||||
out.push(ALPHABET[(n >> 18) as usize & 63] as char);
|
||||
out.push(ALPHABET[(n >> 12) as usize & 63] as char);
|
||||
out.push(if chunk.len() > 1 { ALPHABET[(n >> 6) as usize & 63] as char } else { '=' });
|
||||
out.push(if chunk.len() > 2 { ALPHABET[n as usize & 63] as char } else { '=' });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Decode padded or unpadded standard base64 (whitespace ignored). `None` on invalid input.
|
||||
pub fn base64_decode(text: &str) -> Option<Vec<u8>> {
|
||||
let mut out = Vec::with_capacity(text.len() / 4 * 3);
|
||||
let (mut acc, mut bits) = (0u32, 0u32);
|
||||
for c in text.bytes().filter(|c| !c.is_ascii_whitespace()) {
|
||||
if c == b'=' {
|
||||
break;
|
||||
}
|
||||
let v = ALPHABET.iter().position(|&a| a == c)? as u32;
|
||||
acc = (acc << 6) | v;
|
||||
bits += 6;
|
||||
if bits >= 8 {
|
||||
bits -= 8;
|
||||
out.push((acc >> bits) as u8);
|
||||
acc &= (1 << bits) - 1;
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{base64_decode, base64_encode};
|
||||
|
||||
#[test]
|
||||
fn rfc4648_vectors() {
|
||||
for (i, o) in [("", ""), ("f", "Zg=="), ("fo", "Zm8="), ("foo", "Zm9v"), ("foob", "Zm9vYg=="), ("fooba", "Zm9vYmE="), ("foobar", "Zm9vYmFy")]
|
||||
{
|
||||
assert_eq!(base64_encode(i.as_bytes()), o);
|
||||
}
|
||||
assert_eq!(base64_encode(&[0xff, 0xfe, 0xfd]), "//79");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip() {
|
||||
let data: Vec<u8> = (0..=255u8).chain((0..100).map(|i| (i * 37) as u8)).collect();
|
||||
for n in 0..data.len() {
|
||||
assert_eq!(base64_decode(&base64_encode(&data[..n])).unwrap(), &data[..n]);
|
||||
}
|
||||
assert!(base64_decode("not base64!").is_none());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
//! An in-process [`Session`] that answers the control-channel methods the MCP tools use, so the
|
||||
//! MCP server (and the CLI) work without a window.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use lightcraft_engine::Session;
|
||||
use lightcraft_engine::catalog::PhotoId;
|
||||
use lightcraft_raster::Rgba8;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::backend::Backend;
|
||||
|
||||
/// File extensions recognised as photos when expanding folders.
|
||||
pub const PHOTO_EXTENSIONS: &[&str] =
|
||||
&["jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "arw", "raf", "orf", "rw2", "pef", "psd", "jxl", "gif", "bmp", "avif"];
|
||||
|
||||
/// Headless backend: a [`Session`] with filesystem hooks.
|
||||
pub struct Headless {
|
||||
pub session: Session,
|
||||
}
|
||||
|
||||
impl Default for Headless {
|
||||
fn default() -> Self {
|
||||
Self::new(Session::new().with_fs())
|
||||
}
|
||||
}
|
||||
|
||||
impl Headless {
|
||||
pub fn new(session: Session) -> Self {
|
||||
Self { session }
|
||||
}
|
||||
|
||||
/// A headless session with the procedurally generated demo library.
|
||||
pub fn demo() -> Self {
|
||||
Self::new(Session::with_demo().with_fs())
|
||||
}
|
||||
|
||||
fn photo_or_active(&self, p: &Value) -> Result<PhotoId, String> {
|
||||
p.get("id").and_then(Value::as_u64).map(PhotoId).or(self.session.active()).ok_or_else(|| "no photo (import one or pass `id`)".to_string())
|
||||
}
|
||||
|
||||
/// Render `id` (or the active photo) so its long edge is at most `size` pixels.
|
||||
pub fn render(&mut self, p: &Value, default_size: u64) -> Result<Rgba8, String> {
|
||||
let id = self.photo_or_active(p)?;
|
||||
let size = p.get("size").or(p.get("longEdge")).and_then(Value::as_u64).unwrap_or(default_size).clamp(16, 16384) as usize;
|
||||
Ok(self.session.render_now(id, size, size)?.image)
|
||||
}
|
||||
|
||||
/// The UI command `app.export`, emulated with the same parameters as the desktop app
|
||||
/// (see `lightcraft_engine::export::ExportOptions::from_json`, plus `ids`, `dir`, `path`).
|
||||
/// With `path` and no `format`, the format follows the path's extension.
|
||||
fn export(&mut self, p: &Value) -> Result<Value, String> {
|
||||
use lightcraft_engine::export::{ExportFormat, ExportOptions, export_photo};
|
||||
let mut opts = ExportOptions::from_json(p);
|
||||
if p.get("longEdge").is_none() {
|
||||
opts.long_edge = Some(3000);
|
||||
}
|
||||
let exact = p.get("path").and_then(Value::as_str);
|
||||
if let (Some(path), None) = (exact, p.get("format")) {
|
||||
let ext = Path::new(path).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default();
|
||||
opts.format = ExportFormat::parse(&ext).unwrap_or(ExportFormat::Png);
|
||||
}
|
||||
let ids: Vec<_> = match p.get("ids").and_then(Value::as_array) {
|
||||
Some(a) => a.iter().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect(),
|
||||
None => vec![self.photo_or_active(p)?],
|
||||
};
|
||||
let dir = p.get("dir").and_then(Value::as_str).unwrap_or("");
|
||||
let mut files = Vec::new();
|
||||
for (i, id) in ids.iter().enumerate() {
|
||||
let e = export_photo(&mut self.session, *id, &opts, i + 1)?;
|
||||
let path = match exact.filter(|_| ids.len() == 1) {
|
||||
Some(path) => path.to_string(),
|
||||
None if dir.is_empty() => e.file_name.clone(),
|
||||
None => format!("{dir}/{}", e.file_name),
|
||||
};
|
||||
if let Some(parent) = Path::new(&path).parent().filter(|d| !d.as_os_str().is_empty()) {
|
||||
std::fs::create_dir_all(parent).map_err(|err| err.to_string())?;
|
||||
}
|
||||
std::fs::write(&path, &e.bytes).map_err(|err| format!("{path}: {err}"))?;
|
||||
files.push(json!({"path": path, "width": e.width, "height": e.height, "bytes": e.bytes.len()}));
|
||||
}
|
||||
// Single-photo exports also report path/width/height at the top level (back-compat).
|
||||
let mut out = files.first().cloned().unwrap_or_else(|| json!({}));
|
||||
out["files"] = json!(files);
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
impl Backend for Headless {
|
||||
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||||
let p = if params.is_null() { json!({}) } else { params };
|
||||
match method {
|
||||
"engine.execute" | "ui.menu.invoke" | "command" => {
|
||||
let id = p.get("command").or(p.get("id")).and_then(Value::as_str).ok_or("missing `command`")?;
|
||||
let params = p.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({}));
|
||||
if id == "app.export" {
|
||||
return self.export(¶ms);
|
||||
}
|
||||
self.session.execute(id, ¶ms).map_err(|e| e.to_string())
|
||||
}
|
||||
"engine.commands" => {
|
||||
let mut v: Vec<Value> = self.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect();
|
||||
v.push(json!({"id": "app.export", "label": "Export Now", "menu": [], "shortcut": null,
|
||||
"params": "{path?: output file (.png/.jpg/.tif/.webp), longEdge?: pixels (3000), quality?: 1..100 (JPEG)}",
|
||||
"enabled": self.session.active().is_some()}));
|
||||
Ok(Value::Array(v))
|
||||
}
|
||||
"ui.render" => {
|
||||
let img = self.render(&p, 1600)?;
|
||||
match p.get("path").and_then(Value::as_str) {
|
||||
Some(path) => {
|
||||
write_image(Path::new(path), &img, 92)?;
|
||||
Ok(json!({"path": path, "width": img.width, "height": img.height}))
|
||||
}
|
||||
None => Ok(json!({"width": img.width, "height": img.height})),
|
||||
}
|
||||
}
|
||||
"app.export" => self.export(&p),
|
||||
m if m.starts_with("ui.") || m == "app.quit" => {
|
||||
Err(format!("`{m}` needs the desktop app: start `lightcraft --control 7980` and run the MCP server with `--connect`"))
|
||||
}
|
||||
other => Err(format!("unknown method `{other}`")),
|
||||
}
|
||||
}
|
||||
|
||||
fn has_ui(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn describe(&self) -> String {
|
||||
"headless".into()
|
||||
}
|
||||
}
|
||||
|
||||
/// Expand folders (recursively, sorted) into photo files and make paths absolute.
|
||||
pub fn expand_paths(paths: &[String]) -> Vec<String> {
|
||||
fn walk(p: &Path, out: &mut Vec<String>) {
|
||||
if p.is_dir() {
|
||||
if let Ok(rd) = std::fs::read_dir(p) {
|
||||
let mut v: Vec<_> = rd.flatten().map(|e| e.path()).collect();
|
||||
v.sort();
|
||||
for c in v {
|
||||
if c.file_name().is_some_and(|n| !n.to_string_lossy().starts_with('.')) {
|
||||
walk(&c, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if p.extension().is_some_and(|e| PHOTO_EXTENSIONS.contains(&e.to_string_lossy().to_lowercase().as_str())) {
|
||||
out.push(std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf()).to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for p in paths {
|
||||
let path = Path::new(p);
|
||||
if path.is_dir() {
|
||||
walk(path, &mut out);
|
||||
} else {
|
||||
// Explicit files are kept even with unknown extensions (the probe decides).
|
||||
out.push(std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf()).to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Encode by extension: `.png` (default), `.jpg`/`.jpeg` (quality), `.tif`/`.tiff`, `.webp` (lossless),
|
||||
/// `.avif` — via the shared export encoder (embeds an sRGB profile).
|
||||
pub fn encode_image(ext: &str, img: &Rgba8, quality: u8) -> Result<Vec<u8>, String> {
|
||||
use lightcraft_engine::export::{ExportFormat, ExportOptions};
|
||||
let format = ExportFormat::parse(ext).unwrap_or(ExportFormat::Png);
|
||||
lightcraft_engine::export::encode_image(img, &ExportOptions { format, quality: quality.clamp(1, 100), ..Default::default() })
|
||||
}
|
||||
|
||||
/// Encode by the path's extension and write the file.
|
||||
pub fn write_image(path: &Path, img: &Rgba8, quality: u8) -> Result<(), String> {
|
||||
let ext = path.extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let bytes = encode_image(&ext, img, quality)?;
|
||||
std::fs::write(path, bytes).map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
//! LightCraft's MCP server.
|
||||
//!
|
||||
//! [Model Context Protocol](https://modelcontextprotocol.io) over stdio: newline-delimited
|
||||
//! JSON-RPC 2.0, hand-written (no async runtime). The server exposes LightCraft as MCP tools and
|
||||
//! resources and forwards everything to a [`Backend`]:
|
||||
//!
|
||||
//! - [`Remote`] talks to a running desktop app through its loopback JSON-lines control channel
|
||||
//! (`lightcraft --control 7980`): one `{"id","method","params"}` line in, one
|
||||
//! `{"id","ok","result"|"error"}` line out (see `docs/control-protocol.md`).
|
||||
//! - [`Headless`] hosts an in-process [`lightcraft_engine::Session`] and answers the same
|
||||
//! control-channel method names itself (rendering with the engine's pipeline and encoding with
|
||||
//! `lightcraft-codecs`), so agents can develop photos and look at the result without a window.
|
||||
//!
|
||||
//! Entry points: [`Server::serve`] (stdio loop) and [`Server::handle_line`] (one message).
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod backend;
|
||||
mod base64;
|
||||
mod headless;
|
||||
mod server;
|
||||
mod tools;
|
||||
|
||||
pub use backend::{Backend, Remote};
|
||||
pub use base64::{base64_decode, base64_encode};
|
||||
pub use headless::{Headless, PHOTO_EXTENSIONS, encode_image, expand_paths, write_image};
|
||||
pub use server::{PROTOCOL_VERSION, Server};
|
||||
pub use tools::{COMMAND_TOOL_PREFIX, ToolResult, call_tool, command_tool_name, helper_tools, tool_definitions};
|
||||
|
||||
/// Default control-channel address of the desktop app (`lightcraft --control 7980`).
|
||||
pub const DEFAULT_ADDR: &str = "127.0.0.1:7980";
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
//! JSON-RPC 2.0 framing and the MCP lifecycle / tools / resources methods.
|
||||
|
||||
use std::io::{BufRead, Write};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::backend::Backend;
|
||||
use crate::tools::{call_tool, tool_definitions};
|
||||
|
||||
/// The MCP revision we implement.
|
||||
pub const PROTOCOL_VERSION: &str = "2025-06-18";
|
||||
/// Revisions we can speak; anything else negotiates down to [`PROTOCOL_VERSION`].
|
||||
const SUPPORTED_VERSIONS: &[&str] = &[PROTOCOL_VERSION, "2025-03-26", "2024-11-05"];
|
||||
|
||||
const PARSE_ERROR: i64 = -32700;
|
||||
const INVALID_REQUEST: i64 = -32600;
|
||||
const METHOD_NOT_FOUND: i64 = -32601;
|
||||
const INVALID_PARAMS: i64 = -32602;
|
||||
const INTERNAL_ERROR: i64 = -32603;
|
||||
const RESOURCE_NOT_FOUND: i64 = -32002;
|
||||
|
||||
const INSTRUCTIONS: &str = "LightCraft is a non-destructive photo library and raw developer (a Lightroom alternative). \
|
||||
Photos have integer ids; most editing acts on the *active* photo (tools that take `id` make that photo active first). \
|
||||
Typical loop: query_photos → select_photos (or pass id) → list_controls → set_develop {values: {\"light.exposure\": 0.5}} → \
|
||||
render_photo to look at the result → export. Positions (crop rects, mask points, brush strokes) are normalized image \
|
||||
coordinates 0..1 with the origin at the top-left. Every action is a command: list_commands shows ids and parameter \
|
||||
docs; run_command runs any of them. Edits are undoable (undo / redo).";
|
||||
|
||||
/// Resource URIs.
|
||||
pub const LIBRARY_URI: &str = "lightcraft://library";
|
||||
pub const PHOTOS_URI: &str = "lightcraft://photos";
|
||||
pub const PHOTO_URI: &str = "lightcraft://photo/active";
|
||||
pub const DEVELOP_URI: &str = "lightcraft://develop/active";
|
||||
pub const CONTROLS_URI: &str = "lightcraft://controls";
|
||||
|
||||
/// An MCP server bound to one backend.
|
||||
pub struct Server {
|
||||
backend: Box<dyn Backend>,
|
||||
initialized: bool,
|
||||
command_tools: bool,
|
||||
}
|
||||
|
||||
fn response(id: Value, result: Value) -> Value {
|
||||
json!({"jsonrpc": "2.0", "id": id, "result": result})
|
||||
}
|
||||
|
||||
fn error(id: Value, code: i64, message: impl Into<String>) -> Value {
|
||||
json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message.into()}})
|
||||
}
|
||||
|
||||
fn resource(uri: &str, name: &str, title: &str, description: &str) -> Value {
|
||||
json!({"uri": uri, "name": name, "title": title, "description": description, "mimeType": "application/json"})
|
||||
}
|
||||
|
||||
impl Server {
|
||||
pub fn new(backend: Box<dyn Backend>) -> Self {
|
||||
Self { backend, initialized: false, command_tools: true }
|
||||
}
|
||||
|
||||
/// Whether `tools/list` includes one `cmd_*` tool per registered command (default true).
|
||||
/// Off, every command stays reachable through `run_command`.
|
||||
pub fn with_command_tools(mut self, on: bool) -> Self {
|
||||
self.command_tools = on;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn backend(&mut self) -> &mut dyn Backend {
|
||||
self.backend.as_mut()
|
||||
}
|
||||
|
||||
/// Whether the client has sent `notifications/initialized`.
|
||||
pub fn is_initialized(&self) -> bool {
|
||||
self.initialized
|
||||
}
|
||||
|
||||
/// Serve newline-delimited JSON-RPC until `input` closes. Logs go to stderr only (stdout is
|
||||
/// the protocol stream).
|
||||
pub fn serve(&mut self, input: impl BufRead, mut output: impl Write) -> std::io::Result<()> {
|
||||
for line in input.lines() {
|
||||
let line = line?;
|
||||
if let Some(reply) = self.handle_line(&line) {
|
||||
output.write_all(reply.as_bytes())?;
|
||||
output.write_all(b"\n")?;
|
||||
output.flush()?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle one line; returns the reply line (None for notifications and blank lines).
|
||||
pub fn handle_line(&mut self, line: &str) -> Option<String> {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let reply = match serde_json::from_str::<Value>(line) {
|
||||
Ok(Value::Array(batch)) => {
|
||||
// Batches were removed in 2025-06-18; still answer older clients sensibly.
|
||||
if batch.is_empty() {
|
||||
Some(error(Value::Null, INVALID_REQUEST, "empty batch"))
|
||||
} else {
|
||||
let replies: Vec<Value> = batch.into_iter().filter_map(|m| self.handle(m)).collect();
|
||||
(!replies.is_empty()).then_some(Value::Array(replies))
|
||||
}
|
||||
}
|
||||
Ok(msg) => self.handle(msg),
|
||||
Err(e) => Some(error(Value::Null, PARSE_ERROR, format!("parse error: {e}"))),
|
||||
};
|
||||
reply.map(|r| r.to_string())
|
||||
}
|
||||
|
||||
/// Handle one JSON-RPC message; `None` for notifications and responses.
|
||||
pub fn handle(&mut self, msg: Value) -> Option<Value> {
|
||||
let Value::Object(o) = &msg else { return Some(error(Value::Null, INVALID_REQUEST, "message must be an object")) };
|
||||
let id = o.get("id").cloned();
|
||||
let Some(method) = o.get("method").and_then(Value::as_str) else {
|
||||
// A response to a server→client request (we send none) — ignore; anything else is invalid.
|
||||
if o.contains_key("result") || o.contains_key("error") {
|
||||
return None;
|
||||
}
|
||||
return Some(error(id.filter(|i| i.is_string() || i.is_number()).unwrap_or(Value::Null), INVALID_REQUEST, "missing `method`"));
|
||||
};
|
||||
let params = o.get("params").cloned().unwrap_or(Value::Null);
|
||||
let Some(id) = id else {
|
||||
self.notification(method, ¶ms);
|
||||
return None;
|
||||
};
|
||||
if !(id.is_string() || id.is_number()) {
|
||||
return Some(error(Value::Null, INVALID_REQUEST, "`id` must be a string or number"));
|
||||
}
|
||||
if !(params.is_object() || params.is_null()) {
|
||||
return Some(error(id, INVALID_PARAMS, "`params` must be an object"));
|
||||
}
|
||||
Some(match self.request(method, ¶ms) {
|
||||
Ok(r) => response(id, r),
|
||||
Err((code, m)) => error(id, code, m),
|
||||
})
|
||||
}
|
||||
|
||||
fn notification(&mut self, method: &str, _params: &Value) {
|
||||
match method {
|
||||
"notifications/initialized" => self.initialized = true,
|
||||
"notifications/cancelled" | "notifications/progress" | "notifications/roots/list_changed" => {}
|
||||
other => log::debug!("ignoring notification {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn request(&mut self, method: &str, params: &Value) -> Result<Value, (i64, String)> {
|
||||
match method {
|
||||
"initialize" => {
|
||||
let asked = params.get("protocolVersion").and_then(Value::as_str).unwrap_or(PROTOCOL_VERSION);
|
||||
let version = if SUPPORTED_VERSIONS.contains(&asked) { asked } else { PROTOCOL_VERSION };
|
||||
Ok(json!({
|
||||
"protocolVersion": version,
|
||||
"capabilities": {"tools": {"listChanged": false}, "resources": {"subscribe": false, "listChanged": false}},
|
||||
"serverInfo": {"name": "lightcraft", "title": "LightCraft", "version": env!("CARGO_PKG_VERSION")},
|
||||
"instructions": format!("{INSTRUCTIONS} Backend: {}.", self.backend.describe()),
|
||||
}))
|
||||
}
|
||||
"ping" => Ok(json!({})),
|
||||
"tools/list" => Ok(json!({"tools": tool_definitions(self.backend.as_mut(), self.command_tools)})),
|
||||
"tools/call" => {
|
||||
let name = params.get("name").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing tool `name`".to_string()))?;
|
||||
let args = params.get("arguments").cloned().unwrap_or(Value::Null);
|
||||
Ok(call_tool(self.backend.as_mut(), name, &args).to_value())
|
||||
}
|
||||
"resources/list" => Ok(json!({"resources": [
|
||||
resource(LIBRARY_URI, "library", "Library state", "Current source, filter, sort, selection, active photo/mask and undo/redo labels (library.state)"),
|
||||
resource(PHOTOS_URI, "photos", "Photos in view", "The photos in the current view with ids, ratings, flags and metadata (catalog.query)"),
|
||||
resource(PHOTO_URI, "active-photo", "Active photo", "Everything about the active photo: file, metadata, develop settings, albums, history (photo.inspect)"),
|
||||
resource(DEVELOP_URI, "develop", "Develop settings", "The active photo's full develop settings JSON (develop.get)"),
|
||||
resource(CONTROLS_URI, "controls", "Develop controls", "Every develop slider with id, range, default and current value (develop.controls)"),
|
||||
]})),
|
||||
"resources/templates/list" => Ok(json!({"resourceTemplates": []})),
|
||||
"resources/read" => {
|
||||
let uri = params.get("uri").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing `uri`".to_string()))?;
|
||||
let cmd = match uri {
|
||||
LIBRARY_URI => "library.state",
|
||||
PHOTOS_URI => "catalog.query",
|
||||
PHOTO_URI => "photo.inspect",
|
||||
DEVELOP_URI => "develop.get",
|
||||
CONTROLS_URI => "develop.controls",
|
||||
_ => return Err((RESOURCE_NOT_FOUND, format!("resource not found: {uri}"))),
|
||||
};
|
||||
let v = self.backend.call("engine.execute", json!({"command": cmd, "params": {}})).map_err(|e| (INTERNAL_ERROR, e))?;
|
||||
Ok(json!({"contents": [{"uri": uri, "mimeType": "application/json", "text": serde_json::to_string_pretty(&v).unwrap_or_default()}]}))
|
||||
}
|
||||
other => Err((METHOD_NOT_FOUND, format!("method not found: {other}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Headless, PROTOCOL_VERSION, Server, call_tool, command_tool_name};
|
||||
|
||||
fn server() -> Server {
|
||||
Server::new(Box::new(Headless::demo()))
|
||||
}
|
||||
|
||||
fn rpc(s: &mut Server, id: u64, method: &str, params: Value) -> Value {
|
||||
let line = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string();
|
||||
let reply = s.handle_line(&line).expect("reply");
|
||||
let v: Value = serde_json::from_str(&reply).unwrap();
|
||||
assert_eq!(v["jsonrpc"], "2.0");
|
||||
assert_eq!(v["id"], id);
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initialize_negotiates_version() {
|
||||
let mut s = server();
|
||||
let r = rpc(&mut s, 1, "initialize", json!({"protocolVersion": "2025-03-26", "capabilities": {}, "clientInfo": {"name": "t", "version": "1"}}));
|
||||
assert_eq!(r["result"]["protocolVersion"], "2025-03-26");
|
||||
assert_eq!(r["result"]["serverInfo"]["name"], "lightcraft");
|
||||
assert!(r["result"]["capabilities"]["tools"].is_object());
|
||||
let r = rpc(&mut s, 2, "initialize", json!({"protocolVersion": "1999-01-01"}));
|
||||
assert_eq!(r["result"]["protocolVersion"], PROTOCOL_VERSION);
|
||||
assert!(s.handle_line(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#).is_none());
|
||||
assert!(s.is_initialized());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors() {
|
||||
let mut s = server();
|
||||
let v: Value = serde_json::from_str(&s.handle_line("{not json").unwrap()).unwrap();
|
||||
assert_eq!(v["error"]["code"], -32700);
|
||||
assert_eq!(rpc(&mut s, 1, "nope", json!({}))["error"]["code"], -32601);
|
||||
assert_eq!(rpc(&mut s, 2, "tools/call", json!({}))["error"]["code"], -32602);
|
||||
assert!(s.handle_line(" ").is_none());
|
||||
// Unknown tools and failing commands are tool errors, not protocol errors.
|
||||
let r = rpc(&mut s, 3, "tools/call", json!({"name": "nope", "arguments": {}}));
|
||||
assert_eq!(r["result"]["isError"], true);
|
||||
let r = rpc(&mut s, 4, "tools/call", json!({"name": "run_command", "arguments": {"command": "no.such"}}));
|
||||
assert_eq!(r["result"]["isError"], true);
|
||||
assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("unknown command"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tools_list_has_helpers_and_every_command() {
|
||||
let mut s = server();
|
||||
let r = rpc(&mut s, 1, "tools/list", json!({}));
|
||||
let tools = r["result"]["tools"].as_array().unwrap();
|
||||
let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
|
||||
for n in ["list_commands", "run_command", "import", "set_develop", "render_photo", "export"] {
|
||||
assert!(names.contains(&n), "{n}");
|
||||
}
|
||||
// Headless: no UI tools.
|
||||
assert!(!names.contains(&"screenshot"));
|
||||
let hl = Headless::demo();
|
||||
for c in hl.session.commands() {
|
||||
assert!(!c.id.contains('_'), "command ids must not contain `_` ({})", c.id);
|
||||
let n = command_tool_name(c.id);
|
||||
assert!(names.contains(&n.as_str()), "{n}");
|
||||
assert!(n.len() <= 64 && n.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_'), "{n}");
|
||||
}
|
||||
assert!(names.contains(&"cmd_app_export"));
|
||||
for t in tools {
|
||||
assert_eq!(t["inputSchema"]["type"], "object");
|
||||
}
|
||||
// Compact mode keeps only the helpers.
|
||||
let mut s = Server::new(Box::new(Headless::demo())).with_command_tools(false);
|
||||
let r = rpc(&mut s, 1, "tools/list", json!({}));
|
||||
assert!(r["result"]["tools"].as_array().unwrap().iter().all(|t| !t["name"].as_str().unwrap().starts_with("cmd_")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_tools_run_commands() {
|
||||
let mut b = Headless::demo();
|
||||
let first = b.session.visible_cloned()[0];
|
||||
let r = call_tool(&mut b, "cmd_library_select", &json!({"ids": [first.0]}));
|
||||
assert!(!r.is_error, "{r:?}");
|
||||
let r = call_tool(&mut b, "cmd_photo_rate", &json!({"rating": 4}));
|
||||
assert!(!r.is_error, "{r:?}");
|
||||
assert_eq!(b.session.catalog.photo(first).unwrap().rating, 4);
|
||||
let r = call_tool(&mut b, "cmd_edit_undo", &json!({}));
|
||||
assert!(!r.is_error);
|
||||
assert_eq!(b.session.catalog.photo(first).unwrap().rating, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_develop_values_and_settings() {
|
||||
let mut b = Headless::demo();
|
||||
let first = b.session.visible_cloned()[0];
|
||||
let r = call_tool(&mut b, "set_develop", &json!({"id": first.0, "values": {"light.exposure": 1.25}}));
|
||||
assert!(!r.is_error, "{r:?}");
|
||||
assert_eq!(r.structured.as_ref().unwrap()["controls"][0]["value"], 1.25);
|
||||
assert_eq!(b.session.develop_of(first).unwrap().light.exposure, 1.25);
|
||||
let r = call_tool(&mut b, "set_develop", &json!({"settings": {"light": {"contrast": 30.0}}}));
|
||||
assert!(!r.is_error, "{r:?}");
|
||||
let d = b.session.develop_of(first).unwrap();
|
||||
assert_eq!((d.light.exposure, d.light.contrast), (1.25, 30.0));
|
||||
assert!(call_tool(&mut b, "set_develop", &json!({})).is_error);
|
||||
assert!(call_tool(&mut b, "set_develop", &json!({"values": {"no.such": 1}})).is_error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_tools_need_the_app() {
|
||||
let mut b = Headless::demo();
|
||||
let r = call_tool(&mut b, "screenshot", &json!({}));
|
||||
assert!(r.is_error);
|
||||
assert!(r.content[0]["text"].as_str().unwrap().contains("--connect"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resources() {
|
||||
let mut s = server();
|
||||
let r = rpc(&mut s, 1, "resources/list", json!({}));
|
||||
let list = r["result"]["resources"].as_array().unwrap();
|
||||
assert_eq!(list.len(), 5);
|
||||
for (i, res) in list.iter().enumerate() {
|
||||
let uri = res["uri"].as_str().unwrap();
|
||||
let r = rpc(&mut s, 10 + i as u64, "resources/read", json!({"uri": uri}));
|
||||
let text = r["result"]["contents"][0]["text"].as_str().unwrap_or_else(|| panic!("{uri}: {r}"));
|
||||
serde_json::from_str::<Value>(text).unwrap();
|
||||
}
|
||||
assert_eq!(rpc(&mut s, 99, "resources/read", json!({"uri": "lightcraft://nope"}))["error"]["code"], -32002);
|
||||
}
|
||||
|
|
@ -0,0 +1,559 @@
|
|||
//! MCP tools: hand-written helpers (import, develop, render, screenshot, UI input…) plus one tool
|
||||
//! per command in the engine's command registry (`cmd_<id with . → _>`), all forwarded to a
|
||||
//! [`Backend`] as control-channel methods.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::backend::Backend;
|
||||
use crate::base64::base64_encode;
|
||||
use crate::headless::{encode_image, expand_paths};
|
||||
|
||||
/// Prefix of the tools generated from the command registry.
|
||||
pub const COMMAND_TOOL_PREFIX: &str = "cmd_";
|
||||
|
||||
/// The result of `tools/call`: MCP content blocks plus the error flag.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ToolResult {
|
||||
pub content: Vec<Value>,
|
||||
pub is_error: bool,
|
||||
pub structured: Option<Value>,
|
||||
}
|
||||
|
||||
impl ToolResult {
|
||||
pub fn text(t: impl Into<String>) -> Self {
|
||||
Self { content: vec![json!({"type": "text", "text": t.into()})], ..Default::default() }
|
||||
}
|
||||
|
||||
/// A JSON result as pretty text plus `structuredContent` when it is an object.
|
||||
pub fn json(v: Value) -> Self {
|
||||
let text = if v.is_null() { "ok".to_string() } else { serde_json::to_string_pretty(&v).unwrap_or_default() };
|
||||
let mut r = Self::text(text);
|
||||
if v.is_object() {
|
||||
r.structured = Some(v);
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
pub fn error(msg: impl Into<String>) -> Self {
|
||||
Self { is_error: true, ..Self::text(msg) }
|
||||
}
|
||||
|
||||
fn from(r: Result<Value, String>) -> Self {
|
||||
match r {
|
||||
Ok(v) => Self::json(v),
|
||||
Err(e) => Self::error(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_value(&self) -> Value {
|
||||
let mut v = json!({"content": self.content, "isError": self.is_error});
|
||||
if let Some(s) = &self.structured {
|
||||
v["structuredContent"] = s.clone();
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// The first image block's base64 data and mime type.
|
||||
pub fn image(&self) -> Option<(&str, &str)> {
|
||||
self.content.iter().find(|c| c["type"] == "image").and_then(|c| Some((c["data"].as_str()?, c["mimeType"].as_str()?)))
|
||||
}
|
||||
}
|
||||
|
||||
fn tool(name: &str, title: &str, description: &str, properties: Value, required: &[&str]) -> Value {
|
||||
json!({
|
||||
"name": name,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"inputSchema": {"type": "object", "properties": properties, "required": required},
|
||||
})
|
||||
}
|
||||
|
||||
fn ids_schema(what: &str) -> Value {
|
||||
json!({"type": "array", "items": {"type": "integer"}, "description": what})
|
||||
}
|
||||
|
||||
/// The hand-written helper tools. UI tools are included only when a desktop app is attached.
|
||||
pub fn helper_tools(has_ui: bool) -> Vec<Value> {
|
||||
let photo_id = json!({"type": "integer", "description": "Photo id (default: the active photo). Makes that photo active first."});
|
||||
let mut v = vec![
|
||||
tool(
|
||||
"list_commands",
|
||||
"List commands",
|
||||
"Every command (engine + UI when connected) with id, label, menu, shortcut, parameter doc and whether it is enabled now. Each id is also callable as tool `cmd_<id with . replaced by _>` or via run_command.",
|
||||
json!({"filter": {"type": "string", "description": "Only commands whose id or label contains this text (case-insensitive)"}}),
|
||||
&[],
|
||||
),
|
||||
tool(
|
||||
"run_command",
|
||||
"Run command",
|
||||
"Run any LightCraft command by id with JSON params (see list_commands for ids and parameter docs), e.g. {command: \"photo.rate\", params: {rating: 4}}.",
|
||||
json!({"command": {"type": "string"}, "params": {"type": "object", "description": "Command parameters", "additionalProperties": true}}),
|
||||
&["command"],
|
||||
),
|
||||
tool(
|
||||
"import",
|
||||
"Import photos",
|
||||
"Add image/raw files to the library. Folders are scanned recursively for photos; relative paths resolve against the server's working directory. The first imported photo becomes active. Returns the new photo ids.",
|
||||
json!({"paths": {"type": "array", "items": {"type": "string"}, "description": "Files and/or folders"}, "album": {"type": "integer", "description": "Also add to this album id"}}),
|
||||
&["paths"],
|
||||
),
|
||||
tool(
|
||||
"query_photos",
|
||||
"Query photos",
|
||||
"List photos (id, file name, size, rating, flag, edited…). Without filter/sort: the current library view.",
|
||||
json!({
|
||||
"filter": {"type": "object", "description": "Catalog Filter JSON (e.g. {\"minRating\": 3})", "additionalProperties": true},
|
||||
"sort": {"type": "object", "additionalProperties": true},
|
||||
"offset": {"type": "integer"},
|
||||
"limit": {"type": "integer", "description": "Default 200"}
|
||||
}),
|
||||
&[],
|
||||
),
|
||||
tool(
|
||||
"select_photos",
|
||||
"Select photos",
|
||||
"Set the selection; the active photo (the one develop tools edit) is `active` or the first id.",
|
||||
json!({"ids": ids_schema("Photo ids"), "active": {"type": "integer"}, "mode": {"type": "string", "enum": ["replace", "add", "toggle", "range"]}}),
|
||||
&["ids"],
|
||||
),
|
||||
tool(
|
||||
"list_controls",
|
||||
"List develop controls",
|
||||
"Every develop slider (id like `light.exposure`, label, section, min, max, default, current value) for the active photo.",
|
||||
json!({"section": {"type": "string", "description": "Only this section (e.g. light, color, effects, detail)"}}),
|
||||
&[],
|
||||
),
|
||||
tool(
|
||||
"get_develop",
|
||||
"Get develop settings",
|
||||
"The full develop settings JSON of a photo (white balance, light, color, curves, mixer, grading, effects, crop, masks, spots…).",
|
||||
json!({"id": photo_id}),
|
||||
&[],
|
||||
),
|
||||
tool(
|
||||
"set_develop",
|
||||
"Set develop settings",
|
||||
"Edit a photo (one undo step each for values and settings). `values` sets sliders by control id (see list_controls), e.g. {\"light.exposure\": 0.7, \"light.contrast\": 20}. `settings` deep-merges a partial develop-settings JSON (as returned by get_develop).",
|
||||
json!({
|
||||
"id": photo_id,
|
||||
"values": {"type": "object", "additionalProperties": {"type": "number"}, "description": "controlId → value"},
|
||||
"settings": {"type": "object", "additionalProperties": true, "description": "Partial DevelopSettings JSON to merge"},
|
||||
"label": {"type": "string", "description": "History label for `settings`"}
|
||||
}),
|
||||
&[],
|
||||
),
|
||||
tool(
|
||||
"apply_preset",
|
||||
"Apply preset",
|
||||
"Apply a develop preset (ids from cmd_presets_list) to the selection or `ids`, at `amount` percent (0..200, default 100).",
|
||||
json!({"preset": {"type": "string", "description": "Preset id"}, "amount": {"type": "number"}, "ids": ids_schema("Photo ids (default: selection)")}),
|
||||
&["preset"],
|
||||
),
|
||||
tool(
|
||||
"crop",
|
||||
"Crop / straighten",
|
||||
"Set the crop of the active photo: `rect` [x0,y0,x1,y1] in normalized coordinates (0..1, origin top-left) of the straightened frame, `angle` in degrees. `reset: true` clears it.",
|
||||
json!({"id": photo_id, "rect": {"type": "array", "items": {"type": "number"}, "minItems": 4, "maxItems": 4}, "angle": {"type": "number"}, "reset": {"type": "boolean"}}),
|
||||
&[],
|
||||
),
|
||||
tool(
|
||||
"render_photo",
|
||||
"Render photo",
|
||||
"Render a photo with its current develop settings (cropped) and return it as an image so you can look at the result.",
|
||||
json!({
|
||||
"id": photo_id,
|
||||
"size": {"type": "integer", "description": "Long edge in pixels (default 1024, max 4096)"},
|
||||
"format": {"type": "string", "enum": ["png", "jpeg"], "description": "Image encoding (default png; jpeg is smaller)"},
|
||||
"path": {"type": "string", "description": "Also save the rendered file here"}
|
||||
}),
|
||||
&[],
|
||||
),
|
||||
tool(
|
||||
"export",
|
||||
"Export photo",
|
||||
"Render and write photos. Give `path` (one photo; format from the extension unless `format` is set) or `dir` (batch, file names from `naming`).",
|
||||
json!({
|
||||
"id": photo_id,
|
||||
"ids": {"type": "array", "items": {"type": "integer"}, "description": "Photos to export (default: the given/active photo)"},
|
||||
"path": {"type": "string"},
|
||||
"dir": {"type": "string"},
|
||||
"format": {"type": "string", "enum": ["jpeg", "png", "tiff", "webp", "avif"]},
|
||||
"longEdge": {"type": "integer", "description": "Default 3000; 0 = full size"},
|
||||
"quality": {"type": "integer", "description": "JPEG/AVIF quality 1..100"},
|
||||
"limitKb": {"type": "integer", "description": "JPEG: largest quality that fits this many KB"},
|
||||
"sharpen": {"type": "string", "enum": ["none", "screen", "matte", "glossy"]},
|
||||
"sharpenAmount": {"type": "string", "enum": ["low", "standard", "high"]},
|
||||
"naming": {"type": "string", "description": "e.g. {name}-{seq}"},
|
||||
"metadata": {"type": "string", "enum": ["all", "allExceptCamera", "copyright", "none"]},
|
||||
"removeLocation": {"type": "boolean"}
|
||||
}),
|
||||
&[],
|
||||
),
|
||||
];
|
||||
if has_ui {
|
||||
v.extend([
|
||||
tool(
|
||||
"screenshot",
|
||||
"Screenshot",
|
||||
"Capture the LightCraft window (after pending renders finish) and return it as an image.",
|
||||
json!({"maxSize": {"type": "integer", "description": "Downscale so the long edge is at most this (default 1600)"}, "format": {"type": "string", "enum": ["png", "jpeg"]}, "path": {"type": "string", "description": "Also save the screenshot here"}}),
|
||||
&[],
|
||||
),
|
||||
tool("inspect_ui", "Inspect UI", "UI state: view, open panel, window and image rects, active photo, selection, active mask, status.", json!({}), &[]),
|
||||
tool(
|
||||
"set_ui",
|
||||
"Set UI state",
|
||||
"Merge UI state, e.g. {\"view\": \"detail\"} (photoGrid|squareGrid|detail|compare) (see inspect_ui for fields).",
|
||||
json!({"state": {"type": "object", "additionalProperties": true}}),
|
||||
&["state"],
|
||||
),
|
||||
tool(
|
||||
"list_widgets",
|
||||
"List widgets",
|
||||
"On-screen widgets by automation id with rects [x, y, w, h] in screen points.",
|
||||
json!({"filter": {"type": "string"}}),
|
||||
&[],
|
||||
),
|
||||
tool(
|
||||
"click",
|
||||
"Click",
|
||||
"Click a widget by automation id (`widget`, see list_widgets) or a screen point (`x`, `y`). `count: 2` double-clicks.",
|
||||
json!({"widget": {"type": "string"}, "x": {"type": "number"}, "y": {"type": "number"}, "count": {"type": "integer"}, "button": {"type": "string", "enum": ["left", "right"]}}),
|
||||
&[],
|
||||
),
|
||||
tool(
|
||||
"press_key",
|
||||
"Press key",
|
||||
"Press a key with optional modifiers (shortcuts), e.g. {key: \"Z\", cmd: true}. Names: A–Z, 0–9, Enter, Escape, Delete, Left/Right/Up/Down, Space, Tab, F1… .",
|
||||
json!({"key": {"type": "string"}, "cmd": {"type": "boolean"}, "shift": {"type": "boolean"}, "alt": {"type": "boolean"}, "ctrl": {"type": "boolean"}}),
|
||||
&["key"],
|
||||
),
|
||||
tool("type_text", "Type text", "Type text into the focused field.", json!({"text": {"type": "string"}}), &["text"]),
|
||||
tool(
|
||||
"pointer_gesture",
|
||||
"Pointer gesture",
|
||||
"Replay a pointer gesture on the photo in normalized image coordinates (0..1, origin top-left): brush strokes, gradient drags, crop handles, heal spots. Needs the Detail view.",
|
||||
json!({
|
||||
"events": {"type": "array", "items": {"type": "object", "properties": {"kind": {"type": "string", "enum": ["down", "drag", "move", "up"]}, "x": {"type": "number"}, "y": {"type": "number"}}, "required": ["kind", "x", "y"]}},
|
||||
"alt": {"type": "boolean"}, "shift": {"type": "boolean"}, "cmd": {"type": "boolean"}
|
||||
}),
|
||||
&["events"],
|
||||
),
|
||||
]);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// MCP tool name of a command id (`develop.set` → `cmd_develop_set`).
|
||||
pub fn command_tool_name(id: &str) -> String {
|
||||
format!("{COMMAND_TOOL_PREFIX}{}", id.replace(['.', ' '], "_"))
|
||||
}
|
||||
|
||||
/// One tool per command reported by the backend's `engine.commands`.
|
||||
pub fn command_tools(backend: &mut dyn Backend) -> Vec<Value> {
|
||||
let Ok(Value::Array(cmds)) = backend.call("engine.commands", json!({})) else { return Vec::new() };
|
||||
cmds.iter()
|
||||
.filter_map(|c| {
|
||||
let id = c["id"].as_str()?;
|
||||
let label = c["label"].as_str().unwrap_or(id);
|
||||
let mut d = format!("{label} (command `{id}`).");
|
||||
if let Some(p) = c["params"].as_str().filter(|p| !p.is_empty()) {
|
||||
d.push_str(&format!(" Params: {p}."));
|
||||
}
|
||||
if let Some(m) = c["menu"].as_array().filter(|m| m.iter().any(|x| x.as_str().is_some_and(|s| !s.is_empty()))) {
|
||||
let m: Vec<&str> = m.iter().filter_map(Value::as_str).collect();
|
||||
d.push_str(&format!(" Menu: {}.", m.join(" › ")));
|
||||
}
|
||||
if let Some(s) = c["shortcut"].as_str() {
|
||||
d.push_str(&format!(" Shortcut: {s}."));
|
||||
}
|
||||
Some(json!({
|
||||
"name": command_tool_name(id),
|
||||
"title": label,
|
||||
"description": d,
|
||||
"inputSchema": {"type": "object", "additionalProperties": true},
|
||||
}))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// All tools: helpers, plus the generated command tools when `with_commands`.
|
||||
pub fn tool_definitions(backend: &mut dyn Backend, with_commands: bool) -> Vec<Value> {
|
||||
let mut v = helper_tools(backend.has_ui());
|
||||
if with_commands {
|
||||
v.extend(command_tools(backend));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn exec(b: &mut dyn Backend, command: &str, params: Value) -> Result<Value, String> {
|
||||
b.call("engine.execute", json!({"command": command, "params": params}))
|
||||
}
|
||||
|
||||
/// Make `args.id` (if any) the active photo.
|
||||
fn activate(b: &mut dyn Backend, args: &Value) -> Result<(), String> {
|
||||
if let Some(id) = args.get("id").and_then(Value::as_u64) {
|
||||
exec(b, "library.select", json!({"ids": [id], "active": id}))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn temp_path(tag: &str) -> std::path::PathBuf {
|
||||
static N: AtomicU64 = AtomicU64::new(0);
|
||||
std::env::temp_dir().join(format!("lightcraft-mcp-{}-{}-{tag}.png", std::process::id(), N.fetch_add(1, Ordering::Relaxed)))
|
||||
}
|
||||
|
||||
/// Read a PNG the backend wrote, optionally downscale / re-encode as JPEG, and wrap it as image
|
||||
/// content (+ a text block with the metadata).
|
||||
fn image_result(file: &std::path::Path, max: Option<u32>, format: &str, save_to: Option<&str>, meta: Value) -> ToolResult {
|
||||
let bytes = match std::fs::read(file) {
|
||||
Ok(b) => b,
|
||||
Err(e) => return ToolResult::error(format!("cannot read rendered image {}: {e}", file.display())),
|
||||
};
|
||||
let _ = std::fs::remove_file(file);
|
||||
let jpeg = matches!(format, "jpeg" | "jpg");
|
||||
let (w, h) = (meta["width"].as_u64().unwrap_or(0) as u32, meta["height"].as_u64().unwrap_or(0) as u32);
|
||||
let too_big = max.is_some_and(|m| w.max(h) > m);
|
||||
let (bytes, w, h) = if jpeg || too_big {
|
||||
let opts = match max {
|
||||
Some(m) => lightcraft_codecs::DecodeOptions::fit(m, m),
|
||||
None => lightcraft_codecs::DecodeOptions::default(),
|
||||
};
|
||||
let d = match lightcraft_codecs::decode(&bytes, opts) {
|
||||
Ok(d) => d,
|
||||
Err(e) => return ToolResult::error(format!("cannot decode rendered image: {e}")),
|
||||
};
|
||||
let img = d.to_srgb8();
|
||||
match encode_image(if jpeg { "jpg" } else { "png" }, &img, 88) {
|
||||
Ok(b) => (b, img.width as u32, img.height as u32),
|
||||
Err(e) => return ToolResult::error(e),
|
||||
}
|
||||
} else {
|
||||
(bytes, w, h)
|
||||
};
|
||||
if let Some(p) = save_to
|
||||
&& let Err(e) = std::fs::write(p, &bytes)
|
||||
{
|
||||
return ToolResult::error(format!("{p}: {e}"));
|
||||
}
|
||||
let mime = if jpeg { "image/jpeg" } else { "image/png" };
|
||||
let mut info = meta;
|
||||
info["width"] = json!(w);
|
||||
info["height"] = json!(h);
|
||||
if let Some(p) = save_to {
|
||||
info["path"] = json!(p);
|
||||
} else if let Some(o) = info.as_object_mut() {
|
||||
o.remove("path");
|
||||
}
|
||||
ToolResult {
|
||||
content: vec![json!({"type": "image", "data": base64_encode(&bytes), "mimeType": mime}), json!({"type": "text", "text": info.to_string()})],
|
||||
is_error: false,
|
||||
structured: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_photo(b: &mut dyn Backend, args: &Value) -> ToolResult {
|
||||
let size = args.get("size").and_then(Value::as_u64).unwrap_or(1024).clamp(16, 4096);
|
||||
let id = match args.get("id").and_then(Value::as_u64) {
|
||||
Some(id) => Some(id),
|
||||
None => match exec(b, "library.state", json!({})) {
|
||||
Ok(s) => s["selection"]["active"].as_u64().or(s["selection"]["ids"][0].as_u64()),
|
||||
Err(e) => return ToolResult::error(e),
|
||||
},
|
||||
};
|
||||
let Some(id) = id else { return ToolResult::error("no active photo: import or select one, or pass `id`") };
|
||||
let file = temp_path("render");
|
||||
let path = file.to_string_lossy().to_string();
|
||||
let meta = match b.call("ui.render", json!({"id": id, "size": size, "path": path})) {
|
||||
Ok(m) => m,
|
||||
Err(e) => return ToolResult::error(e),
|
||||
};
|
||||
let mut meta = meta;
|
||||
meta["id"] = json!(id);
|
||||
image_result(&file, None, args.get("format").and_then(Value::as_str).unwrap_or("png"), args.get("path").and_then(Value::as_str), meta)
|
||||
}
|
||||
|
||||
fn screenshot(b: &mut dyn Backend, args: &Value) -> ToolResult {
|
||||
let file = temp_path("screenshot");
|
||||
let path = file.to_string_lossy().to_string();
|
||||
match b.call("ui.screenshot", json!({"path": path})) {
|
||||
Ok(meta) => image_result(
|
||||
&file,
|
||||
Some(args.get("maxSize").and_then(Value::as_u64).unwrap_or(1600).clamp(64, 8192) as u32),
|
||||
args.get("format").and_then(Value::as_str).unwrap_or("png"),
|
||||
args.get("path").and_then(Value::as_str),
|
||||
meta,
|
||||
),
|
||||
Err(e) => ToolResult::error(e),
|
||||
}
|
||||
}
|
||||
|
||||
fn obj(args: &Value, keys: &[&str]) -> Value {
|
||||
let mut m = Map::new();
|
||||
for k in keys {
|
||||
if let Some(v) = args.get(*k).filter(|v| !v.is_null()) {
|
||||
m.insert((*k).to_string(), v.clone());
|
||||
}
|
||||
}
|
||||
Value::Object(m)
|
||||
}
|
||||
|
||||
/// Run one tool.
|
||||
pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {
|
||||
let args = if args.is_null() { &json!({}) } else { args };
|
||||
if !args.is_object() {
|
||||
return ToolResult::error("tool arguments must be an object");
|
||||
}
|
||||
let s = |k: &str| args.get(k).and_then(Value::as_str);
|
||||
if let Some(rest) = name.strip_prefix(COMMAND_TOOL_PREFIX) {
|
||||
// Command ids never contain `_`, so the mapping is reversible.
|
||||
return ToolResult::from(exec(b, &rest.replace('_', "."), args.clone()));
|
||||
}
|
||||
match name {
|
||||
"list_commands" => {
|
||||
let filter = s("filter").unwrap_or("").to_lowercase();
|
||||
ToolResult::from(b.call("engine.commands", json!({})).map(|v| {
|
||||
match v {
|
||||
Value::Array(a) if !filter.is_empty() => Value::Array(
|
||||
a.into_iter()
|
||||
.filter(|c| {
|
||||
c["id"].as_str().unwrap_or("").to_lowercase().contains(&filter)
|
||||
|| c["label"].as_str().unwrap_or("").to_lowercase().contains(&filter)
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
v => v,
|
||||
}
|
||||
}))
|
||||
}
|
||||
"run_command" => match s("command") {
|
||||
Some(c) => ToolResult::from(exec(b, c, args.get("params").cloned().filter(|p| !p.is_null()).unwrap_or(json!({})))),
|
||||
None => ToolResult::error("missing `command`"),
|
||||
},
|
||||
"import" => {
|
||||
let paths: Vec<String> = match args.get("paths") {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(),
|
||||
Some(Value::String(p)) => vec![p.clone()],
|
||||
_ => return ToolResult::error("missing `paths`"),
|
||||
};
|
||||
let files = expand_paths(&paths);
|
||||
if files.is_empty() {
|
||||
return ToolResult::error(format!("no photos found in {paths:?}"));
|
||||
}
|
||||
let mut p = json!({"paths": files});
|
||||
if let Some(a) = args.get("album") {
|
||||
p["album"] = a.clone();
|
||||
}
|
||||
ToolResult::from(exec(b, "library.import", p).map(|mut r| {
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("files".into(), json!(files.len()));
|
||||
}
|
||||
r
|
||||
}))
|
||||
}
|
||||
"query_photos" => ToolResult::from(exec(b, "catalog.query", obj(args, &["filter", "sort", "offset", "limit"]))),
|
||||
"select_photos" => {
|
||||
let mut p = obj(args, &["ids", "active", "mode"]);
|
||||
if p.get("active").is_none()
|
||||
&& let Some(first) = args["ids"].get(0)
|
||||
{
|
||||
p["active"] = first.clone();
|
||||
}
|
||||
ToolResult::from(exec(b, "library.select", p))
|
||||
}
|
||||
"list_controls" => ToolResult::from(exec(b, "develop.controls", obj(args, &["section"]))),
|
||||
"get_develop" => ToolResult::from(exec(b, "develop.get", obj(args, &["id"]))),
|
||||
"set_develop" => {
|
||||
let r = (|| {
|
||||
activate(b, args)?;
|
||||
let values = args.get("values").filter(|v| v.as_object().is_some_and(|o| !o.is_empty()));
|
||||
let settings = args.get("settings").filter(|v| v.as_object().is_some_and(|o| !o.is_empty()));
|
||||
if values.is_none() && settings.is_none() {
|
||||
return Err("give `values` ({controlId: number}) and/or `settings` (partial develop JSON)".to_string());
|
||||
}
|
||||
if let Some(v) = values {
|
||||
exec(b, "develop.set", json!({"values": v}))?;
|
||||
}
|
||||
if let Some(st) = settings {
|
||||
let mut p = json!({"settings": st});
|
||||
if let Some(l) = args.get("label") {
|
||||
p["label"] = l.clone();
|
||||
}
|
||||
exec(b, "develop.merge", p)?;
|
||||
}
|
||||
// Echo the resulting values of the touched sliders.
|
||||
let controls = exec(b, "develop.controls", json!({}))?;
|
||||
let touched: Vec<Value> = match (values, controls) {
|
||||
(Some(Value::Object(v)), Value::Array(all)) => all
|
||||
.into_iter()
|
||||
.filter(|c| c["id"].as_str().is_some_and(|id| v.contains_key(id)))
|
||||
.map(|c| json!({"id": c["id"], "value": c["value"]}))
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
Ok(json!({"ok": true, "controls": touched}))
|
||||
})();
|
||||
ToolResult::from(r)
|
||||
}
|
||||
"apply_preset" => match s("preset").or(s("id")) {
|
||||
Some(p) => {
|
||||
let mut params = obj(args, &["amount", "ids"]);
|
||||
params["id"] = json!(p);
|
||||
ToolResult::from(exec(b, "preset.apply", params))
|
||||
}
|
||||
None => ToolResult::error("missing `preset`"),
|
||||
},
|
||||
"crop" => ToolResult::from(activate(b, args).and_then(|_| {
|
||||
if args.get("reset").and_then(Value::as_bool) == Some(true) {
|
||||
exec(b, "crop.reset", json!({}))
|
||||
} else {
|
||||
exec(b, "crop.set", obj(args, &["rect", "angle"]))
|
||||
}
|
||||
})),
|
||||
"render_photo" => render_photo(b, args),
|
||||
"export" => ToolResult::from(activate(b, args).and_then(|_| {
|
||||
exec(
|
||||
b,
|
||||
"app.export",
|
||||
obj(
|
||||
args,
|
||||
&[
|
||||
"ids",
|
||||
"path",
|
||||
"dir",
|
||||
"format",
|
||||
"longEdge",
|
||||
"quality",
|
||||
"limitKb",
|
||||
"sharpen",
|
||||
"sharpenAmount",
|
||||
"naming",
|
||||
"metadata",
|
||||
"removeLocation",
|
||||
],
|
||||
),
|
||||
)
|
||||
})),
|
||||
"screenshot" => screenshot(b, args),
|
||||
"inspect_ui" => ToolResult::from(b.call("ui.inspect", json!({}))),
|
||||
"set_ui" => ToolResult::from(b.call("ui.set", args.get("state").cloned().unwrap_or(json!({})))),
|
||||
"list_widgets" => ToolResult::from(b.call("ui.widgets", obj(args, &["filter"]))),
|
||||
"click" => {
|
||||
let mut p = obj(args, &["x", "y", "count", "button"]);
|
||||
match s("widget") {
|
||||
Some(w) => {
|
||||
p["id"] = json!(w);
|
||||
ToolResult::from(b.call("ui.clickWidget", p))
|
||||
}
|
||||
None if args.get("x").is_some() && args.get("y").is_some() => ToolResult::from(b.call("ui.click", p)),
|
||||
None => ToolResult::error("give `widget` or `x` and `y`"),
|
||||
}
|
||||
}
|
||||
"press_key" => ToolResult::from(b.call("ui.key", obj(args, &["key", "cmd", "shift", "alt", "ctrl"]))),
|
||||
"type_text" => ToolResult::from(b.call("ui.text", obj(args, &["text"]))),
|
||||
"pointer_gesture" => ToolResult::from(b.call("ui.pointer", obj(args, &["events", "alt", "shift", "cmd"]))),
|
||||
other => ToolResult::error(format!("unknown tool `{other}` (see tools/list)")),
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
//! M0.9 acceptance: drive LightCraft over MCP (stdio framing), set exposure and render an image.
|
||||
//! Runs against the headless backend directly and through the TCP control-channel transport
|
||||
//! (`Remote`) to a stand-in control server.
|
||||
|
||||
use std::io::{BufRead, BufReader, Cursor, Write};
|
||||
use std::net::TcpListener;
|
||||
|
||||
use lightcraft_mcp::{Backend, Headless, Remote, Server, base64_decode};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Feed a whole session through `Server::serve` and return the replies by id.
|
||||
fn session(server: &mut Server, msgs: &[Value]) -> Vec<Value> {
|
||||
let input: String = msgs.iter().map(|m| format!("{m}\n")).collect();
|
||||
let mut out = Vec::new();
|
||||
server.serve(Cursor::new(input), &mut out).unwrap();
|
||||
String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect()
|
||||
}
|
||||
|
||||
fn call(id: u64, name: &str, args: Value) -> Value {
|
||||
json!({"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": {"name": name, "arguments": args}})
|
||||
}
|
||||
|
||||
/// Mean sRGB value of the PNG in an MCP image result.
|
||||
fn mean_of(result: &Value) -> (f64, u32, u32) {
|
||||
let img = result["content"].as_array().unwrap().iter().find(|c| c["type"] == "image").unwrap_or_else(|| panic!("no image in {result}"));
|
||||
assert_eq!(img["mimeType"], "image/png");
|
||||
let png = base64_decode(img["data"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(&png[1..4], b"PNG");
|
||||
let d = lightcraft_codecs::decode(&png, Default::default()).unwrap();
|
||||
let rgba = d.to_srgb8();
|
||||
let sum: u64 = rgba.data.iter().map(|p| p[0] as u64 + p[1] as u64 + p[2] as u64).sum();
|
||||
(sum as f64 / (3 * rgba.data.len()) as f64, d.width, d.height)
|
||||
}
|
||||
|
||||
fn exposure_roundtrip(server: &mut Server) {
|
||||
let replies = session(
|
||||
server,
|
||||
&[
|
||||
json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "e2e", "version": "0"}}}),
|
||||
json!({"jsonrpc": "2.0", "method": "notifications/initialized"}),
|
||||
json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}),
|
||||
call(3, "query_photos", json!({"limit": 1})),
|
||||
],
|
||||
);
|
||||
assert_eq!(replies.len(), 3, "notifications get no reply");
|
||||
assert_eq!(replies[0]["result"]["serverInfo"]["name"], "lightcraft");
|
||||
let tools: Vec<&str> = replies[1]["result"]["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap()).collect();
|
||||
assert!(tools.contains(&"set_develop") && tools.contains(&"render_photo") && tools.contains(&"cmd_develop_set"));
|
||||
let id = replies[2]["result"]["structuredContent"]["photos"][0]["id"].as_u64().expect("a photo");
|
||||
|
||||
let replies = session(
|
||||
server,
|
||||
&[
|
||||
call(10, "render_photo", json!({"id": id, "size": 256})),
|
||||
call(11, "set_develop", json!({"id": id, "values": {"light.exposure": 1.5}})),
|
||||
call(12, "get_develop", json!({"id": id})),
|
||||
call(13, "render_photo", json!({"id": id, "size": 256})),
|
||||
// The generated per-command tool works too: exposure -1.5 via `cmd_develop_set`.
|
||||
call(14, "cmd_develop_set", json!({"control": "light.exposure", "value": -1.5})),
|
||||
call(15, "render_photo", json!({"size": 256})),
|
||||
],
|
||||
);
|
||||
for r in &replies {
|
||||
assert!(r["error"].is_null() && r["result"]["isError"] == false, "{r}");
|
||||
}
|
||||
assert_eq!(replies[1]["result"]["structuredContent"]["controls"][0]["value"], 1.5);
|
||||
assert_eq!(replies[2]["result"]["structuredContent"]["light"]["exposure"], 1.5);
|
||||
let (before, w, h) = mean_of(&replies[0]["result"]);
|
||||
let (brighter, w2, h2) = mean_of(&replies[3]["result"]);
|
||||
let (darker, _, _) = mean_of(&replies[5]["result"]);
|
||||
assert_eq!((w, h), (w2, h2));
|
||||
assert!(w.max(h) <= 256 && w.max(h) >= 200, "{w}x{h}");
|
||||
println!("mean sRGB: exposure 0 → {before:.1}, +1.5 → {brighter:.1}, -1.5 → {darker:.1} ({w}×{h})");
|
||||
assert!(brighter > before + 15.0, "exposure +1.5 should brighten: {before} → {brighter}");
|
||||
assert!(darker < before - 15.0, "exposure -1.5 should darken: {before} → {darker}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headless_set_exposure_and_render() {
|
||||
exposure_roundtrip(&mut Server::new(Box::new(Headless::demo())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_render_export_real_file() {
|
||||
let dir = std::env::temp_dir().join(format!("lightcraft-mcp-e2e-{}", std::process::id()));
|
||||
std::fs::create_dir_all(dir.join("sub")).unwrap();
|
||||
// A synthetic gradient PNG to import.
|
||||
let img = lightcraft_raster::Rgba8::from_fn(96, 64, |x, y| [(x * 2) as u8, (y * 3) as u8, 128, 255]);
|
||||
lightcraft_mcp::write_image(&dir.join("sub/gradient.png"), &img, 90).unwrap();
|
||||
let out = dir.join("out.jpg");
|
||||
let mut server = Server::new(Box::new(Headless::default()));
|
||||
let replies = session(
|
||||
&mut server,
|
||||
&[
|
||||
call(1, "import", json!({"paths": [dir.to_string_lossy()]})),
|
||||
call(2, "set_develop", json!({"values": {"light.exposure": 0.5}})),
|
||||
call(3, "render_photo", json!({"format": "jpeg", "size": 64})),
|
||||
call(4, "export", json!({"path": out.to_string_lossy(), "longEdge": 48})),
|
||||
],
|
||||
);
|
||||
for r in &replies {
|
||||
assert!(r["result"]["isError"] == false, "{r}");
|
||||
}
|
||||
assert_eq!(replies[0]["result"]["structuredContent"]["imported"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(replies[2]["result"]["content"][0]["mimeType"], "image/jpeg");
|
||||
let jpg = std::fs::read(&out).unwrap();
|
||||
assert_eq!(&jpg[..2], &[0xff, 0xd8]);
|
||||
let d = lightcraft_codecs::decode(&jpg, Default::default()).unwrap();
|
||||
assert_eq!((d.width, d.height), (48, 32));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A stand-in for the desktop app's control server: JSON lines over TCP, answered by a headless
|
||||
/// backend (the real app answers the same methods from its UI thread).
|
||||
fn fake_app() -> String {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap().to_string();
|
||||
std::thread::spawn(move || {
|
||||
let mut backend = Headless::demo();
|
||||
for stream in listener.incoming().flatten() {
|
||||
let mut out = stream.try_clone().unwrap();
|
||||
for line in BufReader::new(stream).lines() {
|
||||
let Ok(line) = line else { break };
|
||||
let msg: Value = serde_json::from_str(&line).unwrap();
|
||||
let reply = match backend.call(msg["method"].as_str().unwrap(), msg["params"].clone()) {
|
||||
Ok(v) => json!({"id": msg["id"], "ok": true, "result": v}),
|
||||
Err(e) => json!({"id": msg["id"], "ok": false, "error": e}),
|
||||
};
|
||||
if writeln!(out, "{reply}").is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
addr
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_set_exposure_and_render() {
|
||||
let addr = fake_app();
|
||||
let remote = Remote::connect(&addr).unwrap();
|
||||
assert!(remote.has_ui());
|
||||
let mut server = Server::new(Box::new(remote));
|
||||
exposure_roundtrip(&mut server);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_unreachable_is_a_tool_error() {
|
||||
// Bind and drop to get a port with (almost certainly) nothing listening.
|
||||
let addr = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().to_string();
|
||||
let mut server = Server::new(Box::new(Remote::lazy(&addr)));
|
||||
let r = session(&mut server, &[call(1, "query_photos", json!({}))]);
|
||||
assert_eq!(r[0]["result"]["isError"], true);
|
||||
assert!(r[0]["result"]["content"][0]["text"].as_str().unwrap().contains("not reachable"));
|
||||
}
|
||||
|
|
@ -253,3 +253,101 @@ pub(crate) mod tests {
|
|||
assert_eq!(m.title.as_deref(), Some("From XMP"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialise the interchange subset of `meta` as a TIFF-structured Exif block (little-endian, no
|
||||
/// `Exif\0\0` prefix): IFD0 (make, model, software, artist, copyright, description, orientation,
|
||||
/// date), the Exif IFD (capture settings, lens) and, when present, the GPS IFD.
|
||||
pub fn write_exif(meta: &Metadata) -> Vec<u8> {
|
||||
use lightcraft_tiff::writer::{rational, srational};
|
||||
use lightcraft_tiff::{ByteOrder, IfdBuilder, TiffWriter, Value};
|
||||
let ascii = |s: &Option<String>| s.as_ref().filter(|v| !v.is_empty()).map(|v| Value::Ascii(v.clone()));
|
||||
let mut ifd0 = IfdBuilder::new();
|
||||
for (tag, v) in [
|
||||
(t::MAKE, ascii(&meta.make)),
|
||||
(t::MODEL, ascii(&meta.model)),
|
||||
(t::SOFTWARE, ascii(&meta.software)),
|
||||
(t::ARTIST, ascii(&meta.artist)),
|
||||
(t::COPYRIGHT, ascii(&meta.copyright)),
|
||||
(t::IMAGE_DESCRIPTION, ascii(&meta.caption)),
|
||||
(t::DATE_TIME, meta.capture_time.map(|d| Value::Ascii(d.to_exif()))),
|
||||
(t::ORIENTATION, meta.orientation.map(|o| Value::Short(vec![o.to_exif()]))),
|
||||
] {
|
||||
if let Some(v) = v {
|
||||
ifd0.set(tag, v);
|
||||
}
|
||||
}
|
||||
let mut ex = IfdBuilder::new();
|
||||
ex.set(t::EXIF_VERSION, Value::Undefined(b"0232".to_vec()));
|
||||
let r = |v: Option<f64>| v.filter(|v| v.is_finite() && *v > 0.0).map(|v| Value::Rational(vec![rational(v)]));
|
||||
for (tag, v) in [
|
||||
(t::DATE_TIME_ORIGINAL, meta.capture_time.map(|d| Value::Ascii(d.to_exif()))),
|
||||
(t::EXPOSURE_TIME, r(meta.exposure_time)),
|
||||
(t::F_NUMBER, r(meta.f_number)),
|
||||
(t::FOCAL_LENGTH, r(meta.focal_length)),
|
||||
(t::ISO_SPEED, meta.iso.map(|i| Value::Short(vec![i.min(65_535) as u16]))),
|
||||
(t::LENS_MAKE, ascii(&meta.lens_make)),
|
||||
(t::LENS_MODEL, ascii(&meta.lens_model)),
|
||||
(t::EXPOSURE_BIAS, meta.exposure_bias.map(|v| Value::SRational(vec![srational(v)]))),
|
||||
] {
|
||||
if let Some(v) = v {
|
||||
ex.set(tag, v);
|
||||
}
|
||||
}
|
||||
ifd0.set_child(t::EXIF_IFD, ex);
|
||||
if let Some(g) = meta.gps {
|
||||
let dms = |v: f64| {
|
||||
let v = v.abs();
|
||||
let d = v.floor();
|
||||
let m = ((v - d) * 60.0).floor();
|
||||
let s = (v - d - m / 60.0) * 3600.0;
|
||||
Value::Rational(vec![(d as u32, 1), (m as u32, 1), ((s * 1000.0).round() as u32, 1000)])
|
||||
};
|
||||
let mut gps = IfdBuilder::new();
|
||||
gps.set(t::GPS_VERSION_ID, Value::Byte(vec![2, 3, 0, 0]));
|
||||
gps.set(t::GPS_LATITUDE_REF, Value::Ascii(if g.latitude < 0.0 { "S" } else { "N" }.into()));
|
||||
gps.set(t::GPS_LATITUDE, dms(g.latitude));
|
||||
gps.set(t::GPS_LONGITUDE_REF, Value::Ascii(if g.longitude < 0.0 { "W" } else { "E" }.into()));
|
||||
gps.set(t::GPS_LONGITUDE, dms(g.longitude));
|
||||
if let Some(a) = g.altitude {
|
||||
gps.set(t::GPS_ALTITUDE_REF, Value::Byte(vec![u8::from(a < 0.0)]));
|
||||
gps.set(t::GPS_ALTITUDE, Value::Rational(vec![rational(a.abs())]));
|
||||
}
|
||||
ifd0.set_child(t::GPS_IFD, gps);
|
||||
}
|
||||
TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod write_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn exif_round_trips() {
|
||||
let m = Metadata {
|
||||
make: Some("Synthetic".into()),
|
||||
model: Some("X2".into()),
|
||||
copyright: Some("© 2026 A. Person".into()),
|
||||
artist: Some("A. Person".into()),
|
||||
capture_time: DateTime::parse_iso("2026-09-20T05:40:00"),
|
||||
exposure_time: Some(1.0 / 250.0),
|
||||
f_number: Some(2.8),
|
||||
iso: Some(400),
|
||||
focal_length: Some(35.0),
|
||||
lens_model: Some("35mm F2".into()),
|
||||
gps: Some(Gps { latitude: 43.7904, longitude: -110.6818, altitude: Some(2000.0) }),
|
||||
..Default::default()
|
||||
};
|
||||
let back = read_exif(&write_exif(&m));
|
||||
assert_eq!(back.make, m.make);
|
||||
assert_eq!(back.model, m.model);
|
||||
assert_eq!(back.copyright, m.copyright);
|
||||
assert_eq!(back.artist, m.artist);
|
||||
assert_eq!(back.capture_time.map(|d| d.to_exif()), m.capture_time.map(|d| d.to_exif()));
|
||||
assert_eq!(back.iso, Some(400));
|
||||
assert!((back.f_number.unwrap() - 2.8).abs() < 1e-6);
|
||||
assert!((back.exposure_time.unwrap() - 0.004).abs() < 1e-9);
|
||||
assert_eq!(back.lens_model, m.lens_model);
|
||||
let g = back.gps.unwrap();
|
||||
assert!((g.latitude - 43.7904).abs() < 1e-5 && (g.longitude + 110.6818).abs() < 1e-5, "{g:?}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ mod xmp;
|
|||
|
||||
pub use container::{Embedded, embedded, jpeg_segments, png_chunks, webp_chunks};
|
||||
pub use datetime::DateTime;
|
||||
pub use exif::{from_tiff, read_exif, strip_exif_header, try_read_exif};
|
||||
pub use exif::{from_tiff, read_exif, strip_exif_header, try_read_exif, write_exif};
|
||||
pub use iptc::parse_iptc;
|
||||
pub use lightcraft_geom::Orientation;
|
||||
pub use xmp::{LC_NS, XmpData, XmpError, parse_xmp, write_xmp};
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ mod finish;
|
|||
pub mod geometry;
|
||||
mod local;
|
||||
pub mod masks;
|
||||
pub mod profiles;
|
||||
pub mod spots;
|
||||
mod tone;
|
||||
|
||||
use lightcraft_develop::{DevelopSettings, Treatment};
|
||||
|
|
@ -96,14 +98,15 @@ pub fn output_size(src_w: usize, src_h: usize, s: &DevelopSettings, req: &Render
|
|||
/// Render `src` with settings `s`.
|
||||
pub fn render(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest) -> Rendered {
|
||||
let prof = std::env::var_os("LIGHTCRAFT_PROFILE").is_some();
|
||||
let t0 = std::time::Instant::now();
|
||||
let lap = |what: &str, t: &mut std::time::Instant| {
|
||||
if prof {
|
||||
// `Instant::now()` panics on wasm32-unknown-unknown: only read the clock when profiling.
|
||||
let lap = |what: &str, t: &mut Option<std::time::Instant>| {
|
||||
if let Some(t) = t {
|
||||
eprintln!(" {what}: {:.1} ms", t.elapsed().as_secs_f64() * 1e3);
|
||||
*t = std::time::Instant::now();
|
||||
}
|
||||
};
|
||||
let mut t = t0;
|
||||
let mut t = prof.then(std::time::Instant::now);
|
||||
let s = &*profiles::effective(s);
|
||||
let frame = geometry::Frame::new(src.width, src.height, s, req.apply_crop);
|
||||
let (w, h) = frame.fit(req.max_w, req.max_h);
|
||||
let mut img = frame.sample(src, w, h);
|
||||
|
|
@ -111,7 +114,9 @@ pub fn render(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, req: &Render
|
|||
let px_per_long = frame.px_per_long(w);
|
||||
|
||||
local::scene_linear_pre(&mut img, info, s);
|
||||
lap("wb/exposure", &mut t);
|
||||
spots::apply(&mut img, &s.spots, &frame, px_per_long);
|
||||
local::denoise(&mut img, s, src.width.max(src.height), w.max(h));
|
||||
lap("wb/exposure/spots/nr", &mut t);
|
||||
let prep = local::prepare(img, s, &frame, px_per_long, req.quality);
|
||||
lap("prepare", &mut t);
|
||||
let image = finish::finish(&prep, s, &frame, info);
|
||||
|
|
@ -134,6 +139,23 @@ pub(crate) fn is_bw(s: &DevelopSettings) -> bool {
|
|||
}
|
||||
|
||||
/// Parallel map over output rows with index.
|
||||
/// `LIGHTCRAFT_PROFILE` is set: print per-stage timings to stderr.
|
||||
pub(crate) fn profiling() -> bool {
|
||||
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*ON.get_or_init(|| std::env::var_os("LIGHTCRAFT_PROFILE").is_some())
|
||||
}
|
||||
|
||||
/// Run `f`, printing its duration under `LIGHTCRAFT_PROFILE`.
|
||||
pub(crate) fn timed<R>(what: &str, f: impl FnOnce() -> R) -> R {
|
||||
if !profiling() {
|
||||
return f();
|
||||
}
|
||||
let t = std::time::Instant::now();
|
||||
let r = f();
|
||||
eprintln!(" {what}: {:.1} ms", t.elapsed().as_secs_f64() * 1e3);
|
||||
r
|
||||
}
|
||||
|
||||
pub(crate) fn for_rows<T: Send>(data: &mut [T], w: usize, f: impl Fn(usize, &mut [T]) + Sync + Send) {
|
||||
par_rows(data, w, f)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ use lightcraft_raster::blur::gaussian;
|
|||
use lightcraft_raster::{Plane, Rgb32f};
|
||||
|
||||
use crate::geometry::Frame;
|
||||
use crate::{Prepared, Quality, SourceInfo, for_rows, masks};
|
||||
use crate::{Prepared, Quality, SourceInfo, for_rows, masks, timed};
|
||||
|
||||
/// White balance (relative to the source's as-shot white) and exposure, in place.
|
||||
pub fn scene_linear_pre(img: &mut Rgb32f, info: &SourceInfo, s: &DevelopSettings) {
|
||||
|
|
@ -108,12 +108,67 @@ pub fn guided_fast(p: &Plane, sigma: f32, eps: f32) -> Plane {
|
|||
q
|
||||
}
|
||||
|
||||
/// Noise reduction at output resolution: luminance via an edge-aware self-guided filter on
|
||||
/// log-luminance, colour by blurring chromaticity (rgb / Y) and re-applying the original luminance.
|
||||
/// Radii scale with how much the source was downsampled (preview noise is already averaged out).
|
||||
pub fn denoise(img: &mut Rgb32f, s: &DevelopSettings, src_long: usize, out_long: usize) {
|
||||
let lum = (s.detail.nr_luminance / 100.0) as f32;
|
||||
let col = (s.detail.nr_color / 100.0) as f32;
|
||||
if lum <= 0.0 && col <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let scale = (out_long as f32 / src_long.max(1) as f32).clamp(0.05, 1.0);
|
||||
let w = img.width;
|
||||
if lum > 0.0 {
|
||||
let _t = crate::profiling().then(std::time::Instant::now);
|
||||
let l = img.map(log_lum);
|
||||
let detail = (s.detail.nr_detail / 100.0) as f32;
|
||||
let eps = 0.002 + lum * lum * 0.25 * (1.0 - 0.7 * detail);
|
||||
let f = guided(&l, (1.0 + 2.5 * lum) * scale.max(0.4), eps);
|
||||
let k = lum.sqrt();
|
||||
for_rows(&mut img.data, w, |y, row| {
|
||||
for (x, p) in row.iter_mut().enumerate() {
|
||||
let i = y * w + x;
|
||||
let d = (f.data[i] - l.data[i]) * k;
|
||||
let g = 2f32.powf(d);
|
||||
*p = p.map(|v| v * g);
|
||||
}
|
||||
});
|
||||
if let Some(t) = _t {
|
||||
eprintln!(" nr luminance: {:.1} ms", t.elapsed().as_secs_f64() * 1e3);
|
||||
}
|
||||
}
|
||||
if col > 0.0 {
|
||||
let _t = crate::profiling().then(std::time::Instant::now);
|
||||
let chroma = img.map(|c| {
|
||||
let y = luminance_2020(c).max(1e-6);
|
||||
[c[0] / y, c[1] / y, c[2] / y]
|
||||
});
|
||||
let sigma = (1.5 + 6.0 * col) * scale.max(0.35) * (1.0 + (s.detail.nr_color_smoothness / 100.0) as f32);
|
||||
let b = gaussian(&chroma, sigma);
|
||||
let keep = (s.detail.nr_color_detail / 100.0) as f32 * 0.5;
|
||||
for_rows(&mut img.data, w, |y, row| {
|
||||
for (x, p) in row.iter_mut().enumerate() {
|
||||
let i = y * w + x;
|
||||
let yl = luminance_2020(*p);
|
||||
let c0 = chroma.data[i];
|
||||
let cb = b.data[i];
|
||||
let t = col * (1.0 - keep);
|
||||
*p = [0, 1, 2].map(|k| ((c0[k] + (cb[k] - c0[k]) * t) * yl).max(0.0));
|
||||
}
|
||||
});
|
||||
if let Some(t) = _t {
|
||||
eprintln!(" nr colour: {:.1} ms", t.elapsed().as_secs_f64() * 1e3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn log_lum(c: [f32; 3]) -> f32 {
|
||||
(luminance_2020(c).max(1e-7) / crate::tone::GREY).log2()
|
||||
}
|
||||
|
||||
pub fn prepare(img: Rgb32f, s: &DevelopSettings, frame: &Frame, px_per_long: f64, q: Quality) -> Prepared {
|
||||
let log_l = img.map(log_lum);
|
||||
let log_l = timed("log_l", || img.map(log_lum));
|
||||
let ppl = px_per_long as f32;
|
||||
let tone_active =
|
||||
s.light.highlights != 0.0 || s.light.shadows != 0.0 || s.masks.iter().any(|m| m.adjust.highlights != 0.0 || m.adjust.shadows != 0.0);
|
||||
|
|
@ -121,19 +176,25 @@ pub fn prepare(img: Rgb32f, s: &DevelopSettings, frame: &Frame, px_per_long: f64
|
|||
// Edge-aware base at ~1.5% of the long edge (EV² epsilon: edges of > ~0.6 EV are preserved).
|
||||
let sigma = (0.015 * ppl).max(1.0);
|
||||
let sigma = if q == Quality::Draft { sigma.min(24.0) } else { sigma };
|
||||
guided_fast(&log_l, sigma, 0.35)
|
||||
timed("base", || guided_fast(&log_l, sigma, 0.35))
|
||||
} else {
|
||||
log_l.clone()
|
||||
};
|
||||
let local_any = |f: fn(&lightcraft_develop::LocalAdjustments) -> f64| s.masks.iter().any(|m| f(&m.adjust) != 0.0);
|
||||
let clarity_blur = (s.effects.clarity != 0.0 || local_any(|a| a.clarity)).then(|| guided_fast(&log_l, (0.012 * ppl).max(1.0), 0.8));
|
||||
let clarity_blur =
|
||||
(s.effects.clarity != 0.0 || local_any(|a| a.clarity)).then(|| timed("clarity", || guided_fast(&log_l, (0.012 * ppl).max(1.0), 0.8)));
|
||||
let texture_blur = (s.effects.texture != 0.0 || s.detail.sharpen_amount != 0.0 || local_any(|a| a.texture) || local_any(|a| a.sharpness))
|
||||
.then(|| gaussian(&log_l, (0.0018 * ppl).max(0.6)));
|
||||
.then(|| timed("texture", || gaussian(&log_l, (0.0018 * ppl).max(0.6))));
|
||||
let dark = (s.effects.dehaze != 0.0 || local_any(|a| a.dehaze)).then(|| {
|
||||
let _t = crate::profiling().then(std::time::Instant::now);
|
||||
let min_c = img.map(|c| c[0].min(c[1]).min(c[2]));
|
||||
gaussian(&min_c, (0.02 * ppl).max(1.0))
|
||||
let d = gaussian(&min_c, (0.02 * ppl).max(1.0));
|
||||
if let Some(t) = _t {
|
||||
eprintln!(" dehaze: {:.1} ms", t.elapsed().as_secs_f64() * 1e3);
|
||||
}
|
||||
d
|
||||
});
|
||||
let masks = masks::evaluate(&s.masks, frame, img.width, img.height, &img, &log_l);
|
||||
let masks = timed("masks", || masks::evaluate(&s.masks, frame, img.width, img.height, &img, &log_l));
|
||||
Prepared { img, log_l, base, clarity_blur, texture_blur, dark, masks, px_per_long }
|
||||
}
|
||||
|
||||
|
|
@ -204,3 +265,27 @@ mod fast_tests {
|
|||
assert!(err < 0.08, "{err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod nr_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn luminance_nr_reduces_noise_keeps_mean() {
|
||||
let mut img = Rgb32f::from_fn(64, 64, |x, y| {
|
||||
let n = (((x * 7919 + y * 104729) % 97) as f32 / 97.0 - 0.5) * 0.06;
|
||||
[0.2 + n; 3]
|
||||
});
|
||||
let var = |im: &Rgb32f| {
|
||||
let m = im.data.iter().map(|p| p[1]).sum::<f32>() / im.len() as f32;
|
||||
(im.data.iter().map(|p| (p[1] - m).powi(2)).sum::<f32>() / im.len() as f32, m)
|
||||
};
|
||||
let (v0, m0) = var(&img);
|
||||
let mut s = DevelopSettings::default();
|
||||
s.detail.nr_luminance = 80.0;
|
||||
denoise(&mut img, &s, 64, 64);
|
||||
let (v1, m1) = var(&img);
|
||||
assert!(v1 < v0 * 0.5, "{v0} -> {v1}");
|
||||
assert!((m1 - m0).abs() < 0.01);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
//! Base rendering profiles. A profile is a look applied *underneath* the user's sliders (the sliders
|
||||
//! stay at their displayed values); `profile.amount` (0–200 %) scales it. All looks are our own.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use lightcraft_develop::{DevelopSettings, Treatment};
|
||||
|
||||
/// The settings the pipeline actually renders: user settings plus the profile's look.
|
||||
pub fn effective(s: &DevelopSettings) -> Cow<'_, DevelopSettings> {
|
||||
let k = (s.profile.amount / 100.0).clamp(0.0, 2.0);
|
||||
let id = s.profile.id.as_str();
|
||||
if id == "lc.color" || id.is_empty() || k == 0.0 {
|
||||
return Cow::Borrowed(s);
|
||||
}
|
||||
let mut e = s.clone();
|
||||
let add = |v: &mut f64, d: f64, lo: f64, hi: f64| *v = (*v + d * k).clamp(lo, hi);
|
||||
match id {
|
||||
"lc.neutral" => {
|
||||
add(&mut e.light.contrast, -18.0, -100.0, 100.0);
|
||||
add(&mut e.color.saturation, -10.0, -100.0, 100.0);
|
||||
add(&mut e.light.highlights, -10.0, -100.0, 100.0);
|
||||
add(&mut e.light.shadows, 8.0, -100.0, 100.0);
|
||||
}
|
||||
"lc.vivid" => {
|
||||
add(&mut e.light.contrast, 16.0, -100.0, 100.0);
|
||||
add(&mut e.color.saturation, 14.0, -100.0, 100.0);
|
||||
add(&mut e.color.vibrance, 14.0, -100.0, 100.0);
|
||||
add(&mut e.light.blacks, -6.0, -100.0, 100.0);
|
||||
}
|
||||
"lc.landscape" => {
|
||||
add(&mut e.light.contrast, 10.0, -100.0, 100.0);
|
||||
add(&mut e.color.vibrance, 10.0, -100.0, 100.0);
|
||||
add(&mut e.mixer.green.sat, 14.0, -100.0, 100.0);
|
||||
add(&mut e.mixer.aqua.sat, 10.0, -100.0, 100.0);
|
||||
add(&mut e.mixer.blue.sat, 14.0, -100.0, 100.0);
|
||||
add(&mut e.mixer.blue.lum, -6.0, -100.0, 100.0);
|
||||
add(&mut e.effects.clarity, 6.0, -100.0, 100.0);
|
||||
}
|
||||
"lc.portrait" => {
|
||||
add(&mut e.light.contrast, -6.0, -100.0, 100.0);
|
||||
add(&mut e.mixer.orange.sat, -8.0, -100.0, 100.0);
|
||||
add(&mut e.mixer.orange.lum, 6.0, -100.0, 100.0);
|
||||
add(&mut e.mixer.red.sat, -5.0, -100.0, 100.0);
|
||||
add(&mut e.effects.texture, -6.0, -100.0, 100.0);
|
||||
}
|
||||
"lc.mono" => {
|
||||
e.treatment = Treatment::Bw;
|
||||
add(&mut e.light.contrast, 8.0, -100.0, 100.0);
|
||||
}
|
||||
_ => return Cow::Borrowed(s),
|
||||
}
|
||||
Cow::Owned(e)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_profile_is_borrowed_and_others_change_things() {
|
||||
let s = DevelopSettings::default();
|
||||
assert!(matches!(effective(&s), Cow::Borrowed(_)));
|
||||
for id in ["lc.neutral", "lc.vivid", "lc.landscape", "lc.portrait", "lc.mono"] {
|
||||
let mut t = s.clone();
|
||||
t.profile.id = id.into();
|
||||
assert_ne!(*effective(&t), s, "{id}");
|
||||
t.profile.amount = 0.0;
|
||||
assert_eq!(*effective(&t), t, "{id} at 0%");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
//! Remove / Heal / Clone spots, rendered at output resolution in scene-linear light.
|
||||
//!
|
||||
//! - **Clone** copies the source patch with a feathered edge.
|
||||
//! - **Heal** copies the source's *detail* and keeps the target's *tone*: result = src + blur(target − src)
|
||||
//! (frequency separation — a fast, stable approximation of gradient-domain healing).
|
||||
//! - **Remove** is Heal with an automatically chosen source: candidate offsets on rings around the
|
||||
//! spot are scored by how well the source's surrounding annulus matches the target's.
|
||||
|
||||
use lightcraft_develop::{Spot, SpotMode};
|
||||
use lightcraft_geom::Point;
|
||||
use lightcraft_raster::Rgb32f;
|
||||
|
||||
use crate::geometry::Frame;
|
||||
|
||||
#[inline]
|
||||
fn smooth(e0: f32, e1: f32, x: f32) -> f32 {
|
||||
let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0);
|
||||
t * t * (3.0 - 2.0 * t)
|
||||
}
|
||||
|
||||
/// Mean colour of `img` in a disc/annulus.
|
||||
fn ring_stats(img: &Rgb32f, c: (f32, f32), r0: f32, r1: f32) -> Option<Vec<[f32; 3]>> {
|
||||
let mut out = Vec::new();
|
||||
let n = 24;
|
||||
for i in 0..n {
|
||||
let a = i as f32 / n as f32 * std::f32::consts::TAU;
|
||||
for rr in [r0, (r0 + r1) / 2.0, r1] {
|
||||
let (x, y) = (c.0 + a.cos() * rr, c.1 + a.sin() * rr);
|
||||
if x < 0.0 || y < 0.0 || x >= img.width as f32 || y >= img.height as f32 {
|
||||
return None;
|
||||
}
|
||||
out.push(img.sample_bilinear(x, y));
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn score(img: &Rgb32f, target: (f32, f32), source: (f32, f32), r: f32) -> f32 {
|
||||
let (Some(a), Some(b)) = (ring_stats(img, target, r * 1.05, r * 1.6), ring_stats(img, source, r * 1.05, r * 1.6)) else { return f32::MAX };
|
||||
// also compare the inside of the source against the target's surroundings (texture continuity)
|
||||
let inside = ring_stats(img, source, r * 0.2, r * 0.9);
|
||||
let mut s = 0.0;
|
||||
for (p, q) in a.iter().zip(&b) {
|
||||
for c in 0..3 {
|
||||
let (u, v) = (p[c].max(0.0).sqrt(), q[c].max(0.0).sqrt());
|
||||
s += (u - v) * (u - v);
|
||||
}
|
||||
}
|
||||
if let Some(ins) = inside {
|
||||
let mean_a: f32 = a.iter().map(|p| p[1].max(0.0).sqrt()).sum::<f32>() / a.len() as f32;
|
||||
let var: f32 = ins.iter().map(|p| (p[1].max(0.0).sqrt() - mean_a).powi(2)).sum::<f32>() / ins.len() as f32;
|
||||
s += var * a.len() as f32 * 0.5;
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Pick a source offset (in output pixels) for a remove/heal spot.
|
||||
pub fn auto_source(img: &Rgb32f, target: (f32, f32), r: f32) -> (f32, f32) {
|
||||
let mut best = (f32::MAX, (r * 2.5, 0.0));
|
||||
for ring in [2.4f32, 3.2, 4.2] {
|
||||
for i in 0..16 {
|
||||
let a = i as f32 / 16.0 * std::f32::consts::TAU;
|
||||
let d = (a.cos() * r * ring, a.sin() * r * ring);
|
||||
let s = score(img, target, (target.0 + d.0, target.1 + d.1), r);
|
||||
if s < best.0 {
|
||||
best = (s, d);
|
||||
}
|
||||
}
|
||||
}
|
||||
best.1
|
||||
}
|
||||
|
||||
/// Apply all spots to `img` (output resolution). `ppl` = output pixels per long-edge unit.
|
||||
pub fn apply(img: &mut Rgb32f, spots: &[Spot], frame: &Frame, ppl: f64) {
|
||||
if spots.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (w, h) = (img.width, img.height);
|
||||
let to_out = frame.norm_to_out(w, h);
|
||||
for spot in spots {
|
||||
let r = (spot.size * ppl).max(1.0) as f32;
|
||||
let pts: Vec<(f32, f32)> = spot
|
||||
.points
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let q = to_out.apply(*p);
|
||||
(q.x as f32, q.y as f32)
|
||||
})
|
||||
.collect();
|
||||
let Some(&first) = pts.first() else { continue };
|
||||
let offset = match spot.source_offset {
|
||||
Some(o) => {
|
||||
let a = to_out.apply(Point::new(0.0, 0.0));
|
||||
let b = to_out.apply(o);
|
||||
((b.x - a.x) as f32, (b.y - a.y) as f32)
|
||||
}
|
||||
None => auto_source(img, first, r),
|
||||
};
|
||||
// Bounding box of the stroke.
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
|
||||
for p in &pts {
|
||||
x0 = x0.min(p.0 - r);
|
||||
y0 = y0.min(p.1 - r);
|
||||
x1 = x1.max(p.0 + r);
|
||||
y1 = y1.max(p.1 + r);
|
||||
}
|
||||
let pad = r;
|
||||
let (bx0, by0) = (((x0 - pad).floor().max(0.0)) as usize, ((y0 - pad).floor().max(0.0)) as usize);
|
||||
let (bx1, by1) = (((x1 + pad).ceil() as usize).min(w), ((y1 + pad).ceil() as usize).min(h));
|
||||
if bx1 <= bx0 || by1 <= by0 {
|
||||
continue;
|
||||
}
|
||||
let (bw, bh) = (bx1 - bx0, by1 - by0);
|
||||
let feather = (spot.feather / 100.0).clamp(0.0, 1.0) as f32;
|
||||
let opacity = (spot.opacity / 100.0).clamp(0.0, 1.0) as f32;
|
||||
// alpha of the stroke and source patch in the box
|
||||
let mut alpha = vec![0.0f32; bw * bh];
|
||||
let mut src = vec![[0.0f32; 3]; bw * bh];
|
||||
let mut tgt = vec![[0.0f32; 3]; bw * bh];
|
||||
for y in 0..bh {
|
||||
for x in 0..bw {
|
||||
let (px, py) = ((bx0 + x) as f32 + 0.5, (by0 + y) as f32 + 0.5);
|
||||
let d = pts.iter().map(|p| ((px - p.0).powi(2) + (py - p.1).powi(2)).sqrt()).fold(f32::MAX, f32::min);
|
||||
let inner = r * (1.0 - feather * 0.8);
|
||||
alpha[y * bw + x] = (1.0 - smooth(inner, r, d)) * opacity;
|
||||
src[y * bw + x] = img.sample_bilinear(px + offset.0, py + offset.1);
|
||||
tgt[y * bw + x] = img.get(bx0 + x, by0 + y);
|
||||
}
|
||||
}
|
||||
let heal = spot.mode != SpotMode::Clone;
|
||||
let low = if heal {
|
||||
// Tone correction from the spot's *surroundings* only: normalized convolution of
|
||||
// (target − source) over pixels outside the stroke (a membrane-like interpolation).
|
||||
let outside: Vec<f32> = alpha.iter().map(|a| if *a < 0.02 { 1.0 } else { 0.0 }).collect();
|
||||
let diff = Rgb32f {
|
||||
width: bw,
|
||||
height: bh,
|
||||
data: tgt.iter().zip(&src).zip(&outside).map(|((t, s), m)| [(t[0] - s[0]) * m, (t[1] - s[1]) * m, (t[2] - s[2]) * m]).collect(),
|
||||
};
|
||||
let wts = lightcraft_raster::Plane { width: bw, height: bh, data: outside };
|
||||
let sigma = (r * 0.6).max(1.0);
|
||||
let (bd, bm) = (lightcraft_raster::blur::gaussian(&diff, sigma), lightcraft_raster::blur::gaussian(&wts, sigma));
|
||||
Some(Rgb32f {
|
||||
width: bw,
|
||||
height: bh,
|
||||
data: bd
|
||||
.data
|
||||
.iter()
|
||||
.zip(&bm.data)
|
||||
.map(|(d, m)| {
|
||||
let m = m.max(1e-4);
|
||||
[d[0] / m, d[1] / m, d[2] / m]
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
for y in 0..bh {
|
||||
for x in 0..bw {
|
||||
let i = y * bw + x;
|
||||
let a = alpha[i];
|
||||
if a <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let mut v = src[i];
|
||||
if let Some(l) = &low {
|
||||
let d = l.data[i];
|
||||
v = [v[0] + d[0], v[1] + d[1], v[2] + d[2]];
|
||||
}
|
||||
let t = tgt[i];
|
||||
img.set(bx0 + x, by0 + y, [t[0] + (v[0] - t[0]) * a, t[1] + (v[1] - t[1]) * a, t[2] + (v[2] - t[2]) * a].map(|c| c.max(0.0)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use lightcraft_develop::DevelopSettings;
|
||||
|
||||
#[test]
|
||||
fn remove_erases_a_dot() {
|
||||
// flat grey with a gentle gradient and a black dot in the middle
|
||||
let mut img = Rgb32f::from_fn(120, 80, |x, _| [0.2 + x as f32 * 0.001; 3]);
|
||||
for y in 36..44 {
|
||||
for x in 56..64 {
|
||||
img.set(x, y, [0.0; 3]);
|
||||
}
|
||||
}
|
||||
let frame = Frame::new(120, 80, &DevelopSettings::default(), true);
|
||||
let spot = Spot { points: vec![Point::new(0.5, 0.5)], size: 8.0 / 120.0, feather: 30.0, ..Default::default() };
|
||||
apply(&mut img, &[spot], &frame, frame.px_per_long(120));
|
||||
let c = img.get(60, 40);
|
||||
assert!((c[0] - 0.26).abs() < 0.03, "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clone_copies_source() {
|
||||
let mut img = Rgb32f::from_fn(100, 100, |x, _| if x < 50 { [0.1; 3] } else { [0.9; 3] });
|
||||
let frame = Frame::new(100, 100, &DevelopSettings::default(), true);
|
||||
let spot = Spot {
|
||||
mode: SpotMode::Clone,
|
||||
points: vec![Point::new(0.25, 0.5)],
|
||||
size: 0.05,
|
||||
feather: 0.0,
|
||||
source_offset: Some(Point::new(0.5, 0.0)),
|
||||
..Default::default()
|
||||
};
|
||||
apply(&mut img, &[spot], &frame, frame.px_per_long(100));
|
||||
assert!(img.get(25, 50)[0] > 0.8);
|
||||
assert!(img.get(5, 50)[0] < 0.2);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
//! Micro-benchmark for the blur/map primitives (min of N runs, robust to a loaded machine):
|
||||
//! `RAYON_NUM_THREADS=1 cargo run --release -p lightcraft-raster --example blur_bench`.
|
||||
use lightcraft_raster::{Plane, Rgb32f, blur::gaussian};
|
||||
use std::time::Instant;
|
||||
|
||||
fn best(n: usize, mut f: impl FnMut()) -> f64 {
|
||||
(0..n)
|
||||
.map(|_| {
|
||||
let t = Instant::now();
|
||||
f();
|
||||
t.elapsed().as_secs_f64() * 1e3
|
||||
})
|
||||
.fold(f64::MAX, f64::min)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let (w, h) = (3000, 2400);
|
||||
let p = Plane::from_fn(w, h, |x, y| ((x * 7 + y * 13) % 255) as f32 / 255.0);
|
||||
let c = Rgb32f::from_fn(w, h, |x, y| [x as f32 / w as f32, y as f32 / h as f32, 0.5]);
|
||||
for sigma in [1.0f32, 4.0, 24.0] {
|
||||
println!("plane gaussian σ={sigma}: {:.1} ms", best(9, || drop(gaussian(&p, sigma))));
|
||||
}
|
||||
println!("rgb gaussian σ=4: {:.1} ms", best(9, || drop(gaussian(&c, 4.0))));
|
||||
}
|
||||
|
|
@ -17,37 +17,58 @@ fn box_radii(sigma: f32) -> [usize; 3] {
|
|||
std::array::from_fn(|i| (((if (i as i32) < m { wl } else { wu }) - 1) / 2).max(0) as usize)
|
||||
}
|
||||
|
||||
fn box_h<T: Pixel>(src: &Image<T>, r: usize) -> Image<T> {
|
||||
let w = src.width;
|
||||
let mut out = Image::<T>::new(w, src.height);
|
||||
if r == 0 {
|
||||
return src.clone();
|
||||
/// One horizontal box pass over `row` (clamped edges), using `tmp` as scratch.
|
||||
#[inline]
|
||||
fn box_row<T: Pixel>(row: &mut [T], tmp: &mut Vec<T>, r: usize) {
|
||||
let w = row.len();
|
||||
if r == 0 || w == 0 {
|
||||
return;
|
||||
}
|
||||
tmp.clear();
|
||||
tmp.extend_from_slice(row);
|
||||
let s = &tmp[..];
|
||||
let last = w - 1;
|
||||
let inv = 1.0 / (2 * r + 1) as f32;
|
||||
par_rows(&mut out.data, w, |y, row| {
|
||||
let s = src.row(y);
|
||||
let at = |i: isize| s[i.clamp(0, w as isize - 1) as usize];
|
||||
let mut acc = T::zero();
|
||||
for i in -(r as isize)..=(r as isize) {
|
||||
acc = acc.madd(at(i), 1.0);
|
||||
}
|
||||
for x in 0..w {
|
||||
row[x] = T::zero().madd(acc, inv);
|
||||
acc = acc.madd(at(x as isize + r as isize + 1), 1.0).madd(at(x as isize - r as isize), -1.0);
|
||||
}
|
||||
});
|
||||
out
|
||||
let mut acc = T::zero();
|
||||
for i in 0..=2 * r {
|
||||
acc = acc.madd(s[i.saturating_sub(r).min(last)], 1.0);
|
||||
}
|
||||
for x in 0..w {
|
||||
row[x] = T::zero().madd(acc, inv);
|
||||
acc = acc.madd(s[(x + r + 1).min(last)], 1.0).madd(s[x.saturating_sub(r)], -1.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn transpose<T: Pixel>(src: &Image<T>) -> Image<T> {
|
||||
/// Rows per parallel band in the vertical pass (each band re-primes its running sums).
|
||||
const BAND: usize = 32;
|
||||
|
||||
/// One vertical box pass `src → dst` (clamped edges): a running sum per column, vectorized across
|
||||
/// the row, parallel over horizontal bands. Cache-friendly — no transposes.
|
||||
fn box_v<T: Pixel>(src: &Image<T>, dst: &mut Image<T>, r: usize) {
|
||||
let (w, h) = (src.width, src.height);
|
||||
let mut out = Image::<T>::new(h, w);
|
||||
par_rows(&mut out.data, h, |y, row| {
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
*o = src.data[x * w + y];
|
||||
let inv = 1.0 / (2 * r + 1) as f32;
|
||||
let last = h - 1;
|
||||
let row = |y: usize| &src.data[y * w..(y + 1) * w];
|
||||
par_rows(&mut dst.data, w * BAND, |band, out| {
|
||||
let y0 = band * BAND;
|
||||
let mut acc = vec![T::zero(); w];
|
||||
for i in 0..=2 * r {
|
||||
let yy = (y0 + i).saturating_sub(r).min(last);
|
||||
for (a, v) in acc.iter_mut().zip(row(yy)) {
|
||||
*a = a.madd(*v, 1.0);
|
||||
}
|
||||
}
|
||||
for (k, o) in out.chunks_mut(w).enumerate() {
|
||||
let y = y0 + k;
|
||||
for (d, a) in o.iter_mut().zip(&acc) {
|
||||
*d = T::zero().madd(*a, inv);
|
||||
}
|
||||
let (add, sub) = (row((y + r + 1).min(last)), row(y.saturating_sub(r)));
|
||||
for ((a, p), m) in acc.iter_mut().zip(add).zip(sub) {
|
||||
*a = a.madd(*p, 1.0).madd(*m, -1.0);
|
||||
}
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Blur with Gaussian `sigma` (pixels). `sigma <= 0.3` returns a copy.
|
||||
|
|
@ -57,14 +78,21 @@ pub fn gaussian<T: Pixel>(img: &Image<T>, sigma: f32) -> Image<T> {
|
|||
}
|
||||
let radii = box_radii(sigma);
|
||||
let mut a = img.clone();
|
||||
par_rows(&mut a.data, img.width, |_, row| {
|
||||
let mut tmp = Vec::with_capacity(row.len());
|
||||
for r in radii {
|
||||
box_row(row, &mut tmp, r);
|
||||
}
|
||||
});
|
||||
let mut b = Image::<T>::new(img.width, img.height);
|
||||
for r in radii {
|
||||
a = box_h(&a, r);
|
||||
if r == 0 {
|
||||
continue;
|
||||
}
|
||||
box_v(&a, &mut b, r);
|
||||
std::mem::swap(&mut a, &mut b);
|
||||
}
|
||||
let mut t = transpose(&a);
|
||||
for r in radii {
|
||||
t = box_h(&t, r);
|
||||
}
|
||||
transpose(&t)
|
||||
a
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ lightcraft-raster = { workspace = true }
|
|||
lightcraft-develop = { workspace = true }
|
||||
lightcraft-pipeline = { workspace = true }
|
||||
lightcraft-catalog = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
lightcraft-engine = { workspace = true }
|
||||
lightcraft-preview = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -289,19 +289,47 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest
|
|||
}
|
||||
}
|
||||
|
||||
/// Export the active photo (UI command `app.export`): renders at `longEdge` and writes a PNG/JPEG via services.
|
||||
/// Default export folder: `~/Pictures/LightCraft Exports` (falls back to the working directory).
|
||||
pub fn default_export_dir() -> String {
|
||||
std::env::var("HOME").map(|h| format!("{h}/Pictures/LightCraft Exports")).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Export the selected photos (UI command `app.export`). Params: see
|
||||
/// [`lightcraft_engine::export::ExportOptions::from_json`], plus `dir` (output folder) or `path`
|
||||
/// (exact output file, single photo), `ids` (default: the selection, else the active photo).
|
||||
pub fn export_active(app: &mut LightcraftApp, p: &Value) -> Result<Value, String> {
|
||||
let id = app.session.active().ok_or("no photo selected")?;
|
||||
let size = p.get("longEdge").and_then(Value::as_u64).unwrap_or(3000) as usize;
|
||||
let r = app.session.render_now(id, size, size)?;
|
||||
let name = app.session.catalog.photo(id).map(|p| p.file_name.clone()).unwrap_or_else(|| "export".into());
|
||||
let stem = name.rsplit_once('.').map(|(a, _)| a.to_string()).unwrap_or(name);
|
||||
let path = p.get("path").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| format!("{stem}-lightcraft.png"));
|
||||
let png = app.services.png.as_ref().ok_or("no encoder")?;
|
||||
let bytes = png(&r.image);
|
||||
let w = app.services.write.as_mut().ok_or("no writer")?;
|
||||
w(&path, &bytes)?;
|
||||
Ok(json!({"path": path, "width": r.image.width, "height": r.image.height}))
|
||||
use lightcraft_engine::export::{ExportOptions, export_photo};
|
||||
let mut opts = ExportOptions::from_json(p);
|
||||
if let (Some(path), None) = (p.get("path").and_then(Value::as_str), p.get("format")) {
|
||||
let ext = path.rsplit_once('.').map_or("", |(_, e)| e);
|
||||
opts.format = lightcraft_engine::export::ExportFormat::parse(ext).unwrap_or(opts.format);
|
||||
}
|
||||
let ids: Vec<_> = match p.get("ids").and_then(Value::as_array) {
|
||||
Some(a) => a.iter().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect(),
|
||||
None if !app.session.selection.ids.is_empty() => {
|
||||
// Batch order (and `{seq}`) follows the grid order.
|
||||
let sel: std::collections::HashSet<_> = app.session.selection.ids.iter().copied().collect();
|
||||
let mut v: Vec<_> = app.session.visible().iter().copied().filter(|id| sel.contains(id)).collect();
|
||||
let shown: std::collections::HashSet<_> = v.iter().copied().collect();
|
||||
v.extend(app.session.selection.ids.iter().filter(|id| !shown.contains(id)));
|
||||
v
|
||||
}
|
||||
None => app.session.active().into_iter().collect(),
|
||||
};
|
||||
if ids.is_empty() {
|
||||
return Err("no photo selected".into());
|
||||
}
|
||||
let dir = p.get("dir").and_then(Value::as_str).map(str::to_string).filter(|d| !d.is_empty()).unwrap_or_else(default_export_dir);
|
||||
let exact = p.get("path").and_then(Value::as_str).filter(|_| ids.len() == 1).map(str::to_string);
|
||||
let mut out = Vec::new();
|
||||
for (i, id) in ids.into_iter().enumerate() {
|
||||
let e = export_photo(&mut app.session, id, &opts, i + 1)?;
|
||||
let path = exact.clone().unwrap_or_else(|| if dir.is_empty() { e.file_name.clone() } else { format!("{dir}/{}", e.file_name) });
|
||||
let w = app.services.write.as_mut().ok_or("no writer")?;
|
||||
w(&path, &e.bytes)?;
|
||||
out.push(json!({"path": path, "width": e.width, "height": e.height, "bytes": e.bytes.len()}));
|
||||
}
|
||||
Ok(json!({"files": out}))
|
||||
}
|
||||
|
||||
pub fn save_screenshot(app: &mut LightcraftApp, image: &egui::ColorImage, path: Option<&str>) -> Value {
|
||||
|
|
|
|||
|
|
@ -267,16 +267,10 @@ impl LightcraftApp {
|
|||
}
|
||||
}
|
||||
|
||||
/// Wall-clock milliseconds since the Unix epoch (`web-time` maps to `Date.now()` on the web).
|
||||
pub fn now_ms() -> f64 {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
0.0
|
||||
}
|
||||
use web_time::{SystemTime, UNIX_EPOCH};
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// Whether the settings render in black & white.
|
||||
|
|
|
|||
|
|
@ -275,7 +275,8 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
|||
Ok(Value::Null)
|
||||
}
|
||||
"dialog.export" => {
|
||||
app.ui.dialog = Some(Dialog::Export { format: "jpeg".into(), quality: 90, long_edge: 3000 });
|
||||
app.ui.dialog =
|
||||
Some(Dialog::Export { opts: Default::default(), long_edge: 2048, limit_kb: 0, dir: crate::control::default_export_dir() });
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"app.about" => {
|
||||
|
|
|
|||
|
|
@ -56,20 +56,71 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
|||
}
|
||||
});
|
||||
}
|
||||
Dialog::Export { format, quality, long_edge } => {
|
||||
Dialog::Export { opts, long_edge, limit_kb, dir } => {
|
||||
use lightcraft_engine::export::{ExportFormat as F, SharpenAmount as A, SharpenFor as S};
|
||||
let n = app.session.selection.ids.len().max(1);
|
||||
ui.label(egui::RichText::new(format!("{n} photo{}", if n == 1 { "" } else { "s" })).color(t.text_dim));
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Format");
|
||||
for f in ["jpeg", "png", "tiff"] {
|
||||
ui.selectable_value(format, f.to_string(), f.to_uppercase());
|
||||
for (f, l) in [(F::Jpeg, "JPEG"), (F::Png, "PNG"), (F::Tiff, "TIFF"), (F::Webp, "WebP"), (F::Avif, "AVIF")] {
|
||||
ui.selectable_value(&mut opts.format, f, l);
|
||||
}
|
||||
});
|
||||
ui.add(egui::Slider::new(quality, 10..=100).text("Quality"));
|
||||
ui.add(egui::Slider::new(long_edge, 512..=8000).text("Long edge (px)"));
|
||||
if matches!(opts.format, F::Jpeg | F::Avif) {
|
||||
ui.add(egui::Slider::new(&mut opts.quality, 1..=100).text("Quality"));
|
||||
}
|
||||
if opts.format == F::Jpeg {
|
||||
ui.add(egui::Slider::new(limit_kb, 0..=20_000).text("Limit file size (KB, 0 = off)"));
|
||||
}
|
||||
let mut full = *long_edge == 0;
|
||||
if ui.checkbox(&mut full, "Full size").changed() {
|
||||
*long_edge = if full { 0 } else { 2048 };
|
||||
}
|
||||
if !full {
|
||||
ui.add(egui::Slider::new(long_edge, 256..=12_000).text("Long edge (px)"));
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Sharpen for");
|
||||
for (v, l) in [(S::None, "None"), (S::Screen, "Screen"), (S::Matte, "Matte paper"), (S::Glossy, "Glossy paper")] {
|
||||
ui.selectable_value(&mut opts.sharpen, v, l);
|
||||
}
|
||||
});
|
||||
if opts.sharpen != S::None {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Amount");
|
||||
for (v, l) in [(A::Low, "Low"), (A::Standard, "Standard"), (A::High, "High")] {
|
||||
ui.selectable_value(&mut opts.sharpen_amount, v, l);
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
use lightcraft_engine::export::MetadataPolicy as M;
|
||||
ui.label("Metadata");
|
||||
for (v, l) in
|
||||
[(M::All, "All"), (M::AllExceptCamera, "All except camera"), (M::Copyright, "Copyright only"), (M::None, "None")]
|
||||
{
|
||||
ui.selectable_value(&mut opts.metadata, v, l);
|
||||
}
|
||||
});
|
||||
if !matches!(
|
||||
opts.metadata,
|
||||
lightcraft_engine::export::MetadataPolicy::None | lightcraft_engine::export::MetadataPolicy::Copyright
|
||||
) {
|
||||
ui.checkbox(&mut opts.remove_location, "Remove location info");
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("File name");
|
||||
ui.add(egui::TextEdit::singleline(&mut opts.naming).hint_text("{name}-{seq}").desired_width(f32::INFINITY));
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Folder");
|
||||
ui.add(egui::TextEdit::singleline(dir).desired_width(f32::INFINITY));
|
||||
});
|
||||
}
|
||||
Dialog::About => {
|
||||
ui.label(egui::RichText::new("LightCraft").font(t.semibold(20.0)).color(t.text));
|
||||
ui.label(format!("Version {} — a clean-room, pure-Rust photo library and raw developer.", env!("CARGO_PKG_VERSION")));
|
||||
ui.label("MIT OR Apache-2.0. Fonts: Source Sans 3 (OFL). Icons: original.");
|
||||
ui.label("MIT OR Apache-2.0. Font: Inter (OFL). Icons: original.");
|
||||
}
|
||||
Dialog::Shortcuts => {
|
||||
egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| {
|
||||
|
|
@ -129,7 +180,14 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso
|
|||
app.run("preset.create", json!({"name": if name.is_empty() { "My Preset" } else { name }, "group": group}))
|
||||
}
|
||||
Dialog::CopySettings { groups } => app.run("develop.copy", json!({"groups": groups})),
|
||||
Dialog::Export { format, quality, long_edge } => app.run("app.export", json!({"format": format, "quality": quality, "longEdge": long_edge})),
|
||||
Dialog::Export { opts, long_edge, limit_kb, dir } => app.run(
|
||||
"app.export",
|
||||
json!({
|
||||
"format": opts.format, "quality": opts.quality, "longEdge": long_edge, "limitKb": limit_kb,
|
||||
"sharpen": opts.sharpen, "sharpenAmount": opts.sharpen_amount, "naming": opts.naming, "dir": dir,
|
||||
"metadata": opts.metadata, "removeLocation": opts.remove_location,
|
||||
}),
|
||||
),
|
||||
Dialog::About | Dialog::Shortcuts => Ok(serde_json::Value::Null),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,11 +86,18 @@ impl Renderer {
|
|||
|
||||
/// Collect finished jobs into textures. Returns true if anything changed.
|
||||
pub fn poll(&mut self, ctx: &egui::Context, session: &mut Session) -> bool {
|
||||
// wasm: run one job per frame on this thread, timed with the host clock
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
self.pool.run_inline(1);
|
||||
let inline_ms = {
|
||||
let t0 = crate::now_ms();
|
||||
self.pool.run_inline(1);
|
||||
crate::now_ms() - t0
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let inline_ms = 0.0;
|
||||
let mut changed = false;
|
||||
while let Some(done) = self.pool.try_recv() {
|
||||
let (slot, r, ms) = (done.slot, done.result, done.ms);
|
||||
let (slot, r, ms) = (done.slot, done.result, if done.ms > 0.0 { done.ms } else { inline_ms });
|
||||
session.accept(&r);
|
||||
self.completed += 1;
|
||||
if self.pending.get(&slot).is_some_and(|p| p.0 == r.key) {
|
||||
|
|
|
|||
|
|
@ -119,11 +119,28 @@ pub struct UiState {
|
|||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum Dialog {
|
||||
NewAlbum { name: String, folder: bool },
|
||||
RenameAlbum { id: u64, name: String },
|
||||
CreatePreset { name: String, group: String },
|
||||
CopySettings { groups: Vec<String> },
|
||||
Export { format: String, quality: u32, long_edge: u32 },
|
||||
NewAlbum {
|
||||
name: String,
|
||||
folder: bool,
|
||||
},
|
||||
RenameAlbum {
|
||||
id: u64,
|
||||
name: String,
|
||||
},
|
||||
CreatePreset {
|
||||
name: String,
|
||||
group: String,
|
||||
},
|
||||
CopySettings {
|
||||
groups: Vec<String>,
|
||||
},
|
||||
/// `long_edge` 0 = full size; `limit_kb` 0 = no limit; `dir` empty = default export folder.
|
||||
Export {
|
||||
opts: lightcraft_engine::export::ExportOptions,
|
||||
long_edge: u32,
|
||||
limit_kb: u32,
|
||||
dir: String,
|
||||
},
|
||||
About,
|
||||
Shortcuts,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,15 +107,13 @@ impl Tokens {
|
|||
|
||||
pub fn install_fonts(ctx: &egui::Context) {
|
||||
let mut fonts = FontDefinitions::default();
|
||||
fonts.font_data.insert("SourceSans3".into(), Arc::new(FontData::from_static(include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"))));
|
||||
fonts
|
||||
.font_data
|
||||
.insert("SourceSans3-Semibold".into(), Arc::new(FontData::from_static(include_bytes!("../../../assets/fonts/SourceSans3-Semibold.ttf"))));
|
||||
fonts.font_data.insert("Inter".into(), Arc::new(FontData::from_static(include_bytes!("../../../assets/fonts/Inter-Regular.ttf"))));
|
||||
fonts.font_data.insert("Inter-SemiBold".into(), Arc::new(FontData::from_static(include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"))));
|
||||
let fallback: Vec<String> = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default();
|
||||
let mut prop = vec!["SourceSans3".to_string()];
|
||||
let mut prop = vec!["Inter".to_string()];
|
||||
prop.extend(fallback.clone());
|
||||
fonts.families.insert(FontFamily::Proportional, prop);
|
||||
let mut semi = vec!["SourceSans3-Semibold".to_string()];
|
||||
let mut semi = vec!["Inter-SemiBold".to_string()];
|
||||
semi.extend(fallback);
|
||||
fonts.families.insert(FontFamily::Name(FONT_SEMIBOLD.into()), semi);
|
||||
ctx.set_fonts(fonts);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
# Control protocol
|
||||
|
||||
`lightcraft --control 7980` (or `LIGHTCRAFT_CONTROL_PORT=7980`) starts a JSON-lines server on
|
||||
`127.0.0.1:7980` (loopback only). One request per line, one reply per line, in order:
|
||||
|
||||
```text
|
||||
→ {"id": 1, "method": "engine.execute", "params": {"command": "photo.rate", "params": {"rating": 4}}}
|
||||
← {"id": 1, "ok": true, "result": null}
|
||||
← {"id": 2, "ok": false, "error": "unknown command `nope`"}
|
||||
```
|
||||
|
||||
Requests are answered on the UI thread between frames (timeout 60 s). The MCP server's connect
|
||||
mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation:
|
||||
`crates/ui-egui/src/control.rs` (methods) and `apps/lightcraft/src/control_server.rs` (transport).
|
||||
|
||||
## Methods
|
||||
|
||||
| Method | Params | Result |
|
||||
|---|---|---|
|
||||
| `engine.execute` (alias `ui.menu.invoke`) | `{command, params?}` | Run any engine or UI command (see `engine.commands`) |
|
||||
| `engine.commands` | — | Engine + UI commands: id, label, menu, shortcut, params doc, enabled |
|
||||
| `ui.menu.list` | — | Menu entries |
|
||||
| `ui.inspect` | — | UI state, window, canvas/image rects, active photo, selection, perf, status |
|
||||
| `ui.widgets` | `{filter?}` | On-screen widgets `{id, rect: [x, y, w, h]}` (screen points) |
|
||||
| `ui.clickWidget` / `ui.dragWidget` | `{id, count?, fx?, fy?}` / `{id, toX?, toY?, dx?, dy?, steps?}` | Real egui input on a widget |
|
||||
| `ui.move` / `ui.click` / `ui.drag` | `{x, y, count?, button?}` / `{x, y, toX, toY, steps?}` | Raw pointer input, screen points |
|
||||
| `ui.pointer` | `{events: [{kind: down\|drag\|up, x, y}], alt?, shift?, cmd?}` | Gesture in normalized image coordinates (Detail view) |
|
||||
| `ui.key` | `{key, cmd?, shift?, alt?, ctrl?}` | Key press |
|
||||
| `ui.text` | `{text}` | Text input |
|
||||
| `ui.scroll` | `{dx, dy}` | Mouse wheel |
|
||||
| `ui.set` | partial UI state, e.g. `{"view": "detail"}` | Resulting UI state |
|
||||
| `ui.dialog.confirm` / `ui.dialog.cancel` | — | Close the open dialog |
|
||||
| `ui.resize` | `{width, height}` | Resize the window |
|
||||
| `ui.screenshot` | `{path?}` | `{path, width, height}` once the frame (with finished renders) is captured |
|
||||
| `ui.render` | `{id?, size?, path?}` | Render a photo (PNG to `path`), `{width, height}` |
|
||||
| `app.quit` | — | Close the app |
|
||||
|
Before Width: | Height: | Size: 980 KiB After Width: | Height: | Size: 987 KiB |
|
Before Width: | Height: | Size: 427 KiB After Width: | Height: | Size: 434 KiB |
|
Before Width: | Height: | Size: 916 KiB After Width: | Height: | Size: 923 KiB |
|
Before Width: | Height: | Size: 762 KiB After Width: | Height: | Size: 768 KiB |
|
Before Width: | Height: | Size: 600 KiB After Width: | Height: | Size: 610 KiB |
|
Before Width: | Height: | Size: 679 KiB After Width: | Height: | Size: 687 KiB |
|
Before Width: | Height: | Size: 821 KiB After Width: | Height: | Size: 792 KiB |
|
Before Width: | Height: | Size: 630 KiB After Width: | Height: | Size: 637 KiB |
|
Before Width: | Height: | Size: 739 KiB After Width: | Height: | Size: 750 KiB |
|
Before Width: | Height: | Size: 528 KiB After Width: | Height: | Size: 537 KiB |
|
Before Width: | Height: | Size: 821 KiB After Width: | Height: | Size: 828 KiB |
|
Before Width: | Height: | Size: 361 KiB After Width: | Height: | Size: 370 KiB |
|
Before Width: | Height: | Size: 592 KiB After Width: | Height: | Size: 604 KiB |
|
Before Width: | Height: | Size: 502 KiB After Width: | Height: | Size: 515 KiB |
|
|
@ -0,0 +1,156 @@
|
|||
# LightCraft MCP server
|
||||
|
||||
`lightcraft-cli mcp` exposes LightCraft to AI agents through the
|
||||
[Model Context Protocol](https://modelcontextprotocol.io): newline-delimited JSON-RPC 2.0 over
|
||||
stdio (protocol revision `2025-06-18`; `2025-03-26` and `2024-11-05` clients are accepted). The
|
||||
server lives in `crates/mcp` (`lightcraft-mcp`, layer L5) and is hand-written: no async runtime,
|
||||
no C dependencies.
|
||||
|
||||
It runs in one of two modes:
|
||||
|
||||
| Mode | Command | What it drives |
|
||||
|---|---|---|
|
||||
| **Headless** (default) | `lightcraft-cli mcp [--demo] [FILES/FOLDERS…]` | An in-process engine `Session`. Develop, render and export without a window. |
|
||||
| **Connect** | `lightcraft-cli mcp --connect [127.0.0.1:7980]` | A running desktop app started with `lightcraft --control 7980`, through its loopback JSON-lines control channel ([control-protocol.md](control-protocol.md)). Adds the UI tools (screenshot, clicks, keys, pointer gestures). |
|
||||
|
||||
Options: `--demo` starts the headless session with the procedurally generated demo library;
|
||||
`--compact` lists only the helper tools (see below). In connect mode the server starts even when
|
||||
the app is not running yet and connects on the first call (and reconnects if the app restarts).
|
||||
|
||||
Logs go to stderr; stdout carries only protocol messages.
|
||||
|
||||
## Wiring it into a client
|
||||
|
||||
Build once: `cargo build --release -p lightcraft-cli` (binary: `target/release/lightcraft-cli`).
|
||||
|
||||
### Claude Code
|
||||
|
||||
```sh
|
||||
# headless, with a folder of photos imported at start
|
||||
claude mcp add lightcraft -- /path/to/lightcraft/target/release/lightcraft-cli mcp ~/Pictures/shoot
|
||||
|
||||
# or: drive the running desktop app (start it with `lightcraft --control 7980`)
|
||||
claude mcp add lightcraft-app -- /path/to/lightcraft/target/release/lightcraft-cli mcp --connect 127.0.0.1:7980
|
||||
```
|
||||
|
||||
Or check a project-scoped `.mcp.json` into your repo:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"lightcraft": {
|
||||
"command": "/path/to/lightcraft/target/release/lightcraft-cli",
|
||||
"args": ["mcp", "--connect", "127.0.0.1:7980"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Other clients (Claude Desktop, Cursor, …)
|
||||
|
||||
Every stdio MCP client takes the same shape: a `command` plus `args`. For example
|
||||
`claude_desktop_config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"lightcraft": {
|
||||
"command": "/path/to/lightcraft/target/release/lightcraft-cli",
|
||||
"args": ["mcp", "--demo"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
During development you can also point the client at `cargo run --release -p lightcraft-cli -- mcp`
|
||||
(with `"cwd"` set to the repository), at the cost of a slower start.
|
||||
|
||||
## Tools
|
||||
|
||||
### Helpers
|
||||
|
||||
| Tool | Does |
|
||||
|---|---|
|
||||
| `list_commands {filter?}` | Every command: id, label, menu, shortcut, parameter doc, enabled now |
|
||||
| `run_command {command, params?}` | Run any command by id |
|
||||
| `import {paths, album?}` | Import files/folders (folders are scanned recursively); the first new photo becomes active |
|
||||
| `query_photos {filter?, sort?, offset?, limit?}` | Photos in the current view (or matching a catalog `Filter`) |
|
||||
| `select_photos {ids, active?, mode?}` | Set the selection / active photo |
|
||||
| `list_controls {section?}` | Every develop slider: id (`light.exposure`…), range, default, current value |
|
||||
| `get_develop {id?}` | Full develop-settings JSON |
|
||||
| `set_develop {id?, values?, settings?, label?}` | `values`: `{controlId: number}`; `settings`: partial develop JSON deep-merged. Undoable |
|
||||
| `apply_preset {preset, amount?, ids?}` | Apply a preset (ids from `cmd_presets_list`) |
|
||||
| `crop {id?, rect?, angle?, reset?}` | Normalized crop rect `[x0,y0,x1,y1]` and straighten angle |
|
||||
| `render_photo {id?, size?, format?, path?}` | Render with current settings → **image content** (PNG, or JPEG with `format: "jpeg"`), long edge `size` (default 1024) |
|
||||
| `export {path, id?, longEdge?, quality?}` | Full-quality render to `.png` / `.jpg` / `.tif` / `.webp` (headless; the desktop app writes PNG) |
|
||||
|
||||
Tools taking `id` make that photo active first; without it they act on the active photo.
|
||||
|
||||
### Connect mode only
|
||||
|
||||
| Tool | Does |
|
||||
|---|---|
|
||||
| `screenshot {maxSize?, format?, path?}` | The app window as an image, after pending renders finish |
|
||||
| `inspect_ui` | View, panel, window/image rects, selection, status |
|
||||
| `set_ui {state}` | Merge UI state, e.g. `{"view": "detail"}` |
|
||||
| `list_widgets {filter?}` / `click {widget \| x,y, count?}` | Widgets by automation id; real egui clicks |
|
||||
| `press_key {key, cmd?, shift?, alt?}` / `type_text {text}` | Keyboard input (shortcuts) |
|
||||
| `pointer_gesture {events}` | Gestures in normalized image coordinates (brush strokes, gradients, crop handles) |
|
||||
|
||||
In headless mode these return a tool error explaining how to start the app.
|
||||
|
||||
### One tool per command
|
||||
|
||||
Everything is a command in LightCraft, so `tools/list` also contains one tool per entry of the
|
||||
command registry (engine commands, plus the app's UI commands such as `view.detail` when
|
||||
connected): the id with `.` replaced by `_` and a `cmd_` prefix — `photo.rate` → `cmd_photo_rate`,
|
||||
`develop.set` → `cmd_develop_set`, `edit.undo` → `cmd_edit_undo`. Arguments are the command's
|
||||
JSON params (documented in each tool's description and by `list_commands`). Pass `--compact` to
|
||||
leave these out (about 90 headless / 140 connected) when a client struggles with many tools;
|
||||
`run_command` still reaches every command.
|
||||
|
||||
## Resources
|
||||
|
||||
`resources/list` / `resources/read` serve JSON snapshots:
|
||||
|
||||
| URI | Content |
|
||||
|---|---|
|
||||
| `lightcraft://library` | Source, filter, sort, selection, undo/redo labels (`library.state`) |
|
||||
| `lightcraft://photos` | Photos in the current view (`catalog.query`) |
|
||||
| `lightcraft://photo/active` | Everything about the active photo (`photo.inspect`) |
|
||||
| `lightcraft://develop/active` | The active photo's develop settings (`develop.get`) |
|
||||
| `lightcraft://controls` | Every develop control with its current value (`develop.controls`) |
|
||||
|
||||
## Example session
|
||||
|
||||
```text
|
||||
→ {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"me","version":"1"}}}
|
||||
← {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{…},"resources":{…}},"serverInfo":{"name":"lightcraft",…},"instructions":"…"}}
|
||||
→ {"jsonrpc":"2.0","method":"notifications/initialized"}
|
||||
→ {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"set_develop","arguments":{"values":{"light.exposure":0.7}}}}
|
||||
← {"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"{…}"}],"isError":false,"structuredContent":{"ok":true,"controls":[{"id":"light.exposure","value":0.7}]}}}
|
||||
→ {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"render_photo","arguments":{"size":768}}}
|
||||
← {"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"image","data":"iVBORw0…","mimeType":"image/png"},{"type":"text","text":"{\"id\":21,\"width\":512,\"height\":768}"}],"isError":false}}
|
||||
```
|
||||
|
||||
Errors from commands (unknown control, nothing selected, app not reachable) come back as tool
|
||||
results with `isError: true` so the model can read and correct them; malformed JSON-RPC gets the
|
||||
standard error codes (-32700 parse, -32600 invalid request, -32601 method not found, -32602
|
||||
invalid params, -32002 resource not found).
|
||||
|
||||
## Other CLI subcommands
|
||||
|
||||
```sh
|
||||
lightcraft-cli render in.dng -o out.jpg --set light.exposure=0.5 --set light.contrast=20 --size 2048
|
||||
lightcraft-cli render in.jpg -o out.png --settings look.json --preset <presetId>
|
||||
lightcraft-cli commands [--json] # the command registry
|
||||
lightcraft-cli controls [--json] # develop control ids and ranges
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
- `crates/mcp/tests/e2e.rs` — M0.9 acceptance: over the stdio framing, set exposure and render;
|
||||
checks the decoded PNG gets brighter/darker. Runs headless and through the TCP transport
|
||||
(`Remote`) against a stand-in control server; also import → render → JPEG export of a real file.
|
||||
- `apps/lightcraft-cli/tests/cli.rs` — spawns `lightcraft-cli mcp` with real pipes; `render`;
|
||||
`commands`.
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
# LightCraft in the browser
|
||||
|
||||
`apps/lightcraft-web` runs the same egui UI as the desktop app (`crates/ui-egui`) in the browser,
|
||||
compiled to WebAssembly and drawn with WebGL2 (eframe's `glow` backend).
|
||||
|
||||
## Build and run locally
|
||||
|
||||
One-time setup:
|
||||
|
||||
```sh
|
||||
rustup target add wasm32-unknown-unknown
|
||||
cargo install wasm-bindgen-cli --version <the wasm-bindgen version in Cargo.lock> --locked
|
||||
```
|
||||
|
||||
`cargo xtask web` checks the CLI against `Cargo.lock` and prints the exact install command if
|
||||
it's missing or a different version.
|
||||
|
||||
Build, then serve:
|
||||
|
||||
```sh
|
||||
cargo xtask web # → <target>/web/{index.html, lightcraft_web.js, lightcraft_web_bg.wasm}
|
||||
cargo xtask web --serve # build, then serve on http://127.0.0.1:8080/ (or `--serve 9000`)
|
||||
cargo xtask web --dev # unoptimized build with debug info (faster to compile, slow to run)
|
||||
```
|
||||
|
||||
`<target>` is `target/` unless `CARGO_TARGET_DIR` is set. Any static HTTP server works, as long as
|
||||
it serves `.wasm` as `application/wasm`. The bundle can't be opened from `file://`. If `wasm-opt`
|
||||
(binaryen) is on `PATH`, the release build also runs it over the module.
|
||||
|
||||
The release build uses the `web` Cargo profile (release + thin LTO, no debug info).
|
||||
|
||||
## What works
|
||||
|
||||
- **Demo library on load.** The procedural scenes from `lightcraft-scenes` are loaded at
|
||||
start, so the page always has something in it.
|
||||
- **Importing photos with no filesystem.** *File ▸ Add Photos…* (<kbd>⌘⇧I</kbd>) opens the
|
||||
browser's file picker. You can also drop files anywhere on the page. The bytes are kept in memory
|
||||
(`store::MemStore`) under synthetic paths (`mem/<n>/<name>`) and decoded by the same engine code
|
||||
as the desktop app (`lightcraft_engine::files::{probe_bytes, load_bytes}`). That covers
|
||||
JPEG/PNG/TIFF/WebP and the supported raw formats.
|
||||
- **Every develop control** (sliders, curves, mixer, grading, masking, crop…) works as it does
|
||||
on the desktop, since it's the same crate.
|
||||
- **Export downloads the file.** *Export…* (<kbd>⌘⇧E</kbd>) runs the same `app.export` path as
|
||||
the desktop app (`lightcraft_engine::export`: JPEG/PNG/TIFF/WebP, sizing, naming). The host's
|
||||
`write` service hands each file to the browser as a download instead of writing it to disk.
|
||||
|
||||
## Not yet
|
||||
|
||||
- **Persistence.** Nothing is saved across reloads: the catalog, edits, imported files and UI
|
||||
prefs all start fresh. The planned route is IndexedDB/OPFS for the op log, and file handles
|
||||
where the browser supports them.
|
||||
- **Worker threads.** Renders run on the main thread, one job per frame. On a cold load the grid
|
||||
fills in over a few seconds, and the UI can stall briefly while a thumbnail is generated. The fix
|
||||
is Web Workers, which need wasm threads (`+atomics`, SharedArrayBuffer, COOP/COEP headers).
|
||||
- **Bundle size.** The module is about 12 MB uncompressed, because it includes every codec and
|
||||
raw decoder. Serving it gzip/brotli-compressed or running `wasm-opt` shrinks it a lot.
|
||||
- **Control channel / MCP.** These are desktop-only, because they need a TCP socket.
|
||||
- **AVIF export** is untested in the browser; it's the one encoder that may not be wasm-safe.
|
||||
|
||||
## Measuring
|
||||
|
||||
Open `http://127.0.0.1:8080/?bench` to run a scripted measurement:
|
||||
|
||||
1. Wait for the grid thumbnails.
|
||||
2. Open the first photo in Detail.
|
||||
3. Drag Exposure through 8 steps, exactly as a slider drag does (begin interaction → `develop.set`
|
||||
×8 → end).
|
||||
|
||||
The page then logs one console line:
|
||||
|
||||
```
|
||||
lightcraft-bench {"first_frame_ms":…,"thumbs_done_ms":…,"slider_draft_ms":[…],"slider_draft_median_ms":…,"slider_job_ms":[…],"release_full_ms":…}
|
||||
```
|
||||
|
||||
- `slider_draft_ms`: time from the `develop.set` command until the new loupe texture is ready.
|
||||
- `slider_job_ms`: the pipeline's share of that time.
|
||||
- `release_full_ms`: the full-quality render after the drag ends.
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
//! Asset attribution check (AGENTS.md "Assets" rule): every image, icon, font, sound, video, raw
|
||||
//! file or colour profile in the repository must be covered by a path pattern in the first column of
|
||||
//! `assets/ATTRIBUTION.md`, licence files referenced there must exist, and nothing may look like it
|
||||
//! came from an Adobe product.
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
/// Extensions treated as assets (lower-case).
|
||||
const ASSET_EXT: &[&str] = &[
|
||||
"png", "jpg", "jpeg", "gif", "webp", "avif", "svg", "ico", "icns", "bmp", "ttf", "otf", "woff", "woff2", "wav", "mp3", "ogg", "flac", "mp4",
|
||||
"mov", "tif", "tiff", "dng", "cr2", "cr3", "nef", "arw", "raf", "orf", "rw2", "pef", "heic", "jxl", "psd", "cube", "icc", "icm", "xmp",
|
||||
];
|
||||
/// Adobe-specific formats we never ship (camera/lens profiles, Lightroom templates).
|
||||
const FORBIDDEN_EXT: &[&str] = &["dcp", "lcp", "lrtemplate", "lrcat", "lrsmcol", "aco", "ase", "abr"];
|
||||
const FORBIDDEN_NAME: &[&str] = &["adobe", "lightroom", "photoshop", "creative cloud", "creativecloud"];
|
||||
|
||||
/// `*` matches any run of characters except `/`; a pattern ending in `/` matches everything below it.
|
||||
pub fn glob(pat: &str, path: &str) -> bool {
|
||||
if let Some(dir) = pat.strip_suffix('/') {
|
||||
return path.starts_with(dir) && path[dir.len()..].starts_with('/');
|
||||
}
|
||||
fn rec(p: &[u8], s: &[u8]) -> bool {
|
||||
match (p.first(), s.first()) {
|
||||
(None, None) => true,
|
||||
(Some(b'*'), _) => rec(&p[1..], s) || (!s.is_empty() && s[0] != b'/' && rec(p, &s[1..])),
|
||||
(Some(a), Some(b)) if a == b => rec(&p[1..], &s[1..]),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
rec(pat.as_bytes(), path.as_bytes())
|
||||
}
|
||||
|
||||
fn backticked(cell: &str) -> impl Iterator<Item = &str> {
|
||||
cell.split('`').skip(1).step_by(2)
|
||||
}
|
||||
|
||||
/// (path patterns, referenced licence files) from the attribution table.
|
||||
pub fn parse(md: &str) -> (Vec<String>, Vec<String>) {
|
||||
let (mut pats, mut licences) = (Vec::new(), Vec::new());
|
||||
for line in md.lines().filter(|l| l.trim_start().starts_with('|')) {
|
||||
let cells: Vec<&str> = line.trim().trim_matches('|').split('|').collect();
|
||||
if cells.len() < 5 {
|
||||
continue;
|
||||
}
|
||||
pats.extend(backticked(cells[0]).filter(|p| p.contains('/')).map(str::to_string));
|
||||
licences.extend(backticked(cells[4]).filter(|p| p.contains('/')).map(str::to_string));
|
||||
}
|
||||
(pats, licences)
|
||||
}
|
||||
|
||||
/// Problems with `files` given the attribution document `md`; `exists` checks licence files.
|
||||
pub fn check(files: &[String], md: &str, exists: impl Fn(&str) -> bool) -> Vec<String> {
|
||||
let (pats, licences) = parse(md);
|
||||
let mut out = Vec::new();
|
||||
for f in files {
|
||||
let lower = f.to_ascii_lowercase();
|
||||
let ext = Path::new(&lower).extension().and_then(|e| e.to_str()).unwrap_or("");
|
||||
let name = Path::new(&lower).file_name().and_then(|e| e.to_str()).unwrap_or("");
|
||||
if FORBIDDEN_EXT.contains(&ext) {
|
||||
out.push(format!("{f}: `.{ext}` files (Adobe profile/template formats) must never be committed"));
|
||||
continue;
|
||||
}
|
||||
if !ASSET_EXT.contains(&ext) {
|
||||
continue;
|
||||
}
|
||||
if FORBIDDEN_NAME.iter().any(|n| name.contains(n)) {
|
||||
out.push(format!("{f}: asset name suggests Adobe material; Adobe assets are forbidden (AGENTS.md)"));
|
||||
}
|
||||
if !pats.iter().any(|p| glob(p, f)) {
|
||||
out.push(format!("{f}: no entry in assets/ATTRIBUTION.md (add path, creator, source, licence, date, modifications)"));
|
||||
}
|
||||
}
|
||||
for l in licences {
|
||||
if !exists(&l) {
|
||||
out.push(format!("assets/ATTRIBUTION.md references missing licence file `{l}`"));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn run(root: &Path) -> Result<(), String> {
|
||||
let out = Command::new("git")
|
||||
.args(["ls-files", "--cached", "--others", "--exclude-standard"])
|
||||
.current_dir(root)
|
||||
.output()
|
||||
.map_err(|e| format!("git ls-files: {e}"))?;
|
||||
if !out.status.success() {
|
||||
return Err("git ls-files failed".into());
|
||||
}
|
||||
let files: Vec<String> = String::from_utf8_lossy(&out.stdout).lines().filter(|f| root.join(f).exists()).map(str::to_string).collect();
|
||||
let md = std::fs::read_to_string(root.join("assets/ATTRIBUTION.md")).map_err(|e| format!("assets/ATTRIBUTION.md: {e}"))?;
|
||||
let problems = check(&files, &md, |p| root.join(p).exists());
|
||||
if problems.is_empty() {
|
||||
let n = files
|
||||
.iter()
|
||||
.filter(|f| Path::new(&f.to_ascii_lowercase()).extension().and_then(|e| e.to_str()).is_some_and(|e| ASSET_EXT.contains(&e)))
|
||||
.count();
|
||||
eprintln!("assets: {n} asset files, all attributed");
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("asset rule violations:\n {}", problems.join("\n ")))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const MD: &str = "| Path | Asset | Creator | Source | Licence | Added | Mods |\n|---|---|---|---|---|---|---|\n\
|
||||
| `assets/fonts/Foo-*.ttf` | Foo | A | url | OFL (`assets/fonts/OFL-Foo.txt`) | d | none |\n\
|
||||
| `crates/scenes/` | gen | us | original | MIT | d | n/a |\n";
|
||||
|
||||
#[test]
|
||||
fn globbing() {
|
||||
assert!(glob("docs/images/*-tetons.jpg", "docs/images/ba-tetons.jpg"));
|
||||
assert!(!glob("docs/images/*.jpg", "docs/images/sub/x.jpg"));
|
||||
assert!(glob("crates/scenes/", "crates/scenes/a/b.png"));
|
||||
assert!(!glob("crates/scenes/", "crates/scenesx/b.png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flags_unattributed_forbidden_and_missing_licence() {
|
||||
let files: Vec<String> =
|
||||
["assets/fonts/Foo-Bold.ttf", "assets/icons/x.svg", "crates/scenes/t.png", "src/lib.rs", "p/camera.dcp", "assets/fonts/Adobe-Foo.otf"]
|
||||
.map(String::from)
|
||||
.into();
|
||||
let p = check(&files, MD, |_| false);
|
||||
assert!(p.iter().any(|m| m.starts_with("assets/icons/x.svg: no entry")));
|
||||
assert!(p.iter().any(|m| m.starts_with("p/camera.dcp")));
|
||||
assert!(p.iter().any(|m| m.contains("Adobe-Foo.otf: asset name")));
|
||||
assert!(p.iter().any(|m| m.contains("OFL-Foo.txt")));
|
||||
assert!(!p.iter().any(|m| m.starts_with("assets/fonts/Foo-Bold.ttf") || m.starts_with("crates/scenes") || m.starts_with("src/")));
|
||||
}
|
||||
}
|
||||
|
|
@ -3,8 +3,10 @@
|
|||
//! Pure Rust (std + serde_json). External tools (`cargo`, `curl`, `tar`) are
|
||||
//! invoked through `std::process::Command`.
|
||||
|
||||
mod assets;
|
||||
mod layers;
|
||||
mod stats;
|
||||
mod web;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, ExitCode};
|
||||
|
|
@ -13,9 +15,13 @@ const USAGE: &str = "\
|
|||
usage: cargo xtask <command>
|
||||
|
||||
commands:
|
||||
assets every image/icon/font/media file is attributed in assets/ATTRIBUTION.md; no Adobe assets
|
||||
layers enforce the crate dependency layering (plan/architecture.md §3)
|
||||
wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates
|
||||
ci fmt --check, clippy -D warnings, test, layers, wasm (stops at first failure)
|
||||
wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates (+ the web app)
|
||||
web [--serve [port]] [--dev]
|
||||
build the browser app (apps/lightcraft-web) into <target>/web/;
|
||||
--serve serves it on http://127.0.0.1:<port> (default 8080)
|
||||
ci fmt --check, clippy -D warnings, test, layers, assets, wasm (stops at first failure)
|
||||
corpus [--download]
|
||||
show where test corpora live; --download fetches PngSuite into corpus/pngsuite
|
||||
stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`)
|
||||
|
|
@ -26,7 +32,9 @@ fn main() -> ExitCode {
|
|||
let rest: Vec<&str> = args.iter().skip(1).map(String::as_str).collect();
|
||||
let result = match args.first().map(String::as_str) {
|
||||
Some("layers") => cmd_layers(),
|
||||
Some("assets") => assets::run(&root()),
|
||||
Some("wasm") => cmd_wasm(),
|
||||
Some("web") => web::run(&rest),
|
||||
Some("ci") => cmd_ci(),
|
||||
Some("corpus") => cmd_corpus(rest.contains(&"--download")),
|
||||
Some("stats") => stats::run(&root(), rest.contains(&"--exact")),
|
||||
|
|
@ -56,7 +64,7 @@ pub fn cargo() -> Command {
|
|||
c
|
||||
}
|
||||
|
||||
fn run(mut cmd: Command, what: &str) -> Result<(), String> {
|
||||
pub fn run(mut cmd: Command, what: &str) -> Result<(), String> {
|
||||
eprintln!("$ {what}");
|
||||
let status = cmd.status().map_err(|e| format!("{what}: failed to spawn: {e}"))?;
|
||||
if status.success() { Ok(()) } else { Err(format!("{what}: exited with {status}")) }
|
||||
|
|
@ -105,7 +113,7 @@ fn cmd_layers() -> Result<(), String> {
|
|||
}
|
||||
|
||||
/// Workspace packages that must build for wasm32: all L0–L5 crates plus
|
||||
/// the egui shell.
|
||||
/// the egui shell and the web app.
|
||||
fn wasm_set() -> Result<Vec<String>, String> {
|
||||
let crates = layers::from_metadata(&metadata()?)?;
|
||||
Ok(crates
|
||||
|
|
@ -116,6 +124,7 @@ fn wasm_set() -> Result<Vec<String>, String> {
|
|||
_ => false,
|
||||
})
|
||||
.map(|c| c.name)
|
||||
.chain(std::iter::once("lightcraft-web".to_string()))
|
||||
.collect())
|
||||
}
|
||||
|
||||
|
|
@ -164,6 +173,7 @@ fn cmd_ci() -> Result<(), String> {
|
|||
}),
|
||||
),
|
||||
("layers", Box::new(cmd_layers)),
|
||||
("assets", Box::new(|| assets::run(&root()))),
|
||||
("wasm", Box::new(cmd_wasm)),
|
||||
];
|
||||
let mut done = Vec::new();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,163 @@
|
|||
//! `cargo xtask web`: build `apps/lightcraft-web` for wasm32 and bundle it with `wasm-bindgen`
|
||||
//! into `<target>/web/` (index.html + lightcraft_web.js + lightcraft_web_bg.wasm).
|
||||
//! `--serve [port]` then serves that folder with a tiny static HTTP server (std only).
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use crate::{cargo, metadata, root, run as step};
|
||||
|
||||
const TARGET: &str = "wasm32-unknown-unknown";
|
||||
|
||||
/// The `wasm-bindgen` version pinned in Cargo.lock (the CLI must match it exactly).
|
||||
fn locked_bindgen_version() -> Result<String, String> {
|
||||
let lock = std::fs::read_to_string(root().join("Cargo.lock")).map_err(|e| format!("Cargo.lock: {e}"))?;
|
||||
let mut lines = lock.lines();
|
||||
while let Some(l) = lines.next() {
|
||||
if l.trim() == "name = \"wasm-bindgen\""
|
||||
&& let Some(v) = lines.next().and_then(|v| v.trim().strip_prefix("version = \""))
|
||||
{
|
||||
return Ok(v.trim_end_matches('"').to_string());
|
||||
}
|
||||
}
|
||||
Err("wasm-bindgen not found in Cargo.lock".into())
|
||||
}
|
||||
|
||||
fn check_bindgen(want: &str) -> Result<(), String> {
|
||||
let hint = format!("install it with:\n cargo install wasm-bindgen-cli --version {want} --locked");
|
||||
let out = Command::new("wasm-bindgen").arg("--version").output().map_err(|_| format!("`wasm-bindgen` CLI not found; {hint}"))?;
|
||||
let have = String::from_utf8_lossy(&out.stdout);
|
||||
let have = have.split_whitespace().nth(1).unwrap_or("?");
|
||||
if have != want {
|
||||
return Err(format!("wasm-bindgen CLI is {have} but Cargo.lock has {want}; {hint}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run(args: &[&str]) -> Result<(), String> {
|
||||
let dev = args.contains(&"--dev");
|
||||
let serve = args.iter().position(|a| *a == "--serve").map(|i| args.get(i + 1).and_then(|p| p.parse::<u16>().ok()).unwrap_or(8080));
|
||||
|
||||
let want = locked_bindgen_version()?;
|
||||
check_bindgen(&want)?;
|
||||
let meta = metadata()?;
|
||||
let target_dir = PathBuf::from(meta["target_directory"].as_str().ok_or("cargo metadata: no target_directory")?);
|
||||
let profile = if dev { "dev" } else { "web" };
|
||||
|
||||
let mut c = cargo();
|
||||
c.args(["build", "-p", "lightcraft-web", "--lib", "--target", TARGET, "--profile", profile]);
|
||||
step(c, &format!("cargo build -p lightcraft-web --target {TARGET} --profile {profile}"))?;
|
||||
|
||||
let wasm = target_dir.join(TARGET).join(if dev { "debug" } else { profile }).join("lightcraft_web.wasm");
|
||||
let out = target_dir.join("web");
|
||||
std::fs::create_dir_all(&out).map_err(|e| format!("{}: {e}", out.display()))?;
|
||||
let mut b = Command::new("wasm-bindgen");
|
||||
b.arg(&wasm).args(["--target", "web", "--no-typescript", "--out-name", "lightcraft_web", "--out-dir"]).arg(&out);
|
||||
if dev {
|
||||
b.arg("--debug");
|
||||
}
|
||||
step(b, &format!("wasm-bindgen {} → {}", wasm.display(), out.display()))?;
|
||||
|
||||
// optional size pass when binaryen is installed
|
||||
let bg = out.join("lightcraft_web_bg.wasm");
|
||||
if !dev && Command::new("wasm-opt").arg("--version").output().is_ok() {
|
||||
let mut o = Command::new("wasm-opt");
|
||||
o.args(["-O2", "--enable-bulk-memory", "--enable-nontrapping-float-to-int", "--enable-sign-ext", "--enable-mutable-globals"])
|
||||
.arg(&bg)
|
||||
.arg("-o")
|
||||
.arg(&bg);
|
||||
if step(o, "wasm-opt -O2").is_err() {
|
||||
eprintln!("(wasm-opt failed; keeping the unoptimized module)");
|
||||
}
|
||||
}
|
||||
let index = root().join("apps/lightcraft-web/index.html");
|
||||
std::fs::copy(&index, out.join("index.html")).map_err(|e| format!("copy index.html: {e}"))?;
|
||||
let size = std::fs::metadata(&bg).map(|m| m.len()).unwrap_or(0);
|
||||
println!("\nweb build ready: {} ({:.1} MB wasm)", out.display(), size as f64 / 1e6);
|
||||
|
||||
match serve {
|
||||
Some(port) => serve_dir(&out, port),
|
||||
None => {
|
||||
println!("serve it with `cargo xtask web --serve` (or any static HTTP server) and open http://127.0.0.1:8080/");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mime(path: &Path) -> &'static str {
|
||||
match path.extension().and_then(|e| e.to_str()).unwrap_or("") {
|
||||
"html" => "text/html; charset=utf-8",
|
||||
"js" => "text/javascript; charset=utf-8",
|
||||
"wasm" => "application/wasm",
|
||||
"css" => "text/css; charset=utf-8",
|
||||
"json" => "application/json",
|
||||
"png" => "image/png",
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"svg" => "image/svg+xml",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
/// Serve `dir` on 127.0.0.1:`port` (GET/HEAD only, no directory listing). Blocks.
|
||||
fn serve_dir(dir: &Path, port: u16) -> Result<(), String> {
|
||||
let listener = TcpListener::bind(("127.0.0.1", port)).map_err(|e| format!("bind 127.0.0.1:{port}: {e}"))?;
|
||||
println!("serving {} at http://127.0.0.1:{port}/ (Ctrl+C to stop)", dir.display());
|
||||
for stream in listener.incoming().flatten() {
|
||||
if let Err(e) = handle(stream, dir) {
|
||||
eprintln!("http: {e}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle(mut stream: TcpStream, dir: &Path) -> std::io::Result<()> {
|
||||
let mut reader = BufReader::new(stream.try_clone()?);
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line)?;
|
||||
// drain headers
|
||||
let mut h = String::new();
|
||||
while reader.read_line(&mut h)? > 2 {
|
||||
h.clear();
|
||||
}
|
||||
let mut parts = line.split_whitespace();
|
||||
let method = parts.next().unwrap_or("");
|
||||
let target = parts.next().unwrap_or("/");
|
||||
let path = target.split(['?', '#']).next().unwrap_or("/");
|
||||
let rel = path.trim_start_matches('/');
|
||||
let rel = if rel.is_empty() { "index.html" } else { rel };
|
||||
let file = dir.join(rel);
|
||||
let safe = !rel.split('/').any(|c| c == ".." || c.contains('\\'));
|
||||
let (status, body, ctype) = match (method, safe, std::fs::read(&file)) {
|
||||
("GET" | "HEAD", true, Ok(b)) => ("200 OK", b, mime(&file)),
|
||||
("GET" | "HEAD", _, _) => ("404 Not Found", b"not found".to_vec(), "text/plain"),
|
||||
_ => ("405 Method Not Allowed", Vec::new(), "text/plain"),
|
||||
};
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 {status}\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nCache-Control: no-cache\r\nConnection: close\r\n\r\n",
|
||||
body.len()
|
||||
)?;
|
||||
if method != "HEAD" {
|
||||
stream.write_all(&body)?;
|
||||
}
|
||||
stream.flush()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bindgen_version_is_locked() {
|
||||
let v = locked_bindgen_version().unwrap();
|
||||
assert!(v.starts_with("0.2."), "{v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wasm_mime() {
|
||||
assert_eq!(mime(Path::new("a/lightcraft_web_bg.wasm")), "application/wasm");
|
||||
assert_eq!(mime(Path::new("index.html")), "text/html; charset=utf-8");
|
||||
}
|
||||
}
|
||||