diff --git a/AGENTS.md b/AGENTS.md index 7b1bce3a..1ac82715 100644 --- a/AGENTS.md +++ b/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-`; 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`). diff --git a/CLAUDE.md b/CLAUDE.md index 7b1bce3a..1ac82715 100644 --- a/CLAUDE.md +++ b/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-`; 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`). diff --git a/Cargo.lock b/Cargo.lock index a1dd320c..d8f36eab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 1e47d213..86780d7a 100644 --- a/Cargo.toml +++ b/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" diff --git a/README.md b/README.md index 7fa919a4..2a6e72be 100644 --- a/README.md +++ b/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).
@@ -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 🚧 |
@@ -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: G grid · D detail · E edit · C crop · M masking · Shift+P presets · \\ original · Y before/after · Z zoom · J clipping · ⌘Z undo · ⌘/ all shortcuts. @@ -235,7 +256,20 @@ Keyboard: G grid · D detail · E edit · 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).
@@ -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. diff --git a/apps/lightcraft-cli/Cargo.toml b/apps/lightcraft-cli/Cargo.toml new file mode 100644 index 00000000..6a733f31 --- /dev/null +++ b/apps/lightcraft-cli/Cargo.toml @@ -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 } diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs new file mode 100644 index 00000000..11c04265 --- /dev/null +++ b/apps/lightcraft-cli/src/main.rs @@ -0,0 +1,244 @@ +//! `lightcraft-cli`: headless LightCraft. +//! +//! ```text +//! lightcraft-cli mcp [--connect [ADDR]] [--demo] [--compact] [FILES/FOLDERS…] +//! lightcraft-cli render -o [--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 -o [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 = 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 = None; + let mut demo = false; + let mut library: Option = 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 = 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 = None; + let mut preset = None; + let mut size: Option = 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(()) +} diff --git a/apps/lightcraft-cli/tests/cli.rs b/apps/lightcraft-cli/tests/cli.rs new file mode 100644 index 00000000..bf26e8ff --- /dev/null +++ b/apps/lightcraft-cli/tests/cli.rs @@ -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::() / (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")); +} diff --git a/apps/lightcraft-web/Cargo.toml b/apps/lightcraft-web/Cargo.toml new file mode 100644 index 00000000..b9c7bdf2 --- /dev/null +++ b/apps/lightcraft-web/Cargo.toml @@ -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", +] } diff --git a/apps/lightcraft-web/index.html b/apps/lightcraft-web/index.html new file mode 100644 index 00000000..ac1ed167 --- /dev/null +++ b/apps/lightcraft-web/index.html @@ -0,0 +1,33 @@ + + + + + + LightCraft + + + + + + +
Loading LightCraft…
+
+ + + diff --git a/apps/lightcraft-web/src/bench.rs b/apps/lightcraft-web/src/bench.rs new file mode 100644 index 00000000..7ede4a7a --- /dev/null +++ b/apps/lightcraft-web/src/bench.rs @@ -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, + thumbs_done_ms: Option, + open_ms: f64, + drafts: Vec, + jobs: Vec, + 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 { + 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 { + 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::() / 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::>(), + "slider_draft_median_ms": r(median), + "slider_draft_mean_ms": r(mean), + "slider_job_ms": self.jobs.iter().copied().map(r).collect::>(), + "release_full_ms": r(self.final_ms), + "loupe_px": tex, + "frames": self.frames, + }) + } +} diff --git a/apps/lightcraft-web/src/lib.rs b/apps/lightcraft-web/src/lib.rs new file mode 100644 index 00000000..0ab06bc9 --- /dev/null +++ b/apps/lightcraft-web/src/lib.rs @@ -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; diff --git a/apps/lightcraft-web/src/store.rs b/apps/lightcraft-web/src/store.rs new file mode 100644 index 00000000..acac328e --- /dev/null +++ b/apps/lightcraft-web/src/store.rs @@ -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//`), 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>, + /// Paths added since the last [`MemStore::take_pending`] (to import on the next frame). + pending: Vec, + 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>); + +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) -> 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> { + self.lock().files.get(path).cloned() + } + + /// Paths added since the last call. + pub fn take_pending(&self) -> Vec { + 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 { + let bytes: Vec = (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"); + } +} diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs new file mode 100644 index 00000000..21817cda --- /dev/null +++ b/apps/lightcraft-web/src/web.rs @@ -0,0 +1,202 @@ +//! Browser host: starts eframe on ``, 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::()) else { return }; + input.set_type("file"); + input.set_multiple(true); + input.set_accept(ACCEPT); + let inp = input.clone(); + let on_change = Closure::::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, + 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::().ok()) + else { + log::error!("missing "); + 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:?}")); + } + } + }); +} diff --git a/apps/lightcraft/examples/gen_photos.rs b/apps/lightcraft/examples/gen_photos.rs new file mode 100644 index 00000000..ee7ba9a5 --- /dev/null +++ b/apps/lightcraft/examples/gen_photos.rs @@ -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()); +} diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 19685c94..ca7a649f 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -1,6 +1,12 @@ //! LightCraft desktop app. //! -//! Usage: `lightcraft [--control ] [--demo] [files or folders…]` +//! Usage: `lightcraft [--library DIR | --memory] [--no-demo] [--control ] [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 ` (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) { - 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, 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 = 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"); diff --git a/assets/ATTRIBUTION.md b/assets/ATTRIBUTION.md index 9c83589d..33485087 100644 --- a/assets/ATTRIBUTION.md +++ b/assets/ATTRIBUTION.md @@ -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. diff --git a/assets/fonts/OFL-SourceSans3.txt b/assets/fonts/OFL-SourceSans3.txt deleted file mode 100644 index 22c601b8..00000000 --- a/assets/fonts/OFL-SourceSans3.txt +++ /dev/null @@ -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. diff --git a/assets/fonts/SourceSans3-Bold.ttf b/assets/fonts/SourceSans3-Bold.ttf deleted file mode 100644 index 85693ccc..00000000 Binary files a/assets/fonts/SourceSans3-Bold.ttf and /dev/null differ diff --git a/assets/fonts/SourceSans3-It.ttf b/assets/fonts/SourceSans3-It.ttf deleted file mode 100644 index 8e0576f2..00000000 Binary files a/assets/fonts/SourceSans3-It.ttf and /dev/null differ diff --git a/assets/fonts/SourceSans3-Regular.ttf b/assets/fonts/SourceSans3-Regular.ttf deleted file mode 100644 index c5dd0b88..00000000 Binary files a/assets/fonts/SourceSans3-Regular.ttf and /dev/null differ diff --git a/assets/fonts/SourceSans3-Semibold.ttf b/assets/fonts/SourceSans3-Semibold.ttf deleted file mode 100644 index 4aff9a3a..00000000 Binary files a/assets/fonts/SourceSans3-Semibold.ttf and /dev/null differ diff --git a/crates/catalog/Cargo.toml b/crates/catalog/Cargo.toml index c5f6dc86..fb772703 100644 --- a/crates/catalog/Cargo.toml +++ b/crates/catalog/Cargo.toml @@ -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 } diff --git a/crates/catalog/src/journal.rs b/crates/catalog/src/journal.rs new file mode 100644 index 00000000..f7089c06 --- /dev/null +++ b/crates/catalog/src/journal.rs @@ -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-`, 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, + /// No catalog files existed (a new library). + pub created: bool, +} + +pub struct Journal { + store: Box, + /// `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) -> 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 = 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() + } +} diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index 1381edbc..4b03795c 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -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 = std::result::Result; +/// 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, }, + /// 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; diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index b3c0d4fa..4a2d6a07 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -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, } 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 { diff --git a/crates/catalog/src/store.rs b/crates/catalog/src/store.rs new file mode 100644 index 00000000..98e690b9 --- /dev/null +++ b/crates/catalog/src/store.rs @@ -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>>; + /// 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) -> io::Result { + 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>> { + 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>>>, +} + +impl MemStore { + pub fn new() -> MemStore { + MemStore::default() + } + pub fn get(&self, name: &str) -> Option> { + self.files.lock().unwrap_or_else(|e| e.into_inner()).get(name).cloned() + } + pub fn set(&self, name: &str, data: Vec) { + 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>> { + 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() + } +} diff --git a/crates/catalog/src/tests_journal.rs b/crates/catalog/src/tests_journal.rs new file mode 100644 index 00000000..2cbe8098 --- /dev/null +++ b/crates/catalog/src/tests_journal.rs @@ -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 = c.photos().map(|p| p.id).collect(); + let albums: Vec = 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 = 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); +} diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 6015ab24..39a9e2f5 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -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 } diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 0a8f6d46..5030286e 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -364,23 +364,94 @@ pub fn specs() -> Vec { ok() }), // ---- import - cmd!("library.import", "Add Photos…", ["File"], Some("Cmd+Shift+I"), "{paths: [path], album?: albumId}", always, |s, p| { - let paths: Vec = 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 = 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::>()}))?; - } - if let Some(f) = imported.first() { - s.selection = Selection::single(*f); - } - Ok(json!({"imported": imported.iter().map(|i| i.0).collect::>()})) + ), + // ---- 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()) } +} diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs new file mode 100644 index 00000000..107a501b --- /dev/null +++ b/crates/engine/src/export.rs @@ -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 { + 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, + /// JPEG only: largest quality whose file fits in this many KB. + pub limit_kb: Option, + 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, +} + +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(p: &Value, k: &str) -> Option { + 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, 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, 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 { + let s = s.trim().trim_end_matches(['s', '"']).trim(); + match s.split_once('/') { + Some((n, d)) => Some(n.trim().parse::().ok()? / d.trim().parse::().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 { + 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, + 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 { + 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"); + } +} diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index 0e79c9c9..f4a4a08a 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -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 { + 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 { 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 { "" => 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. diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs new file mode 100644 index 00000000..f685dead --- /dev/null +++ b/crates/engine/src/import.rs @@ -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, + /// `"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, + pub duplicates: Vec, + /// (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 { + fn walk(p: &Path, skip: Option<&Path>, out: &mut Vec, 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 = 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> { + 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>> = 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 { + 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 { + 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 = 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 + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 13ff7733..01ae5da9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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, + /// The persistent library this session writes to (`None` = in-memory only). + pub library: Option, } 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 { - 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; diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs new file mode 100644 index 00000000..1ec5c4f4 --- /dev/null +++ b/crates/engine/src/library.rs @@ -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 { + 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, + presets_written: String, +} + +#[derive(Default, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct PresetsFile { + user: Vec, + favorites: Vec, +} + +#[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, 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::(&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::(&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(()) + } +} diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 21100410..c9cd303f 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -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>, + /// Decoded thumbnail-level sources (LRU by bytes). + thumbs: Lru>, previews: Vec<(PhotoId, Arc)>, /// How many previews to keep (LRU). pub preview_capacity: usize, pub file_loader: Option, pub file_probe: Option, scenes: Vec, + /// Rendered thumbnails (memory, plus disk once a library is attached). + pub rendered: Arc, +} + +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> { + /// 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> { 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) { 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, 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 { + 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 { + 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, + ) -> Option { 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, 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, } pub type FileProbe = Arc Result + 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> { - 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) -} diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs new file mode 100644 index 00000000..6e1289e3 --- /dev/null +++ b/crates/engine/src/tests_import.rs @@ -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 = 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"); +} diff --git a/crates/engine/src/tests_library.rs b/crates/engine/src/tests_library.rs new file mode 100644 index 00000000..e3e75753 --- /dev/null +++ b/crates/engine/src/tests_library.rs @@ -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); +} diff --git a/crates/mcp/Cargo.toml b/crates/mcp/Cargo.toml new file mode 100644 index 00000000..b05dae56 --- /dev/null +++ b/crates/mcp/Cargo.toml @@ -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 } diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs new file mode 100644 index 00000000..5db2aaaa --- /dev/null +++ b/crates/mcp/src/backend.rs @@ -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; + /// 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)>, + 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 { + 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 { + 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 { + 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) + } +} diff --git a/crates/mcp/src/base64.rs b/crates/mcp/src/base64.rs new file mode 100644 index 00000000..9c02ac32 --- /dev/null +++ b/crates/mcp/src/base64.rs @@ -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> { + 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 = (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()); + } +} diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs new file mode 100644 index 00000000..b0e5386d --- /dev/null +++ b/crates/mcp/src/headless.rs @@ -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 { + 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 { + 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 { + 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 { + 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 = 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 { + fn walk(p: &Path, out: &mut Vec) { + 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, 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())) +} diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs new file mode 100644 index 00000000..2b8fa10f --- /dev/null +++ b/crates/mcp/src/lib.rs @@ -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; diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs new file mode 100644 index 00000000..c3371be5 --- /dev/null +++ b/crates/mcp/src/server.rs @@ -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, + 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) -> 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) -> 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 { + let line = line.trim(); + if line.is_empty() { + return None; + } + let reply = match serde_json::from_str::(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 = 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 { + 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 { + 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}"))), + } + } +} diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs new file mode 100644 index 00000000..b97e0497 --- /dev/null +++ b/crates/mcp/src/tests.rs @@ -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::(text).unwrap(); + } + assert_eq!(rpc(&mut s, 99, "resources/read", json!({"uri": "lightcraft://nope"}))["error"]["code"], -32002); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs new file mode 100644 index 00000000..663c8f08 --- /dev/null +++ b/crates/mcp/src/tools.rs @@ -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_`), 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, + pub is_error: bool, + pub structured: Option, +} + +impl ToolResult { + pub fn text(t: impl Into) -> 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) -> Self { + Self { is_error: true, ..Self::text(msg) } + } + + fn from(r: Result) -> 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 { + 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_` 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 { + 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 { + 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 { + 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, 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 = 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 = 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)")), + } +} diff --git a/crates/mcp/tests/e2e.rs b/crates/mcp/tests/e2e.rs new file mode 100644 index 00000000..ea27919c --- /dev/null +++ b/crates/mcp/tests/e2e.rs @@ -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 { + 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")); +} diff --git a/crates/meta/src/exif.rs b/crates/meta/src/exif.rs index a480c049..ca8d7b7a 100644 --- a/crates/meta/src/exif.rs +++ b/crates/meta/src/exif.rs @@ -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 { + use lightcraft_tiff::writer::{rational, srational}; + use lightcraft_tiff::{ByteOrder, IfdBuilder, TiffWriter, Value}; + let ascii = |s: &Option| 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| 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:?}"); + } +} diff --git a/crates/meta/src/lib.rs b/crates/meta/src/lib.rs index 936fb21e..71284677 100644 --- a/crates/meta/src/lib.rs +++ b/crates/meta/src/lib.rs @@ -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}; diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 7247fb1f..b137551a 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -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| { + 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 = 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(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(data: &mut [T], w: usize, f: impl Fn(usize, &mut [T]) + Sync + Send) { par_rows(data, w, f) } diff --git a/crates/pipeline/src/local.rs b/crates/pipeline/src/local.rs index 8a73ffc1..b84a048e 100644 --- a/crates/pipeline/src/local.rs +++ b/crates/pipeline/src/local.rs @@ -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::() / im.len() as f32; + (im.data.iter().map(|p| (p[1] - m).powi(2)).sum::() / 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); + } +} diff --git a/crates/pipeline/src/profiles.rs b/crates/pipeline/src/profiles.rs new file mode 100644 index 00000000..ff95eed6 --- /dev/null +++ b/crates/pipeline/src/profiles.rs @@ -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%"); + } + } +} diff --git a/crates/pipeline/src/spots.rs b/crates/pipeline/src/spots.rs new file mode 100644 index 00000000..3e79f67c --- /dev/null +++ b/crates/pipeline/src/spots.rs @@ -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> { + 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::() / a.len() as f32; + let var: f32 = ins.iter().map(|p| (p[1].max(0.0).sqrt() - mean_a).powi(2)).sum::() / 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 = 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); + } +} diff --git a/crates/preview/Cargo.toml b/crates/preview/Cargo.toml new file mode 100644 index 00000000..12e21b16 --- /dev/null +++ b/crates/preview/Cargo.toml @@ -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 } diff --git a/crates/preview/src/disk.rs b/crates/preview/src/disk.rs new file mode 100644 index 00000000..ccf0d480 --- /dev/null +++ b/crates/preview/src/disk.rs @@ -0,0 +1,176 @@ +//! Rendered thumbnails on disk: `/<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>, + 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 { + 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> { + 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 { + 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 }) +} diff --git a/crates/preview/src/hash.rs b/crates/preview/src/hash.rs new file mode 100644 index 00000000..e3ab51fe --- /dev/null +++ b/crates/preview/src/hash.rs @@ -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 { + (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() +} diff --git a/crates/preview/src/lib.rs b/crates/preview/src/lib.rs new file mode 100644 index 00000000..478c784d --- /dev/null +++ b/crates/preview/src/lib.rs @@ -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>>, + disk: Option, +} + +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> { + 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) { + 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; diff --git a/crates/preview/src/lru.rs b/crates/preview/src/lru.rs new file mode 100644 index 00000000..1be8bca4 --- /dev/null +++ b/crates/preview/src/lru.rs @@ -0,0 +1,97 @@ +//! A cost-bounded least-recently-used map. + +use std::collections::{BTreeMap, HashMap}; +use std::hash::Hash; + +pub struct Lru { + map: HashMap, + /// Use tick → key, oldest first. + order: BTreeMap, + tick: u64, + cost: usize, + budget: usize, +} + +impl Lru { + /// 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 { + 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 = 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; + } +} diff --git a/crates/preview/src/pool.rs b/crates/preview/src/pool.rs new file mode 100644 index 00000000..be1bbc64 --- /dev/null +++ b/crates/preview/src/pool.rs @@ -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 = Box R + Send>; + +struct Entry { + slot: S, + key: u64, + priority: u32, + seq: u64, + job: Job, +} + +struct Shared { + queue: Mutex>>, + cv: Condvar, + shutdown: AtomicBool, +} + +/// A finished job. +pub struct Done { + 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 { + shared: Arc>, + tx: Sender>, + rx: Receiver>, + threads: usize, + started: bool, + seq: u64, +} + +impl JobPool { + /// 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) { + 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) -> Vec { + 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> { + 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(shared: &Shared, wait: bool) -> Option> { + 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 Drop for JobPool { + fn drop(&mut self) { + self.shared.shutdown.store(true, Ordering::Relaxed); + self.shared.cv.notify_all(); + } +} diff --git a/crates/preview/src/tests.rs b/crates/preview/src/tests.rs new file mode 100644 index 00000000..038f935a --- /dev/null +++ b/crates/preview/src/tests.rs @@ -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 = 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 = 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)); +} diff --git a/crates/raster/examples/blur_bench.rs b/crates/raster/examples/blur_bench.rs new file mode 100644 index 00000000..0a222a33 --- /dev/null +++ b/crates/raster/examples/blur_bench.rs @@ -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)))); +} diff --git a/crates/raster/src/blur.rs b/crates/raster/src/blur.rs index 13f9c43a..ac8eeda6 100644 --- a/crates/raster/src/blur.rs +++ b/crates/raster/src/blur.rs @@ -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(src: &Image, r: usize) -> Image { - let w = src.width; - let mut out = Image::::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(row: &mut [T], tmp: &mut Vec, 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(src: &Image) -> Image { +/// 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(src: &Image, dst: &mut Image, r: usize) { let (w, h) = (src.width, src.height); - let mut out = Image::::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(img: &Image, sigma: f32) -> Image { } 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::::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)] diff --git a/crates/scenes/src/paint.rs b/crates/scenes/src/paint.rs index 6d2b5236..5cfd61e3 100644 --- a/crates/scenes/src/paint.rs +++ b/crates/scenes/src/paint.rs @@ -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))); diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index f574fb28..499dbfdd 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -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 } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index b74947da..97e130f9 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -77,7 +77,7 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value { "selection": app.session.selection.ids.iter().map(|p| p.0).collect::>(), "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 { - 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 { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c83e7c5f..0244b4df 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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. diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 042f4eab..5b760cd0 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -275,7 +275,8 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - 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" => { diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 2b10eca8..361c14f8 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -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)) { diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index c27aef66..05eda2d4 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -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 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), } } diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 3c4bc15c..6312c0cb 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -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)) { diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index 5babdeb0..ecfe694b 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -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>, Condvar)>; - pub struct Renderer { - queue: Queue, - results_tx: Sender<(Slot, RenderResult, f64)>, - results: Receiver<(Slot, RenderResult, f64)>, - pending: HashMap, + pool: JobPool, + /// Slot → (key, priority) of the request in flight (queued or running). + pending: HashMap, pub textures: HashMap, - 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::::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, 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; diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index c1b07808..34b3b15a 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -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 }, - 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, + }, + /// `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, } diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 4d888905..c2990a2b 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -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 = 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); diff --git a/docs/control-protocol.md b/docs/control-protocol.md new file mode 100644 index 00000000..875d2bce --- /dev/null +++ b/docs/control-protocol.md @@ -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 | diff --git a/docs/images/ba-blue-marble.jpg b/docs/images/ba-blue-marble.jpg index 6d9444f2..ae1ed9d1 100644 Binary files a/docs/images/ba-blue-marble.jpg and b/docs/images/ba-blue-marble.jpg differ diff --git a/docs/images/ba-earthrise.jpg b/docs/images/ba-earthrise.jpg index 33f7b2a6..76cbf59b 100644 Binary files a/docs/images/ba-earthrise.jpg and b/docs/images/ba-earthrise.jpg differ diff --git a/docs/images/ba-migrant-mother.jpg b/docs/images/ba-migrant-mother.jpg index 2ee7e429..7583448f 100644 Binary files a/docs/images/ba-migrant-mother.jpg and b/docs/images/ba-migrant-mother.jpg differ diff --git a/docs/images/ba-tetons.jpg b/docs/images/ba-tetons.jpg index 07ebf913..ce35ac72 100644 Binary files a/docs/images/ba-tetons.jpg and b/docs/images/ba-tetons.jpg differ diff --git a/docs/images/bw-split.jpg b/docs/images/bw-split.jpg index bc4ec46d..ce2644ec 100644 Binary files a/docs/images/bw-split.jpg and b/docs/images/bw-split.jpg differ diff --git a/docs/images/crop-tetons.jpg b/docs/images/crop-tetons.jpg index f4ab18e0..87faf649 100644 Binary files a/docs/images/crop-tetons.jpg and b/docs/images/crop-tetons.jpg differ diff --git a/docs/images/curve-tetons.jpg b/docs/images/curve-tetons.jpg index c744ef4b..93f73cc2 100644 Binary files a/docs/images/curve-tetons.jpg and b/docs/images/curve-tetons.jpg differ diff --git a/docs/images/grading-migrant-mother.jpg b/docs/images/grading-migrant-mother.jpg index 6c04c800..e6012a70 100644 Binary files a/docs/images/grading-migrant-mother.jpg and b/docs/images/grading-migrant-mother.jpg differ diff --git a/docs/images/grid-demo.jpg b/docs/images/grid-demo.jpg index 6d891cdc..2e0d5de2 100644 Binary files a/docs/images/grid-demo.jpg and b/docs/images/grid-demo.jpg differ diff --git a/docs/images/grid-pd.jpg b/docs/images/grid-pd.jpg index 8b3beafc..c6ff0fc3 100644 Binary files a/docs/images/grid-pd.jpg and b/docs/images/grid-pd.jpg differ diff --git a/docs/images/hero-tetons.jpg b/docs/images/hero-tetons.jpg index 25113954..c72273f5 100644 Binary files a/docs/images/hero-tetons.jpg and b/docs/images/hero-tetons.jpg differ diff --git a/docs/images/info-earthrise.jpg b/docs/images/info-earthrise.jpg index d93af913..5030e145 100644 Binary files a/docs/images/info-earthrise.jpg and b/docs/images/info-earthrise.jpg differ diff --git a/docs/images/masking.jpg b/docs/images/masking.jpg index 850d3fdb..826258e1 100644 Binary files a/docs/images/masking.jpg and b/docs/images/masking.jpg differ diff --git a/docs/images/presets-mixer.jpg b/docs/images/presets-mixer.jpg index 2babc65b..93747490 100644 Binary files a/docs/images/presets-mixer.jpg and b/docs/images/presets-mixer.jpg differ diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 00000000..02bdcb09 --- /dev/null +++ b/docs/mcp.md @@ -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 +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`. diff --git a/docs/showcase/README.md b/docs/showcase/README.md index b5e4b167..9d69e7ae 100644 --- a/docs/showcase/README.md +++ b/docs/showcase/README.md @@ -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) diff --git a/docs/showcase/demo.jsonl b/docs/showcase/demo.jsonl index ab9b1031..dfb44c38 100644 --- a/docs/showcase/demo.jsonl +++ b/docs/showcase/demo.jsonl @@ -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}} diff --git a/docs/web.md b/docs/web.md new file mode 100644 index 00000000..e60263ff --- /dev/null +++ b/docs/web.md @@ -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 --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 # → /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) +``` + +`` 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…* (⌘⇧I) 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//`) 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…* (⌘⇧E) 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. diff --git a/xtask/src/assets.rs b/xtask/src/assets.rs new file mode 100644 index 00000000..7a932945 --- /dev/null +++ b/xtask/src/assets.rs @@ -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 { + cell.split('`').skip(1).step_by(2) +} + +/// (path patterns, referenced licence files) from the attribution table. +pub fn parse(md: &str) -> (Vec, Vec) { + 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 { + 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::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 = + ["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/"))); + } +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 305d3dc4..0218d4a9 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -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 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 /web/; + --serve serves it on http://127.0.0.1: (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, String> { let crates = layers::from_metadata(&metadata()?)?; Ok(crates @@ -116,6 +124,7 @@ fn wasm_set() -> Result, 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(); diff --git a/xtask/src/web.rs b/xtask/src/web.rs new file mode 100644 index 00000000..18218f44 --- /dev/null +++ b/xtask/src/web.rs @@ -0,0 +1,163 @@ +//! `cargo xtask web`: build `apps/lightcraft-web` for wasm32 and bundle it with `wasm-bindgen` +//! into `/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 { + 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::().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"); + } +}