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 29db1292..b691609a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -934,6 +934,7 @@ dependencies = [ "egui-wgpu", "egui-winit", "egui_glow", + "glow", "glutin", "glutin-winit", "image", @@ -2000,6 +2001,17 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "lightcraft-cli" +version = "0.1.0" +dependencies = [ + "lightcraft-codecs", + "lightcraft-engine", + "lightcraft-mcp", + "lightcraft-raster", + "serde_json", +] + [[package]] name = "lightcraft-codecs" version = "0.1.0" @@ -2074,6 +2086,17 @@ dependencies = [ "serde", ] +[[package]] +name = "lightcraft-mcp" +version = "0.1.0" +dependencies = [ + "lightcraft-codecs", + "lightcraft-engine", + "lightcraft-raster", + "log", + "serde_json", +] + [[package]] name = "lightcraft-meta" version = "0.1.0" @@ -2176,6 +2199,25 @@ dependencies = [ "log", "serde", "serde_json", + "web-time", +] + +[[package]] +name = "lightcraft-web" +version = "0.1.0" +dependencies = [ + "eframe", + "egui", + "js-sys", + "lightcraft-codecs", + "lightcraft-engine", + "lightcraft-raster", + "lightcraft-ui-egui", + "log", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", ] [[package]] @@ -2233,6 +2275,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" dependencies = [ "cfg-if", + "rayon", ] [[package]] @@ -3255,6 +3298,7 @@ dependencies = [ "loop9", "quick-error 2.0.1", "rav1e", + "rayon", "rgb", ] diff --git a/Cargo.toml b/Cargo.toml index 8c349952..856cbe9a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,7 +53,7 @@ tiff = "0.11" image-webp = "0.2" moxcms = { version = "0.9", default-features = false, features = ["lut"] } jxl-oxide = { version = "0.12", default-features = false } -ravif = { version = "0.13", default-features = false } +ravif = { version = "0.13", default-features = false, features = ["threading"] } # no "asm": it needs nasm (non-Rust) # ui egui = "0.36" @@ -61,6 +61,13 @@ egui_extras = { version = "0.36", default-features = false } eframe = { version = "0.36", default-features = false } egui_kittest = "0.36" rfd = "0.17" +web-time = "1" + +# web (apps/lightcraft-web) +wasm-bindgen = "0.2.129" +wasm-bindgen-futures = "0.4" +js-sys = "0.3" +web-sys = "0.3" [workspace.lints.rust] unsafe_code = "deny" @@ -87,3 +94,9 @@ inherits = "release" lto = "thin" codegen-units = 1 debug = false + +# `cargo xtask web`: the browser build (no debug info; size matters more than build time) +[profile.web] +inherits = "release" +debug = false +lto = "thin" diff --git a/README.md b/README.md index 812a12ac..1cf51747 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,15 @@ LightCraft is young and moving fast — see the [roadmap](ROADMAP.md) for estima | Light, Color, Effects, Tone Curve, Color Mixer, Color Grading, B&W | ✅ | | Masking: brush, linear, radial, luminance/colour range, add/subtract/intersect | ✅ (AI subject/sky use classical heuristics for now) | | Crop, straighten, flip, rotate, aspect ratios, overlays | ✅ | -| Presets, versions, history, copy/paste/sync settings | ✅ | +| Profiles (Color, Neutral, Vivid, Landscape, Portrait, Monochrome — our own looks), presets, versions, history, copy/paste/sync settings | ✅ | | Control channel + every widget addressable | ✅ | -| RAW: DNG, CR2 | ✅ · NEF, ARW, CR3, RAF, ORF, RW2… 🚧 | -| Detail (sharpening ✅, noise reduction 🚧), Optics, Geometry/Upright | 🚧 | -| Remove / Heal / Clone, Red Eye | 🚧 (spots are recorded; rendering lands in M8) | -| Export dialog, MCP server, CLI, GPU pipeline, web build | 🚧 | +| RAW: DNG, CR2, ARW, NEF (uncompressed) | ✅ · compressed NEF, CR3, RAF, ORF, RW2, PEF… 🚧 | +| Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise, Super Resolution ⬜ | +| Remove / Heal / Clone spots (auto source) | ✅ · content-aware fill (PatchMatch), Red Eye 🚧 | +| Export: JPEG / PNG / TIFF / WebP / AVIF, sizing, file-size limit, output sharpening, naming, batch | ✅ · metadata policy, watermark, DNG ⬜ | +| MCP server, CLI, control channel | ✅ | +| Optics, Geometry/Upright, GPU pipeline, persistent library | 🚧 | +| Web build (same UI in the browser via WASM; in-memory imports, export downloads) | ✅ · persistence, workers 🚧 |
@@ -225,9 +239,14 @@ cargo run --release -p lightcraft # opens your library (~/ cargo run --release -p lightcraft -- ~/Pictures/trip # import your photos (folders are scanned, duplicates skipped) cargo run --release -p lightcraft -- --memory # a throwaway in-memory demo session (writes nothing) cargo run --release -p lightcraft -- --control 7980 # with the automation channel +cargo xtask web --serve # the same app in the browser: http://127.0.0.1:8080/ +cargo run --release -p lightcraft-cli -- render photo.jpg -o out.jpg --set light.exposure=0.5 cargo xtask ci # fmt, clippy, tests, layering, wasm checks ``` +The web build needs the `wasm32-unknown-unknown` target and the matching `wasm-bindgen` CLI +(`cargo xtask web` prints the exact install command); see [docs/web.md](docs/web.md). + Keyboard: 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. @@ -236,7 +255,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).
@@ -259,7 +291,7 @@ Clean-room, pure-Rust creative tools from the same workshop — native on macOS, Showcase photographs are public-domain works, used via Wikimedia Commons: Ansel Adams, *The Tetons and the Snake River* (1942, U.S. National Archives); Dorothea Lange, *Migrant Mother* (1936, Library of Congress); Bill Anders / NASA, *Earthrise* (1968); NASA, *The Blue Marble* (1972). The demo library is procedurally generated by LightCraft. UI font: -Source Sans 3 (SIL OFL). All icons are original. See [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md). +Inter (SIL OFL). All icons are original. See [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md). LightCraft is an independent project and is not affiliated with or endorsed by Adobe. Licence: MIT OR Apache-2.0. By the artcraft team. 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..8bc2869c --- /dev/null +++ b/apps/lightcraft-cli/src/main.rs @@ -0,0 +1,232 @@ +//! `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 + --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 compact = false; + let mut files = Vec::new(); + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "--connect" => { + // Optional address argument. + match args.get(i + 1).filter(|a| !a.starts_with("--") && a.contains(':')) { + Some(a) => { + connect = Some(a.clone()); + i += 1; + } + None => connect = Some(DEFAULT_ADDR.to_string()), + } + } + a if a.starts_with("--connect=") => connect = Some(a["--connect=".len()..].to_string()), + "--headless" => connect = None, + "--demo" => demo = true, + "--compact" => compact = true, + a if a.starts_with("--") => return Err(format!("unknown option `{a}`")), + f => files.push(f.to_string()), + } + i += 1; + } + let backend: Box = match connect { + Some(addr) => { + if !files.is_empty() || demo { + return Err("FILES and --demo apply to headless mode only (import through the `import` tool instead)".into()); + } + match Remote::connect(&addr) { + Ok(r) => { + eprintln!("lightcraft-cli mcp: connected to LightCraft at {addr}"); + Box::new(r) + } + Err(e) => { + eprintln!("lightcraft-cli mcp: LightCraft is not reachable at {addr} yet ({e}); will retry on each call"); + Box::new(Remote::lazy(&addr)) + } + } + } + None => { + let mut h = if demo { Headless::demo() } else { Headless::default() }; + if !files.is_empty() { + let paths = expand_paths(&files); + let r = h.session.execute("library.import", &json!({"paths": paths})).map_err(|e| e.to_string())?; + eprintln!("lightcraft-cli mcp: imported {} photo(s)", r["imported"].as_array().map_or(0, Vec::len)); + } + Box::new(h) + } + }; + eprintln!("lightcraft-cli mcp: serving MCP on stdio ({})", backend.describe()); + let mut server = Server::new(backend).with_command_tools(!compact); + let stdin = std::io::stdin(); + server.serve(BufReader::new(stdin.lock()), std::io::stdout().lock()).map_err(|e| e.to_string()) +} + +fn take_value<'a>(args: &'a [String], i: &mut usize, flag: &str) -> Result<&'a str, String> { + *i += 1; + args.get(*i).map(String::as_str).ok_or_else(|| format!("{flag} needs a value")) +} + +fn render(args: &[String]) -> Result<(), String> { + let mut input = None; + let mut output = None; + let mut values = serde_json::Map::new(); + let mut settings: Option = 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/src/main.rs b/apps/lightcraft/src/main.rs index c23b0a58..5b39ee4e 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -85,7 +85,12 @@ fn services() -> Services { .map(|p| p.to_string_lossy().to_string()) .collect() })), - write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), + write: Some(Box::new(|p: &str, b: &[u8]| { + if let Some(dir) = std::path::Path::new(p).parent().filter(|d| !d.as_os_str().is_empty()) { + std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; + } + std::fs::write(p, b).map_err(|e| e.to_string()) + })), png: Some(Box::new(|img: &lightcraft_raster::Rgba8| { lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default() })), 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/engine/src/export.rs b/crates/engine/src/export.rs new file mode 100644 index 00000000..affcbc3d --- /dev/null +++ b/crates/engine/src/export.rs @@ -0,0 +1,384 @@ +//! Export: encode a rendered image to JPEG / PNG / TIFF / WebP / AVIF with an embedded sRGB profile, +//! optional output sharpening and a JPEG file-size limit. Pure (bytes in, bytes out) so the desktop +//! app, CLI, MCP and the web build share it; writing the file is the caller's job. + +use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, NamedSpace, TiffCompression, encode, icc}; +use lightcraft_meta::{DateTime, Gps, Metadata}; +use lightcraft_raster::Rgba8; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ExportFormat { + #[default] + Jpeg, + Png, + Tiff, + Webp, + Avif, +} + +impl ExportFormat { + pub fn parse(s: &str) -> Option { + Some(match s.to_ascii_lowercase().as_str() { + "jpeg" | "jpg" => Self::Jpeg, + "png" => Self::Png, + "tiff" | "tif" => Self::Tiff, + "webp" => Self::Webp, + "avif" => Self::Avif, + _ => return None, + }) + } + pub fn extension(self) -> &'static str { + match self { + Self::Jpeg => "jpg", + Self::Png => "png", + Self::Tiff => "tif", + Self::Webp => "webp", + Self::Avif => "avif", + } + } +} + +/// Output sharpening target (applied after resizing, in display-encoded values). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SharpenFor { + #[default] + None, + Screen, + Matte, + Glossy, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SharpenAmount { + Low, + #[default] + Standard, + High, +} + +/// Which metadata is embedded in exported files. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum MetadataPolicy { + /// Everything we know: camera, capture settings, lens, location, title, caption, keywords, copyright. + #[default] + All, + /// Everything except camera/lens make, model and capture settings. + AllExceptCamera, + /// Copyright and creator only. + Copyright, + /// Nothing (the sRGB profile is still embedded). + None, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ExportOptions { + pub format: ExportFormat, + /// 1–100 (JPEG, AVIF). + pub quality: u8, + /// Resize so the long edge is at most this many pixels (`None` = full size). + pub long_edge: Option, + /// 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, +} + +impl Default for ExportOptions { + fn default() -> Self { + Self { + format: ExportFormat::Jpeg, + quality: 90, + long_edge: None, + limit_kb: None, + sharpen: SharpenFor::None, + sharpen_amount: SharpenAmount::Standard, + naming: "{name}".into(), + metadata: MetadataPolicy::All, + remove_location: false, + } + } +} + +impl ExportOptions { + /// Read options from command params (`format`, `quality`, `longEdge`, `limitKb`, `sharpen`, `sharpenAmount`, `naming`). + pub fn from_json(p: &serde_json::Value) -> Self { + use serde_json::Value; + let d = Self::default(); + let s = |k: &str| p.get(k).and_then(Value::as_str); + let u = |k: &str| p.get(k).and_then(Value::as_u64); + fn enm(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), + } + } + + /// 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); + 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 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/lib.rs b/crates/engine/src/lib.rs index ddc6f784..01ae5da9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -12,6 +12,7 @@ pub mod cmd; pub mod demo; +pub mod export; pub mod files; pub mod import; pub mod library; 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..0ad3f7f7 --- /dev/null +++ b/crates/mcp/src/headless.rs @@ -0,0 +1,178 @@ +//! An in-process [`Session`] that answers the control-channel methods the MCP tools use, so the +//! MCP server (and the CLI) work without a window. + +use std::path::Path; + +use lightcraft_engine::Session; +use lightcraft_engine::catalog::PhotoId; +use lightcraft_raster::Rgba8; +use serde_json::{Value, json}; + +use crate::backend::Backend; + +/// File extensions recognised as photos when expanding folders. +pub const PHOTO_EXTENSIONS: &[&str] = + &["jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "arw", "raf", "orf", "rw2", "pef", "psd", "jxl", "gif", "bmp", "avif"]; + +/// Headless backend: a [`Session`] with filesystem hooks. +pub struct Headless { + pub session: Session, +} + +impl Default for Headless { + fn default() -> Self { + Self::new(Session::new().with_fs()) + } +} + +impl Headless { + pub fn new(session: Session) -> Self { + Self { session } + } + + /// A headless session with the procedurally generated demo library. + pub fn demo() -> Self { + Self::new(Session::with_demo().with_fs()) + } + + fn photo_or_active(&self, p: &Value) -> Result { + 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..de3b78b2 --- /dev/null +++ b/crates/mcp/src/tools.rs @@ -0,0 +1,559 @@ +//! MCP tools: hand-written helpers (import, develop, render, screenshot, UI input…) plus one tool +//! per command in the engine's command registry (`cmd_`), 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"} + }), + &[], + ), + ]; + 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", + ], + ), + ) + })), + "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/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/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 21e287f7..499dbfdd 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -15,6 +15,7 @@ lightcraft-raster = { workspace = true } lightcraft-develop = { workspace = true } lightcraft-pipeline = { workspace = true } lightcraft-catalog = { workspace = true } +web-time = { workspace = true } lightcraft-engine = { workspace = true } lightcraft-preview = { workspace = true } egui = { workspace = true } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 60b19d6c..97e130f9 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -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 33287095..0244b4df 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -267,16 +267,10 @@ impl LightcraftApp { } } +/// Wall-clock milliseconds since the Unix epoch (`web-time` maps to `Date.now()` on the web). pub fn now_ms() -> f64 { - #[cfg(not(target_arch = "wasm32"))] - { - use std::time::{SystemTime, UNIX_EPOCH}; - SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0) - } - #[cfg(target_arch = "wasm32")] - { - 0.0 - } + use web_time::{SystemTime, UNIX_EPOCH}; + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0) } /// Whether the settings render in black & white. 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/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index c27aef66..8dd90702 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -56,20 +56,71 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } }); } - Dialog::Export { format, quality, long_edge } => { + Dialog::Export { opts, long_edge, limit_kb, dir } => { + use lightcraft_engine::export::{ExportFormat as F, SharpenAmount as A, SharpenFor as S}; + let n = app.session.selection.ids.len().max(1); + ui.label(egui::RichText::new(format!("{n} photo{}", if n == 1 { "" } else { "s" })).color(t.text_dim)); ui.horizontal(|ui| { ui.label("Format"); - for f in ["jpeg", "png", "tiff"] { - ui.selectable_value(format, f.to_string(), f.to_uppercase()); + for (f, l) in [(F::Jpeg, "JPEG"), (F::Png, "PNG"), (F::Tiff, "TIFF"), (F::Webp, "WebP"), (F::Avif, "AVIF")] { + ui.selectable_value(&mut opts.format, f, l); } }); - ui.add(egui::Slider::new(quality, 10..=100).text("Quality")); - ui.add(egui::Slider::new(long_edge, 512..=8000).text("Long edge (px)")); + if matches!(opts.format, F::Jpeg | F::Avif) { + ui.add(egui::Slider::new(&mut opts.quality, 1..=100).text("Quality")); + } + if opts.format == F::Jpeg { + ui.add(egui::Slider::new(limit_kb, 0..=20_000).text("Limit file size (KB, 0 = off)")); + } + let mut full = *long_edge == 0; + if ui.checkbox(&mut full, "Full size").changed() { + *long_edge = if full { 0 } else { 2048 }; + } + if !full { + ui.add(egui::Slider::new(long_edge, 256..=12_000).text("Long edge (px)")); + } + ui.horizontal(|ui| { + ui.label("Sharpen for"); + for (v, l) in [(S::None, "None"), (S::Screen, "Screen"), (S::Matte, "Matte paper"), (S::Glossy, "Glossy paper")] { + ui.selectable_value(&mut opts.sharpen, v, l); + } + }); + if opts.sharpen != S::None { + ui.horizontal(|ui| { + ui.label("Amount"); + for (v, l) in [(A::Low, "Low"), (A::Standard, "Standard"), (A::High, "High")] { + ui.selectable_value(&mut opts.sharpen_amount, v, l); + } + }); + } + ui.horizontal(|ui| { + use lightcraft_engine::export::MetadataPolicy as M; + ui.label("Metadata"); + for (v, l) in + [(M::All, "All"), (M::AllExceptCamera, "All except camera"), (M::Copyright, "Copyright only"), (M::None, "None")] + { + ui.selectable_value(&mut opts.metadata, v, l); + } + }); + if !matches!( + opts.metadata, + lightcraft_engine::export::MetadataPolicy::None | lightcraft_engine::export::MetadataPolicy::Copyright + ) { + ui.checkbox(&mut opts.remove_location, "Remove location info"); + } + ui.horizontal(|ui| { + ui.label("File name"); + ui.add(egui::TextEdit::singleline(&mut opts.naming).hint_text("{name}-{seq}").desired_width(f32::INFINITY)); + }); + ui.horizontal(|ui| { + ui.label("Folder"); + ui.add(egui::TextEdit::singleline(dir).desired_width(f32::INFINITY)); + }); } Dialog::About => { ui.label(egui::RichText::new("LightCraft").font(t.semibold(20.0)).color(t.text)); ui.label(format!("Version {} — a clean-room, pure-Rust photo library and raw developer.", env!("CARGO_PKG_VERSION"))); - ui.label("MIT OR Apache-2.0. Fonts: Source Sans 3 (OFL). Icons: original."); + ui.label("MIT OR Apache-2.0. Font: Inter (OFL). Icons: original."); } Dialog::Shortcuts => { egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| { @@ -129,7 +180,14 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result app.run("develop.copy", json!({"groups": groups})), - Dialog::Export { format, quality, long_edge } => app.run("app.export", json!({"format": format, "quality": quality, "longEdge": long_edge})), + Dialog::Export { opts, long_edge, limit_kb, dir } => app.run( + "app.export", + json!({ + "format": opts.format, "quality": opts.quality, "longEdge": long_edge, "limitKb": limit_kb, + "sharpen": opts.sharpen, "sharpenAmount": opts.sharpen_amount, "naming": opts.naming, "dir": dir, + "metadata": opts.metadata, "removeLocation": opts.remove_location, + }), + ), Dialog::About | Dialog::Shortcuts => Ok(serde_json::Value::Null), } } diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index dab06d33..ecfe694b 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -86,11 +86,18 @@ impl Renderer { /// Collect finished jobs into textures. Returns true if anything changed. pub fn poll(&mut self, ctx: &egui::Context, session: &mut Session) -> bool { + // wasm: run one job per frame on this thread, timed with the host clock #[cfg(target_arch = "wasm32")] - self.pool.run_inline(1); + let inline_ms = { + let t0 = crate::now_ms(); + self.pool.run_inline(1); + crate::now_ms() - t0 + }; + #[cfg(not(target_arch = "wasm32"))] + let inline_ms = 0.0; let mut changed = false; while let Some(done) = self.pool.try_recv() { - let (slot, r, ms) = (done.slot, done.result, done.ms); + let (slot, r, ms) = (done.slot, done.result, if done.ms > 0.0 { done.ms } else { inline_ms }); session.accept(&r); self.completed += 1; if self.pending.get(&slot).is_some_and(|p| p.0 == r.key) { 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..1242a7a1 --- /dev/null +++ b/docs/mcp.md @@ -0,0 +1,156 @@ +# LightCraft MCP server + +`lightcraft-cli mcp` exposes LightCraft to AI agents through the +[Model Context Protocol](https://modelcontextprotocol.io): newline-delimited JSON-RPC 2.0 over +stdio (protocol revision `2025-06-18`; `2025-03-26` and `2024-11-05` clients are accepted). The +server lives in `crates/mcp` (`lightcraft-mcp`, layer L5) and is hand-written: no async runtime, +no C dependencies. + +It runs in one of two modes: + +| Mode | Command | What it drives | +|---|---|---| +| **Headless** (default) | `lightcraft-cli mcp [--demo] [FILES/FOLDERS…]` | An in-process engine `Session`. Develop, render and export without a window. | +| **Connect** | `lightcraft-cli mcp --connect [127.0.0.1:7980]` | A running desktop app started with `lightcraft --control 7980`, through its loopback JSON-lines control channel ([control-protocol.md](control-protocol.md)). Adds the UI tools (screenshot, clicks, keys, pointer gestures). | + +Options: `--demo` starts the headless session with the procedurally generated demo library; +`--compact` lists only the helper tools (see below). In connect mode the server starts even when +the app is not running yet and connects on the first call (and reconnects if the app restarts). + +Logs go to stderr; stdout carries only protocol messages. + +## Wiring it into a client + +Build once: `cargo build --release -p lightcraft-cli` (binary: `target/release/lightcraft-cli`). + +### Claude Code + +```sh +# headless, with a folder of photos imported at start +claude mcp add lightcraft -- /path/to/lightcraft/target/release/lightcraft-cli mcp ~/Pictures/shoot + +# or: drive the running desktop app (start it with `lightcraft --control 7980`) +claude mcp add lightcraft-app -- /path/to/lightcraft/target/release/lightcraft-cli mcp --connect 127.0.0.1:7980 +``` + +Or check a project-scoped `.mcp.json` into your repo: + +```json +{ + "mcpServers": { + "lightcraft": { + "command": "/path/to/lightcraft/target/release/lightcraft-cli", + "args": ["mcp", "--connect", "127.0.0.1:7980"] + } + } +} +``` + +### Other clients (Claude Desktop, Cursor, …) + +Every stdio MCP client takes the same shape: a `command` plus `args`. For example +`claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "lightcraft": { + "command": "/path/to/lightcraft/target/release/lightcraft-cli", + "args": ["mcp", "--demo"] + } + } +} +``` + +During development you can also point the client at `cargo run --release -p lightcraft-cli -- mcp` +(with `"cwd"` set to the repository), at the cost of a slower start. + +## Tools + +### Helpers + +| Tool | Does | +|---|---| +| `list_commands {filter?}` | Every command: id, label, menu, shortcut, parameter doc, enabled now | +| `run_command {command, params?}` | Run any command by id | +| `import {paths, album?}` | Import files/folders (folders are scanned recursively); the first new photo becomes active | +| `query_photos {filter?, sort?, offset?, limit?}` | Photos in the current view (or matching a catalog `Filter`) | +| `select_photos {ids, active?, mode?}` | Set the selection / active photo | +| `list_controls {section?}` | Every develop slider: id (`light.exposure`…), range, default, current value | +| `get_develop {id?}` | Full develop-settings JSON | +| `set_develop {id?, values?, settings?, label?}` | `values`: `{controlId: number}`; `settings`: partial develop JSON deep-merged. Undoable | +| `apply_preset {preset, amount?, ids?}` | Apply a preset (ids from `cmd_presets_list`) | +| `crop {id?, rect?, angle?, reset?}` | Normalized crop rect `[x0,y0,x1,y1]` and straighten angle | +| `render_photo {id?, size?, format?, path?}` | Render with current settings → **image content** (PNG, or JPEG with `format: "jpeg"`), long edge `size` (default 1024) | +| `export {path, id?, longEdge?, quality?}` | Full-quality render to `.png` / `.jpg` / `.tif` / `.webp` (headless; the desktop app writes PNG) | + +Tools taking `id` make that photo active first; without it they act on the active photo. + +### Connect mode only + +| Tool | Does | +|---|---| +| `screenshot {maxSize?, format?, path?}` | The app window as an image, after pending renders finish | +| `inspect_ui` | View, panel, window/image rects, selection, status | +| `set_ui {state}` | Merge UI state, e.g. `{"view": "detail"}` | +| `list_widgets {filter?}` / `click {widget \| x,y, count?}` | Widgets by automation id; real egui clicks | +| `press_key {key, cmd?, shift?, alt?}` / `type_text {text}` | Keyboard input (shortcuts) | +| `pointer_gesture {events}` | Gestures in normalized image coordinates (brush strokes, gradients, crop handles) | + +In headless mode these return a tool error explaining how to start the app. + +### One tool per command + +Everything is a command in LightCraft, so `tools/list` also contains one tool per entry of the +command registry (engine commands, plus the app's UI commands such as `view.detail` when +connected): the id with `.` replaced by `_` and a `cmd_` prefix — `photo.rate` → `cmd_photo_rate`, +`develop.set` → `cmd_develop_set`, `edit.undo` → `cmd_edit_undo`. Arguments are the command's +JSON params (documented in each tool's description and by `list_commands`). Pass `--compact` to +leave these out (about 90 headless / 140 connected) when a client struggles with many tools; +`run_command` still reaches every command. + +## Resources + +`resources/list` / `resources/read` serve JSON snapshots: + +| URI | Content | +|---|---| +| `lightcraft://library` | Source, filter, sort, selection, undo/redo labels (`library.state`) | +| `lightcraft://photos` | Photos in the current view (`catalog.query`) | +| `lightcraft://photo/active` | Everything about the active photo (`photo.inspect`) | +| `lightcraft://develop/active` | The active photo's develop settings (`develop.get`) | +| `lightcraft://controls` | Every develop control with its current value (`develop.controls`) | + +## Example session + +```text +→ {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"me","version":"1"}}} +← {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{…},"resources":{…}},"serverInfo":{"name":"lightcraft",…},"instructions":"…"}} +→ {"jsonrpc":"2.0","method":"notifications/initialized"} +→ {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"set_develop","arguments":{"values":{"light.exposure":0.7}}}} +← {"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"{…}"}],"isError":false,"structuredContent":{"ok":true,"controls":[{"id":"light.exposure","value":0.7}]}}} +→ {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"render_photo","arguments":{"size":768}}} +← {"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"image","data":"iVBORw0…","mimeType":"image/png"},{"type":"text","text":"{\"id\":21,\"width\":512,\"height\":768}"}],"isError":false}} +``` + +Errors from commands (unknown control, nothing selected, app not reachable) come back as tool +results with `isError: true` so the model can read and correct them; malformed JSON-RPC gets the +standard error codes (-32700 parse, -32600 invalid request, -32601 method not found, -32602 +invalid params, -32002 resource not found). + +## Other CLI subcommands + +```sh +lightcraft-cli render in.dng -o out.jpg --set light.exposure=0.5 --set light.contrast=20 --size 2048 +lightcraft-cli render in.jpg -o out.png --settings look.json --preset +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/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"); + } +}