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