Merge main into raw formats branch (nrw/raf/orf/rw2/pef/cr3 in the open dialog, nrw import)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-01 00:31:23 -04:00
commit daa32c3e4a
95 changed files with 6982 additions and 428 deletions

View File

@ -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`).

View File

@ -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`).

92
Cargo.lock generated
View File

@ -2,6 +2,22 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "ab_glyph"
version = "0.2.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2"
dependencies = [
"ab_glyph_rasterizer",
"owned_ttf_parser",
]
[[package]]
name = "ab_glyph_rasterizer"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
[[package]]
name = "accesskit"
version = "0.24.1"
@ -934,6 +950,7 @@ dependencies = [
"egui-wgpu",
"egui-winit",
"egui_glow",
"glow",
"glutin",
"glutin-winit",
"image",
@ -1991,13 +2008,26 @@ dependencies = [
name = "lightcraft-catalog"
version = "0.1.0"
dependencies = [
"crc32fast",
"lightcraft-develop",
"log",
"proptest",
"serde",
"serde_json",
"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"
@ -2047,6 +2077,7 @@ dependencies = [
name = "lightcraft-engine"
version = "0.1.0"
dependencies = [
"ab_glyph",
"lightcraft-catalog",
"lightcraft-codecs",
"lightcraft-color",
@ -2054,6 +2085,7 @@ dependencies = [
"lightcraft-geom",
"lightcraft-meta",
"lightcraft-pipeline",
"lightcraft-preview",
"lightcraft-raster",
"lightcraft-raw",
"lightcraft-scenes",
@ -2071,6 +2103,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"
@ -2098,6 +2141,18 @@ dependencies = [
"serde",
]
[[package]]
name = "lightcraft-preview"
version = "0.1.0"
dependencies = [
"jpeg-encoder",
"lightcraft-raster",
"log",
"siphasher",
"zune-core",
"zune-jpeg",
]
[[package]]
name = "lightcraft-raster"
version = "0.1.0"
@ -2156,10 +2211,30 @@ dependencies = [
"lightcraft-engine",
"lightcraft-geom",
"lightcraft-pipeline",
"lightcraft-preview",
"lightcraft-raster",
"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]]
@ -2217,6 +2292,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519"
dependencies = [
"cfg-if",
"rayon",
]
[[package]]
@ -2806,6 +2882,15 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "owned_ttf_parser"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b"
dependencies = [
"ttf-parser",
]
[[package]]
name = "parking"
version = "2.2.1"
@ -3239,6 +3324,7 @@ dependencies = [
"loop9",
"quick-error 2.0.1",
"rav1e",
"rayon",
"rgb",
]
@ -3861,6 +3947,12 @@ dependencies = [
"once_cell",
]
[[package]]
name = "ttf-parser"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
[[package]]
name = "type-map"
version = "0.5.1"

View File

@ -22,6 +22,7 @@ lightcraft-develop = { path = "crates/develop" }
lightcraft-scenes = { path = "crates/scenes" }
lightcraft-pipeline = { path = "crates/pipeline" }
lightcraft-catalog = { path = "crates/catalog" }
lightcraft-preview = { path = "crates/preview" }
lightcraft-engine = { path = "crates/engine" }
lightcraft-ui-egui = { path = "crates/ui-egui" }
lightcraft-mcp = { path = "crates/mcp" }
@ -34,6 +35,8 @@ log = "0.4"
rayon = "1"
proptest = "1"
bytemuck = { version = "1", features = ["derive"] }
crc32fast = "1"
siphasher = "1"
# formats
quick-xml = "0.38"
@ -45,12 +48,13 @@ zune-jpeg = { version = "0.5", default-features = false, features = ["std", "x86
zune-core = { version = "0.5", default-features = false, features = ["std"] }
jpeg-decoder = { version = "0.3", default-features = false }
jpeg-encoder = { version = "0.7", default-features = false, features = ["std"] }
ab_glyph = { version = "0.2", default-features = false, features = ["std"] } # Apache-2.0, pure Rust glyph rasterizer (export watermarks)
png = "0.18"
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"
@ -58,6 +62,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"
@ -84,3 +95,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"

View File

@ -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,16 @@ 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, text watermark | ✅ · DNG export, image watermark ⬜ |
| Library persistence (crash-safe op log + snapshots), disk thumbnail cache, import with duplicate detection | ✅ |
| MCP server (headless or live app, persistent libraries), CLI, control channel | ✅ |
| Optics, Geometry/Upright, XMP sidecars, GPU pipeline | 🚧 |
| Web build (same UI in the browser via WASM; in-memory imports, export downloads) | ✅ · persistence, workers 🚧 |
<br>
@ -221,12 +236,18 @@ LightCraft is young and moving fast — see the [roadmap](ROADMAP.md) for estima
```sh
git clone https://github.com/storytold/lightcraft && cd lightcraft
cargo run --release -p lightcraft # opens with a procedurally generated demo library
cargo run --release -p lightcraft -- ~/Pictures/trip # or point it at your photos (folders are scanned)
cargo run --release -p lightcraft # opens your library (~/Pictures/LightCraft Library; a new one starts with demo photos)
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.
@ -235,7 +256,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>
@ -258,7 +292,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.

View File

@ -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 }

View File

@ -0,0 +1,244 @@
//! `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
--library DIR headless: open (or create) a persistent LightCraft library; edits are saved
--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 library: Option<String> = None;
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,
"--library" => library = Some(take_value(args, &mut i, "--library")?.to_string()),
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 || library.is_some() {
return Err("FILES, --demo and --library 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 = match &library {
Some(dir) => {
let mut h = Headless::default();
let r = h.session.open_library(dir, demo).map_err(|e| e.to_string())?;
eprintln!("lightcraft-cli mcp: opened library {dir} ({r:?})");
h
}
None if demo => Headless::demo(),
None => 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(())
}

View File

@ -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"));
}

View File

@ -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",
] }

View File

@ -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>

View File

@ -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,
})
}
}

View File

@ -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;

View File

@ -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");
}
}

View File

@ -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:?}"));
}
}
});
}

View File

@ -0,0 +1,45 @@
//! Generate a folder of procedural test photos (JPEG) for import/startup benchmarks.
//!
//! `cargo run --release -p lightcraft --example gen_photos -- OUT_DIR [COUNT=200] [LONG_EDGE=2000]`
//!
//! Each photo is a `lightcraft-scenes` demo scene rendered through the pipeline with a varied
//! look (exposure, temperature, vibrance), so every file has distinct bytes. The output is our own
//! procedural content; keep it out of the repository (write to a scratch or gitignored folder).
use lightcraft_engine::Session;
use serde_json::json;
fn main() {
let mut args = std::env::args().skip(1);
let out = std::path::PathBuf::from(args.next().expect("usage: gen_photos OUT_DIR [COUNT] [LONG_EDGE]"));
let count: usize = args.next().and_then(|c| c.parse().ok()).unwrap_or(200);
let edge: usize = args.next().and_then(|c| c.parse().ok()).unwrap_or(2000);
std::fs::create_dir_all(&out).expect("create output folder");
let mut s = Session::with_demo();
let ids = s.visible_cloned();
let t0 = std::time::Instant::now();
for i in 0..count {
let id = ids[i % ids.len()];
let round = i / ids.len();
s.execute("library.select", &json!({"ids": [id.0]})).expect("select");
s.execute("develop.reset", &json!({})).expect("reset");
let v = json!({
"light.exposure": (round as f64 * 0.37).sin() * 0.8,
"wb.temp": 6500.0 + (round as f64 * 1.3).cos() * 1500.0,
"color.vibrance": (round * 13 % 50) as f64,
});
let _ = s.execute("develop.set", &json!({"values": v}));
let img = s.render_now(id, edge, edge).expect("render");
let bytes = lightcraft_codecs::encode_jpeg(
&lightcraft_codecs::EncodeImage::rgba8(&img.image),
88,
lightcraft_codecs::ChromaSubsampling::S420,
&lightcraft_codecs::EncodeMeta::default(),
)
.expect("encode");
let dir = out.join(format!("roll-{:02}", i / 50 + 1));
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(dir.join(format!("LCG{:05}.jpg", i + 1)), bytes).expect("write");
}
eprintln!("wrote {count} photos ({edge} px) to {} in {:.1} s", out.display(), t0.elapsed().as_secs_f64());
}

View File

@ -1,6 +1,12 @@
//! LightCraft desktop app.
//!
//! Usage: `lightcraft [--control <port>] [--demo] [files or folders…]`
//! Usage: `lightcraft [--library DIR | --memory] [--no-demo] [--control <port>] [files or folders…]`
//!
//! The library (catalog, presets, thumbnail cache) lives in `--library DIR`, else
//! `$LIGHTCRAFT_LIBRARY`, else `~/Pictures/LightCraft Library`; a new library starts with the
//! procedural demo photos unless `--no-demo` or files are given. Files and folders on the command
//! line are imported (duplicates are skipped). `--memory` runs an in-memory session that writes
//! nothing (demo photos unless files are given; used by the README showcase scripts).
//!
//! `--control <port>` (or `LIGHTCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
//! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
@ -25,6 +31,9 @@ impl eframe::App for App {
}
fn on_exit(&mut self) {
save_prefs(&self.0);
if let Err(e) = self.0.session.close_library() {
eprintln!("lightcraft: saving the library failed: {e}");
}
}
}
@ -72,8 +81,8 @@ fn services() -> Services {
.add_filter(
"Photos",
&[
"jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "nef", "nrw", "arw", "raf", "rw2", "pef", "orf", "psd", "jxl",
"gif", "bmp",
"jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "pef", "psd",
"jxl", "gif", "bmp",
],
)
.pick_files()
@ -82,64 +91,67 @@ 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()
})),
}
}
/// Expand folders (recursively) into photo files.
fn collect(path: &std::path::Path, out: &mut Vec<String>) {
if path.is_dir() {
if let Ok(rd) = std::fs::read_dir(path) {
let mut v: Vec<_> = rd.flatten().map(|e| e.path()).collect();
v.sort();
for p in v {
collect(&p, out);
}
/// The persistent library session (or an in-memory one with `--memory` / if the library can't open).
fn open_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: bool) -> Session {
let fallback = || if seed_demo { Session::with_demo() } else { Session::new() }.with_fs().with_system_clock();
if in_memory {
return fallback();
}
let Some(dir) = dir else {
eprintln!("lightcraft: no library location (set --library or LIGHTCRAFT_LIBRARY); running in memory");
return fallback();
};
let t0 = std::time::Instant::now();
let mut s = Session::new().with_fs().with_system_clock();
match s.open_library(&dir, seed_demo) {
Ok(r) => {
let (replayed, torn) = (r.replayed, r.torn_bytes);
eprintln!(
"lightcraft: library {}: {} photos, {replayed} log records replayed{} ({:.1} ms)",
dir.display(),
s.catalog.len(),
if torn > 0 { ", torn tail repaired" } else { "" },
t0.elapsed().as_secs_f64() * 1000.0
);
s
}
Err(e) => {
eprintln!("lightcraft: can't open library {}: {e}; running in memory", dir.display());
fallback()
}
} else if path.extension().is_some_and(|e| {
matches!(
e.to_string_lossy().to_lowercase().as_str(),
"jpg"
| "jpeg"
| "png"
| "tif"
| "tiff"
| "webp"
| "dng"
| "cr2"
| "nef"
| "nrw"
| "arw"
| "raf"
| "rw2"
| "pef"
| "psd"
| "jxl"
| "gif"
| "bmp"
)
}) {
out.push(path.to_string_lossy().to_string());
}
}
fn main() -> eframe::Result {
let mut control_port: Option<u16> = std::env::var("LIGHTCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let mut files = Vec::new();
let mut demo = true;
let mut seed_demo = true;
let mut in_memory = false;
let mut library_dir = lightcraft_engine::library::default_dir();
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
"--no-demo" => demo = false,
"--library" => library_dir = args.next().map(std::path::PathBuf::from),
"--no-demo" => seed_demo = false,
"--memory" | "--demo" => in_memory = true,
"--version" => {
println!("lightcraft {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
_ => collect(std::path::Path::new(&a), &mut files),
_ => files.push(a),
}
}
let options = eframe::NativeOptions {
@ -157,7 +169,7 @@ fn main() -> eframe::Result {
"LightCraft",
options,
Box::new(move |cc| {
let session = if demo && files.is_empty() { Session::with_demo() } else { Session::new() }.with_fs();
let session = open_session(in_memory, library_dir, seed_demo && files.is_empty());
let mut app = LightcraftApp::new(session, services());
load_prefs(&mut app);
app.integrated_titlebar = cfg!(target_os = "macos");

View File

@ -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.

View File

@ -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.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -13,6 +13,8 @@ lightcraft-develop = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
crc32fast = { workspace = true }
log = { workspace = true }
[dev-dependencies]
proptest = { workspace = true }

View File

@ -0,0 +1,240 @@
//! Crash-safe catalog persistence: an append-only op log plus periodic snapshots.
//!
//! Files (in a [`Store`]):
//! - `catalog.snap` — `{"format":"lightcraft-catalog","version":1,"seq":N,"catalog":{…}}`, the
//! state after op `N`; replaced atomically (temp + fsync + rename).
//! - `catalog.log` — JSON lines, one per op applied after the snapshot:
//! `{"seq":N,"crc":C,"op":{…}}` where `C` is the CRC-32 of the op's JSON text. Appends are
//! fsynced before [`Journal::append`] returns.
//!
//! Loading = snapshot + replay of the records with `seq > snapshot seq`. Recovery rules:
//! - a **torn final record** (crash mid-append: truncated line or bad CRC at the end) is dropped
//! and the file is cut back to the last good record, so later appends start on a clean line;
//! - records already covered by the snapshot (crash between writing the snapshot and resetting
//! the log) are skipped;
//! - a bad record **followed by good ones** is real damage: replay stops there, the log is kept
//! as `catalog.log.damaged-<seq>`, and a fresh snapshot is written so the library stays usable.
use crate::store::Store;
use crate::{Catalog, CatalogError, Op, Result};
pub const SNAPSHOT: &str = "catalog.snap";
pub const LOG: &str = "catalog.log";
const FORMAT: &str = "lightcraft-catalog";
const VERSION: u32 = 1;
/// When [`Journal::wants_snapshot`] says it's time to compact the log.
#[derive(Clone, Copy, Debug)]
pub struct SnapshotPolicy {
pub max_records: u64,
pub max_bytes: u64,
}
impl Default for SnapshotPolicy {
fn default() -> Self {
SnapshotPolicy { max_records: 2000, max_bytes: 16 << 20 }
}
}
/// What happened while loading.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct LoadReport {
/// `seq` of the snapshot that was loaded (0 = none).
pub snapshot_seq: u64,
/// Log records applied on top of the snapshot.
pub replayed: usize,
/// Records skipped because the snapshot already contained them.
pub stale: usize,
/// Records whose op failed to apply (should never happen; reported, not fatal).
pub failed: usize,
/// Bytes of a torn final record that were dropped.
pub torn_bytes: u64,
/// The damaged log was preserved under this name.
pub damaged: Option<String>,
/// No catalog files existed (a new library).
pub created: bool,
}
pub struct Journal {
store: Box<dyn Store>,
/// `seq` of the last durable op.
seq: u64,
snapshot_seq: u64,
log_records: u64,
log_bytes: u64,
pub policy: SnapshotPolicy,
}
fn io(e: std::io::Error) -> CatalogError {
CatalogError::Io(e.to_string())
}
/// One log line (without the newline) for op number `seq`.
pub fn encode_record(seq: u64, op: &Op) -> String {
let body = serde_json::to_string(op).unwrap_or_default();
let crc = crc32fast::hash(body.as_bytes());
format!("{{\"seq\":{seq},\"crc\":{crc},\"op\":{body}}}")
}
/// Parse one log line (without the newline). `None` if it's malformed or the CRC doesn't match.
pub fn decode_record(line: &str) -> Option<(u64, Op)> {
let rest = line.strip_prefix("{\"seq\":")?;
let (seq, rest) = rest.split_once(",\"crc\":")?;
let (crc, rest) = rest.split_once(",\"op\":")?;
let body = rest.strip_suffix('}')?;
let seq: u64 = seq.parse().ok()?;
let crc: u32 = crc.parse().ok()?;
if crc32fast::hash(body.as_bytes()) != crc {
return None;
}
serde_json::from_str(body).ok().map(|op| (seq, op))
}
#[derive(serde::Deserialize)]
struct SnapFile {
format: String,
version: u32,
seq: u64,
catalog: Catalog,
}
impl Journal {
/// Open (or create) the catalog in `store`: load the snapshot, replay the log, repair a torn
/// tail. Fails only if the snapshot itself is unreadable (then nothing is modified).
pub fn open(mut store: Box<dyn Store>) -> Result<(Journal, Catalog, LoadReport)> {
let mut report = LoadReport::default();
let snap = store.read(SNAPSHOT).map_err(io)?;
let log = store.read(LOG).map_err(io)?;
report.created = snap.is_none() && log.is_none();
let (mut catalog, snapshot_seq) = match snap {
Some(bytes) => {
let s: SnapFile = serde_json::from_slice(&bytes).map_err(|e| CatalogError::Corrupt(format!("{SNAPSHOT}: {e}")))?;
if s.format != FORMAT || s.version > VERSION {
return Err(CatalogError::Corrupt(format!("{SNAPSHOT}: unsupported format {} v{}", s.format, s.version)));
}
(s.catalog, s.seq)
}
None => (Catalog::new(), 0),
};
report.snapshot_seq = snapshot_seq;
let mut j = Journal { store, seq: snapshot_seq, snapshot_seq, log_records: 0, log_bytes: 0, policy: SnapshotPolicy::default() };
let log = log.unwrap_or_default();
// Scan records; `good_end` is the byte offset just past the last good record.
let mut pos = 0usize;
let mut good_end = 0usize;
let mut damaged_at: Option<usize> = None;
let mut needs_newline = false;
while pos < log.len() {
let (line_end, next, has_nl) = match log[pos..].iter().position(|b| *b == b'\n') {
Some(i) => (pos + i, pos + i + 1, true),
None => (log.len(), log.len(), false),
};
let line = std::str::from_utf8(&log[pos..line_end]).ok().map(|l| l.trim_end_matches('\r'));
if line.is_some_and(|l| l.trim().is_empty()) {
pos = next;
good_end = next;
continue;
}
match line.and_then(decode_record) {
Some((seq, op)) if seq <= j.seq => {
// already in the snapshot
let _ = op;
report.stale += 1;
}
Some((seq, op)) if seq == j.seq + 1 => {
if catalog.apply(op).is_err() {
report.failed += 1;
} else {
report.replayed += 1;
}
j.seq = seq;
}
_ => {
// bad record (or a gap): torn tail if nothing good follows, else damage
let rest_has_good = log[next..].split(|b| *b == b'\n').any(|l| std::str::from_utf8(l).ok().and_then(decode_record).is_some());
if rest_has_good {
damaged_at = Some(pos);
}
break;
}
}
j.log_records += 1;
pos = next;
good_end = next;
needs_newline = !has_nl;
}
if let Some(at) = damaged_at {
let name = format!("{LOG}.damaged-{}", j.seq);
j.store.write_atomic(&name, &log).map_err(io)?;
log::warn!("catalog log damaged at byte {at}; kept as {name}; state recovered up to op {}", j.seq);
report.damaged = Some(name);
j.snapshot(&catalog)?;
} else {
if good_end < log.len() {
report.torn_bytes = (log.len() - good_end) as u64;
log::warn!("catalog log: dropped a torn final record ({} bytes)", report.torn_bytes);
j.store.truncate(LOG, good_end as u64).map_err(io)?;
} else if needs_newline {
j.store.append(LOG, b"\n").map_err(io)?;
}
j.log_bytes = good_end as u64;
}
catalog.revision = 0;
Ok((j, catalog, report))
}
/// Append ops (already applied to the live catalog) durably, in order.
pub fn append(&mut self, ops: &[Op]) -> Result<()> {
if ops.is_empty() {
return Ok(());
}
let mut buf = String::new();
let mut seq = self.seq;
for op in ops {
seq += 1;
buf.push_str(&encode_record(seq, op));
buf.push('\n');
}
self.store.append(LOG, buf.as_bytes()).map_err(io)?;
self.seq = seq;
self.log_records += ops.len() as u64;
self.log_bytes += buf.len() as u64;
Ok(())
}
/// Write a snapshot of `catalog` (which must reflect every appended op) and reset the log.
pub fn snapshot(&mut self, catalog: &Catalog) -> Result<()> {
let body = serde_json::to_string(catalog).map_err(|e| CatalogError::Invalid(e.to_string()))?;
let file = format!("{{\"format\":\"{FORMAT}\",\"version\":{VERSION},\"seq\":{},\"catalog\":{body}}}\n", self.seq);
self.store.write_atomic(SNAPSHOT, file.as_bytes()).map_err(io)?;
// A crash here leaves old records in the log; they are skipped by seq on load.
self.store.write_atomic(LOG, b"").map_err(io)?;
self.snapshot_seq = self.seq;
self.log_records = 0;
self.log_bytes = 0;
Ok(())
}
/// The log is long enough to be worth compacting.
pub fn wants_snapshot(&self) -> bool {
self.log_records >= self.policy.max_records || self.log_bytes >= self.policy.max_bytes
}
pub fn seq(&self) -> u64 {
self.seq
}
pub fn snapshot_seq(&self) -> u64 {
self.snapshot_seq
}
pub fn log_records(&self) -> u64 {
self.log_records
}
pub fn log_bytes(&self) -> u64 {
self.log_bytes
}
pub fn describe(&self) -> String {
self.store.describe()
}
}

View File

@ -2,21 +2,25 @@
//!
//! State changes only through [`Op`]s. [`Catalog::apply`] returns the inverse op, which gives:
//! - **persistence**: ops are appended to a log (JSON lines) and replayed on load after the last
//! snapshot — crash-safe and diff-friendly;
//! snapshot — crash-safe and diff-friendly (see [`journal`]);
//! - **undo/redo**: the engine keeps inverse ops;
//! - **determinism**: replaying the log reproduces the state exactly (property-tested).
#![forbid(unsafe_code)]
pub mod journal;
pub mod model;
pub mod query;
pub mod store;
use std::collections::BTreeMap;
use std::sync::Arc;
pub use journal::{Journal, LoadReport, SnapshotPolicy};
use lightcraft_develop::DevelopSettings;
pub use model::*;
pub use query::{DateGroup, Filter, RatingOp, Sort, SortKey};
use serde::{Deserialize, Serialize};
pub use store::{FsStore, MemStore, Store};
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum CatalogError {
@ -28,10 +32,15 @@ pub enum CatalogError {
Invalid(String),
#[error("corrupt catalog data: {0}")]
Corrupt(String),
#[error("catalog storage: {0}")]
Io(String),
}
pub type Result<T> = std::result::Result<T, CatalogError>;
/// Maximum History entries kept per photo.
pub const HISTORY_LIMIT: usize = 200;
/// Every catalog mutation.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "camelCase")]
@ -76,6 +85,12 @@ pub enum Op {
id: PhotoId,
history: Vec<HistoryStep>,
},
/// Append one History entry (dropping the oldest beyond [`HISTORY_LIMIT`]). Logged instead of
/// a full `SetHistory` so each edit costs one step in the op log.
PushHistory {
id: PhotoId,
step: HistoryStep,
},
AddAlbum {
album: Album,
},
@ -227,6 +242,16 @@ impl Catalog {
let p = self.photo_mut(id)?;
Op::SetHistory { id, history: std::mem::replace(&mut p.history, history) }
}
Op::PushHistory { id, step } => {
let p = self.photo_mut(id)?;
let old = p.history.clone();
p.history.push(step);
if p.history.len() > HISTORY_LIMIT {
let n = p.history.len() - HISTORY_LIMIT;
p.history.drain(..n);
}
Op::SetHistory { id, history: old }
}
Op::AddAlbum { album } => {
if self.albums.contains_key(&album.id) {
return Err(CatalogError::Invalid(format!("album {:?} exists", album.id)));
@ -352,3 +377,5 @@ impl Catalog {
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_journal;

View File

@ -143,6 +143,9 @@ pub struct Photo {
/// Raw files: the camera's as-shot white balance (Kelvin, tint).
#[serde(default)]
pub as_shot_wb: Option<(f64, f64)>,
/// Hash of the file's bytes (hex), for duplicate detection and preview-cache keys.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content_hash: Option<String>,
}
impl Photo {
@ -169,6 +172,7 @@ impl Photo {
deleted: false,
duration: None,
as_shot_wb: None,
content_hash: None,
}
}
pub fn is_edited(&self) -> bool {

148
crates/catalog/src/store.rs Normal file
View File

@ -0,0 +1,148 @@
//! Byte storage for the catalog files (`catalog.snap`, `catalog.log`).
//!
//! [`FsStore`] is the native implementation (a library directory); [`MemStore`] keeps files in
//! memory (tests, ephemeral sessions) and lets tests simulate crashes by editing the bytes. A web
//! host can implement [`Store`] over OPFS.
use std::collections::BTreeMap;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
/// A flat namespace of named files with the few operations the journal needs.
pub trait Store: Send {
/// The whole file, or `None` if it doesn't exist.
fn read(&mut self, name: &str) -> io::Result<Option<Vec<u8>>>;
/// Replace a file atomically: after a crash either the old or the new content is visible,
/// never a mix (native: write temp + fsync + rename + fsync directory).
fn write_atomic(&mut self, name: &str, data: &[u8]) -> io::Result<()>;
/// Append and make durable (fsync) before returning.
fn append(&mut self, name: &str, data: &[u8]) -> io::Result<()>;
/// Cut a file to `len` bytes (drop a torn tail before appending again).
fn truncate(&mut self, name: &str, len: u64) -> io::Result<()>;
/// Human-readable location (diagnostics).
fn describe(&self) -> String;
}
/// Files in a directory.
pub struct FsStore {
dir: PathBuf,
/// Open append handle (kept between appends; dropped on rewrite/truncate).
appender: Option<(String, std::fs::File)>,
}
impl FsStore {
/// Use `dir` (created if missing).
pub fn open(dir: impl AsRef<Path>) -> io::Result<FsStore> {
std::fs::create_dir_all(dir.as_ref())?;
Ok(FsStore { dir: dir.as_ref().to_path_buf(), appender: None })
}
pub fn dir(&self) -> &Path {
&self.dir
}
fn path(&self, name: &str) -> PathBuf {
self.dir.join(name)
}
fn sync_dir(&self) {
// Makes the rename durable on POSIX; opening a directory fails on Windows (where
// `rename` is already journaled by NTFS), so errors are ignored.
if let Ok(d) = std::fs::File::open(&self.dir) {
let _ = d.sync_all();
}
}
}
impl Store for FsStore {
fn read(&mut self, name: &str) -> io::Result<Option<Vec<u8>>> {
match std::fs::read(self.path(name)) {
Ok(b) => Ok(Some(b)),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e),
}
}
fn write_atomic(&mut self, name: &str, data: &[u8]) -> io::Result<()> {
use std::io::Write;
if self.appender.as_ref().is_some_and(|(n, _)| n == name) {
self.appender = None;
}
let tmp = self.path(&format!("{name}.tmp"));
{
let mut f = std::fs::File::create(&tmp)?;
f.write_all(data)?;
f.sync_all()?;
}
std::fs::rename(&tmp, self.path(name))?;
self.sync_dir();
Ok(())
}
fn append(&mut self, name: &str, data: &[u8]) -> io::Result<()> {
use std::io::Write;
if self.appender.as_ref().is_none_or(|(n, _)| n != name) {
let existed = self.path(name).exists();
let f = std::fs::OpenOptions::new().create(true).append(true).open(self.path(name))?;
if !existed {
self.sync_dir();
}
self.appender = Some((name.to_string(), f));
}
let (_, f) = self.appender.as_mut().expect("appender just opened");
f.write_all(data)?;
f.sync_data()
}
fn truncate(&mut self, name: &str, len: u64) -> io::Result<()> {
if self.appender.as_ref().is_some_and(|(n, _)| n == name) {
self.appender = None;
}
let f = std::fs::OpenOptions::new().write(true).open(self.path(name))?;
f.set_len(len)?;
f.sync_all()
}
fn describe(&self) -> String {
self.dir.display().to_string()
}
}
/// In-memory files. Clones share the same files (so a test can "reopen" or corrupt them).
#[derive(Clone, Default)]
pub struct MemStore {
pub files: Arc<Mutex<BTreeMap<String, Vec<u8>>>>,
}
impl MemStore {
pub fn new() -> MemStore {
MemStore::default()
}
pub fn get(&self, name: &str) -> Option<Vec<u8>> {
self.files.lock().unwrap_or_else(|e| e.into_inner()).get(name).cloned()
}
pub fn set(&self, name: &str, data: Vec<u8>) {
self.files.lock().unwrap_or_else(|e| e.into_inner()).insert(name.to_string(), data);
}
}
impl Store for MemStore {
fn read(&mut self, name: &str) -> io::Result<Option<Vec<u8>>> {
Ok(self.get(name))
}
fn write_atomic(&mut self, name: &str, data: &[u8]) -> io::Result<()> {
self.set(name, data.to_vec());
Ok(())
}
fn append(&mut self, name: &str, data: &[u8]) -> io::Result<()> {
self.files.lock().unwrap_or_else(|e| e.into_inner()).entry(name.to_string()).or_default().extend_from_slice(data);
Ok(())
}
fn truncate(&mut self, name: &str, len: u64) -> io::Result<()> {
if let Some(f) = self.files.lock().unwrap_or_else(|e| e.into_inner()).get_mut(name) {
f.truncate(len as usize);
}
Ok(())
}
fn describe(&self) -> String {
"memory".into()
}
}

View File

@ -0,0 +1,230 @@
//! Journal (op log + snapshot) tests: replay(log) == state, torn tails, stale logs, damage.
use std::sync::Arc;
use lightcraft_develop::DevelopSettings;
use proptest::prelude::*;
use crate::journal::{LOG, SNAPSHOT, decode_record, encode_record};
use crate::*;
fn open(m: &MemStore) -> (Journal, Catalog, LoadReport) {
Journal::open(Box::new(m.clone())).unwrap()
}
/// Interpret a random action against the live catalog as an op (may be invalid: then the live
/// apply fails and, like in the engine, nothing is logged).
fn op_for(c: &mut Catalog, kind: u8, a: u8, b: u8) -> Op {
let photos: Vec<PhotoId> = c.photos().map(|p| p.id).collect();
let albums: Vec<AlbumId> = c.albums().map(|a| a.id).collect();
let pid = photos.get(a as usize % photos.len().max(1)).copied().unwrap_or(PhotoId(99));
let aid = albums.get(a as usize % albums.len().max(1)).copied().unwrap_or(AlbumId(99));
match kind % 10 {
0 | 1 => {
let id = c.alloc_photo_id();
let mut p = Photo::new(id, Source::File { path: format!("/p/{}.jpg", id.0) }, &format!("{}.jpg", id.0), "JPEG", 60, 40, "2026-01-01");
p.content_hash = Some(format!("{:032x}", id.0 * 7919));
Op::AddPhoto { photo: Box::new(p) }
}
2 => Op::SetRating { id: pid, rating: b % 7 },
3 => Op::SetFlag { id: pid, flag: [Flag::None, Flag::Pick, Flag::Reject][b as usize % 3] },
4 => {
let mut d = DevelopSettings::default();
d.light.exposure = b as f64 / 37.0 - 2.0;
d.light.contrast = -(a as f64) * 0.37;
let settings = Arc::new(d);
Op::Batch {
ops: vec![
Op::SetDevelop { id: pid, settings: settings.clone(), label: "Exposure".into(), edited: Some(format!("t{b}")) },
Op::PushHistory { id: pid, step: HistoryStep { label: "Exposure".into(), settings } },
],
}
}
5 => {
let id = c.alloc_album_id();
Op::AddAlbum { album: Album { id, name: format!("A{b}"), parent: None, folder: b.is_multiple_of(4), photos: vec![], cover: None } }
}
6 => Op::SetAlbumPhotos { id: aid, photos: photos.iter().copied().filter(|p| (p.0 + b as u64).is_multiple_of(3)).collect() },
7 => Op::RenameAlbum { id: aid, name: format!("Renamed {b} ✓") },
8 => Op::SetDeleted { id: pid, deleted: b.is_multiple_of(2) },
_ => c.delete_permanently_ops(pid),
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(64))]
/// replay(snapshot + log) == live state, with snapshots at random points and reopening
/// (resuming appends) in between.
#[test]
fn replay_equals_state(actions in proptest::collection::vec((0u8..20, 0u8..255, 0u8..255, 0u8..12), 1..80)) {
let m = MemStore::new();
let (mut j, mut live, r) = open(&m);
prop_assert!(r.created);
for (kind, a, b, ctl) in actions {
let op = op_for(&mut live, kind, a, b);
if live.apply(op.clone()).is_ok() {
j.append(&[op]).unwrap();
}
match ctl {
0 => j.snapshot(&live).unwrap(),
1 => {
let (j2, c2, _) = open(&m);
prop_assert_eq!(c2.to_snapshot(), live.to_snapshot());
j = j2;
}
_ => {}
}
}
let (_, loaded, r) = open(&m);
prop_assert_eq!(r.failed, 0);
prop_assert_eq!(loaded.to_snapshot(), live.to_snapshot());
// allocation continues where it left off
prop_assert_eq!(loaded.clone().alloc_photo_id(), live.clone().alloc_photo_id());
}
}
fn sample_catalog_with_log() -> (MemStore, Catalog, Catalog) {
let m = MemStore::new();
let (mut j, mut c, _) = open(&m);
for k in 0..6u8 {
let op = op_for(&mut c, k, k, k * 3);
if c.apply(op.clone()).is_ok() {
j.append(&[op]).unwrap();
}
}
let before_last = c.clone();
let op = Op::SetRating { id: c.photos().next().unwrap().id, rating: 5 };
c.apply(op.clone()).unwrap();
j.append(&[op]).unwrap();
(m, before_last, c)
}
/// A crash mid-append: every possible truncation of the last record still loads (to the state
/// before that op), the torn bytes are cut off, and appending afterwards works.
#[test]
fn torn_last_record_is_tolerated() {
let (m, before_last, full) = sample_catalog_with_log();
let log = m.get(LOG).unwrap();
let last_start = log[..log.len() - 1].iter().rposition(|b| *b == b'\n').unwrap() + 1;
for cut in last_start..log.len() {
let t = MemStore::new();
t.set(LOG, log[..cut].to_vec());
let (mut j, mut c, r) = open(&t);
// the full record minus only its newline is complete and kept
let expect = if cut == log.len() - 1 { &full } else { &before_last };
assert_eq!(c.to_snapshot(), expect.to_snapshot(), "cut at {cut}");
assert!(r.damaged.is_none());
if cut < log.len() - 1 {
assert_eq!(r.torn_bytes as usize, cut - last_start, "cut at {cut}");
}
// keeps working after recovery
let id = c.photos().next().unwrap().id;
let op = Op::SetFlag { id, flag: Flag::Reject };
c.apply(op.clone()).unwrap();
j.append(&[op]).unwrap();
let (_, c2, r2) = open(&t);
assert_eq!(c2.to_snapshot(), c.to_snapshot(), "cut at {cut}");
assert_eq!(r2.torn_bytes, 0);
}
// garbage bytes (a partially flushed page) at the end are a torn tail too
let t = MemStore::new();
let mut g = log.clone();
g.extend_from_slice(&[0, 0, 0, b'{', 0xff]);
t.set(LOG, g);
let (_, c, r) = open(&t);
assert_eq!(c.to_snapshot(), full.to_snapshot());
assert_eq!(r.torn_bytes, 5);
}
/// Crash after the snapshot was written but before the log was reset: the stale records are
/// skipped by sequence number.
#[test]
fn stale_log_after_snapshot_is_skipped() {
let (m, _, full) = sample_catalog_with_log();
let old_log = m.get(LOG).unwrap();
let (mut j, c, _) = open(&m);
j.snapshot(&c).unwrap();
assert!(m.get(LOG).unwrap().is_empty());
m.set(LOG, old_log);
let (_, c2, r) = open(&m);
assert_eq!(c2.to_snapshot(), full.to_snapshot());
assert!(r.stale > 0 && r.replayed == 0);
}
/// Damage in the middle of the log (not a torn tail): recover what precedes it, keep the
/// damaged file, and write a consistent snapshot.
#[test]
fn mid_log_damage_is_preserved_and_recovered() {
let (m, _, _) = sample_catalog_with_log();
let log = m.get(LOG).unwrap();
let text = String::from_utf8(log).unwrap();
let mut lines: Vec<String> = text.lines().map(str::to_string).collect();
assert!(lines.len() > 4);
lines[2] = lines[2].replacen("\"op\":{\"op\":\"", "\"op\":{\"op\":\"x", 1);
let bad = format!("{}\n", lines.join("\n")).into_bytes();
m.set(LOG, bad.clone());
let (_, c, r) = open(&m);
let name = r.damaged.clone().expect("damage reported");
assert_eq!(m.get(&name).unwrap(), bad);
assert_eq!(r.replayed, 2);
// the recovered state was snapshotted: reopening is clean and identical
let (_, c2, r2) = open(&m);
assert_eq!(c2.to_snapshot(), c.to_snapshot());
assert!(r2.damaged.is_none());
}
#[test]
fn record_codec() {
let op = Op::RenameAlbum { id: AlbumId(3), name: "Line\nbreak \"quoted\" }".into() };
let line = encode_record(42, &op);
assert!(!line.contains('\n'));
assert_eq!(decode_record(&line), Some((42, op)));
assert_eq!(decode_record(&line.replace("Line", "Lime")), None, "crc mismatch");
assert_eq!(decode_record(&line[..line.len() - 1]), None);
// log lines stay plain JSON
let v: serde_json::Value = serde_json::from_str(&line).unwrap();
assert_eq!(v["op"]["op"], "renameAlbum");
}
#[test]
fn push_history_is_bounded_and_invertible() {
let mut c = Catalog::new();
let id = c.alloc_photo_id();
c.apply(Op::AddPhoto { photo: Box::new(Photo::new(id, Source::Demo { scene: 1 }, "a", "JPEG", 1, 1, "t")) }).unwrap();
let step = |i: usize| HistoryStep { label: format!("s{i}"), settings: Arc::new(DevelopSettings::default()) };
for i in 0..HISTORY_LIMIT + 5 {
c.apply(Op::PushHistory { id, step: step(i) }).unwrap();
}
let h = &c.photo(id).unwrap().history;
assert_eq!(h.len(), HISTORY_LIMIT);
assert_eq!(h[0].label, "s5");
let before = c.to_snapshot();
let inv = c.apply(Op::PushHistory { id, step: step(999) }).unwrap();
c.apply(inv).unwrap();
assert_eq!(c.to_snapshot(), before);
}
#[test]
fn fs_store_roundtrip() {
let dir = std::env::temp_dir().join(format!("lc-journal-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let (mut j, mut c, r) = Journal::open(Box::new(FsStore::open(&dir).unwrap())).unwrap();
assert!(r.created);
let id = c.alloc_photo_id();
let add = Op::AddPhoto { photo: Box::new(Photo::new(id, Source::File { path: "/a.jpg".into() }, "a.jpg", "JPEG", 1, 1, "t")) };
c.apply(add.clone()).unwrap();
c.apply(Op::SetRating { id, rating: 4 }).unwrap();
j.append(&[add, Op::SetRating { id, rating: 4 }]).unwrap();
drop(j);
let (mut j, c2, r) = Journal::open(Box::new(FsStore::open(&dir).unwrap())).unwrap();
assert_eq!((r.replayed, r.created), (2, false));
assert_eq!(c2.to_snapshot(), c.to_snapshot());
j.snapshot(&c2).unwrap();
assert_eq!(std::fs::metadata(dir.join(LOG)).unwrap().len(), 0);
assert!(!dir.join(format!("{SNAPSHOT}.tmp")).exists());
let (_, c3, r) = Journal::open(Box::new(FsStore::open(&dir).unwrap())).unwrap();
assert_eq!((r.snapshot_seq, r.replayed), (2, 0));
assert_eq!(c3.to_snapshot(), c.to_snapshot());
let _ = std::fs::remove_dir_all(&dir);
}

View File

@ -16,6 +16,7 @@ lightcraft-develop = { workspace = true }
lightcraft-scenes = { workspace = true }
lightcraft-pipeline = { workspace = true }
lightcraft-catalog = { workspace = true }
lightcraft-preview = { workspace = true }
lightcraft-codecs = { workspace = true }
lightcraft-raw = { workspace = true }
lightcraft-meta = { workspace = true }
@ -23,3 +24,4 @@ serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
ab_glyph = { workspace = true }

View File

@ -364,23 +364,94 @@ pub fn specs() -> Vec<CommandSpec> {
ok()
}),
// ---- import
cmd!("library.import", "Add Photos…", ["File"], Some("Cmd+Shift+I"), "{paths: [path], album?: albumId}", always, |s, p| {
let paths: Vec<String> = p
.get("paths")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect())
.unwrap_or_default();
if paths.is_empty() {
return Err(bad("library.import", "no paths"));
cmd!(
"library.import",
"Add Photos…",
["File"],
Some("Cmd+Shift+I"),
"{paths: [file or folder (recursive)], mode?: add|copy (copy into the library's Originals/), album?: albumId} → {imported, duplicates, failed}",
always,
|s, p| {
let paths: Vec<String> = p
.get("paths")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect())
.unwrap_or_default();
if paths.is_empty() {
return Err(bad("library.import", "no paths"));
}
let mode = match str_param(p, "mode").unwrap_or("add") {
"add" => crate::import::ImportMode::Add,
"copy" => crate::import::ImportMode::Copy,
other => return Err(bad("library.import", format!("unknown mode `{other}` (add|copy)"))),
};
let report = crate::import::import(s, &paths, mode)?;
if let Some(a) = p.get("album").and_then(Value::as_u64)
&& !report.imported.is_empty()
{
s.execute("album.addPhotos", &json!({"id": a, "ids": report.imported}))?;
}
if let Some(f) = report.imported.first() {
s.selection = Selection::single(PhotoId(*f));
}
Ok(serde_json::to_value(&report).unwrap_or_default())
}
let imported = crate::media::import_paths(s, &paths)?;
if let Some(a) = p.get("album").and_then(Value::as_u64) {
s.execute("album.addPhotos", &json!({"id": a, "ids": imported.iter().map(|i| i.0).collect::<Vec<_>>()}))?;
}
if let Some(f) = imported.first() {
s.selection = Selection::single(*f);
}
Ok(json!({"imported": imported.iter().map(|i| i.0).collect::<Vec<_>>()}))
),
// ---- persistence
cmd!(query "library.info", "Library Info", [], None, "{}", always, |s, _| {
let photos = s.catalog.len();
let albums = s.catalog.albums().count();
let (rendered_n, rendered_bytes) = s.media.rendered.mem_usage();
let (sources_n, sources_bytes) = s.media.source_usage();
let disk = s.media.rendered.disk().map(|d| {
use std::sync::atomic::Ordering::Relaxed;
json!({
"path": d.dir().display().to_string(),
"bytes": d.size(),
"hits": d.hits.load(Relaxed),
"misses": d.misses.load(Relaxed),
"writes": d.writes.load(Relaxed),
})
});
let cache = json!({
"renderedInMemory": rendered_n, "renderedBytes": rendered_bytes,
"sourcesInMemory": sources_n, "sourceBytes": sources_bytes,
"disk": disk,
});
let Some(lib) = &s.library else {
return Ok(json!({"persistent": false, "photos": photos, "albums": albums, "cache": cache}));
};
let j = lib.journal();
Ok(json!({
"persistent": true,
"path": lib.dir.display().to_string(),
"photos": photos,
"albums": albums,
"seq": j.seq(),
"snapshotSeq": j.snapshot_seq(),
"logRecords": j.log_records(),
"logBytes": j.log_bytes(),
"lastError": lib.last_error,
"cache": cache,
"load": {
"created": lib.report.created,
"replayed": lib.report.replayed,
"tornBytes": lib.report.torn_bytes,
"damaged": lib.report.damaged,
},
}))
}),
cmd!("library.compact", "Optimize Library", ["File"], None, "{}", has_library, |s, _| {
s.compact_library()?;
ok()
}),
cmd!("library.clearPreviews", "Clear Preview Cache", ["File"], None, "{}", always, |s, _| {
s.media.rendered.clear();
ok()
}),
]
}
fn has_library(s: &Session) -> std::result::Result<(), String> {
if s.library.is_some() { Ok(()) } else { Err("no library is open (in-memory session)".into()) }
}

519
crates/engine/src/export.rs Normal file
View File

@ -0,0 +1,519 @@
//! 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,
}
/// Where a watermark sits.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Anchor {
TopLeft,
Top,
TopRight,
Left,
Center,
Right,
BottomLeft,
Bottom,
#[default]
BottomRight,
}
/// A text watermark, sized relative to the image so every export size looks the same.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Watermark {
pub text: String,
/// Text height as a fraction of the image's short edge.
pub size: f32,
/// 0..1.
pub opacity: f32,
pub anchor: Anchor,
/// Margin as a fraction of the short edge.
pub inset: f32,
/// sRGB colour.
pub color: [u8; 3],
/// Soft dark drop shadow for legibility on bright areas.
pub shadow: bool,
}
impl Default for Watermark {
fn default() -> Self {
Self { text: String::new(), size: 0.035, opacity: 0.7, anchor: Anchor::BottomRight, inset: 0.025, color: [255; 3], shadow: true }
}
}
/// Inter SemiBold (OFL, see assets/ATTRIBUTION.md).
static WATERMARK_FONT: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf");
/// Draw `wm` onto `img` (straight sRGB alpha blending).
pub fn draw_watermark(img: &mut Rgba8, wm: &Watermark) {
use ab_glyph::{Font, FontRef, PxScale, ScaleFont, point};
let text = wm.text.trim();
if text.is_empty() || img.width == 0 || img.height == 0 {
return;
}
let Ok(font) = FontRef::try_from_slice(WATERMARK_FONT) else { return };
let short = img.width.min(img.height) as f32;
let px = (wm.size.clamp(0.005, 0.5) * short).max(6.0);
let sf = font.as_scaled(PxScale::from(px));
// Lay out one line.
let mut glyphs = Vec::new();
let mut x = 0.0f32;
let mut prev = None;
for ch in text.chars() {
let id = sf.glyph_id(ch);
if let Some(p) = prev {
x += sf.kern(p, id);
}
glyphs.push(id.with_scale_and_position(px, point(x, sf.ascent())));
x += sf.h_advance(id);
prev = Some(id);
}
let (tw, th) = (x, sf.ascent() - sf.descent());
let inset = wm.inset.clamp(0.0, 0.4) * short;
let (w, h) = (img.width as f32, img.height as f32);
use Anchor::*;
let ox = match wm.anchor {
TopLeft | Left | BottomLeft => inset,
Top | Center | Bottom => (w - tw) / 2.0,
TopRight | Right | BottomRight => w - inset - tw,
};
let oy = match wm.anchor {
TopLeft | Top | TopRight => inset,
Left | Center | Right => (h - th) / 2.0,
BottomLeft | Bottom | BottomRight => h - inset - th,
};
let alpha = wm.opacity.clamp(0.0, 1.0);
let mut blend = |gx: i32, gy: i32, cov: f32, col: [u8; 3], a: f32| {
if gx < 0 || gy < 0 || gx >= img.width as i32 || gy >= img.height as i32 {
return;
}
let k = (cov * a).clamp(0.0, 1.0);
let p = &mut img.data[gy as usize * img.width + gx as usize];
for c in 0..3 {
p[c] = (p[c] as f32 + (col[c] as f32 - p[c] as f32) * k).round() as u8;
}
};
let passes: &[(f32, [u8; 3], f32)] =
if wm.shadow { &[((px * 0.05).max(1.0), [0, 0, 0], 0.45), (0.0, wm.color, 1.0)] } else { &[(0.0, wm.color, 1.0)] };
for &(off, col, a) in passes {
for g in &glyphs {
if let Some(o) = font.outline_glyph(g.clone()) {
let b = o.px_bounds();
o.draw(|gx, gy, cov| blend((ox + off + b.min.x) as i32 + gx as i32, (oy + off + b.min.y) as i32 + gy as i32, cov, col, a * alpha));
}
}
}
}
/// 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,
/// Text watermark (none when absent or the text is empty).
pub watermark: Option<Watermark>,
}
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,
watermark: None,
}
}
}
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),
watermark: match p.get("watermark") {
Some(Value::String(t)) => Some(Watermark { text: t.clone(), ..Default::default() }),
Some(v @ Value::Object(_)) => serde_json::from_value(v.clone()).ok(),
_ => None,
}
.filter(|w: &Watermark| !w.text.trim().is_empty()),
}
}
/// 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);
if let Some(wm) = &o.watermark {
draw_watermark(&mut img, wm);
}
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 watermark_draws_in_the_anchored_corner_only() {
let mut img = Rgba8::new(400, 300);
for p in img.data.iter_mut() {
*p = [0, 0, 0, 255];
}
let o = ExportOptions::from_json(&serde_json::json!({"watermark": {"text": "LightCraft", "size": 0.08, "opacity": 1.0, "shadow": false}}));
let wm = o.watermark.clone().unwrap();
draw_watermark(&mut img, &wm);
let lit = |x0: usize, x1: usize, y0: usize, y1: usize| {
(y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|&(x, y)| img.get(x, y)[0] > 128).count()
};
assert!(lit(200, 400, 225, 300) > 100, "bottom-right has text");
assert!(lit(0, 200, 0, 150) == 0, "top-left untouched");
// string shorthand
assert_eq!(ExportOptions::from_json(&serde_json::json!({"watermark": "© Me"})).watermark.unwrap().text, "© Me");
assert!(ExportOptions::from_json(&serde_json::json!({"watermark": ""})).watermark.is_none());
}
#[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");
}
}

View File

@ -40,6 +40,7 @@ fn ext_upper(name: &str) -> String {
/// Probe a file's bytes: kind, dimensions (oriented), metadata.
pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
let content_hash = Some(lightcraft_preview::hash_bytes(bytes).to_string());
let m = lightcraft_meta::extract(bytes);
let (meta, captured) = meta_of(&m);
if lightcraft_raw::probe(bytes).is_some() {
@ -62,6 +63,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
captured,
meta,
as_shot_wb,
content_hash,
});
}
let fmt = lightcraft_codecs::sniff(bytes).ok_or("unrecognized file format")?;
@ -79,7 +81,17 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
"" => format!("{fmt:?}").to_uppercase(),
e => e.to_string(),
};
Ok(ProbeInfo { width: w, height: h, format, kind: MediaKind::Image, file_size: bytes.len() as u64, captured, meta, as_shot_wb: None })
Ok(ProbeInfo {
width: w,
height: h,
format,
kind: MediaKind::Image,
file_size: bytes.len() as u64,
captured,
meta,
as_shot_wb: None,
content_hash,
})
}
/// Decode a file into a linear Rec.2020 image no larger than `max_edge`, oriented.

267
crates/engine/src/import.rs Normal file
View File

@ -0,0 +1,267 @@
//! Import: files and folders (recursive) → photos.
//!
//! 1. Expand folders recursively into supported files (hidden entries and the library's own
//! folder are skipped), sorted for a stable order.
//! 2. Skip paths already in the catalog.
//! 3. Probe the rest — dimensions, metadata, and the **content hash** of the bytes — in parallel
//! on native targets.
//! 4. Skip **duplicates by content** (same bytes already in the library, or twice in this batch).
//! 5. *Add* in place (the photo points at the original file) or *copy* into the library's
//! `Originals/YYYY/YYYY-MM-DD/` folder (names made unique) and point at the copy.
//! 6. Commit all new photos as one undoable op.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
use serde::Serialize;
use crate::Session;
use crate::media::ProbeInfo;
/// File extensions LightCraft imports (lower case).
pub const EXTENSIONS: &[&str] = &[
"jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "pef", "psd", "jxl", "gif", "bmp",
"heic", "avif",
];
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ImportMode {
/// Reference the files where they are.
#[default]
Add,
/// Copy the files into the library's `Originals/` folder.
Copy,
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct Duplicate {
pub path: String,
/// The photo that already has these bytes (or this path).
pub existing: Option<u64>,
/// `"path"` (already imported from there) or `"content"` (same bytes elsewhere).
pub reason: &'static str,
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct ImportReport {
pub imported: Vec<u64>,
pub duplicates: Vec<Duplicate>,
/// (path, error)
pub failed: Vec<(String, String)>,
/// Files found after expanding folders.
pub scanned: usize,
}
pub fn is_supported(path: &Path) -> bool {
path.extension().is_some_and(|e| EXTENSIONS.contains(&e.to_string_lossy().to_lowercase().as_str()))
}
/// Expand files and folders (recursively) into supported files. `skip` (e.g. the library folder)
/// is never descended into.
pub fn expand(paths: &[String], skip: Option<&Path>) -> Vec<String> {
fn walk(p: &Path, skip: Option<&Path>, out: &mut Vec<String>, top: bool) {
let hidden = p.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.'));
if (hidden && !top) || skip.is_some_and(|s| p == s) {
return;
}
if p.is_dir() {
let Ok(rd) = std::fs::read_dir(p) else { return };
let mut v: Vec<PathBuf> = rd.flatten().map(|e| e.path()).collect();
v.sort();
for c in v {
walk(&c, skip, out, false);
}
} else if top || is_supported(p) {
// explicitly named files are attempted even with an unknown extension (sniffed)
out.push(p.to_string_lossy().to_string());
}
}
let mut out = Vec::new();
for p in paths {
walk(Path::new(p), skip, &mut out, true);
}
let mut seen = std::collections::HashSet::new();
out.retain(|p| seen.insert(p.clone()));
out
}
fn probe_all(s: &Session, paths: &[String]) -> Vec<Result<ProbeInfo, String>> {
let Some(probe) = s.media.file_probe.clone() else {
return paths
.iter()
.map(|p| {
Ok(ProbeInfo {
format: Path::new(p).extension().map(|e| e.to_string_lossy().to_uppercase()).unwrap_or_default(),
..Default::default()
})
})
.collect();
};
#[cfg(not(target_arch = "wasm32"))]
{
let n = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(1, 8).min(paths.len().max(1));
if n > 1 {
let mut results: Vec<Option<Result<ProbeInfo, String>>> = vec![None; paths.len()];
let next = std::sync::atomic::AtomicUsize::new(0);
let out = std::sync::Mutex::new(&mut results);
std::thread::scope(|sc| {
for _ in 0..n {
sc.spawn(|| {
loop {
let i = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if i >= paths.len() {
break;
}
let r = probe(&paths[i]);
out.lock().unwrap_or_else(|e| e.into_inner())[i] = Some(r);
}
});
}
});
return results.into_iter().map(|r| r.unwrap_or_else(|| Err("not probed".into()))).collect();
}
}
paths.iter().map(|p| probe(p)).collect()
}
/// `Originals/YYYY/YYYY-MM-DD/name`, made unique.
fn copy_into_library(lib: &Path, src: &str, date: &str) -> Result<String, String> {
let day = date.get(..10).filter(|d| d.len() == 10).unwrap_or("undated");
let year = day.get(..4).unwrap_or("undated");
let dir = lib.join("Originals").join(year).join(day);
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let name = Path::new(src).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "photo".into());
let (stem, ext) = match name.rsplit_once('.') {
Some((s, e)) => (s.to_string(), format!(".{e}")),
None => (name.clone(), String::new()),
};
let mut dst = dir.join(&name);
let mut i = 1;
while dst.exists() {
dst = dir.join(format!("{stem}-{i}{ext}"));
i += 1;
}
std::fs::copy(src, &dst).map_err(|e| format!("copy {src}: {e}"))?;
Ok(dst.to_string_lossy().to_string())
}
/// Import files/folders. See the module docs.
pub fn import(s: &mut Session, paths: &[String], mode: ImportMode) -> crate::Result<ImportReport> {
let lib_dir = s.library.as_ref().map(|l| l.dir.clone());
if mode == ImportMode::Copy && lib_dir.is_none() {
return Err(crate::EngineError::Other("copying into the library needs an open library".into()));
}
let files = expand(paths, lib_dir.as_deref());
let mut report = ImportReport { scanned: files.len(), ..Default::default() };
// existing paths and content hashes
let mut by_path: HashMap<&str, PhotoId> = HashMap::new();
let mut by_hash: HashMap<String, PhotoId> = HashMap::new();
for p in s.catalog.photos() {
if let Source::File { path } = &p.source {
by_path.insert(path.as_str(), p.id);
}
if let Some(h) = &p.content_hash {
by_hash.insert(h.clone(), p.id);
}
}
let mut todo = Vec::new();
for f in files {
match by_path.get(f.as_str()) {
Some(id) => report.duplicates.push(Duplicate { path: f, existing: Some(id.0), reason: "path" }),
None => todo.push(f),
}
}
let probed = probe_all(s, &todo);
let now = (s.clock)();
let mut ops = Vec::new();
for (path, info) in todo.into_iter().zip(probed) {
let info = match info {
Ok(i) => i,
Err(e) => {
log::warn!("import {path}: {e}");
report.failed.push((path, e));
continue;
}
};
if let Some(h) = &info.content_hash
&& let Some(id) = by_hash.get(h)
{
report.duplicates.push(Duplicate { path, existing: Some(id.0), reason: "content" });
continue;
}
let stored = match (mode, &lib_dir) {
(ImportMode::Copy, Some(lib)) if !Path::new(&path).starts_with(lib) => {
match copy_into_library(lib, &path, info.captured.as_deref().unwrap_or(&now)) {
Ok(p) => p,
Err(e) => {
report.failed.push((path, e));
continue;
}
}
}
_ => path.clone(),
};
let id = s.catalog.alloc_photo_id();
if let Some(h) = &info.content_hash {
by_hash.insert(h.clone(), id);
}
let name = Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.clone());
let mut p = Photo::new(id, Source::File { path: stored }, &name, &info.format, info.width, info.height, &now);
p.kind = info.kind;
p.file_size = info.file_size;
p.captured = info.captured;
p.meta = info.meta;
p.as_shot_wb = info.as_shot_wb;
p.content_hash = info.content_hash;
if let Some((t, tint)) = info.as_shot_wb {
p.develop = std::sync::Arc::new(lightcraft_develop::DevelopSettings::for_raw(t, tint));
}
report.imported.push(id.0);
ops.push(Op::AddPhoto { photo: Box::new(p) });
}
if !ops.is_empty() {
s.commit(&format!("Add {} Photo{}", ops.len(), if ops.len() == 1 { "" } else { "s" }), Op::Batch { ops })?;
}
Ok(report)
}
/// The current local time as ISO 8601 (`YYYY-MM-DDTHH:MM:SS`, UTC on targets without a clock
/// offset); for [`Session::clock`] on native hosts.
pub fn system_clock() -> String {
#[cfg(not(target_arch = "wasm32"))]
{
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0);
civil(secs)
}
#[cfg(target_arch = "wasm32")]
{
"2026-01-01T00:00:00".to_string()
}
}
/// Unix seconds → `YYYY-MM-DDTHH:MM:SS` (UTC), proleptic Gregorian (H. Hinnant's algorithm).
pub fn civil(secs: i64) -> String {
let days = secs.div_euclid(86_400);
let rem = secs.rem_euclid(86_400);
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + i64::from(m <= 2);
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}", rem / 3600, rem % 3600 / 60, rem % 60)
}
impl Session {
/// Use the system clock for import/edit times (native hosts).
pub fn with_system_clock(mut self) -> Self {
self.clock = Box::new(system_clock);
self
}
}

View File

@ -12,7 +12,10 @@
pub mod cmd;
pub mod demo;
pub mod export;
pub mod files;
pub mod import;
pub mod library;
pub mod media;
pub mod presets;
mod view;
@ -85,6 +88,8 @@ pub struct Session {
depth: u32,
/// Selected mask (Masking panel), by mask id.
pub active_mask: Option<u32>,
/// The persistent library this session writes to (`None` = in-memory only).
pub library: Option<library::Library>,
}
impl Default for Session {
@ -115,6 +120,7 @@ impl Session {
clock: Box::new(|| "2026-09-30T12:00:00".to_string()),
depth: 0,
active_mask: None,
library: None,
}
}
@ -140,6 +146,11 @@ impl Session {
self.journal.drain(..1000);
}
}
if self.depth == 0 && self.library.is_some() {
// Make the command durable before reporting success (on failure the ops stay pending,
// are retried after the next command, and `library.info` reports the error).
let _ = self.persist();
}
r
}
@ -216,15 +227,11 @@ impl Session {
/// The op that sets a photo's develop settings and appends a History entry (for batches).
pub fn develop_op(&self, id: PhotoId, settings: DevelopSettings, label: &str) -> Option<Op> {
let p = self.catalog.photo(id)?;
self.catalog.photo(id)?;
let settings = Arc::new(settings);
let mut history = p.history.clone();
history.push(lightcraft_catalog::HistoryStep { label: label.into(), settings: settings.clone() });
if history.len() > 200 {
history.remove(0);
}
let step = lightcraft_catalog::HistoryStep { label: label.into(), settings: settings.clone() };
Some(Op::Batch {
ops: vec![Op::SetDevelop { id, settings, label: label.into(), edited: Some((self.clock)()) }, Op::SetHistory { id, history }],
ops: vec![Op::SetDevelop { id, settings, label: label.into(), edited: Some((self.clock)()) }, Op::PushHistory { id, step }],
})
}
@ -300,3 +307,7 @@ impl Session {
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_import;
#[cfg(test)]
mod tests_library;

View File

@ -0,0 +1,203 @@
//! A persistent library: a directory holding the catalog (op log + snapshot), user presets, the
//! last view state and the preview cache.
//!
//! ```text
//! LightCraft Library/
//! catalog.snap catalog.log (lightcraft-catalog journal)
//! presets.json view.json (user presets + favourites; last source/filter/sort/selection)
//! thumbs/ (rendered thumbnail cache, safe to delete)
//! Originals/ (photos imported with "copy into library")
//! ```
//!
//! Every top-level [`Session::execute`] persists the ops it produced (fsynced) before returning,
//! so a crash loses at most the command in flight. The log is compacted into a snapshot when it
//! grows (see [`lightcraft_catalog::SnapshotPolicy`]) and on [`Session::close_library`].
use std::path::{Path, PathBuf};
use lightcraft_catalog::{FsStore, Journal, LoadReport, Store};
use lightcraft_develop::Preset;
use serde::{Deserialize, Serialize};
use crate::{EngineError, LibrarySource, Result, Selection, Session};
/// Library directory name inside the user's Pictures folder.
pub const DEFAULT_NAME: &str = "LightCraft Library";
/// The default library location: `$LIGHTCRAFT_LIBRARY` if set, else `~/Pictures/LightCraft Library`
/// (`%USERPROFILE%\Pictures\LightCraft Library` on Windows).
pub fn default_dir() -> Option<PathBuf> {
if let Some(p) = std::env::var_os("LIGHTCRAFT_LIBRARY").filter(|p| !p.is_empty()) {
return Some(PathBuf::from(p));
}
let home = if cfg!(windows) { std::env::var_os("USERPROFILE") } else { std::env::var_os("HOME") }?;
Some(PathBuf::from(home).join("Pictures").join(DEFAULT_NAME))
}
pub struct Library {
pub dir: PathBuf,
journal: Journal,
/// What happened when the catalog was loaded.
pub report: LoadReport,
/// Last persistence error (shown by the UI; ops stay pending and are retried).
pub last_error: Option<String>,
presets_written: String,
}
#[derive(Default, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
struct PresetsFile {
user: Vec<Preset>,
favorites: Vec<String>,
}
#[derive(Default, Serialize, Deserialize)]
#[serde(default)]
struct ViewFile {
source: LibrarySource,
filter: lightcraft_catalog::Filter,
sort: lightcraft_catalog::Sort,
selection: Selection,
}
impl Library {
pub fn journal(&self) -> &Journal {
&self.journal
}
pub fn thumbs_dir(&self) -> PathBuf {
self.dir.join("thumbs")
}
pub fn originals_dir(&self) -> PathBuf {
self.dir.join("Originals")
}
}
fn presets_json(presets: &[Preset]) -> String {
let f = PresetsFile {
user: presets.iter().filter(|p| !p.builtin).cloned().collect(),
favorites: presets.iter().filter(|p| p.builtin && p.favorite).map(|p| p.id.clone()).collect(),
};
serde_json::to_string_pretty(&f).unwrap_or_default()
}
fn write_atomic(dir: &Path, name: &str, data: &[u8]) -> std::io::Result<()> {
FsStore::open(dir)?.write_atomic(name, data)
}
impl Session {
/// Open (or create) the library at `dir` into this session, replacing its catalog. With
/// `seed_demo`, a newly created library starts with the procedural demo photos.
pub fn open_library(&mut self, dir: impl AsRef<Path>, seed_demo: bool) -> Result<&LoadReport> {
let dir = dir.as_ref().to_path_buf();
let store = FsStore::open(&dir).map_err(|e| EngineError::Other(format!("can't open library {}: {e}", dir.display())))?;
let (mut journal, catalog, report) = Journal::open(Box::new(store))?;
self.catalog = catalog;
self.undo.clear();
self.redo.clear();
self.interaction = None;
self.pending_log.clear();
self.selection = Selection::default();
self.source = LibrarySource::All;
if report.created && seed_demo {
crate::demo::load(self);
journal.snapshot(&self.catalog)?;
}
// presets
if let Ok(bytes) = std::fs::read(dir.join("presets.json"))
&& let Ok(f) = serde_json::from_slice::<PresetsFile>(&bytes)
{
for p in &mut self.presets {
p.favorite = p.builtin && f.favorites.contains(&p.id) || (!p.builtin && p.favorite);
}
for u in f.user {
if !self.presets.iter().any(|p| p.id == u.id) {
self.presets.push(u);
}
}
}
// view state
if let Ok(bytes) = std::fs::read(dir.join("view.json"))
&& let Ok(v) = serde_json::from_slice::<ViewFile>(&bytes)
{
self.source = v.source;
self.filter = v.filter;
self.sort = v.sort;
self.selection = v.selection;
self.selection.ids.retain(|id| self.catalog.photo(*id).is_some());
self.selection.active = self.selection.active.filter(|id| self.catalog.photo(*id).is_some());
}
if self.selection.active.is_none()
&& let Some(first) = self.visible_cloned().first()
{
self.selection = Selection::single(*first);
}
let presets_written = presets_json(&self.presets);
self.media.attach_disk_cache(&dir.join("thumbs"));
self.library = Some(Library { dir, journal, report, last_error: None, presets_written });
Ok(&self.library.as_ref().expect("just set").report)
}
/// Write pending ops to the log (fsynced), compact when due, and save changed presets.
/// Called after every top-level command; cheap when nothing changed.
pub fn persist(&mut self) -> Result<()> {
let Some(lib) = self.library.as_mut() else { return Ok(()) };
if !self.pending_log.is_empty() {
if let Err(e) = lib.journal.append(&self.pending_log) {
lib.last_error = Some(e.to_string());
log::error!("library: {e}");
return Err(e.into());
}
self.pending_log.clear();
lib.last_error = None;
}
// Never snapshot mid-interaction: the catalog then holds an uncommitted preview value.
if lib.journal.wants_snapshot() && self.interaction.is_none() {
lib.journal.snapshot(&self.catalog)?;
}
let presets = presets_json(&self.presets);
if presets != lib.presets_written {
if let Err(e) = write_atomic(&lib.dir, "presets.json", presets.as_bytes()) {
log::error!("library: presets: {e}");
} else {
lib.presets_written = presets;
}
}
Ok(())
}
/// Persist if commands left ops pending (cheap; frontends call it once per frame for state
/// changed outside [`Session::execute`]).
pub fn persist_if_dirty(&mut self) {
if self.library.is_some() && !self.pending_log.is_empty() {
let _ = self.persist();
}
}
/// Flush everything and write a snapshot (on quit). Ends an open interaction first.
pub fn close_library(&mut self) -> Result<()> {
if self.library.is_none() {
return Ok(());
}
let _ = self.end_interaction();
self.persist()?;
let view = ViewFile { source: self.source, filter: self.filter.clone(), sort: self.sort, selection: self.selection.clone() };
let Some(lib) = self.library.as_mut() else { return Ok(()) };
lib.journal.snapshot(&self.catalog)?;
if let Ok(v) = serde_json::to_vec_pretty(&view) {
let _ = write_atomic(&lib.dir, "view.json", &v);
}
Ok(())
}
/// Compact the log into a snapshot now.
pub fn compact_library(&mut self) -> Result<()> {
self.persist()?;
if self.interaction.is_some() {
return Err(EngineError::Other("can't compact during an interaction".into()));
}
if let Some(lib) = self.library.as_mut() {
lib.journal.snapshot(&self.catalog)?;
}
Ok(())
}
}

View File

@ -1,19 +1,36 @@
//! Source proxies and render jobs.
//!
//! Sources are decoded (or generated) once per level and cached: `Thumb` (≤ 512 px long edge) for
//! the grid and filmstrip, `Preview` (≤ 2560 px) for the loupe. A [`RenderJob`] is self-contained
//! and `Send`: frontends run it on a worker thread; if the source wasn't cached yet the job loads it
//! and hands it back in the [`RenderResult`] so the cache can keep it.
//! Sources are decoded (or generated) once per level and cached in memory LRUs: `Thumb` (≤ 512 px
//! long edge) for the grid and filmstrip, `Preview` (≤ 2560 px) for the loupe. A [`RenderJob`] is
//! self-contained and `Send`: frontends run it on a worker thread; if the source wasn't cached yet
//! the job loads it and hands it back in the [`RenderResult`] so the cache can keep it.
//!
//! Rendered thumbnails are cached too ([`lightcraft_preview::PreviewCache`]: memory LRU, plus a
//! disk cache in the library's `thumbs/` folder), keyed by the photo's content hash, the develop
//! settings hash, the size and [`RENDER_CACHE_VERSION`] — so reopening a library shows its grid
//! without decoding a single original, and an edit simply produces a new key.
use std::collections::HashMap;
use std::sync::Arc;
use lightcraft_catalog::{MediaKind, Photo, PhotoId, Source};
use lightcraft_develop::DevelopSettings;
use lightcraft_pipeline::{RenderRequest, Rendered, SourceInfo};
use lightcraft_raster::Rgb32f;
use lightcraft_preview::{Hash128, Hasher128, Lru, PreviewCache};
use lightcraft_raster::{Histogram, Rgb32f};
use serde::{Deserialize, Serialize};
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
pub const RENDER_CACHE_VERSION: u64 = 2;
/// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache).
pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512];
/// Memory budgets.
const THUMB_SOURCE_BYTES: usize = 384 << 20;
const RENDERED_MEM_BYTES: usize = 128 << 20;
/// Disk budget for the thumbnail cache.
pub const DISK_CACHE_BYTES: u64 = 2 << 30;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SourceLevel {
@ -57,19 +74,40 @@ impl SourceRef {
}
}
#[derive(Default)]
pub struct MediaCache {
thumbs: HashMap<PhotoId, Arc<Rgb32f>>,
/// Decoded thumbnail-level sources (LRU by bytes).
thumbs: Lru<PhotoId, Arc<Rgb32f>>,
previews: Vec<(PhotoId, Arc<Rgb32f>)>,
/// How many previews to keep (LRU).
pub preview_capacity: usize,
pub file_loader: Option<FileLoader>,
pub file_probe: Option<FileProbe>,
scenes: Vec<lightcraft_scenes::Scene>,
/// Rendered thumbnails (memory, plus disk once a library is attached).
pub rendered: Arc<PreviewCache>,
}
impl Default for MediaCache {
fn default() -> Self {
MediaCache {
thumbs: Lru::new(THUMB_SOURCE_BYTES),
previews: Vec::new(),
preview_capacity: 0,
file_loader: None,
file_probe: None,
scenes: Vec::new(),
rendered: Arc::new(PreviewCache::memory(RENDERED_MEM_BYTES)),
}
}
}
impl MediaCache {
pub fn get(&self, id: PhotoId, level: SourceLevel) -> Option<Arc<Rgb32f>> {
/// Keep rendered thumbnails on disk in `dir` as well.
pub fn attach_disk_cache(&mut self, dir: &std::path::Path) {
self.rendered = Arc::new(PreviewCache::with_disk(RENDERED_MEM_BYTES, dir, DISK_CACHE_BYTES));
}
pub fn get(&mut self, id: PhotoId, level: SourceLevel) -> Option<Arc<Rgb32f>> {
match level {
SourceLevel::Thumb => self.thumbs.get(&id).cloned(),
SourceLevel::Preview => self.previews.iter().find(|(p, _)| *p == id).map(|(_, a)| a.clone()),
@ -79,7 +117,8 @@ impl MediaCache {
pub fn insert(&mut self, id: PhotoId, level: SourceLevel, img: Arc<Rgb32f>) {
match level {
SourceLevel::Thumb => {
self.thumbs.insert(id, img);
let cost = img.width * img.height * 12 + 64;
self.thumbs.insert(id, img, cost);
}
SourceLevel::Preview => {
self.previews.retain(|(p, _)| *p != id);
@ -92,6 +131,17 @@ impl MediaCache {
}
}
/// Forget a photo's decoded sources (e.g. after its file changed).
pub fn forget(&mut self, id: PhotoId) {
self.thumbs.remove(&id);
self.previews.retain(|(p, _)| *p != id);
}
/// (decoded thumbnail sources, bytes).
pub fn source_usage(&self) -> (usize, usize) {
(self.thumbs.len(), self.thumbs.cost())
}
pub fn source_ref(&mut self, p: &Photo, level: SourceLevel) -> SourceRef {
if let Some(a) = self.get(p.id, level) {
return SourceRef::Loaded(a);
@ -122,6 +172,8 @@ pub struct RenderJob {
pub request: RenderRequest,
/// Identifies the result for caching: hash of settings + request.
pub key: u64,
/// Rendered-thumbnail cache and this job's key in it.
pub cache: Option<(Arc<PreviewCache>, Hash128)>,
}
pub struct RenderResult {
@ -135,10 +187,20 @@ pub struct RenderResult {
impl RenderJob {
pub fn run(self) -> RenderResult {
if let Some((cache, key)) = &self.cache
&& let Some(img) = cache.get(*key)
{
let image = Arc::unwrap_or_clone(img);
let histogram = Histogram::of_srgb8(&image);
return RenderResult { photo: self.photo, level: self.level, key: self.key, rendered: Ok(Rendered { image, histogram }), loaded: None };
}
let was_loaded = matches!(self.source, SourceRef::Loaded(_));
match self.source.load() {
Ok(src) => {
let rendered = lightcraft_pipeline::render(&src, &self.info, &self.settings, &self.request);
if let Some((cache, key)) = &self.cache {
cache.put(*key, Arc::new(rendered.image.clone()));
}
RenderResult { photo: self.photo, level: self.level, key: self.key, rendered: Ok(rendered), loaded: (!was_loaded).then_some(src) }
}
Err(e) => RenderResult { photo: self.photo, level: self.level, key: self.key, rendered: Err(e), loaded: None },
@ -146,6 +208,15 @@ impl RenderJob {
}
}
/// What identifies a photo's pixels for caching: its content hash, else its source.
pub fn content_key(p: &Photo) -> String {
match (&p.content_hash, &p.source) {
(Some(h), _) => h.clone(),
(None, Source::Demo { scene }) => format!("demo:{scene}"),
(None, Source::File { path }) => format!("file:{path}:{}", p.file_size),
}
}
pub fn source_info(p: &Photo) -> SourceInfo {
// Procedural demo scenes are scene-referred HDR (like raw files): use the filmic tone map.
if matches!(p.source, Source::Demo { .. }) {
@ -157,6 +228,25 @@ pub fn source_info(p: &Photo) -> SourceInfo {
impl crate::Session {
/// Build a render job for `id` fitting `max_w × max_h`. `before` renders the unedited look.
pub fn render_job(&mut self, id: PhotoId, max_w: usize, max_h: usize, before: bool, apply_crop: bool) -> Option<RenderJob> {
self.build_job(id, max_w, max_h, before, apply_crop, None)
}
/// A grid/filmstrip thumbnail job: the long edge is rounded up to one of [`THUMB_SIZES`]
/// (≥ `long_edge`) and the result comes from / goes to the thumbnail cache (memory + disk).
pub fn thumb_job(&mut self, id: PhotoId, long_edge: usize) -> Option<RenderJob> {
let b = THUMB_SIZES.iter().copied().find(|s| *s >= long_edge).unwrap_or(THUMB_SIZES[THUMB_SIZES.len() - 1]);
self.build_job(id, b, b, false, true, Some(b))
}
fn build_job(
&mut self,
id: PhotoId,
max_w: usize,
max_h: usize,
before: bool,
apply_crop: bool,
thumb_bucket: Option<usize>,
) -> Option<RenderJob> {
let p = self.catalog.photo(id)?.clone();
let level = SourceLevel::for_size(max_w.max(max_h));
let source = self.media.source_ref(&p, level);
@ -167,7 +257,11 @@ impl crate::Session {
^ ((max_h as u64) << 20)
^ (apply_crop as u64)
^ ((level == SourceLevel::Preview) as u64) << 60;
Some(RenderJob { photo: id, level, source, info: source_info(&p), settings, request, key })
let cache = thumb_bucket.map(|b| {
let k = Hasher128::new().str(&content_key(&p)).u64(settings.hash64()).u64(b as u64).u64(RENDER_CACHE_VERSION).finish();
(self.media.rendered.clone(), k)
});
Some(RenderJob { photo: id, level, source, info: source_info(&p), settings, request, key, cache })
}
/// Accept a finished job's loaded source into the cache.
@ -205,49 +299,8 @@ pub struct ProbeInfo {
pub captured: Option<String>,
pub meta: lightcraft_catalog::Meta,
pub as_shot_wb: Option<(f64, f64)>,
/// Hash of the file's bytes (hex), for duplicate detection.
pub content_hash: Option<String>,
}
pub type FileProbe = Arc<dyn Fn(&str) -> Result<ProbeInfo, String> + Send + Sync>;
/// Register files as photos (duplicates by path are skipped). Returns the new ids.
pub fn import_paths(s: &mut crate::Session, paths: &[String]) -> crate::Result<Vec<PhotoId>> {
let now = (s.clock)();
let mut ops = Vec::new();
let mut ids = Vec::new();
for path in paths {
let exists = s.catalog.photos().any(|p| matches!(&p.source, Source::File { path: q } if q == path));
if exists {
continue;
}
let info = match &s.media.file_probe {
Some(probe) => match probe(path) {
Ok(i) => i,
Err(e) => {
log::warn!("import {path}: {e}");
continue;
}
},
None => ProbeInfo {
format: std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_uppercase()).unwrap_or_default(),
..Default::default()
},
};
let id = s.catalog.alloc_photo_id();
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.clone());
let mut p = Photo::new(id, Source::File { path: path.clone() }, &name, &info.format, info.width, info.height, &now);
p.kind = info.kind;
p.file_size = info.file_size;
p.captured = info.captured;
p.meta = info.meta;
p.as_shot_wb = info.as_shot_wb;
if let Some((t, tint)) = info.as_shot_wb {
p.develop = std::sync::Arc::new(lightcraft_develop::DevelopSettings::for_raw(t, tint));
}
ids.push(id);
ops.push(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) });
}
if !ops.is_empty() {
s.commit("Add Photos", lightcraft_catalog::Op::Batch { ops })?;
}
Ok(ids)
}

View File

@ -0,0 +1,103 @@
//! Import: recursive folders, duplicate detection by content, add vs copy into the library.
use std::path::Path;
use serde_json::{Value, json};
use crate::Session;
fn temp_dir(tag: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("lc-import-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
/// A small procedural PNG (distinct per seed).
fn write_png(path: &Path, seed: u8) {
let (w, h) = (48usize, 32usize);
let data: Vec<[u8; 4]> = (0..w * h).map(|i| [(i % w * 5) as u8, (i / w * 7) as u8, seed, 255]).collect();
let img = lightcraft_raster::Rgba8 { width: w, height: h, data };
let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, bytes).unwrap();
}
fn ids(v: &Value, key: &str) -> usize {
v[key].as_array().map(Vec::len).unwrap_or(0)
}
#[test]
fn recursive_import_with_duplicates() {
let src = temp_dir("src");
write_png(&src.join("a.png"), 1);
write_png(&src.join("trip/b.png"), 2);
write_png(&src.join("trip/day2/c.PNG"), 3);
std::fs::copy(src.join("a.png"), src.join("trip/a-copy.png")).unwrap(); // same bytes
write_png(&src.join(".hidden/x.png"), 9);
std::fs::write(src.join("notes.txt"), "not a photo").unwrap();
std::fs::write(src.join("trip/broken.jpg"), "garbage").unwrap();
let mut s = Session::new().with_fs();
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
assert_eq!(r["scanned"], 5, "{r}"); // a, b, c, a-copy, broken (hidden + txt skipped)
assert_eq!(ids(&r, "imported"), 3, "{r}");
assert_eq!(ids(&r, "duplicates"), 1, "{r}");
assert_eq!(r["duplicates"][0]["reason"], "content");
assert_eq!(ids(&r, "failed"), 1, "{r}");
assert_eq!(s.catalog.len(), 3);
assert!(s.catalog.photos().all(|p| p.content_hash.as_ref().is_some_and(|h| h.len() == 32) && p.width == 48));
// importing again: everything is a duplicate (by path), one undo step for the first import
let r2 = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
assert_eq!(ids(&r2, "imported"), 0);
assert_eq!(r2["duplicates"].as_array().unwrap().iter().filter(|d| d["reason"] == "path").count(), 3);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.catalog.len(), 0);
let _ = std::fs::remove_dir_all(&src);
}
#[test]
fn copy_into_library_and_persist() {
let src = temp_dir("copysrc");
let lib = temp_dir("copylib");
write_png(&src.join("one.png"), 4);
write_png(&src.join("sub/one.png"), 5); // same name, different content
let mut s = Session::new().with_fs();
assert!(s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "mode": "copy"})).is_err(), "copy needs a library");
s.open_library(&lib, false).unwrap();
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "mode": "copy"})).unwrap();
assert_eq!(ids(&r, "imported"), 2, "{r}");
let paths: Vec<String> = s
.catalog
.photos()
.map(|p| match &p.source {
lightcraft_catalog::Source::File { path } => path.clone(),
_ => panic!(),
})
.collect();
for p in &paths {
assert!(Path::new(p).starts_with(lib.join("Originals")), "{p}");
assert!(Path::new(p).exists());
}
assert_ne!(paths[0], paths[1], "unique names");
// originals deleted from the source: the library copies remain usable after a restart
std::fs::remove_dir_all(&src).unwrap();
drop(s);
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
assert_eq!(s.catalog.len(), 2);
let id = s.catalog.photos().next().unwrap().id;
assert!(s.render_now(id, 32, 32).is_ok());
// the library folder itself is never re-imported
let r = s.execute("library.import", &json!({"paths": [lib.to_string_lossy()]})).unwrap();
assert_eq!(r["scanned"], 0, "{r}");
let _ = std::fs::remove_dir_all(&lib);
}
#[test]
fn civil_dates() {
assert_eq!(crate::import::civil(0), "1970-01-01T00:00:00");
assert_eq!(crate::import::civil(951_782_400), "2000-02-29T00:00:00");
assert_eq!(crate::import::civil(1_790_000_000), "2026-09-21T14:13:20");
}

View File

@ -0,0 +1,128 @@
//! Persistent library: commands survive a restart (with and without a clean close).
use serde_json::json;
use crate::Session;
fn temp_dir(tag: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("lc-engine-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
d
}
fn open(dir: &std::path::Path, seed: bool) -> Session {
let mut s = Session::new();
s.open_library(dir, seed).unwrap();
s
}
#[test]
fn edits_survive_restart_without_close() {
let dir = temp_dir("crash");
let mut s = open(&dir, true);
assert!(s.library.as_ref().unwrap().report.created);
assert!(s.catalog.len() > 5, "seeded demo");
let id = s.selection.active.unwrap();
s.execute("photo.rate", &json!({"rating": 5})).unwrap();
s.execute("photo.flag", &json!({"flag": "reject"})).unwrap();
s.execute("develop.set", &json!({"control": "light.exposure", "value": 1.25})).unwrap();
// a slider drag: one undo step, logged once at the end
s.execute("develop.beginInteraction", &json!({"label": "Contrast"})).unwrap();
for v in [5, 10, 20, 33] {
s.execute("develop.set", &json!({"control": "light.contrast", "value": v})).unwrap();
}
s.execute("develop.endInteraction", &json!({})).unwrap();
s.execute("album.create", &json!({"name": "Keepers", "addSelected": true})).unwrap();
s.execute("preset.create", &json!({"name": "Bright"})).unwrap();
s.execute("edit.undo", &json!({})).ok();
s.execute("edit.redo", &json!({})).ok();
let expect = s.catalog.to_snapshot();
let info = s.execute("library.info", &json!({})).unwrap();
assert!(info["logRecords"].as_u64().unwrap() >= 5, "{info}");
drop(s); // no close: like a crash
let s2 = open(&dir, true);
let r = &s2.library.as_ref().unwrap().report;
assert!(!r.created && r.replayed >= 5, "{r:?}");
assert_eq!(s2.catalog.to_snapshot(), expect);
let p = s2.catalog.photo(id).unwrap();
assert_eq!((p.rating, p.develop.light.exposure, p.develop.light.contrast), (5, 1.25, 33.0));
assert!(s2.catalog.albums().any(|a| a.name == "Keepers" && a.photos == vec![id]));
assert!(s2.presets.iter().any(|p| p.name == "Bright" && !p.builtin));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn close_writes_snapshot_and_view_state() {
let dir = temp_dir("close");
let mut s = open(&dir, true);
let ids = s.visible_cloned();
s.execute("library.select", &json!({"ids": [ids[3].0]})).unwrap();
s.execute("photo.rate", &json!({"rating": 2})).unwrap();
let expect = s.catalog.to_snapshot();
s.close_library().unwrap();
assert_eq!(std::fs::metadata(dir.join("catalog.log")).unwrap().len(), 0);
let s2 = open(&dir, true);
let r = &s2.library.as_ref().unwrap().report;
assert_eq!(r.replayed, 0);
assert_eq!(s2.catalog.to_snapshot(), expect);
assert_eq!(s2.selection.active, Some(ids[3]));
let _ = std::fs::remove_dir_all(&dir);
}
/// Thumbnails rendered in one session are served from the disk cache in the next, without
/// loading the source; an edit changes the key.
#[test]
fn thumbnails_come_from_the_disk_cache_after_restart() {
let dir = temp_dir("thumbs");
let mut s = open(&dir, true);
let ids: Vec<_> = s.visible_cloned().into_iter().take(3).collect();
for id in &ids {
let job = s.thumb_job(*id, 230).unwrap();
assert_eq!(job.request.max_w, 256, "bucketed");
let r = job.run();
assert!(r.loaded.is_some(), "first render decodes the source");
s.accept(&r);
let img = r.rendered.unwrap().image;
assert_eq!(img.width.max(img.height), 256);
}
drop(s);
let mut s = open(&dir, true);
for id in &ids {
let r = s.thumb_job(*id, 240).unwrap().run();
assert!(r.loaded.is_none(), "served from cache");
let img = r.rendered.unwrap().image;
assert_eq!(img.width.max(img.height), 256);
}
let info = s.execute("library.info", &json!({})).unwrap();
assert_eq!(info["cache"]["disk"]["hits"], 3, "{info}");
// an edit is a new key: rendered (from the source), then cached
s.execute("library.select", &json!({"ids": [ids[0].0]})).unwrap();
s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.5})).unwrap();
let r = s.thumb_job(ids[0], 230).unwrap().run();
assert!(r.loaded.is_some());
// exact-size renders (loupe, before/after, exports) bypass the thumbnail cache
assert!(s.render_job(ids[0], 230, 230, true, true).unwrap().cache.is_none());
assert!(s.render_job(ids[0], 230, 230, false, true).unwrap().cache.is_none());
s.execute("library.clearPreviews", &json!({})).unwrap();
assert!(s.thumb_job(ids[1], 230).unwrap().run().loaded.is_some());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn new_library_without_seed_is_empty_and_compacts() {
let dir = temp_dir("empty");
let mut s = open(&dir, false);
assert!(s.catalog.is_empty());
s.execute("album.create", &json!({"name": "A"})).unwrap();
s.execute("library.compact", &json!({})).unwrap();
let info = s.execute("library.info", &json!({})).unwrap();
assert_eq!(info["logRecords"], 0);
assert_eq!(info["snapshotSeq"], info["seq"]);
let s2 = open(&dir, true);
assert_eq!(s2.catalog.len(), 0, "an existing library is never seeded");
assert_eq!(s2.catalog.albums().count(), 1);
let _ = std::fs::remove_dir_all(&dir);
}

16
crates/mcp/Cargo.toml Normal file
View File

@ -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 }

112
crates/mcp/src/backend.rs Normal file
View File

@ -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)
}
}

60
crates/mcp/src/base64.rs Normal file
View File

@ -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());
}
}

185
crates/mcp/src/headless.rs Normal file
View File

@ -0,0 +1,185 @@
//! 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 Drop for Headless {
/// Snapshot a persistent library on exit (a no-op for in-memory sessions).
fn drop(&mut self) {
let _ = self.session.close_library();
}
}
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(&params);
}
self.session.execute(id, &params).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()))
}

33
crates/mcp/src/lib.rs Normal file
View File

@ -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;

191
crates/mcp/src/server.rs Normal file
View File

@ -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, &params);
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, &params) {
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}"))),
}
}
}

126
crates/mcp/src/tests.rs Normal file
View File

@ -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);
}

561
crates/mcp/src/tools.rs Normal file
View File

@ -0,0 +1,561 @@
//! 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"},
"watermark": {"description": "Text, or {text, size (fraction of short edge), opacity, anchor (topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight), inset, color [r,g,b], shadow}"}
}),
&[],
),
];
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",
"watermark",
],
),
)
})),
"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)")),
}
}

155
crates/mcp/tests/e2e.rs Normal file
View File

@ -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"));
}

View File

@ -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:?}");
}
}

View File

@ -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};

View File

@ -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)
}

View File

@ -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);
}
}

View File

@ -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%");
}
}
}

View File

@ -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);
}
}

17
crates/preview/Cargo.toml Normal file
View File

@ -0,0 +1,17 @@
[package]
name = "lightcraft-preview"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "LightCraft preview cache: content hashing, memory LRU, disk thumbnail cache, prioritized job pool (L3)"
[lints]
workspace = true
[dependencies]
lightcraft-raster = { workspace = true }
siphasher = { workspace = true }
jpeg-encoder = { workspace = true }
zune-jpeg = { workspace = true }
zune-core = { workspace = true }
log = { workspace = true }

176
crates/preview/src/disk.rs Normal file
View File

@ -0,0 +1,176 @@
//! Rendered thumbnails on disk: `<dir>/<2 hex>/<32 hex>.jpg`, bounded in bytes.
//!
//! The cache is disposable: files are written via temp + rename (readers never see partial
//! files) but not fsynced; unreadable files are deleted and re-rendered. When the total size
//! exceeds the budget, the least recently used files (by modification time, refreshed on read)
//! are removed down to 80 % of the budget.
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::sync::atomic::{AtomicU64, Ordering};
use lightcraft_raster::Rgba8;
use crate::Hash128;
const QUALITY: u8 = 90;
pub struct DiskCache {
dir: PathBuf,
budget: u64,
/// Bytes on disk (`None` until first scanned).
total: Mutex<Option<u64>>,
pub hits: AtomicU64,
pub misses: AtomicU64,
pub writes: AtomicU64,
}
impl DiskCache {
pub fn new(dir: &Path, budget: u64) -> DiskCache {
DiskCache {
dir: dir.to_path_buf(),
budget,
total: Mutex::new(None),
hits: AtomicU64::new(0),
misses: AtomicU64::new(0),
writes: AtomicU64::new(0),
}
}
pub fn dir(&self) -> &Path {
&self.dir
}
fn path(&self, key: Hash128) -> PathBuf {
let hex = key.to_string();
self.dir.join(&hex[..2]).join(format!("{hex}.jpg"))
}
pub fn get(&self, key: Hash128) -> Option<Rgba8> {
let p = self.path(key);
let Ok(bytes) = std::fs::read(&p) else {
self.misses.fetch_add(1, Ordering::Relaxed);
return None;
};
match decode(&bytes) {
Some(img) => {
self.hits.fetch_add(1, Ordering::Relaxed);
// refresh recency for pruning
if let Ok(f) = std::fs::File::options().write(true).open(&p) {
let _ = f.set_modified(std::time::SystemTime::now());
}
Some(img)
}
None => {
log::warn!("preview cache: dropping unreadable {}", p.display());
let _ = std::fs::remove_file(&p);
self.misses.fetch_add(1, Ordering::Relaxed);
None
}
}
}
pub fn put(&self, key: Hash128, img: &Rgba8) {
let Some(bytes) = encode(img) else { return };
let p = self.path(key);
let Some(parent) = p.parent() else { return };
if std::fs::create_dir_all(parent).is_err() {
return;
}
// know the current total before adding (the first scan must not count this file twice)
let _ = self.size();
let tmp = p.with_extension(format!("tmp{:?}", std::thread::current().id()).replace(['(', ')'], ""));
if std::fs::write(&tmp, &bytes).is_err() || std::fs::rename(&tmp, &p).is_err() {
let _ = std::fs::remove_file(&tmp);
return;
}
self.writes.fetch_add(1, Ordering::Relaxed);
let over = {
let mut t = self.total.lock().unwrap_or_else(|e| e.into_inner());
let total = t.get_or_insert(0);
*total += bytes.len() as u64;
*total > self.budget
};
if over {
self.prune();
}
}
/// All cache files: (path, size, modified).
fn scan(&self) -> Vec<(PathBuf, u64, std::time::SystemTime)> {
let mut out = Vec::new();
let Ok(rd) = std::fs::read_dir(&self.dir) else { return out };
for sub in rd.flatten() {
let Ok(files) = std::fs::read_dir(sub.path()) else { continue };
for f in files.flatten() {
if let Ok(m) = f.metadata()
&& m.is_file()
{
out.push((f.path(), m.len(), m.modified().unwrap_or(std::time::UNIX_EPOCH)));
}
}
}
out
}
/// Remove least recently used files until the cache is at most 80 % of its budget.
pub fn prune(&self) {
let mut files = self.scan();
let mut total: u64 = files.iter().map(|f| f.1).sum();
let target = self.budget / 5 * 4;
files.sort_by_key(|f| f.2);
for (p, len, _) in files {
if total <= target {
break;
}
if std::fs::remove_file(&p).is_ok() {
total -= len;
}
}
*self.total.lock().unwrap_or_else(|e| e.into_inner()) = Some(total);
}
/// Bytes on disk (scans if not yet known).
pub fn size(&self) -> u64 {
let mut t = self.total.lock().unwrap_or_else(|e| e.into_inner());
*t.get_or_insert_with(|| self.scan().iter().map(|f| f.1).sum())
}
pub fn clear(&self) {
for (p, _, _) in self.scan() {
let _ = std::fs::remove_file(p);
}
*self.total.lock().unwrap_or_else(|e| e.into_inner()) = Some(0);
}
}
fn encode(img: &Rgba8) -> Option<Vec<u8>> {
if img.width == 0 || img.height == 0 || img.width > u16::MAX as usize || img.height > u16::MAX as usize {
return None;
}
let mut rgb = Vec::with_capacity(img.width * img.height * 3);
for p in &img.data {
rgb.extend_from_slice(&p[..3]);
}
let mut out = Vec::new();
let mut enc = jpeg_encoder::Encoder::new(&mut out, QUALITY);
enc.set_sampling_factor(jpeg_encoder::SamplingFactor::R_4_2_0);
enc.encode(&rgb, img.width as u16, img.height as u16, jpeg_encoder::ColorType::Rgb).ok()?;
Some(out)
}
fn decode(bytes: &[u8]) -> Option<Rgba8> {
use zune_core::bytestream::ZCursor;
use zune_core::colorspace::ColorSpace;
use zune_core::options::DecoderOptions;
let opts = DecoderOptions::default().jpeg_set_out_colorspace(ColorSpace::RGB);
let mut d = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(bytes), opts);
let px = d.decode().ok()?;
let info = d.info()?;
let (w, h) = (info.width as usize, info.height as usize);
if px.len() < w * h * 3 {
return None;
}
let data = px.chunks_exact(3).take(w * h).map(|c| [c[0], c[1], c[2], 255]).collect();
Some(Rgba8 { width: w, height: h, data })
}

View File

@ -0,0 +1,61 @@
//! 128-bit content hashing (SipHash-1-3, fixed key). Not cryptographic — it identifies files
//! for duplicate detection and keys caches; collisions are astronomically unlikely by accident.
use std::fmt;
use std::hash::Hasher;
use siphasher::sip128::{Hasher128 as _, SipHasher13};
/// A 128-bit hash, shown as 32 hex digits.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Hash128(pub u128);
impl fmt::Display for Hash128 {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:032x}", self.0)
}
}
impl Hash128 {
pub fn parse(s: &str) -> Option<Hash128> {
(s.len() == 32).then(|| u128::from_str_radix(s, 16).ok().map(Hash128)).flatten()
}
}
/// Streaming hasher.
#[derive(Clone, Debug)]
pub struct Hasher128(SipHasher13);
impl Default for Hasher128 {
fn default() -> Self {
Hasher128(SipHasher13::new_with_keys(0x4c69_6768_7443_7261, 0x6674_2043_6f6e_7465))
}
}
impl Hasher128 {
pub fn new() -> Self {
Self::default()
}
pub fn update(&mut self, bytes: &[u8]) -> &mut Self {
self.0.write(bytes);
self
}
/// Length-prefixed string (so `("ab","c")` and `("a","bc")` differ).
pub fn str(&mut self, s: &str) -> &mut Self {
self.0.write(&(s.len() as u64).to_le_bytes());
self.0.write(s.as_bytes());
self
}
pub fn u64(&mut self, v: u64) -> &mut Self {
self.0.write(&v.to_le_bytes());
self
}
pub fn finish(&self) -> Hash128 {
Hash128(self.0.finish128().as_u128())
}
}
/// Hash of a whole byte string.
pub fn hash_bytes(bytes: &[u8]) -> Hash128 {
Hasher128::new().update(bytes).finish()
}

86
crates/preview/src/lib.rs Normal file
View File

@ -0,0 +1,86 @@
//! Previews: everything that makes a big library show up instantly.
//!
//! - [`hash`]: 128-bit content hashes (file bytes → duplicate detection and cache keys).
//! - [`Lru`]: a cost-bounded memory LRU (decoded sources, rendered thumbnails).
//! - [`DiskCache`]: rendered thumbnails as JPEG files keyed by `(content hash, settings hash,
//! size, renderer version)`, bounded in bytes (least recently used files are pruned).
//! - [`PreviewCache`]: memory LRU in front of the disk cache; shared by render workers.
//! - [`JobPool`]: worker threads with per-slot de-duplication and priorities (visible thumbnails
//! before off-screen prefetch); runs jobs inline on wasm.
//!
//! No UI dependencies (L3): the egui frontend, the CLI and the MCP server share it.
#![forbid(unsafe_code)]
pub mod disk;
pub mod hash;
pub mod lru;
pub mod pool;
use std::path::Path;
use std::sync::{Arc, Mutex};
pub use disk::DiskCache;
pub use hash::{Hash128, Hasher128, hash_bytes};
use lightcraft_raster::Rgba8;
pub use lru::Lru;
pub use pool::JobPool;
/// Rendered-thumbnail cache: memory LRU over an optional disk cache.
pub struct PreviewCache {
mem: Mutex<Lru<Hash128, Arc<Rgba8>>>,
disk: Option<DiskCache>,
}
impl PreviewCache {
/// Memory-only cache of at most `mem_bytes`.
pub fn memory(mem_bytes: usize) -> PreviewCache {
PreviewCache { mem: Mutex::new(Lru::new(mem_bytes)), disk: None }
}
/// Memory LRU + disk cache in `dir` (at most `disk_bytes`).
pub fn with_disk(mem_bytes: usize, dir: &Path, disk_bytes: u64) -> PreviewCache {
PreviewCache { mem: Mutex::new(Lru::new(mem_bytes)), disk: Some(DiskCache::new(dir, disk_bytes)) }
}
pub fn disk(&self) -> Option<&DiskCache> {
self.disk.as_ref()
}
pub fn get(&self, key: Hash128) -> Option<Arc<Rgba8>> {
if let Some(v) = self.mem.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
return Some(v.clone());
}
let img = Arc::new(self.disk.as_ref()?.get(key)?);
self.mem.lock().unwrap_or_else(|e| e.into_inner()).insert(key, img.clone(), cost(&img));
Some(img)
}
pub fn put(&self, key: Hash128, img: Arc<Rgba8>) {
if let Some(d) = &self.disk {
d.put(key, &img);
}
let c = cost(&img);
self.mem.lock().unwrap_or_else(|e| e.into_inner()).insert(key, img, c);
}
/// Drop everything (memory and disk).
pub fn clear(&self) {
self.mem.lock().unwrap_or_else(|e| e.into_inner()).clear();
if let Some(d) = &self.disk {
d.clear();
}
}
/// (entries, bytes) held in memory.
pub fn mem_usage(&self) -> (usize, usize) {
let m = self.mem.lock().unwrap_or_else(|e| e.into_inner());
(m.len(), m.cost())
}
}
fn cost(img: &Rgba8) -> usize {
img.width * img.height * 4 + 64
}
#[cfg(test)]
mod tests;

97
crates/preview/src/lru.rs Normal file
View File

@ -0,0 +1,97 @@
//! A cost-bounded least-recently-used map.
use std::collections::{BTreeMap, HashMap};
use std::hash::Hash;
pub struct Lru<K, V> {
map: HashMap<K, (V, usize, u64)>,
/// Use tick → key, oldest first.
order: BTreeMap<u64, K>,
tick: u64,
cost: usize,
budget: usize,
}
impl<K: Eq + Hash + Clone, V> Lru<K, V> {
/// An LRU holding at most `budget` cost units (e.g. bytes). The most recent entry is always
/// kept, even if it alone exceeds the budget.
pub fn new(budget: usize) -> Self {
Lru { map: HashMap::new(), order: BTreeMap::new(), tick: 0, cost: 0, budget }
}
fn touch(&mut self, k: &K) {
self.tick += 1;
let t = self.tick;
if let Some(e) = self.map.get_mut(k) {
self.order.remove(&e.2);
e.2 = t;
self.order.insert(t, k.clone());
}
}
pub fn get(&mut self, k: &K) -> Option<&V> {
if !self.map.contains_key(k) {
return None;
}
self.touch(k);
self.map.get(k).map(|e| &e.0)
}
pub fn peek(&self, k: &K) -> Option<&V> {
self.map.get(k).map(|e| &e.0)
}
pub fn contains(&self, k: &K) -> bool {
self.map.contains_key(k)
}
pub fn insert(&mut self, k: K, v: V, cost: usize) {
self.remove(&k);
self.tick += 1;
self.order.insert(self.tick, k.clone());
self.map.insert(k, (v, cost, self.tick));
self.cost += cost;
while self.cost > self.budget && self.map.len() > 1 {
let Some((_, old)) = self.order.pop_first() else { break };
if let Some((_, c, _)) = self.map.remove(&old) {
self.cost -= c;
}
}
}
pub fn remove(&mut self, k: &K) -> Option<V> {
let (v, c, t) = self.map.remove(k)?;
self.order.remove(&t);
self.cost -= c;
Some(v)
}
pub fn retain(&mut self, mut f: impl FnMut(&K) -> bool) {
let drop: Vec<K> = self.map.keys().filter(|k| !f(k)).cloned().collect();
for k in drop {
self.remove(&k);
}
}
pub fn clear(&mut self) {
self.map.clear();
self.order.clear();
self.cost = 0;
}
pub fn len(&self) -> usize {
self.map.len()
}
pub fn is_empty(&self) -> bool {
self.map.is_empty()
}
pub fn cost(&self) -> usize {
self.cost
}
pub fn budget(&self) -> usize {
self.budget
}
pub fn set_budget(&mut self, budget: usize) {
self.budget = budget;
}
}

179
crates/preview/src/pool.rs Normal file
View File

@ -0,0 +1,179 @@
//! A prioritized job pool with per-slot de-duplication.
//!
//! Each job targets a *slot* (a grid cell, the loupe…). Submitting for a slot replaces a job for
//! that slot that hasn't started yet, so scrolling or dragging never builds a backlog. Workers
//! take the highest priority first (newest first among equals). Frontends re-prioritize or drop
//! queued jobs when the view changes (e.g. thumbnails scrolled out of view).
//!
//! Native: a fixed set of worker threads, started on first use. wasm32 (no threads): the host
//! calls [`JobPool::run_inline`] once per frame.
use std::hash::Hash;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::sync::{Arc, Condvar, Mutex};
pub type Job<R> = Box<dyn FnOnce() -> R + Send>;
struct Entry<S, R> {
slot: S,
key: u64,
priority: u32,
seq: u64,
job: Job<R>,
}
struct Shared<S, R> {
queue: Mutex<Vec<Entry<S, R>>>,
cv: Condvar,
shutdown: AtomicBool,
}
/// A finished job.
pub struct Done<S, R> {
pub slot: S,
/// The key the job was submitted with (to drop stale results).
pub key: u64,
pub result: R,
/// Run time in milliseconds (0 on wasm).
pub ms: f64,
}
pub struct JobPool<S, R> {
shared: Arc<Shared<S, R>>,
tx: Sender<Done<S, R>>,
rx: Receiver<Done<S, R>>,
threads: usize,
started: bool,
seq: u64,
}
impl<S: Copy + Eq + Hash + Send + 'static, R: Send + 'static> JobPool<S, R> {
/// A pool with `threads` workers (none on wasm; `0` = jobs only run via [`JobPool::run_inline`]).
pub fn new(threads: usize) -> Self {
let (tx, rx) = channel();
JobPool {
shared: Arc::new(Shared { queue: Mutex::new(Vec::new()), cv: Condvar::new(), shutdown: AtomicBool::new(false) }),
tx,
rx,
threads,
started: false,
seq: 0,
}
}
/// Workers for this machine: cores − 1, between 2 and 8.
pub fn default_threads() -> usize {
#[cfg(not(target_arch = "wasm32"))]
{
std::thread::available_parallelism().map(|n| n.get().saturating_sub(1)).unwrap_or(4).clamp(2, 8)
}
#[cfg(target_arch = "wasm32")]
{
1
}
}
fn start(&mut self) {
if self.started {
return;
}
self.started = true;
#[cfg(not(target_arch = "wasm32"))]
for i in 0..self.threads {
let shared = self.shared.clone();
let tx = self.tx.clone();
let _ = std::thread::Builder::new().name(format!("lc-job-{i}")).spawn(move || {
while let Some(e) = take(&shared, true) {
let t0 = std::time::Instant::now();
let result = (e.job)();
let done = Done { slot: e.slot, key: e.key, result, ms: t0.elapsed().as_secs_f64() * 1000.0 };
if tx.send(done).is_err() {
break;
}
}
});
}
}
/// Queue `job` for `slot`, replacing a queued (not yet running) job for the same slot.
pub fn submit(&mut self, slot: S, key: u64, priority: u32, job: Job<R>) {
self.start();
self.seq += 1;
let (lock, cv) = (&self.shared.queue, &self.shared.cv);
let mut q = lock.lock().unwrap_or_else(|e| e.into_inner());
q.retain(|e| e.slot != slot);
q.push(Entry { slot, key, priority, seq: self.seq, job });
cv.notify_one();
}
/// Change queued jobs' priorities: `f(slot, priority)` returns the new priority, or `None` to
/// drop the job. Returns the dropped slots.
pub fn reprioritize(&self, mut f: impl FnMut(&S, u32) -> Option<u32>) -> Vec<S> {
let mut dropped = Vec::new();
let mut q = self.shared.queue.lock().unwrap_or_else(|e| e.into_inner());
q.retain_mut(|e| match f(&e.slot, e.priority) {
Some(p) => {
e.priority = p;
true
}
None => {
dropped.push(e.slot);
false
}
});
dropped
}
/// Queued (not started) jobs.
pub fn queued(&self) -> usize {
self.shared.queue.lock().map(|q| q.len()).unwrap_or(0)
}
/// Is a job for `slot` queued?
pub fn is_queued(&self, slot: S) -> bool {
self.shared.queue.lock().map(|q| q.iter().any(|e| e.slot == slot)).unwrap_or(false)
}
/// A finished job, if any.
pub fn try_recv(&self) -> Option<Done<S, R>> {
self.rx.try_recv().ok()
}
/// Run up to `n` jobs on the calling thread (wasm, tests). Returns how many ran.
pub fn run_inline(&mut self, n: usize) -> usize {
let mut ran = 0;
while ran < n {
let Some(e) = take(&self.shared, false) else { break };
let result = (e.job)();
let _ = self.tx.send(Done { slot: e.slot, key: e.key, result, ms: 0.0 });
ran += 1;
}
ran
}
}
/// Pop the best job (highest priority, newest first). Blocks when `wait` until one is queued or
/// the pool shuts down.
fn take<S, R>(shared: &Shared<S, R>, wait: bool) -> Option<Entry<S, R>> {
let mut q = shared.queue.lock().unwrap_or_else(|e| e.into_inner());
loop {
if shared.shutdown.load(Ordering::Relaxed) {
return None;
}
if let Some(i) = q.iter().enumerate().max_by_key(|(_, e)| (e.priority, e.seq)).map(|(i, _)| i) {
return Some(q.swap_remove(i));
}
if !wait {
return None;
}
q = shared.cv.wait(q).unwrap_or_else(|e| e.into_inner());
}
}
impl<S, R> Drop for JobPool<S, R> {
fn drop(&mut self) {
self.shared.shutdown.store(true, Ordering::Relaxed);
self.shared.cv.notify_all();
}
}

156
crates/preview/src/tests.rs Normal file
View File

@ -0,0 +1,156 @@
use std::sync::Arc;
use lightcraft_raster::Rgba8;
use super::*;
fn temp_dir(tag: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("lc-preview-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
d
}
fn gradient(w: usize, h: usize, seed: u8) -> Rgba8 {
let data = (0..w * h).map(|i| [((i % w) * 255 / w) as u8, ((i / w) * 255 / h) as u8, seed, 255]).collect();
Rgba8 { width: w, height: h, data }
}
#[test]
fn hashes_are_stable_and_distinct() {
let a = hash_bytes(b"hello");
assert_eq!(a, hash_bytes(b"hello"));
assert_ne!(a, hash_bytes(b"hellp"));
assert_eq!(Hash128::parse(&a.to_string()), Some(a));
assert_ne!(Hasher128::new().str("ab").str("c").finish(), Hasher128::new().str("a").str("bc").finish());
// streaming == one shot
assert_eq!(Hasher128::new().update(b"hel").update(b"lo").finish(), a);
}
#[test]
fn lru_evicts_least_recent_by_cost() {
let mut l = Lru::new(10);
l.insert(1, "a", 4);
l.insert(2, "b", 4);
assert_eq!(l.get(&1), Some(&"a")); // 1 is now most recent
l.insert(3, "c", 4); // over budget: evicts 2
assert!(l.contains(&1) && !l.contains(&2) && l.contains(&3));
assert_eq!(l.cost(), 8);
l.insert(4, "huge", 50); // alone over budget: kept, everything else evicted
assert_eq!((l.len(), l.cost()), (1, 50));
l.insert(4, "small", 1);
assert_eq!((l.len(), l.cost()), (1, 1));
l.retain(|k| *k != 4);
assert!(l.is_empty());
}
#[test]
fn disk_cache_roundtrip_and_prune() {
let dir = temp_dir("disk");
let d = DiskCache::new(&dir, 40_000);
let img = gradient(160, 100, 7);
let k = hash_bytes(b"k1");
assert!(d.get(k).is_none());
d.put(k, &img);
let back = d.get(k).expect("hit");
assert_eq!((back.width, back.height), (160, 100));
let err: i64 = img.data.iter().zip(&back.data).map(|(a, b)| (a[0] as i64 - b[0] as i64).abs() + (a[1] as i64 - b[1] as i64).abs()).max().unwrap();
assert!(err < 24, "JPEG error {err}");
assert!(back.data.iter().all(|p| p[3] == 255));
// fill past the budget: the oldest entries go, the newest stay
let keys: Vec<_> = (0..40u8).map(|i| hash_bytes(&[i, 1])).collect();
for (i, k) in keys.iter().enumerate() {
d.put(*k, &gradient(160, 100, i as u8));
}
assert!(d.size() <= 40_000, "{}", d.size());
assert!(d.get(*keys.last().unwrap()).is_some());
assert!(d.get(keys[0]).is_none());
// a corrupt file is a miss and gets removed
let k2 = hash_bytes(b"corrupt");
d.put(k2, &img);
let hex = k2.to_string();
let p = dir.join(&hex[..2]).join(format!("{hex}.jpg"));
std::fs::write(&p, b"not a jpeg").unwrap();
assert!(d.get(k2).is_none());
assert!(!p.exists());
d.clear();
assert_eq!(d.size(), 0);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn preview_cache_memory_then_disk() {
let dir = temp_dir("pc");
let k = hash_bytes(b"x");
{
let c = PreviewCache::with_disk(1 << 20, &dir, 1 << 24);
c.put(k, Arc::new(gradient(64, 48, 1)));
assert_eq!(c.mem_usage().0, 1);
assert!(c.get(k).is_some());
}
// a new process: memory is empty, disk serves it
let c = PreviewCache::with_disk(1 << 20, &dir, 1 << 24);
assert_eq!(c.mem_usage().0, 0);
assert_eq!(c.get(k).map(|i| i.width), Some(64));
assert_eq!(c.mem_usage().0, 1);
assert_eq!(c.disk().unwrap().hits.load(std::sync::atomic::Ordering::Relaxed), 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn pool_priorities_dedupe_and_drop() {
let mut p: JobPool<u32, u32> = JobPool::new(1);
// One worker, held busy by a gate job while the others queue up.
let gate = Arc::new((std::sync::Mutex::new(false), std::sync::Condvar::new()));
let g = gate.clone();
p.submit(
999,
0,
1000,
Box::new(move || {
let (m, cv) = &*g;
let mut open = m.lock().unwrap();
while !*open {
open = cv.wait(open).unwrap();
}
999
}),
);
// wait until the worker took the gate job
while p.queued() > 0 {
std::thread::yield_now();
}
p.submit(1, 1, 5, Box::new(|| 1)); // off-screen thumb
p.submit(2, 1, 10, Box::new(|| 2)); // visible thumb
p.submit(3, 1, 100, Box::new(|| 3)); // loupe
p.submit(2, 2, 10, Box::new(|| 22)); // newer request for slot 2 replaces the queued one
p.submit(4, 1, 5, Box::new(|| 4));
assert_eq!(p.queued(), 4);
let dropped = p.reprioritize(|s, pr| if *s == 4 { None } else { Some(pr) });
assert_eq!(dropped, vec![4]);
{
let (m, cv) = &*gate;
*m.lock().unwrap() = true;
cv.notify_all();
}
let mut order = Vec::new();
while order.len() < 4 {
if let Some(d) = p.try_recv() {
order.push((d.slot, d.key, d.result));
} else {
std::thread::yield_now();
}
}
assert_eq!(order, vec![(999, 0, 999), (3, 1, 3), (2, 2, 22), (1, 1, 1)]);
}
#[test]
fn pool_runs_inline() {
// No workers: run_inline drains the queue on the caller's thread (the wasm path).
let mut p: JobPool<u8, u8> = JobPool::new(0);
p.submit(1, 0, 1, Box::new(|| 1));
p.submit(2, 0, 9, Box::new(|| 2));
assert_eq!(p.run_inline(1), 1);
assert_eq!(p.try_recv().map(|d| d.result), Some(2));
assert_eq!(p.run_inline(10), 1);
assert_eq!(p.try_recv().map(|d| d.result), Some(1));
}

View File

@ -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))));
}

View File

@ -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)]

View File

@ -127,11 +127,18 @@ fn layer_h(l: &Layer, x: f32) -> f32 {
/// Terrain shading for a layer hit at (x, v) whose crest is at `top`.
fn terrain(l: &Layer, x: f32, v: f32, top: f32, sun_x: f32, sun_col: C, haze_col: C) -> C {
let e = 0.012;
let slope = (layer_h_lo(l, x + e) - layer_h_lo(l, x - e)) / (2.0 * e);
let dir = if sun_x > x { 1.0 } else { -1.0 };
let face = (slope * dir * 1.6).clamp(-1.0, 1.0);
let depth = (v - top).max(0.0);
// Facets: slope of the low-octave crest, sampled with a wide stencil (ridged noise has creases
// whose slope jumps would otherwise become full-height vertical stripes) at an x warped with
// depth so faces lean and break up instead of running straight down.
let e = 0.035;
let xs = x + fbm(x * 7.0, v * 7.0, l.seed ^ 31, 2) * 0.03 + depth * 0.15 * (sun_x - x).signum();
let slope = (layer_h_lo(l, xs + e) - layer_h_lo(l, xs - e)) / (2.0 * e);
// Light direction blends smoothly through the sun's x (no hard flip).
let dir = ((sun_x - x) * 10.0).tanh();
let face = (slope * dir * 1.6).clamp(-1.0, 1.0) * (1.0 - smooth(0.0, 0.6, depth) * 0.5)
// aerial perspective: distant (hazy) ranges have little facet contrast
* (1.0 - l.haze * 1.6).max(0.15);
let tex = fbm(x * 38.0, v * 38.0, l.seed ^ 77, 4) * 0.12;
let light = (0.62 + 0.38 * face + tex).max(0.05);
let mut c = mul(l.col, light * (1.0 - (depth * 2.5).min(0.45)));

View File

@ -15,7 +15,9 @@ 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 }
serde = { workspace = true }
serde_json = { workspace = true }

View File

@ -77,7 +77,7 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value {
"selection": app.session.selection.ids.iter().map(|p| p.0).collect::<Vec<_>>(),
"activeMask": app.session.active_mask,
"widgetCount": app.widgets.len(),
"perf": {"frameMs": app.perf.frame_ms, "fps": app.perf.fps, "lastRenderMs": app.renderer.last_main_ms, "renderQueue": app.renderer.queued(), "rendersDone": app.renderer.completed},
"perf": {"frameMs": app.perf.frame_ms, "fps": app.perf.fps, "lastRenderMs": app.renderer.last_main_ms, "renderQueue": app.renderer.queued(), "rendersInFlight": app.renderer.in_flight(), "rendersDone": app.renderer.completed, "thumbTextures": app.renderer.thumb_textures()},
"status": app.ui.status,
})
}
@ -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 {

View File

@ -201,6 +201,7 @@ impl LightcraftApp {
ctx.request_repaint();
}
self.renderer.poll(ctx, &mut self.session);
self.session.persist_if_dirty();
self.collect_screenshots(ctx);
self.issue_screenshots(ctx);
if self.fonts_ready {
@ -266,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.

View File

@ -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" => {

View File

@ -626,7 +626,7 @@ fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) {
p.text(pos2(cr.right() - 8.0, cr.top() + 10.0), Align2::RIGHT_CENTER, &ph.format, t.semibold(8.5), t.text_dim);
}
let img_area = Rect::from_min_max(cr.min + vec2(10.0, 22.0), cr.max - vec2(10.0, 8.0));
if let Some(job) = app.session.render_job(*id, (256.0 * ppp.min(2.0) / 2.0) as usize * 2, 256, false, true) {
if let Some(job) = app.session.thumb_job(*id, (256.0 * ppp.min(2.0) / 2.0) as usize * 2) {
app.renderer.request(Slot::Thumb(*id), job, 8);
}
if let Some(tex) = app.renderer.textures.get(&Slot::Thumb(*id)) {

View File

@ -56,20 +56,89 @@ 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");
}
let mut wm_on = opts.watermark.is_some();
if ui.checkbox(&mut wm_on, "Watermark").changed() {
opts.watermark = wm_on.then(|| lightcraft_engine::export::Watermark { text: "© ".into(), ..Default::default() });
}
if let Some(wm) = &mut opts.watermark {
use lightcraft_engine::export::Anchor as P;
ui.add(egui::TextEdit::singleline(&mut wm.text).hint_text("© Your Name").desired_width(f32::INFINITY));
ui.horizontal(|ui| {
ui.label("Position");
for (v, l) in [(P::TopLeft, "↖"), (P::TopRight, "↗"), (P::Center, "•"), (P::BottomLeft, "↙"), (P::BottomRight, "↘")]
{
ui.selectable_value(&mut wm.anchor, v, l);
}
});
ui.add(egui::Slider::new(&mut wm.size, 0.01..=0.15).text("Size"));
ui.add(egui::Slider::new(&mut wm.opacity, 0.05..=1.0).text("Opacity"));
ui.checkbox(&mut wm.shadow, "Shadow");
}
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 +198,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, "watermark": opts.watermark,
}),
),
Dialog::About | Dialog::Shortcuts => Ok(serde_json::Value::Null),
}
}

View File

@ -144,7 +144,7 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square
};
// thumbnail
let size = thumb_px(img_rect.width().max(img_rect.height()), ppp);
if let Some(job) = app.session.render_job(id, size, size, false, true) {
if let Some(job) = app.session.thumb_job(id, size) {
app.renderer.request(Slot::Thumb(id), job, if onscreen { 10 } else { 5 });
}
if let Some(tex) = app.renderer.textures.get(&Slot::Thumb(id)) {

View File

@ -1,16 +1,17 @@
//! Background rendering: a small worker pool runs engine [`RenderJob`]s; results become textures.
//! Background rendering: engine [`RenderJob`]s run on a [`JobPool`]; results become textures.
//!
//! Each visible thing that needs pixels is a *slot* (a grid thumbnail, the loupe, the "before"
//! image…). Requests are deduplicated per slot (a newer request replaces a queued older one) and
//! prioritised (loupe first, then visible thumbnails). On wasm the jobs run inline, one per frame.
//! prioritised (loupe first, then on-screen thumbnails, then prefetch); thumbnails scrolled far
//! out of view are dropped from the queue. Thumbnail jobs hit the engine's preview cache (memory +
//! disk) before rendering. On wasm the jobs run inline, one per frame.
use std::collections::HashMap;
use std::sync::mpsc::{Receiver, Sender, channel};
use std::sync::{Arc, Condvar, Mutex};
use lightcraft_catalog::PhotoId;
use lightcraft_engine::Session;
use lightcraft_engine::media::{RenderJob, RenderResult};
use lightcraft_preview::JobPool;
use lightcraft_raster::Histogram;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
@ -30,15 +31,11 @@ pub struct Tex {
pub ms: f64,
}
type Queue = Arc<(Mutex<Vec<(Slot, u32, RenderJob)>>, Condvar)>;
pub struct Renderer {
queue: Queue,
results_tx: Sender<(Slot, RenderResult, f64)>,
results: Receiver<(Slot, RenderResult, f64)>,
pending: HashMap<Slot, u64>,
pool: JobPool<Slot, RenderResult>,
/// Slot → (key, priority) of the request in flight (queued or running).
pending: HashMap<Slot, (u64, u32)>,
pub textures: HashMap<Slot, Tex>,
started: bool,
pub last_main_ms: f64,
/// Jobs finished since start (for inspect/perf).
pub completed: u64,
@ -46,111 +43,64 @@ pub struct Renderer {
impl Default for Renderer {
fn default() -> Self {
let (tx, rx) = channel();
Renderer {
queue: Arc::new((Mutex::new(Vec::new()), Condvar::new())),
results_tx: tx,
results: rx,
pending: HashMap::new(),
textures: HashMap::new(),
started: false,
last_main_ms: 0.0,
completed: 0,
}
}
}
fn now() -> 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
let threads = if cfg!(target_arch = "wasm32") { 0 } else { JobPool::<Slot, RenderResult>::default_threads().min(6) };
Renderer { pool: JobPool::new(threads), pending: HashMap::new(), textures: HashMap::new(), last_main_ms: 0.0, completed: 0 }
}
}
impl Renderer {
fn start(&mut self) {
if self.started {
return;
}
self.started = true;
#[cfg(not(target_arch = "wasm32"))]
{
let n = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(2, 6);
for i in 0..n {
let q = self.queue.clone();
let tx = self.results_tx.clone();
let _ = std::thread::Builder::new().name(format!("lc-render-{i}")).spawn(move || {
loop {
let job = {
let (lock, cv) = &*q;
let mut g = lock.lock().unwrap_or_else(|e| e.into_inner());
while g.is_empty() {
g = cv.wait(g).unwrap_or_else(|e| e.into_inner());
}
// highest priority, newest first
let best = g.iter().enumerate().max_by_key(|(i, (_, p, _))| (*p, *i)).map(|(i, _)| i).unwrap_or(0);
g.remove(best)
};
let t0 = now();
let (slot, _, job) = job;
let r = job.run();
if tx.send((slot, r, now() - t0)).is_err() {
break;
}
}
});
}
}
}
/// Is the slot's current texture (or pending request) already for `key`?
pub fn is_current(&self, slot: Slot, key: u64) -> bool {
self.textures.get(&slot).is_some_and(|t| t.key == key) || self.pending.get(&slot) == Some(&key)
self.textures.get(&slot).is_some_and(|t| t.key == key) || self.pending.get(&slot).is_some_and(|p| p.0 == key)
}
/// Request a render for `slot` (no-op if already current or pending).
/// Request a render for `slot` (no-op if already current or pending at the same priority).
pub fn request(&mut self, slot: Slot, job: RenderJob, priority: u32) {
if self.is_current(slot, job.key) {
if self.textures.get(&slot).is_some_and(|t| t.key == job.key) {
return;
}
self.start();
self.pending.insert(slot, job.key);
let (lock, cv) = &*self.queue;
let mut g = lock.lock().unwrap_or_else(|e| e.into_inner());
g.retain(|(s, _, _)| *s != slot);
g.push((slot, priority, job));
cv.notify_one();
if let Some(&(key, prio)) = self.pending.get(&slot)
&& key == job.key
&& (prio == priority || !self.pool.is_queued(slot))
{
return;
}
self.pending.insert(slot, (job.key, priority));
let key = job.key;
self.pool.submit(slot, key, priority, Box::new(move || job.run()));
}
pub fn queued(&self) -> usize {
self.queue.0.lock().map(|g| g.len()).unwrap_or(0)
self.pool.queued()
}
/// Requests queued or running.
pub fn in_flight(&self) -> usize {
self.pending.len()
}
/// Thumbnail textures currently loaded.
pub fn thumb_textures(&self) -> usize {
self.textures.keys().filter(|s| matches!(s, Slot::Thumb(_))).count()
}
/// 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")]
{
// inline: run one job per frame
let job = {
let mut g = self.queue.0.lock().unwrap_or_else(|e| e.into_inner());
let best = g.iter().enumerate().max_by_key(|(i, (_, p, _))| (*p, *i)).map(|(i, _)| i);
best.map(|b| g.remove(b))
};
if let Some((slot, _, job)) = job {
let r = job.run();
let _ = self.results_tx.send((slot, r, 0.0));
}
}
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 Ok((slot, r, ms)) = self.results.try_recv() {
while let Some(done) = self.pool.try_recv() {
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) == Some(&r.key) {
if self.pending.get(&slot).is_some_and(|p| p.0 == r.key) {
self.pending.remove(&slot);
}
let Ok(rendered) = r.rendered else {
@ -187,8 +137,16 @@ impl Renderer {
changed
}
/// Drop thumbnails that aren't in `keep` (bounded memory for huge libraries).
/// Drop thumbnails that aren't in `keep` (bounded memory for huge libraries), and queued
/// thumbnail jobs for photos that scrolled out of `keep`.
pub fn evict_thumbs(&mut self, keep: &std::collections::HashSet<PhotoId>, max: usize) {
let dropped = self.pool.reprioritize(|s, p| match s {
Slot::Thumb(id) if !keep.contains(id) && p <= 10 => None,
_ => Some(p),
});
for s in dropped {
self.pending.remove(&s);
}
let thumbs = self.textures.keys().filter(|s| matches!(s, Slot::Thumb(_))).count();
if thumbs <= max {
return;

View File

@ -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,
}

View File

@ -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);

36
docs/control-protocol.md Normal file
View File

@ -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 |

Binary file not shown.

Before

Width:  |  Height:  |  Size: 980 KiB

After

Width:  |  Height:  |  Size: 987 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 427 KiB

After

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 916 KiB

After

Width:  |  Height:  |  Size: 923 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 KiB

After

Width:  |  Height:  |  Size: 768 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 600 KiB

After

Width:  |  Height:  |  Size: 610 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 679 KiB

After

Width:  |  Height:  |  Size: 687 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 821 KiB

After

Width:  |  Height:  |  Size: 792 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 630 KiB

After

Width:  |  Height:  |  Size: 637 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 739 KiB

After

Width:  |  Height:  |  Size: 750 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 528 KiB

After

Width:  |  Height:  |  Size: 537 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 821 KiB

After

Width:  |  Height:  |  Size: 828 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 361 KiB

After

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 592 KiB

After

Width:  |  Height:  |  Size: 604 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 502 KiB

After

Width:  |  Height:  |  Size: 515 KiB

159
docs/mcp.md Normal file
View File

@ -0,0 +1,159 @@
# 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: `--library DIR` opens (or creates) a persistent LightCraft library — the same crash-safe
format the desktop app uses (`~/Pictures/LightCraft Library` by default there) — so ratings, edits and albums
survive between sessions (with `--demo`, a new library is seeded with the demo photos);
`--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`.

View File

@ -4,11 +4,11 @@ The screenshots in `docs/images/` are captured by driving LightCraft over its co
```sh
# public-domain set (downloaded into the gitignored corpus/, see assets/ATTRIBUTION.md for sources)
cargo run --release -p lightcraft -- --control 7980 corpus/images/pd &
cargo run --release -p lightcraft -- --memory --control 7980 corpus/images/pd &
python3 docs/showcase/run.py docs/showcase/pd-edits.jsonl
# procedural demo library
cargo run --release -p lightcraft -- --control 7980 &
cargo run --release -p lightcraft -- --memory --control 7980 &
python3 docs/showcase/run.py docs/showcase/demo.jsonl
# compress for the repo (macOS)

View File

@ -1,4 +1,4 @@
# Demo-library showcase: run LightCraft with `--control 7980` (no files → procedural demo library)
# Demo-library showcase: run LightCraft with `--memory --control 7980` (no files → procedural demo library)
{"method":"ui.resize","params":{"width":1600,"height":1000}}
{"method":"engine.execute","params":{"command":"view.photoGrid"}}
{"method":"ui.set","params":{"leftPanel":true,"right":"edit","thumbSize":230,"openSections":["light","color"],"presets":false}}

77
docs/web.md Normal file
View File

@ -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.

135
xtask/src/assets.rs Normal file
View File

@ -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/")));
}
}

View File

@ -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();

163
xtask/src/web.rs Normal file
View File

@ -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");
}
}