diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..a8516d43 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# lrg-develop's fixtures and goldens are compared byte for byte by tests (the +# registry snapshot, the XMP writer goldens in written/, the Lua wire goldens +# in wire/); keep them LF on every +# checkout, Windows runners with core.autocrlf=true included. Only this +# folder: server-rs/testdata also holds binary files. +server-rs/testdata/develop/** text eol=lf diff --git a/.github/workflows/golden-tests-full.yml b/.github/workflows/golden-tests-full.yml index e355a4ba..0fde7fc3 100644 --- a/.github/workflows/golden-tests-full.yml +++ b/.github/workflows/golden-tests-full.yml @@ -7,6 +7,12 @@ name: "Golden tests (all model assets)" # family — AuraFace (261 MB) from Hugging Face, the rest from releases — and # runs with LRG_REQUIRE_GOLDENS=all, so a missing asset fails instead of # skipping: the numerical checks either run or the job goes red. +# `all` leaves out the gate's LOCAL_ONLY families (crates/lrg-ml/tests/common), +# whose data is private or not redistributable and can never be fetched here: +# lrg-develop's local sweeps `training-dump` (the maintainer's training rows), +# `lrc-presets` (Lightroom's bundled presets and profiles), `acr-presets` +# (Camera Raw's user folder: its presets and profiles) and `xmp-corpus` +# (a private sidecar corpus). # # Deliberately *not* cached. A fresh download each night also verifies that # the published release assets are still present and fetchable, which is what diff --git a/.github/workflows/lint-format.yml b/.github/workflows/lint-format.yml index 79169c5f..4185b8a6 100644 --- a/.github/workflows/lint-format.yml +++ b/.github/workflows/lint-format.yml @@ -54,6 +54,14 @@ jobs: set -o pipefail python3 scripts/check-docs.py 2>&1 | tee /tmp/check-docs.txt continue-on-error: false + - name: Develop fixtures carry no private data + # server-rs/testdata/develop is derived from the maintainer's own + # Lightroom edits; the check is stdlib-only Python. + run: | + python3 server-rs/scripts/develop_registry/extract_fixtures.py --check-only \ + --out-dir server-rs/testdata/develop/lua --root server-rs/testdata/develop + python3 server-rs/scripts/develop_registry/extract_fixtures.py --self-test \ + --out-dir server-rs/testdata/develop/lua --root server-rs/testdata/develop - name: Report possibly-stale pages if: always() run: | @@ -92,6 +100,13 @@ jobs: - name: Linting (Rust) working-directory: server-rs run: cargo clippy --workspace --all-targets -- -D warnings + # The invariant also guards plugin/LrGeniusAI.lrdevplugin/DevelopEditManager.lua, + # and server-rs-tests.yml runs only for `server-rs/**` changes, so a + # plugin-only PR that reintroduced a `Temp` key would pass there and + # fail on the next unrelated server-rs PR. This job is unfiltered. + - name: No Temp develop key (Rust + DevelopEditManager.lua) + working-directory: server-rs + run: cargo test -p lrg-develop --test no_temp_key # `llamacpp` is off by default, but lrg-llama is a workspace member, so # the step above already compiles llama.cpp from source and lints that diff --git a/.github/workflows/lua-tests.yml b/.github/workflows/lua-tests.yml index f517ab31..ec7da5b8 100644 --- a/.github/workflows/lua-tests.yml +++ b/.github/workflows/lua-tests.yml @@ -6,6 +6,9 @@ on: - ".busted" - ".luacheckrc" - ".github/workflows/lua-tests.yml" + # The backend's wire goldens: plugin/spec/native_wire_format_spec.lua + # decodes them, so a re-bless must re-run busted. + - "server-rs/testdata/develop/wire/**" push: branches: [main] paths: @@ -13,6 +16,9 @@ on: - ".busted" - ".luacheckrc" - ".github/workflows/lua-tests.yml" + # The backend's wire goldens: plugin/spec/native_wire_format_spec.lua + # decodes them, so a re-bless must re-run busted. + - "server-rs/testdata/develop/wire/**" # A newer commit on the same ref makes the in-flight run obsolete; main is # exempt so each commit that lands keeps its own result. concurrency: diff --git a/.gitignore b/.gitignore index 39891710..28f0a6d2 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,8 @@ model_scripts/**/*.bin **/._.DS_Store **/.DS_Store build/ +# Source, not build output: lrg-develop's mask builders (`lrg_develop::build`). +!server-rs/crates/lrg-develop/src/build/ dist/ models/ tests/results/*.json diff --git a/.luacheckrc b/.luacheckrc index 5c00913c..0b7f3c9d 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -4,6 +4,7 @@ std = "lua51" globals = { "import", "AestheticRefine", + "EditLoop", "AgentBridge", "AgentCommands", "PluginTests", @@ -35,6 +36,7 @@ globals = { "SkipPhotoContextDialog", "SpeciesLinks", "DevelopEditManager", + "DevelopExperiments", "TaskUpdate" } diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5ca66f9f..b663ac2b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -31,3 +31,21 @@ repos: # checks read; staleness warnings surface in CI. files: ^(server-rs/crates/lrg-api/src/routes/|plugin/LrGeniusAI\.lrdevplugin/APISearchIndex\.lua|docs/|scripts/check-docs\.py$|CLAUDE\.md$|AGENTS\.md$) pass_filenames: false + - id: develop-fixture-hygiene + name: develop fixtures carry no private data + # The fixtures under server-rs/testdata/develop are derived from the + # maintainer's own edits and the repository is public. Stdlib-only, so + # plain python3 is enough (no uv). + entry: python3 server-rs/scripts/develop_registry/extract_fixtures.py --check-only --out-dir server-rs/testdata/develop/lua --root server-rs/testdata/develop + language: system + files: ^server-rs/(testdata/develop/|scripts/develop_registry/(extract_fixtures|outpath)\.py$) + pass_filenames: false + - id: develop-fixture-hygiene-self-test + name: develop fixture hygiene check flags what it should + # Crafted files on a copy of the tree; the same cases as + # crates/lrg-develop/tests/fixture_hygiene.rs, so the Python and Rust + # checks cannot drift apart unnoticed. + entry: python3 server-rs/scripts/develop_registry/extract_fixtures.py --self-test --out-dir server-rs/testdata/develop/lua --root server-rs/testdata/develop + language: system + files: ^server-rs/(testdata/develop/|scripts/develop_registry/(extract_fixtures|outpath)\.py$) + pass_filenames: false diff --git a/AGENTS.md b/AGENTS.md index f7d07652..e1441c4d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,7 +91,15 @@ failure — this is what stops CI being green on numerical checks that never ran PR CI provisions the face pair (89 MB) and requires it; the nightly `golden-tests-full.yml` fetches every family. If you add a golden test, gate it through `crates/lrg-ml/tests/common/assets_ready` rather than an early -`return`. +`return`. Families whose data can never be in CI (private or not +redistributable) are listed in the gate's `LOCAL_ONLY`: `all` leaves them out, +only naming one explicitly makes its absence a failure. They are `lrg-develop`'s +local sweeps, each reading a path from its variable: `training-dump` +(`LRG_DEVELOP_TRAINING_DUMP`, the maintainer's training rows), `lrc-presets` +(`LRG_LRC_PRESETS_DIR`, Lightroom's bundled presets and profiles), +`acr-presets` (`LRG_ACR_PRESETS_DIR`, Camera Raw's user folder: its presets and +profiles) and `xmp-corpus` (`LRG_XMP_CORPUS_DIR`, a private sidecar corpus); see +`docs/wiki/Dev-Develop-Model.md`. ### Plugin — load into Lightroom @@ -157,12 +165,13 @@ Cargo workspace, one binary (`geniusai-server`) across these crates: - `lrg-common` — config/CLI, error→envelope, version (baked in via `LRG_BACKEND_*` build-time env vars, `option_env!`), logging+rotation, PID/OK lifecycle handshake. - `lrg-store` — LanceDB wrapper, Arrow schemas, `db_path` bind state machine, backup, stats. `lrg-chroma-reader` is migration-only: reads an old ChromaDB directory (WAL + HNSW segment binaries) left over from the retired Python backend, to migrate its data into LanceDB — no Chroma dependency at runtime. -- `lrg-imaging` — image conversion, EXIF/IPTC/XMP, pHash, culling metrics. +- `lrg-imaging` — image conversion, EXIF/IPTC/XMP, pHash, culling metrics, a render's tones in numbers for AI Edit (`tone.rs`). - `lrg-ml` — ONNX Runtime (`ort` crate) session management, SigLIP2 pre/post-processing + `tokenizers`-crate Gemma tokenizer, YuNet face detection + FaceNet embedding, MediaPipe Face Mesh V2 + Blendshape V2 for eye state, smile and gaze (`face_state.rs`). Model file locations resolved in `model_paths.rs` (env vars, see [server-rs/README.md](server-rs/README.md)). - `lrg-analysis` — clustering, person matching, group/cull grading, style engine, keyword clustering. - `lrg-providers` — LLM provider trait + REST clients (OpenAI, Gemini, Anthropic — Claude through Anthropic's own Messages API in `anthropic.rs`, not its OpenAI-compatible endpoint, which lists no models and ignores the answer format — Ollama, OpenAI-compatible servers — LM Studio found on this computer, and the user's own "Other AI server" such as OpenRouter, llama.cpp `llama-server` or LiteLLM, both in `openai_compatible.rs`), edit-recipe schemas. `local_provider.rs` serves *both* local backends off one `LocalEngine` trait; it has no llama.cpp or MLX dependency of its own. +- `lrg-develop` — the native Lightroom develop model, a leaf crate (no image, network or async dependencies, so `cargo test -p lrg-develop` takes seconds): the key registry (`registry/table.rs`, one `KeySpec` per `crs:` key with type, stored range, UI scale, number format, defaults, policy class, frame scope, file kind), the typed model (`DevelopSettings`, corrections and masks, `Look`, white balance, the policy filter), the readers for `getDevelopSettings()` blobs (`lua::read`) and XMP sidecars, presets and profiles (`xmp::read`, on `roxmltree`), which share their typing and classification rules so both land in the same model, the deterministic develop-preset writer (`xmp::write`), the Lua writer for `applyDevelopSettings` (`lua::write`; provisional until experiments E1/E2/E4/E11, its open choices in `LuaOptions::PROVISIONAL`) — both writers' lossless test modes exist only behind the crate's `test-roundtrip` feature, which only its own dev-dependency enables — and the correction builders (`build`: semantic, gradient and luminance-range masks with SHA-256-derived sync ids). The LLM edit-recipe schema stays in `lrg-providers`, learning/blending in `lrg-analysis::style_engine`. See [Dev-Develop-Model.md](docs/wiki/Dev-Develop-Model.md). - `lrg-mlx` — supervises the `lrgenius-mlx` Swift sidecar (`native/mlx-sidecar/`) and speaks its JSON-lines stdio protocol. No native build step, no cargo feature; Apple silicon only at runtime. -- `lrg-api` — axum routers (one module per API domain under `routes/`), `db_path` auto-bind middleware, jobs registry, and the browser UI: the pages under `src/ui/` served by `routes/ui.rs`, plus the `ui_bridge` queue that hands their actions to the plugin, and the `agent_bridge` that hands MCP tool calls to the plugin (`/v1/agent/*`). +- `lrg-api` — axum routers (one module per API domain under `routes/`), `db_path` auto-bind middleware, jobs registry, and the browser UI: the pages under `src/ui/` served by `routes/ui.rs`, plus the `ui_bridge` queue that hands their actions to the plugin, the `agent_bridge` that hands MCP tool calls to the plugin (`/v1/agent/*`), and AI Edit's retoucher sessions (`edit_loop.rs`, `/v1/edit/sessions`): a vision model edits through `lrg_providers::chat::ChatModel` with the tools in `lrg_providers::edit_loop`, each edit becomes an `applyDevelopSettings` table through `lrg_develop::build::recipe` and the Lua writer, and the plugin's `EditLoop.lua` applies, renders and hands back each round. - `lrg-mcp` — the MCP endpoint (`/mcp`, bearer agent token required) AI agents connect to: tools over the index (calling the REST routes in-process) and over Lightroom (through `agent_bridge` to the plugin's `AgentBridge.lua`/`AgentCommands.lua`, which starts with Lightroom), result sets, prompts, and `geniusai-server mcp`, a stdio proxy for Claude Desktop. See [Dev-Agent-Bridge.md](docs/wiki/Dev-Agent-Bridge.md). - `lrg-chat` — the in-app agent chat (`/v1/chat/*`): a local or cloud model works the catalog through the same tools, as an in-memory MCP client of `lrg-mcp`, with the user's approval (`permissions.rs`). The model answers through `lrg_providers::chat::ChatModel`: `cloud_chat.rs` uses each API's own tool calling, and `local_chat.rs` does it by decoding against a JSON Schema, since llama.cpp cannot render the Jinja templates that carry a model's native tool-call format. See [Dev-Agent-Chat.md](docs/wiki/Dev-Agent-Chat.md). - `lrg-server` — the binary: CLI (`clap`), lifecycle, self-updater (`routes::update`), `migrate` and `mcp` (stdio proxy) subcommands; merges the `lrg-mcp` and `lrg-chat` routers next to `lrg_api::build_router`'s. @@ -194,7 +203,7 @@ Cargo workspace, one binary (`geniusai-server`) across these crates: ### Rust / Backend (`server-rs/`) -- Routes in `lrg-api::routes` (one axum `Router` per domain, merged in `lrg_api::build_router`); business logic in `lrg-analysis`/`lrg-imaging`/`lrg-ml`; LLM/cloud clients in `lrg-providers`. +- Routes in `lrg-api::routes` (one axum `Router` per domain, merged in `lrg_api::build_router`); business logic in `lrg-analysis`/`lrg-imaging`/`lrg-ml`; LLM/cloud clients in `lrg-providers`; the native Lightroom develop model (key registry, typed model, readers and writers of the Lua and XMP forms) in `lrg-develop`, while the LLM edit-recipe schema stays in `lrg-providers` — a new develop key is a registry row in `lrg-develop`, not a field or a string literal elsewhere. - Always use the `log` facade (`log::info!`/`warn!`/`error!`). - Manage dependencies via `cargo add`/`cargo remove` (updates `Cargo.toml` + `Cargo.lock`); commit both. - Code must pass `cargo fmt` and `cargo clippy --workspace --all-targets` with zero warnings before considering a change done. diff --git a/CLAUDE.md b/CLAUDE.md index 9af64ab1..b2e2d40b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,7 +91,15 @@ failure — this is what stops CI being green on numerical checks that never ran PR CI provisions the face pair (89 MB) and requires it; the nightly `golden-tests-full.yml` fetches every family. If you add a golden test, gate it through `crates/lrg-ml/tests/common/assets_ready` rather than an early -`return`. +`return`. Families whose data can never be in CI (private or not +redistributable) are listed in the gate's `LOCAL_ONLY`: `all` leaves them out, +only naming one explicitly makes its absence a failure. They are `lrg-develop`'s +local sweeps, each reading a path from its variable: `training-dump` +(`LRG_DEVELOP_TRAINING_DUMP`, the maintainer's training rows), `lrc-presets` +(`LRG_LRC_PRESETS_DIR`, Lightroom's bundled presets and profiles), +`acr-presets` (`LRG_ACR_PRESETS_DIR`, Camera Raw's user folder: its presets and +profiles) and `xmp-corpus` (`LRG_XMP_CORPUS_DIR`, a private sidecar corpus); see +`docs/wiki/Dev-Develop-Model.md`. ### Plugin — load into Lightroom @@ -157,12 +165,13 @@ Cargo workspace, one binary (`geniusai-server`) across these crates: - `lrg-common` — config/CLI, error→envelope, version (baked in via `LRG_BACKEND_*` build-time env vars, `option_env!`), logging+rotation, PID/OK lifecycle handshake. - `lrg-store` — LanceDB wrapper, Arrow schemas, `db_path` bind state machine, backup, stats. `lrg-chroma-reader` is migration-only: reads an old ChromaDB directory (WAL + HNSW segment binaries) left over from the retired Python backend, to migrate its data into LanceDB — no Chroma dependency at runtime. -- `lrg-imaging` — image conversion, EXIF/IPTC/XMP, pHash, culling metrics. +- `lrg-imaging` — image conversion, EXIF/IPTC/XMP, pHash, culling metrics, a render's tones in numbers for AI Edit (`tone.rs`). - `lrg-ml` — ONNX Runtime (`ort` crate) session management, SigLIP2 pre/post-processing + `tokenizers`-crate Gemma tokenizer, YuNet face detection + FaceNet embedding, MediaPipe Face Mesh V2 + Blendshape V2 for eye state, smile and gaze (`face_state.rs`). Model file locations resolved in `model_paths.rs` (env vars, see [server-rs/README.md](server-rs/README.md)). - `lrg-analysis` — clustering, person matching, group/cull grading, style engine, keyword clustering. - `lrg-providers` — LLM provider trait + REST clients (OpenAI, Gemini, Anthropic — Claude through Anthropic's own Messages API in `anthropic.rs`, not its OpenAI-compatible endpoint, which lists no models and ignores the answer format — Ollama, OpenAI-compatible servers — LM Studio found on this computer, and the user's own "Other AI server" such as OpenRouter, llama.cpp `llama-server` or LiteLLM, both in `openai_compatible.rs`), edit-recipe schemas. `local_provider.rs` serves *both* local backends off one `LocalEngine` trait; it has no llama.cpp or MLX dependency of its own. +- `lrg-develop` — the native Lightroom develop model, a leaf crate (no image, network or async dependencies, so `cargo test -p lrg-develop` takes seconds): the key registry (`registry/table.rs`, one `KeySpec` per `crs:` key with type, stored range, UI scale, number format, defaults, policy class, frame scope, file kind), the typed model (`DevelopSettings`, corrections and masks, `Look`, white balance, the policy filter), the readers for `getDevelopSettings()` blobs (`lua::read`) and XMP sidecars, presets and profiles (`xmp::read`, on `roxmltree`), which share their typing and classification rules so both land in the same model, the deterministic develop-preset writer (`xmp::write`), the Lua writer for `applyDevelopSettings` (`lua::write`; provisional until experiments E1/E2/E4/E11, its open choices in `LuaOptions::PROVISIONAL`) — both writers' lossless test modes exist only behind the crate's `test-roundtrip` feature, which only its own dev-dependency enables — and the correction builders (`build`: semantic, gradient and luminance-range masks with SHA-256-derived sync ids). The LLM edit-recipe schema stays in `lrg-providers`, learning/blending in `lrg-analysis::style_engine`. See [Dev-Develop-Model.md](docs/wiki/Dev-Develop-Model.md). - `lrg-mlx` — supervises the `lrgenius-mlx` Swift sidecar (`native/mlx-sidecar/`) and speaks its JSON-lines stdio protocol. No native build step, no cargo feature; Apple silicon only at runtime. -- `lrg-api` — axum routers (one module per API domain under `routes/`), `db_path` auto-bind middleware, jobs registry, and the browser UI: the pages under `src/ui/` served by `routes/ui.rs`, plus the `ui_bridge` queue that hands their actions to the plugin, and the `agent_bridge` that hands MCP tool calls to the plugin (`/v1/agent/*`). +- `lrg-api` — axum routers (one module per API domain under `routes/`), `db_path` auto-bind middleware, jobs registry, and the browser UI: the pages under `src/ui/` served by `routes/ui.rs`, plus the `ui_bridge` queue that hands their actions to the plugin, the `agent_bridge` that hands MCP tool calls to the plugin (`/v1/agent/*`), and AI Edit's retoucher sessions (`edit_loop.rs`, `/v1/edit/sessions`): a vision model edits through `lrg_providers::chat::ChatModel` with the tools in `lrg_providers::edit_loop`, each edit becomes an `applyDevelopSettings` table through `lrg_develop::build::recipe` and the Lua writer, and the plugin's `EditLoop.lua` applies, renders and hands back each round. - `lrg-mcp` — the MCP endpoint (`/mcp`, bearer agent token required) AI agents connect to: tools over the index (calling the REST routes in-process) and over Lightroom (through `agent_bridge` to the plugin's `AgentBridge.lua`/`AgentCommands.lua`, which starts with Lightroom), result sets, prompts, and `geniusai-server mcp`, a stdio proxy for Claude Desktop. See [Dev-Agent-Bridge.md](docs/wiki/Dev-Agent-Bridge.md). - `lrg-chat` — the in-app agent chat (`/v1/chat/*`): a local or cloud model works the catalog through the same tools, as an in-memory MCP client of `lrg-mcp`, with the user's approval (`permissions.rs`). The model answers through `lrg_providers::chat::ChatModel`: `cloud_chat.rs` uses each API's own tool calling, and `local_chat.rs` does it by decoding against a JSON Schema, since llama.cpp cannot render the Jinja templates that carry a model's native tool-call format. See [Dev-Agent-Chat.md](docs/wiki/Dev-Agent-Chat.md). - `lrg-server` — the binary: CLI (`clap`), lifecycle, self-updater (`routes::update`), `migrate` and `mcp` (stdio proxy) subcommands; merges the `lrg-mcp` and `lrg-chat` routers next to `lrg_api::build_router`'s. @@ -194,7 +203,7 @@ Cargo workspace, one binary (`geniusai-server`) across these crates: ### Rust / Backend (`server-rs/`) -- Routes in `lrg-api::routes` (one axum `Router` per domain, merged in `lrg_api::build_router`); business logic in `lrg-analysis`/`lrg-imaging`/`lrg-ml`; LLM/cloud clients in `lrg-providers`. +- Routes in `lrg-api::routes` (one axum `Router` per domain, merged in `lrg_api::build_router`); business logic in `lrg-analysis`/`lrg-imaging`/`lrg-ml`; LLM/cloud clients in `lrg-providers`; the native Lightroom develop model (key registry, typed model, readers and writers of the Lua and XMP forms) in `lrg-develop`, while the LLM edit-recipe schema stays in `lrg-providers` — a new develop key is a registry row in `lrg-develop`, not a field or a string literal elsewhere. - Always use the `log` facade (`log::info!`/`warn!`/`error!`). - Manage dependencies via `cargo add`/`cargo remove` (updates `Cargo.toml` + `Cargo.lock`); commit both. - Code must pass `cargo fmt` and `cargo clippy --workspace --all-targets` with zero warnings before considering a change done. diff --git a/README.md b/README.md index 7ffd53b9..6848a6e4 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Whether you prefer running local models to ensure maximum privacy or want to lev ## ✨ Core Features - **🤖 AI-Powered Tagging & Describing:** Uses advanced LLMs to accurately recognize image content, generate metadata, and provide detailed descriptions of your photos. -- **🎛️ AI Lightroom Edit (Develop) *(beta)*:** Generates a Lightroom edit recipe per photo and applies it in Develop, either to the photo itself or to a new virtual copy. No language model is involved: the recipe is interpolated from the edits you saved yourself (see *Style Training*), so at least five training examples are required. Per-photo review is on by default. +- **🎛️ AI Lightroom Edit (Develop) *(beta)*:** Edits each photo and applies the result as ordinary develop settings, to the photo itself or to a new virtual copy. The **AI retoucher** works like a person at the Develop panel: a vision model edits, Lightroom renders the edit, the model looks at the render with its measurements and corrects it — with looks (natural, moody, cinematic, film, black & white, …), your own instructions, and Lightroom's AI masks for sky, subject, background, skin, eyes and more. With five or more saved edits it starts from your own style and only fixes what does not suit the frame. **My style only** needs no language model at all: the recipe is interpolated from the edits you saved yourself (see *Style Training*). Per-photo review is on by default. - **🔍 Semantic Free-Text Search (Advanced Search):** Find images naturally through descriptive queries (e.g., *"Red sports car parked in front of a garage"* or *"Sunset over the mountains"*). LrGeniusAI automatically creates a relevance-sorted Collection in Lightroom based on your prompt. - **📸 Image Culling *(beta)*:** Group similar photos into bursts or near-duplicate stacks, automatically pick the strongest frames, and create Lightroom collections for picks, alternates, reject candidates, and optional duplicates. - **👥 People & Faces:** Detect and cluster faces, assign names to persons, browse person collections, and find similar faces across your catalog. @@ -59,13 +59,13 @@ Whether you prefer running local models to ensure maximum privacy or want to lev 4. **Pick an AI model.** Either enter a cloud API key in the **Plug-in Manager**, or stay fully local: the **Local AI Model** section offers a curated list of vision models (Gemma 4, Ministral 3, Qwen3-VL, Qwen2.5-VL) — choose one and click *Download*. The section shows the engine your platform ships: **MLX** on macOS, **llama.cpp** on Windows. See [Local AI Models](https://github.com/LrGenius/LrGeniusAI/wiki/Help-Local-AI-Models). 5. Select photos in the library and choose one of the AI actions from **Library -> Plug-in Extras**: - **Analyze & Index Photos...** — AI tagging, descriptions, search index, faces, and optional species identification - - **AI Edit Photos...** *(beta)* — generate and apply Lightroom develop edits learned from your own edits + - **AI Edit Photos...** *(beta)* — an AI retoucher edits, looks at the result and refines it; or apply edits learned from your own - **Advanced Search...** — semantic free-text search - **Cull Similar Photos...** *(beta)* — burst grouping and auto-ranking - **People...** — face clusters and named person collections - **Find Similar Images...** — find near-duplicates or visually similar photos - **Deduplicate Keyword Synonyms...** — clean up synonym sprawl in your catalog (in the **Help** menu) -6. For AI Edit, first teach it your style: edit a few photos by hand and run **Save Edits as AI Training Examples...** (in the **Help** menu). AI Edit builds every recipe from those examples and needs at least five of them — it calls no LLM. Keep **Review each proposed edit before applying it** enabled while you validate the results. +6. For AI Edit, pick a vision model for the **AI retoucher** in its dialog (larger models edit better). To have it keep your own look, edit a few photos by hand and run **Save Edits as AI Training Examples...** (in the **Help** menu); with five or more it starts from your style, and **My style only** uses nothing else. Keep **Review each edit** enabled while you validate the results. *For comprehensive details, model setup guides, and tips, please visit [lrgenius.com/help](http://lrgenius.com/help/).* diff --git a/docs/doc-sources.toml b/docs/doc-sources.toml index f9d21136..bd19628c 100644 --- a/docs/doc-sources.toml +++ b/docs/doc-sources.toml @@ -63,11 +63,17 @@ sources = [ # Likewise the agent bridge behind /v1/agent/*, and the /mcp endpoint. "server-rs/crates/lrg-api/src/agent_bridge.rs", "server-rs/crates/lrg-mcp/src/lib.rs", + # The /v1/edit/sessions section documents the session lifecycle and steps. + "server-rs/crates/lrg-api/src/edit_loop.rs", # The agent chat's routes, merged at the root beside /mcp. "server-rs/crates/lrg-chat/src/routes.rs", # The page names the release tags the download routes pull assets from, so a # change to how a tag is published belongs in the same review. ".github/workflows/model-assets-bioclip.yml", + # The /v1/edit/style and /v1/edit/training sections state the white-balance + # thresholds and the canonical_version semantics defined in these files. + "server-rs/crates/lrg-analysis/src/style_engine.rs", + "server-rs/crates/lrg-analysis/src/training.rs", ] [page."Dev-Server-README.md"] @@ -82,6 +88,9 @@ sources = [ # check, which is exactly what this module has to stay in step with. "server-rs/crates/lrg-ml/src/bioclip_pre.rs", "server-rs/scripts/export_bioclip2_fp16.py", + # The README's golden-test section documents the shared gate, including + # which families are local-only and what `all` means. + "server-rs/crates/lrg-ml/tests/common/mod.rs", ] [page."Dev-Build-Environment-Setup.md"] @@ -133,12 +142,18 @@ sources = [ [page."Help-AI-Edit.md"] # `routes/edit.rs` is deliberately absent: the plugin no longer reaches the -# LLM edit path, so the page documents the style engine only. +# single-shot LLM edit path. The retoucher loop is documented here from its +# prompt and tools (lrg-providers), its sessions (lrg-api) and its Lightroom +# side (EditLoop.lua). sources = [ "plugin/LrGeniusAI.lrdevplugin/TaskAiEditPhotos.lua", + "plugin/LrGeniusAI.lrdevplugin/EditLoop.lua", "plugin/LrGeniusAI.lrdevplugin/AestheticRefine.lua", "plugin/LrGeniusAI.lrdevplugin/DevelopEditManager.lua", "server-rs/crates/lrg-api/src/routes/style_edit.rs", + "server-rs/crates/lrg-api/src/edit_loop.rs", + "server-rs/crates/lrg-providers/src/edit_loop.rs", + "server-rs/crates/lrg-imaging/src/tone.rs", "server-rs/crates/lrg-analysis/src/style_engine.rs", "server-rs/crates/lrg-api/src/edit_budget.rs", ] @@ -319,3 +334,39 @@ sources = [ "plugin/LrGeniusAI.lrdevplugin/AiProviders.lua", "plugin/LrGeniusAI.lrdevplugin/PluginInfoDialogSections.lua", ] + +# The findings are point-in-time, but the page also documents how to run the +# experiment task, which does move. +[page."Dev-AI-Edit-XMP-Findings.md"] +sources = [ + "plugin/LrGeniusAI.lrdevplugin/TaskDevelopExperiments.lua", + "plugin/LrGeniusAI.lrdevplugin/DevelopExperiments.lua", + # "What the answers change" and "Still open" name the LuaOptions fields + # each experiment bears on (LuaOptions::PROVISIONAL). + "server-rs/crates/lrg-develop/src/lua/write.rs", +] + +# The native develop model. `*` in fnmatch also crosses `/`, so this covers +# src/lib.rs and every module below it (`**/*.rs` would miss src/lib.rs). +# The generator is listed because the page documents how to re-seed the table. +[page."Dev-Develop-Model.md"] +sources = [ + "server-rs/crates/lrg-develop/src/*.rs", + "server-rs/crates/lrg-develop/tests/*.rs", + "server-rs/scripts/develop_registry/gen_table_rs.py", + # The non-raw default column comes from this readback (and spec.py's rows). + "server-rs/scripts/develop_registry/spec.py", + "server-rs/testdata/develop/defaults/*.json", + # The page documents the fixture hygiene rules, which this script's + # --check-only/--self-test and tests/fixture_hygiene.rs implement together. + "server-rs/scripts/develop_registry/extract_fixtures.py", + # The page documents the LOCAL_ONLY golden families and the gate's + # behaviour for them. + "server-rs/crates/lrg-ml/tests/common/mod.rs", + # The test-roundtrip feature wiring (the self dev-dependency). + "server-rs/crates/lrg-develop/Cargo.toml", + # The plugin-side contract test of the Lua writer's wire goldens, and the + # workflow that runs it when only the goldens change. + "plugin/spec/native_wire_format_spec.lua", + ".github/workflows/lua-tests.yml", +] diff --git a/docs/wiki/Credits.md b/docs/wiki/Credits.md index 033c2f1c..da4bc3be 100644 --- a/docs/wiki/Credits.md +++ b/docs/wiki/Credits.md @@ -41,6 +41,8 @@ LrGeniusAI is made possible by these amazing open-source projects and AI framewo - **LM Studio**: Discover, download, and run local LLMs. [Website](https://lmstudio.ai/) ## Utilities & SDKs +- **roxmltree** (MIT OR Apache-2.0): Read-only XML tree the backend parses Lightroom's XMP sidecars, develop presets and profiles with (`lrg-develop::xmp`). [GitHub](https://github.com/RazrFalcon/roxmltree) +- **sha2** (MIT OR Apache-2.0): SHA-256 from the RustCrypto project; the backend verifies self-update downloads with it (`lrg-api`) and derives stable mask and correction ids for generated develop presets (`lrg-develop::build`). [GitHub](https://github.com/RustCrypto/hashes) - **JSON.lua** (CC-BY 3.0): A complete JSON encoder/decoder in Lua by Jeffrey Friedl, vendored into the plugin. [Upstream](http://regex.info/blog/lua/json) - **Adobe Lightroom Classic SDK**: The Lua API the plugin frontend is built on. - **Hugging Face Hub**: Distribution for the downloadable local models. diff --git a/docs/wiki/Dev-AI-Edit-XMP-Findings.md b/docs/wiki/Dev-AI-Edit-XMP-Findings.md new file mode 100644 index 00000000..67203204 --- /dev/null +++ b/docs/wiki/Dev-AI-Edit-XMP-Findings.md @@ -0,0 +1,596 @@ +# AI Edit via XMP — Findings and Experiments + +> **Status: research done 2026-09-26; step 1 in progress (1d/1f changed AI +> Edit's style path); first experiment results 2026-10-03 (one non-raw +> photo, [below](#results-first-run-2026-10-03)).** +> The page records what Camera Raw's XMP (`crs:`) can express, how that maps +> onto the Lightroom SDK, and which questions only Lightroom itself can answer. +> Those questions are what *Help → Plug-in Extras → Developer: Run +> Develop-Settings Experiments…* runs (see [below](#running-the-experiments)). +> If the XMP idea comes back, start here rather than re-running the +> investigation. +> +> Work has started: the first building blocks, a native model of Lightroom's +> develop settings (key registry, typed model, readers for the Lua form and +> for XMP), are the `lrg-develop` crate. The XMP reader parses Lightroom's +> bundled presets and the current state of that sidecar corpus with no +> unknown key (counts and method in +> [Native Develop Model](Dev-Develop-Model#checked-against-real-files)). The +> crate also writes develop presets and builds semantic, gradient and +> luminance-range corrections +> ([Writing XMP presets](Dev-Develop-Model#writing-xmp-presets), +> [Building corrections](Dev-Develop-Model#building-corrections)). Rebuilt +> with those builders, Adobe's adaptive presets match Adobe's corrections +> field for field once both go through the preset writer (names and sync ids +> apart); see Native Develop Model for the fields the writer holds back +> (LocalHue, LocalGrain, point colour, digests) and the lower +> `CompatibleVersion` in 10 of 38. Nothing calls the preset writer yet; AI +> Edit's style path already uses the crate (white balance in 1d, more +> sliders in 1f — see [Native Develop Model](Dev-Develop-Model)). + +## The question + +AI Edit applies a recipe of global sliders through `photo:applyDevelopSettings` +and adds masks through `LrDevelopController` UI automation. The proposal was to +write `.xmp` develop settings instead, so AI Edit could use *every* develop +feature. There were three ways to deliver them: the current pipeline, a develop +preset, or an XMP per photo. The concern that came with it: the image AI Edit +analyses is a downscaled, cropped export, so mask coordinates predicted on it do +not match the full-size photo. + +## Short answer + +- **XMP is the right data model, but usually not the right transport.** The + `crs:` key space is the same table `photo:getDevelopSettings()` returns and + `applyDevelopSettings()` / `addDevelopPresetForPlugin()` accept. That includes + masks, as `MaskGroupBasedCorrections → CorrectionMasks`. The mapping is + mechanical: + - drop the `crs:` prefix + - `rdf:Seq` becomes an array, a nested `rdf:Description` becomes a table + - point curves become flat number arrays +- **What AI Edit lacks is in its recipe schema, not in the SDK.** Missing today: + the full colour grading, profiles as a `Look`, B&W mix, calibration, lens + profile, lens blur, every AI mask type (people parts, landscape classes), + linear/radial/range masks. This is proven for the global sliders. New masks + written through the SDK are so far shown only by third-party plugins; a + new AI mask through `applyDevelopSettings` is what E2 tests, and on + Lightroom 15.6 it works once `photo:updateAISettings()` follows (one + non-raw photo, [Results](#results-first-run-2026-10-03)). +- **The resolution concern disappears for semantic AI masks.** Subject, sky, + background, people parts and landscape classes are stored without geometry. + Lightroom computes them itself at about 2880 px. +- **The frame of reference is the real problem.** Geometric masks and the crop + live in a different frame from the export. The mapping is exact and + computable, except under lens warp and Upright. +- **The training data already holds masks.** 497 of 1,294 stored training + examples carry the full `getDevelopSettings()` table *with* masks, so a style + engine could learn masks from the user's own edits without anyone re-saving. + Masks are also the norm in real edits: on the corpus below, 83 % of photos + edited in 2026 have one, and across all years 79 % of masked photos use a + subject mask. + +## Evidence base + +- The 1,045 presets bundled with Lightroom Classic 15.5.1, 38 of them Adobe + "Adaptive" presets with AI masks. +- A corpus of 12,338 edited photos: LrC sidecars written by LrC 10.2–15.5.1, + Canon 3:2 sensors, one photographer. +- A read-only copy of that catalog: `Adobe_imageDevelopSettings`, + `croppedWidth/Height` and orientation. +- The Lightroom Classic SDK 15.3 API reference. +- Third-party plugins that write masks: jasondentler/photo-workflow-mcp and + Wyyyyuu/lightroom-style. +- darktable's `src/develop/lightroom.c`, ExifTool's `crs` tag table, and Adobe + community threads. + +## Masks in XMP + +``` +crs:MaskGroupBasedCorrections rdf:Seq — one entry per mask in the Masks panel + What="Correction", CorrectionAmount (1 = 100 %, 0..2), CorrectionActive, + CorrectionName, CorrectionSyncID (32 hex), Local* sliders + crs:CorrectionMasks rdf:Seq — the mask's components, applied in order + What, MaskActive, MaskName, MaskBlendMode, MaskInverted, MaskSyncID, MaskValue, ... +``` + +### Combining components + +| Encoding | Meaning | +|---|---| +| `MaskBlendMode=0` | add | +| `MaskBlendMode=1` + `MaskValue=0` | subtract | +| `MaskBlendMode=1` + `MaskInverted=true` | intersect (A ∩ B = A − ¬B; 105 corpus cases) | + +For a radial gradient, `Flipped` is always `!MaskInverted`. For a range mask, +`CorrectionRangeMask.Invert` is always equal to `MaskInverted` (every range +mask in the sidecar corpus, luminance and colour, intersections included), so +an intersected luminance range carries `Invert="true"`; the builders set both +mirrors themselves. + +### Mask types by robustness + +| Class | `What` | Depends on frame/resolution? | +|---|---|---| +| Semantic AI | `Mask/Image` + `MaskSubType` / `MaskSubCategoryID` | **No.** Only the intent is stored; Lightroom recomputes it. This is the only kind Adobe's presets contain. | +| Select Object | `Mask/Image` subtype 0 + `crs:Gesture` (a 4-point polygon or brush strokes) | The hint geometry is frame-dependent; Lightroom refines it. | +| Geometric | `Mask/Gradient` (`ZeroX/Y`, `FullX/Y`), `Mask/CircularGradient` (`Top/Left/Bottom/Right`, `Angle`, `Feather`) | Frame-dependent, see below. | +| Brush | `Mask/Aggregate` → `Mask/Paint` (`Dabs`: `d x y`, `M x y`, `r R`, `f F`) | Frame-dependent. Strokes sometimes live only in `.acr` / the catalog (`MaskBrushTable`). | +| Range | `Mask/RangeMask` + `CorrectionRangeMask` (`Type` 2 = luminance `LumRange`, 1 = colour) | Luminance: no. Colour: photo-specific samples. | + +Computed AI mask bitmaps never live in modern XMP. They sit in a sibling +`.acr` (8-bit TIFF, JPEG-XL) keyed by `MaskDigest`, or in the catalog's +`.lrcat-data`. They cannot be written by us. + +### `MaskSubType` / `MaskSubCategoryID` + +| SubType | Category | Meaning | Evidence | +|---|---|---|---| +| 1 | – | Subject | Adobe presets + 5,149 corpus masks | +| 2 | – | Sky | Adobe presets + corpus | +| 0 | 22 | Background | corpus (603), user presets; not in Adobe presets | +| 0 | – + `Gesture` | Select Object | corpus (99) | +| 3 | 2–9, 11, 12 | People part, preset form (reportedly all people, unconfirmed) | every Adobe portrait preset | +| 0 | 2–9, 11, 12 | People part of one specific person (created in the UI) | corpus | +| 0 | 50001–50008 | Landscape classes | Adobe landscape presets | +| 0 | 20036 | Whole person | 4 corpus masks, meaning unconfirmed | + +People-part categories: + +| Id | Part | +|---|---| +| 2 | Face skin | +| 3 | Iris & pupil | +| 4 | Body skin | +| 5 | Hair | +| 6 | Lips | +| 7 | Facial hair | +| 8 | Sclera | +| 9 | Eyebrows | +| 11 | Clothes | +| 12 | Teeth | + +Landscape categories: + +| Id | Class | +|---|---| +| 50001 | Architecture | +| 50002 | Mountains | +| 50003 | Artificial ground | +| 50004 | Natural ground | +| 50005 | Vegetation | +| 50006 | Sky (≠ subtype 2) | +| 50007 | Water | +| 50008 | Snow | + +### Local slider scaling + +- `LocalExposure2012` is stored as **EV/4**: ±1 means ±4 EV. +- The other signed sliders are UI/100: `Contrast2012`, `Highlights2012`, + `Shadows2012`, `Whites2012`, `Blacks2012`, `Clarity2012`, `Dehaze`, `Texture`, + `Saturation`, `Temperature`, `Tint`, `Sharpness`. +- `LocalToningHue` is in degrees. +- The tone keys carry `2012`. `LocalTemperature`, `LocalTint`, `LocalTexture`, + `LocalDehaze` and `LocalSaturation` do not. + +A preset-safe AI mask looks like Adobe's own: the definition only, with +`ReferencePoint="0.500000 0.500000"`, `ErrorReason="0"`, and none of `MaskDigest`, +`InputDigest`, `FullMaskSize`, `WholeImageArea`, `Origin`, `CorrectionID`, `MaskID`. + +## Coordinates and the resolution problem + +Verified on the corpus and the catalog: + +- **Every geometric value lives in one frame.** This covers crop, gradients, + radial, brush dabs, AI `ReferencePoint`s and even LrC's own `mwg-rs` face + regions. The frame is the **uncropped image in sensor orientation** (before + `tiff:Orientation`), normalised **per axis** (x/W, y/H). A pixel circle is + therefore a normalised ellipse. + - `croppedWidth = (R−L)·W_sensor` holds for orientation 8 in 123/123 cases, + for orientation 6 in 10/10 and for orientation 1 in 271/271. The display-frame + hypothesis never matches. +- **The crop is a rotated rectangle.** `CropLeft/Top` and `CropRight/Bottom` are + opposite corners of a rectangle rotated by +`CropAngle` in sensor *pixel* space. + This reproduces the catalog's cropped size to ≤2.5 px on 570/570 angled crops. +- **Brush `Radius` is normalised by the sensor width**, which is the long side + for every camera in the corpus. Dabs are spaced at 0.30 × radius. +- **AI mask canvases are always about 2880×1920 in sensor orientation**, + independent of the crop and of the size of any export. + +The AI Edit export is none of that. It is rendered (default 3072 px long edge, +`Defaults.lua`) *after* lens correction, Upright, crop, crop angle and +orientation. Today the plugin sends the backend no dimensions, crop or +orientation. + +**Export pixel → XMP mask coordinate** (without warp), given the develop +settings `CropLeft/Top/Right/Bottom = L/T/R/B`, `CropAngle = θ`, orientation and +sensor-oriented `W, H`: + +1. Normalise the export pixel: `x = (i+0.5)/w_export`, `y = (j+0.5)/h_export`. +2. Undo the orientation to get crop-local `(u, v)`: + + | Orientation | `(u, v)` | + |---|---| + | 1 | `(x, y)` | + | 3 | `(1−x, 1−y)` | + | 6 | `(y, 1−x)` | + | 8 | `(1−y, x)` | + + Mirrored orientations flip the sense of θ; they are untested. +3. Compute the crop rectangle in sensor pixels: + - `Δx = (R−L)·W`, `Δy = (B−T)·H` + - `w_c = cos θ·Δx + sin θ·Δy`, `h_c = −sin θ·Δx + cos θ·Δy` +4. Place the point: `P = (L·W, T·H) + u·w_c·(cos θ, sin θ) + v·h_c·(−sin θ, cos θ)`. +5. The mask coordinate is `(P.x / W, P.y / H)`. + +Lengths scale uniformly by `w_c / w_export`. With an active lens profile the +residual is a few pixels. With Upright or manual transforms it is unknown, so +geometric masks should not be generated there. + +**Strategy:** + +1. Prefer semantic AI masks. They are resolution-independent and the only + preset-safe kind. +2. Per photo only: object boxes, people `ReferencePoint`s, and gradient/radial + masks through the mapping above. +3. Never generate brush strokes. +4. Never put geometry or a crop into a shared preset. + +## Ways into Lightroom + +| Path | Coverage | Batch | Undo | Catalog safety | Verdict | +|---|---|---|---|---|---| +| `photo:applyDevelopSettings(tbl, name, flattenAuto)` | all flat keys; new gradient/radial/brush/range masks proven by third-party plugins; **new AI masks work** when followed by `photo:updateAISettings()` (E2, LrC 15.6, one non-raw photo) | one write gate, no module switch | one step | good | primary path, AI masks included | +| `LrApplication.addDevelopPresetForPlugin` + `applyDevelopPreset(p, _PLUGIN, amount, updateAI)` (updateAI: 15.3+) | same table; AI masks incl. sub-categories proven by photo-workflow-mcp; in LrC 15.6 the preset's masks **replace** the photo's, `updateAI = true` left the preset's sky mask pending for 60 s, and the amount changed nothing (E4c, E4e) | AI compute per photo | one step | hidden presets, **no delete API**; deleting the file right after applying keeps the edit within the session (E4f) | not needed for AI masks | +| `.xmp` preset file | full XMP incl. `SupportsAmount` flags | no SDK import; the user imports via *Presets → Import* (visible immediately), a file dropped into the folder needs a restart | – | LrC normalises on import and silently drops invalid keys | export artifact, not an apply path | +| `.xmp` sidecar + *Read Metadata from Files* | full XMP | **no documented SDK call**; undocumented `photo:readMetadata()` is unverified | replaces **all** metadata | auto-write-XMP can clobber the file; JPEG/DNG means rewriting originals; virtual copies have no XMP | expert export only | +| `LrDevelopController` (today) | AI types without sub-category or geometry | module switch, sleeps | many steps | ok | fallback for LrC < 15.3 | + +"Write an `.xmp` preset, import it, apply it, delete it" has no SDK form as +such: there is no call to import a preset file and none to delete a preset. +Its programmatic equivalent is the plugin-preset row: +`addDevelopPresetForPlugin` writes the preset file, `applyDevelopPreset` +applies it, and `LrFileUtils.delete` removes the file at the path +`preset:getFile()` returns. Whether the edit survives that, and what Lightroom +still lists until a restart, is E4f. + +## Bugs found in the current code + +- AI Edit writes and trains on the develop key **`Temp`**, which occurs in 0 of + 17,814 catalog rows. The real keys are `Temperature`/`Tint` (raw) and + `IncrementalTemperature`/`IncrementalTint` (non-raw). White balance is most + likely never learnt and never applied. E1 confirmed it on a non-raw photo + (LrC 15.6): `{Temp}` is accepted without an error and changes nothing, and + next to `WhiteBalance = "Custom"` only the mode changes (E1a, E1d). *Fixed in AI Edit (step 1, PR 1d):* the style engine now + reads the white balance typed from the real keys and sends it as + `edit.white_balance` (see [Dev-Develop-Model](Dev-Develop-Model) and + `POST /v1/edit/style` in [Dev-Backend-API](Dev-Backend-API)); only the + develop experiments still write `Temp`, on purpose. +- The dormant LLM path has several problems: + - `EnableLensCorrections` (the panel toggle) is written where the profile + toggle `LensProfileEnable` is meant. + - The profile is written as `CameraProfile="Adobe Color"`. In current + Lightroom that is a `Look` on top of `CameraProfile="Adobe Standard"`. + Importing an `.xmp` preset silently drops that value (verified); that + `applyDevelopSettings` does the same is likely but unverified. E11 tests + the correct form, a `Look` on top of an `Adobe Standard` base profile. + - The crop maths assumes the export frame. +- Reported by the research, not yet reproduced: the mask fallback in + `DevelopEditManager` writes a `Correction` sub-table with `local_*` keys in UI + units. That shape does not exist, so the fallback is a no-op that reports + success. + +## Proposed modes + +1. **Apply directly** (replaces today's pipeline). Apply the full native table + through `applyDevelopSettings` in one write gate and one history step. AI + masks go in the same table, followed by `photo:updateAISettings()`: E2 + shows that works (LrC 15.6), and the plugin-preset route replaces the + photo's masks (E4c). UI automation stays only for LrC < 15.3. +2. **As preset.** The backend renders an `.xmp` preset containing only semantic + masks, with no crop, geometry or lens identity. The user imports it through + the Presets panel. It is shareable and gets the amount slider. +3. **XMP per photo.** Only as an explicit "export a sidecar for ACR or other + tools" for raw masters, with warnings, because of the metadata replacement + and auto-write clobbering above. + +Wherever the XMP is produced, it should come from one native-table model in the +backend. That model needs a whitelist with ranges, a mask builder, and a +denylist: `FilterList`, `RetouchAreas`, digests, `CorrectionID`/`MaskID`, +`Crop*` in presets, and camera-specific looks. + +## Running the experiments + +*Help → Plug-in Extras → Developer: Run Develop-Settings Experiments…* +(`TaskDevelopExperiments.lua`; the pure helpers are in `DevelopExperiments.lua`). + +**Use a throwaway test catalog.** +- Import copies of three or four photos (the task takes at most four): + - a raw rotated in camera (portrait) whose crop is straightened or changes + the aspect ratio. Without that, E13 can only answer "ambiguous"; + - a raw that shows a person and sky. The sky and hair masks in E2 find + nothing on other photos; + - optionally a raw with strong lens distortion; + - a JPEG. + + E13's full readback gives Lightroom's defaults only for photos without + develop edits (its verdict says which photos have edits), so leave at least + one raw and the JPEG untouched after import, with *Raw Defaults* set to + *Adobe Default*. E11 needs a raw. No develop preset is needed: E11b takes its + complete `Look` from a photo, best from a second raw with a different + profile. Lightroom's bundled presets carry Looks only as stubs + (`Stubbed="true"`, no `Parameters`), so they cannot supply one. +- Turn *Automatically write changes into XMP* off in that catalog. +- Select the photos, then run the task from any module. It switches to the + Library module on purpose, because E2 asks whether masks can be added outside + Develop. + +| Id | Question | What it does | +|---|---|---| +| E13 | Which orientation do `getRawMetadata('dimensions')` / `'croppedDimensions'` use, and what does `orientation` look like? What does a photo's full develop-settings table hold? | Read-only. Logs metadata, the full list of raw-metadata keys, and the develop settings. Fits the crop model under every width/height interpretation, and says "ambiguous" when two interpretations fit equally. Also records every top-level `getDevelopSettings()` key: scalars verbatim, tables by shape (small all-scalar tables such as tone curves with their values), `Look` as name/UUID/amount plus whether `Parameters` exist, masks as in E2. Whether the photo has develop edits comes from Lightroom's own *Has Adjustments* search (narrowed to the file name, and trusted only when the name search finds the photo), plus `editCount` and `lastEditTime`. On an unedited raw and JPEG this is Lightroom's raw and non-raw default table; the verdict says "has develop edits - not a default table" when it is not, and names the white-balance family. The full dump is in the JSON. | +| E1 | Does `applyDevelopSettings` accept `Temp`? Does a white-balance mode on its own make Lightroom recompute temperature and tint? | Uses a fresh virtual copy per variant (`LrG Exp E1a` … `E1g`), so each starts from the master's white balance, and diffs the white-balance keys. Raw: `{Temp}`, `{Temperature}`, `{WhiteBalance="Custom", Temperature, Tint}`, `{WhiteBalance="Custom", Temp}`, then mode only: `{WhiteBalance="Daylight"}`, `{WhiteBalance="Auto"}` and `{WhiteBalance="Auto"}` with `optFlattenAutoNow=true`. Non-raw: `{Temp}`, `{IncrementalTemperature/Tint}`, `{Temperature}`, `{WhiteBalance="Custom", Temp}` and the two Auto variants (non-raw files have no Daylight). The `Custom`+`Temp` variant keeps a no-op under "As Shot" from being read as a rejected key. Each mode-only copy is first put on a distinctive Custom white balance in its own history step (raw `Temperature=3000, Tint=40`; non-raw `IncrementalTemperature=-40, IncrementalTint=40`), so a recomputation always shows as a change. After the mode is written, the copy is read back right away and then every 0.5 s for up to 15 s, stopping at the first change of `Temperature`/`Tint` (raw) or `IncrementalTemperature`/`IncrementalTint` (non-raw). The verdict says "recomputed ... after N s" or "not recomputed within N s". A flattened Auto that reads back as Custom with new values is reported as flattened, not as a rejected mode. Raw or not is decided by the photo's white-balance family, so a DNG converted from a JPEG counts as non-raw. | +| E11 | Does an Adobe Raw profile `Look` transfer through `applyDevelopSettings`, and in which form? | Raw photos only. Non-raw photos get a "skipped" verdict: E11 tests Adobe Raw Looks, which need a raw file, and creative Looks on non-raw files are not tested. The base profile is the master's when it is an `Adobe Standard` variant (e.g. `Adobe Standard v2`), otherwise `Adobe Standard`, so only the Look changes. `LrG Exp E11a`: a stub in the form Lightroom's presets carry it, `{Name, UUID, Amount=1, Stubbed=true}`, using the first of Adobe Vivid, Adobe Landscape and Adobe Color whose name differs from the photo's current Look. `LrG Exp E11c`: the same stub without `Stubbed`, so the difference between the two forms is visible. `LrG Exp E11b`: a complete `Look` with `Parameters`, taken at run time from, in this order: another selected photo's Look (not an Adobe Adaptive Look, and a camera-restricted Look only from a photo of the same camera model); the Look the E11a copy read back, if Lightroom filled in the stub; the photo's own Look, written onto a copy that was first switched to a stubbed different Look in an `LrGenius E11b prepare` step (inconclusive if that switch did not take); and, as a last resort, an installed develop preset whose Look has `Parameters`. The source goes into the step data. Nothing of a Look is stored in the plugin or the report beyond name, UUID, flags and key counts. Reads back `CameraProfile`, `Look.Name`, `Look.UUID` and whether `Parameters` exist, and says honoured / ignored / changed / error. If the preset scan runs, presets it could not read are counted and named in the skip reason, and a scan in which every read failed is a failed step. | +| E2 | Can `applyDevelopSettings` add a new AI mask, when is it computed, and how long does it take? | On an `LrG Exp E2` copy: adds a subject mask (+1 EV), then waits up to 30 s for Lightroom to compute it unasked. The first detection in a session loads the model, so a short wait would be misleading. If nothing happened, calls `photo:updateAISettings()` and polls for up to 60 s for `MaskDigest` / `ErrorReason`, recording `needsUpdateAISettings` and `isAvailableForEditing` along the way. The E2c verdict and step data carry how long the `updateAISettings()` call itself took, any wait for write access before it, and how long from the start of the call until the mask reached a final state. The time is marked as a lower bound when Lightroom was already computing: the photo was locked before the update could start, or at some point during the unasked wait. A correction whose AI tool disappears is reported as such and is not counted as a ready mask. A run-level line lists the timings per photo and marks the first measured photo, whose time may include loading the model. Then adds sky, background and hair masks in one call, updates once and polls each. "Computed, nothing found" means the definition was accepted but the photo has no such content. | +| E4 | How do plugin presets behave, and are preset masks merged with or replacing the photo's? | Adds a preset twice under one name with different content, and records the uuid, file path, file contents and preset count. On an `LrG Exp E4` copy it first seeds a linear gradient via `applyDevelopSettings`, then applies the preset with `updateAI=true`; whether the gradient survives answers merge vs replace. It applies the preset again and counts the corrections carrying its sync id. Then it applies two amount presets, one without and one with the `SupportsAmount` flags, at 50 and 200, and reads back the before and after values. Last, E4f: creates "LrGenius Experiment E4f" (`Contrast2012=25` plus a subject mask), applies it with `updateAI=true` to an `LrG Exp E4f` copy and reads it back, then deletes the file `preset:getFile()` reports with `LrFileUtils.delete`. It deletes only a file (never a directory) whose name is the preset's name, alone or followed by a suffix starting with a non-alphanumeric character ("E4f 2"), that is not another E4 preset's file, and that sits next to the other E4 preset files or inside a `Plugin Develop Presets` folder (`DevelopExperiments.presetFileDeletable`); anything else is refused and reported. After the delete it records whether `getDevelopPresetsForPlugin` still lists the preset (by name and uuid), whether `preset:getSetting()` still works, whether the first copy kept its contrast and mask, and whether applying the deleted preset object to a second copy (`LrG Exp E4f after delete`) works, fails or does nothing. It then re-adds the same name, records whether and where a file appears, and deletes that file too. Before the add it records every preset of that name already listed, with whether its file exists. After the re-add it also records whether the new preset holds the new settings (`Contrast2012=15`) or the deleted one's, and how many presets of that name are listed. The verdict says whether "apply then delete" is viable within the session, judged only on what the apply changed: a preset with no visible effect is inconclusive, and a subject mask that was still pending at the delete limits the verdict to the settings. Restart behaviour is a manual check: plugin presets never appear in the Develop presets panel, so it looks in the `Plugin Develop Presets` folder; running E4 again after the restart reports the presets of that name still listed, split into "file missing" (kept somewhere other than the file), "file present" (left over, or written back at quit) and none. The final dialog always shows the E4f outcome, past the per-experiment cap. | + +**Output.** +- A Markdown report plus the same data as JSON next to it, written where you + choose. If a `.json` of that name already exists, a numbered name is used + instead; the final dialog shows both paths. The report holds the verdicts, a checklist of things the SDK cannot read back (history + step names, what a mask covers, AI progress dialogs), and every step's raw + readback. Long step data (E13's full develop-settings readback) is cut off + in the Markdown; the JSON has it in full. +- A failed step is recorded, not hidden. An error is often the answer itself + (e.g. `Temp` rejected). + +**Cleanup.** +- Delete the `LrG Exp …` virtual copies (E1a–E1g, E11a–E11c, E2, E4, the + E4 amount copies, and E4f's `LrG Exp E4f` and `LrG Exp E4f after delete`). +- E4's plugin presets ("LrGenius Experiment E4", "… E4 Amount", "… E4 Amount + Flagged") cannot be deleted through the SDK. They are files in Lightroom's + preset folder, outside the catalog and shared by every catalog, so deleting + the test catalog does not remove them. Their paths are in the final dialog + and in the report header; delete those files by hand. A later run records how + many presets of that name already existed. +- E4f deletes its own preset files. The list in the final dialog and the report + header holds only files that still exist, so an E4f file shows up there only + when E4f stopped before deleting it (no virtual copy, the photo stayed + locked, the apply or its readback failed, or the run was canceled), when the + delete failed or was refused, or when applying the deleted preset wrote the + file back. + +### What the answers change + +The Lua writer (`lrg_develop::lua::write`, PR 1g) represents every +alternative E1, E2, E4 and E11 measure as a `LuaOptions` switch and decides +none of them; the defaults are `LuaOptions::PROVISIONAL` and how far the +evidence carries each is `LuaOptions::EVIDENCE`. Which field each +experiment bears on, the evidence, what is still open, each field's status +and the steps after a run are in one table: +[Dev: Native Develop Model — Options and what decides them](Dev-Develop-Model#options-and-what-decides-them). +Read its "Still open" column before marking a field settled: for several +fields the experiments apply only one form, so a working run confirms it +without measuring the alternative, and one field (`int_flag_as`) no +experiment touches at all. The first run's answers are in +[Results](#results-first-run-2026-10-03). +The plugin spec `plugin/spec/native_wire_format_spec.lua` checks the +goldens only for rules that hold whatever the experiments answer and +accepts both forms of each provisional encoding, and the Rust tests check +every single flip, so flipping a field needs a re-bless, not a test change +([Wire goldens](Dev-Develop-Model#wire-goldens)). + +### Still open after these experiments + +Write-side forms the experiments as built do not measure (the Lua writer +holds each as a `LuaOptions` alternative; see the table linked above): + +- Whether the 0/1 flag keys (`LensProfileEnable`, `AutoLateralCA`, + `CropConstrainToWarp`, `HDREditMode`) take booleans, or need numbers: no + experiment writes one (`int_flag_as`). Cheap evidence: read back photos + the LLM path already applied `AutoLateralCA` to as a boolean. +- Mask enums as numeric strings (`mask_enum_as = String`): E2/E4 write + numbers only. +- A mask correction without `EnableMaskGroupBasedCorrections` + (`panel_switches = None`; the default is now `MaskOnly`, the form E2 + applied): applied by no experiment; the first run cannot tell it from + `MaskOnly`, because the switch was already on in the source photo (E13). + Every other panel switch (`PANEL_SWITCHES`) is unverified. +- A correction with only its own `Local*` keys (`local_form = Sparse`), an + AI mask without `MaskVersion`/`ReferencePoint`/`ErrorReason` + (`mask_form = Minimal`), and any luminance range applied through + `applyDevelopSettings`. +- A non-raw `{WhiteBalance = "Custom", IncrementalTemperature, + IncrementalTint}` (`wb_custom_with_numbers` on non-raw files), and a named + mode other than `Daylight`/`Auto` alone (`wb_mode_only`). +- A camera-restricted or an Adobe Adaptive `Look`, and whether an adaptive + one computes its `AILook` after `updateAISettings()`. +- Applying a wire golden unchanged (decoded with JSON.lua): the experiments + apply hand-built tables, never the writer's own output. Needed before the + goldens are frozen. + +Other open questions: + +- `CircularGradient.Angle` convention and the direction of `Zero` vs `Full`. +- Whether masks sit before or after lens correction and Upright. +- Mirrored orientations and non-3:2 sensors. +- Whether a digest-less AI mask in a *sidecar* is recomputed on *Read Metadata*. +- Whether LrC accepts a self-built Denoise `Table_` blob. +- Import normalisation of generated `.xmp` presets. +- Whether subtype-3 people parts apply to every person in the photo. + +## Results: first run (2026-10-03) + +One run of the experiments on **Lightroom Classic 15.6** in a test catalog, +**run from the Library module**, with **one photo**: a non-raw TIFF (a +virtual copy without develop edits, process version 15.4). No raw file was +in the run, so everything raw-only (E1e, E11) did not run. E2's people-part +mask ended with `ErrorReason` 1 (nothing found); the report does not say +whether the photo shows a person. Every answer below is about one non-raw +photo on 15.6, read back within seconds in the Library module: Lightroom may +resolve Auto white balance or compute AI masks only when Develop renders the +photo. The manual checks of the report (history step names, the white +balance in the Basic panel, what the masks cover, the AI progress dialog, +the look of the amount copies, E4f after a restart, a freshly imported raw +and JPEG for the default tables) are not answered yet. + +| Experiment | Result | Settles | Stays open | +|---|---|---|---| +| E13 (frame) | `orientation = "AB"`; the crop model fits with 0 px error, under the reported and the swapped frame alike (a landscape photo without a crop) | nothing about the frame | a portrait raw with a straightened crop | +| E13 (readback) | 172 top-level keys, `editCount` 0, *Has Adjustments* false: the non-raw default table as one photo (a virtual copy of a TIFF) carries it. E.g. `Sharpness` 0 (raw 40), `SharpenRadius` 1, `SharpenDetail` 25, `SharpenEdgeMasking` 0, `LuminanceSmoothing` 0, `ColorNoiseReduction` 0 (raw 25) with `Detail`/`Smoothness` 50, `CameraProfile` "Embedded", `ToneCurveName2012` "Linear", `WhiteBalance` "As Shot", `IncrementalTemperature`/`IncrementalTint` 0, no `Temperature` key | the registry's non-raw defaults for the keys it read (145 rows; `HDRMaxValue` 2.3 held back as possibly per image; [Native Develop Model](Dev-Develop-Model#maintaining-the-registry)) | keys absent from the readback (absence can mean "written only when used"); the report's own check of a freshly imported JPEG and raw for the default tables | +| E1a | `{Temp = 20}` accepted, nothing changed | `Temp` is silently ignored, not rejected | — | +| E1b | `{IncrementalTemperature = 21, IncrementalTint = 11}` without a mode: both stored, `WhiteBalance` stays "As Shot" | numbers alone leave the mode at As Shot in the readback, so the writer keeps writing `Custom` with them (`wb_custom_with_numbers`, supported); whether As Shot + numbers renders them is the open Basic-panel check | `Custom` + incremental numbers in one call; whether As Shot + numbers renders them (Basic panel); raw E1b against E1c | +| E1c | `{Temperature = 22}` on non-raw: stored as `Temperature`, `WhiteBalance` stays "As Shot", the incremental keys unchanged | Lightroom keeps a raw-family key on a non-raw photo as a dead value: the writer's one-family rule stands | raw E1c (`Custom` + numbers) | +| E1d | `{Temp = 15, WhiteBalance = "Custom"}`: `Temp` ignored, only the mode changed | as E1a | — | +| E1e | did not run (`Daylight`, raw only) | — | a raw file | +| E1f/E1g | `{WhiteBalance = "Auto"}` alone, from a Custom precondition: the mode becomes Auto, the incremental numbers read back unchanged within 15 s, with and without `optFlattenAutoNow` | the readback showed no recomputed numbers within 15 s (run from the Library module; the Basic-panel check is open): `wb_mode_only = false`, `flatten_auto_now = false`, and a mode alone is refused for non-raw photos; `EMIT_NAMED_WB_MODES` stays off | raw Auto and Daylight; the Basic-panel check | +| E11 | skipped (Adobe Raw Looks need a raw file) | — | `look_form`, on a raw file | +| E2a | a digest-less subject mask in the preset form (numbers, the 23 zero `Local*` keys, the mask switch on) is kept, run from the Library module | `applyDevelopSettings` adds new AI masks outside Develop; `mask_form` (subject, sky and background), `local_form` and `mask_enum_as` work. `panel_switches = MaskOnly` was sent, but E13 shows the switch was already `true` on the source photo (E2's copies inherit it), so the run does not tell `None` from `MaskOnly` | people parts (E2d); `None` against `MaskOnly` | +| E2b | without `updateAISettings()` the mask is still pending after 30 s | still pending after 30 s in the Library module: the writer asks for the update (`ai_update`) | whether opening the photo in Develop computes it | +| E2c | after `photo:updateAISettings()` it is computed (call 0.6 s, ready 0.6 s) | `ai_update = true` | timing on a raw and with the model not yet loaded | +| E2d | sky and background computed the same way; a hair (people part) mask, `MaskSubType` 3 / `MaskSubCategoryID` 5 (the writer's form), ended `failed` with `ErrorReason` 1 (nothing found); the report does not say whether the photo shows a person | one update computes several masks | people parts: the subType-3 form on a photo with a visible person (form or photo is not known) | +| E4a/E4b | plugin presets are created outside a write gate; a same-name add overwrites (same uuid, same file) | the preset lifecycle | — | +| E4c | applying a preset with masks **replaces** the photo's existing masks; `applyDevelopPreset(..., updateAI = true)` left the preset's sky mask pending for 60 s (Library module) | the preset route loses masks, and its `updateAI` flag did not compute the mask within 60 s in the Library module | whether the AI progress dialog appeared (manual) | +| E4d | re-applying updates the mask in place (same sync id, one correction) | sync ids identify a correction across applies | — | +| E4e | amount 50 and 200 change nothing: globals and mask values stay at the preset's, with and without `SupportsAmount`/`SupportsAmount2` | `preset_amount_flags` has no effect in 15.6 | the look of the amount copies (manual) | +| E4f | deleting the preset file right after applying keeps the photo's edit; until a restart Lightroom keeps listing the deleted preset and even applies it; re-adding the name writes the file again with the same uuid | "apply then delete" works within a session (settings only: the mask was still pending at the delete) | what a restart changes (manual) | + +**What it settles for step 2.** The viable route for AI masks is direct: +`photo:applyDevelopSettings()` with the masks in the table, then +`photo:updateAISettings()`. Plugin presets are not needed for masks and are +worse at them: they replace the photo's existing masks, their `updateAI` +flag did not compute the mask, and their amount is ignored. The Lua writer's +options follow this run (`LuaOptions::EVIDENCE` and +[Options and what decides them](Dev-Develop-Model#options-and-what-decides-them)): +the AI-mask forms for subject, sky and background, `ai_update` and the +non-raw mode-only choices (`wb_mode_only`, `flatten_auto_now`) are settled +for non-raw files on 15.6. `wb_custom_with_numbers` is only supported: +`Custom` with the incremental numbers (the writer's non-raw form) was never +applied. `panel_switches = MaskOnly` is only supported (the switch was +already on). People-part masks, `int_flag_as`, `look_form` and every +raw-side white-balance question stay open. + +## Results: second run (2026-10-23, raw) + +**Lightroom Classic 15.6**, test catalog, Library module, one photo: an +**edited Canon CR3** (`editCount` 5, cropped square, Look "R5M2 Standard V2a", +process version 15.4). It answers the raw side of E1, E11 and E13 and repeats +E2 and E4. Its report is not committed. + +| Experiment | Result | Settles | Stays open | +|---|---|---|---| +| E13 | `orientation = "AB"`; the crop model fits with 0.0015 px error under the reported frame and not under swapped dimensions (2728 px off); 178 top-level keys on an edited photo | `dimensions` and `croppedDimensions` come as reported; the readback is not a default table | a portrait raw with a straightened crop; a fresh raw for the raw defaults | +| E1a/E1d | `{Temp}` accepted, nothing changed; with `WhiteBalance = "Custom"` only the mode changed | `Temp` is ignored on raw too | none | +| E1b/E1c | `{Temperature}` alone is stored; `Custom` + `Temperature` + `Tint` is stored whole | the raw white-balance family writes as the writer does | Basic-panel check | +| E1e/E1f/E1g | `Daylight`, `Auto` and `Auto` + `optFlattenAutoNow` set the mode; `Temperature`/`Tint` stay at 3000/40 for 15.2 s | `wb_mode_only` and `flatten_auto_now` stay off on raw as well | whether Develop resolves the mode on render (Basic panel) | +| E11a/E11c | a stub of "Adobe Vivid", with or without `Stubbed`, leaves `CameraProfile` Adobe Standard and **no Look** (the photo's own Look is dropped, none is set) | the stub form does not apply a Look | E11b: the copy could not be switched away from its own Look, so a complete Look with `Parameters` is untested | +| E2 | as in the first run: pending after 30.4 s, computed 1.0 s after `updateAISettings()` (call 0.9 s); sky, background and hair computed within 2.3 s | the hair mask (subType 3, sub-category 5) **works**; its first failure was the photo | people parts other than hair; several people | +| E4 | as in the first run: masks replaced, re-apply in place, amount ignored (identical values at 50 and 200 with and without the flags), apply then delete works in the session | the preset route stays rejected; amount has no effect | the look of the amount copies; the restart behaviour (E4f) | + +**Third run (2026-10-23, after a Lightroom restart).** E11 and E4 again, on a +second Canon CR3 (`018A0060.CR3`, Look "Adobe Color" with `Parameters`): + +- E11a/E11c: the same as before. A stub of "Adobe Vivid", with or without + `Stubbed`, leaves `CameraProfile` Adobe Standard and no Look. The result does + not depend on the photo or on which Look it carried. +- E11b: inconclusive again, for the same reason: the copy could not be + switched away from its own Look first, because every Look write ends with no + Look. A complete Look needs a source photo that carries a *different* Look + than the target: select two such photos for the run. +- E4: identical to the earlier run (masks replaced, sky mask pending after + 60.7 s, amount ignored, re-apply in place). The restart question is + answered: "LrGenius Experiment E4f" was **not listed** after the restart and + had no file, so Lightroom forgets a deleted plugin preset at the restart. + +**Fourth and fifth reports (2026-10-23).** `report3` holds E1 (not E11) on +two more raws, `018A0028.CR3` and `018A0060.CR3`; `report4` holds E13 on a +portrait raw, `018A0052.CR3` without crop: + +- E1 on two further raws: identical to the first raw run. `Temp` is ignored, + `Temperature` alone is stored, `Custom` + `Temperature` + `Tint` is stored + whole, and `Daylight`/`Auto`/`Auto` + `optFlattenAutoNow` set the mode only, + with `Temperature`/`Tint` unchanged for 15.2 s. Three raws now agree. +- E13, portrait raw (`orientation = "DA"`): `dimensions` and + `croppedDimensions` are reported in the **display frame** (5464 x 8192), + while the raw-metadata `width`/`height` stay in the sensor frame + (8192 x 5464). The verdict stays "ambiguous", because an uncropped photo + fits both frames; the portrait case still lacks a straightened crop. +- E13 readback of an unedited raw (`hasAdjustments` false, though `editCount` + is 3): 178 top-level keys, `CameraProfile` "Adobe Standard", Look + "Adobe Color" with `Parameters`, `Temperature` 4250, `Tint` 7, crop + 0/1/0/1. This is a raw default table, apart from the as-shot white balance + and the lens profile digest, which depend on the file. + +**Sixth and seventh reports (2026-10-23).** E11 on two raws, `018A0040.CR3` +and `018A0065.CR3` (`report_e11`), and E13 on a straightened portrait raw, +`018A0062.CR3` (`report_e13`): + +- E11a/E11c: the stub again leaves no Look, on both photos. E11b is + **inconclusive a third time**, because both photos carry the same Look + ("Adobe Color", UUID `B952C231...`), so there is no other Look to take as + the source and the copy cannot be switched away from its own. E11 needs two + raws with *different* Looks; neither of the photos picked so far has one. +- E13, `orientation = "DA"`, `CropAngle` 19.98, `CropLeft` 0.25487, + `CropRight` 0.74513: the verdict is settled. `dimensions` (5464 x 8192) and + `croppedDimensions` (3763 x 5641) are both in **display orientation**. The + crop model fits only with both dimensions swapped (0.49 px error against + more than 1500 px for every other reading): the `Crop*` fractions and the + angle act on the **sensor frame** (8192 x 5464), and the result is + reported rotated by `orientation`. The first portrait report (no crop) + could not tell these apart. +- The unedited raw readback has 179 top-level keys here against 178 on + `018A0052.CR3`; the difference was not examined. + +**Eighth report (2026-10-23, `report_e11_2`).** E11 on `018A0500.CR3` +(Look "Adobe Color") and `018A0516.CR3` (CameraProfile "Camera Neutral", no +Look): + +- E11b is answered: a **complete Look with `Parameters`**, taken from the + other selected photo ("Adobe Color"), is **honoured**. The copy ends on + CameraProfile Adobe Standard with Look "Adobe Color", `Parameters` kept. +- E11a and E11c, the stub with and without `Stubbed`, are **ignored** on the + photo with no Look (the Look stays empty). On a photo with a Look they drop + it. A stub never sets a Look. +- So `look_form` has to be `Full` to transfer a Look, and it is now the + default. It needs the source Look's `Parameters`, which a Look read from an + XMP preset or a training row may not carry: the writer then reports + `Look.Parameters` as skipped. The wire goldens with a Look were re-blessed. + +**What changes for step 2.** Nothing in the writer: it already writes +`Temperature`/`Tint`/`Custom` as a family, never `Temp`, and asks for +`updateAISettings()` after masks. `look_form` is `Full` since E11b. The evidence +rows in `LuaOptions::EVIDENCE` carry the raw answers. + +**Still open, for a second run on raw files:** +- a portrait raw (rotated in camera) with a straightened crop, for E13's + frame, and a fresh, unedited raw for the raw default table; +- a raw for E1b/E1c (`Custom` + numbers), E1e (`Daylight`), Auto on raw, and + E11 (the `Look` forms); +- a photo with a visible person, to check E2's people-part form + (`MaskSubType` 3 with a sub-category, which failed with `ErrorReason` 1 in + the first run) and not only the photo; +- a variant that applies a wire golden unchanged (decoded with JSON.lua), + still needed before the goldens are frozen; +- the manual checks listed above. + +## Results: AI Edit retoucher, live (2026-11-13) + +**Lightroom Classic 16.0**, test catalog, Library module, the plugin from the +checkout driving AI Edit's retoucher (`EditLoop.lua`) on Canon CR3 files: every +round's table from the Lua writer (`LuaMode::Apply`, `LuaOptions::default()`) +through `photo:applyDevelopSettings()`, then `photo:updateAISettings()`, with the +photo's own corrections passed back raw (merged by `CorrectionSyncID`). Seven +photos, cloud model Gemini 3.1 Pro and local Gemma 4 E4B (MLX). + +| Question | Result | +|---|---| +| AI masks the experiments had not covered | **Face skin** and **iris and pupil** (people parts, the preset form `MaskSubType` 3) and the landscape class **vegetation** (50005) were all computed on photos that show them, next to subject, sky and background | +| The photo's own masks passed back raw | Kept and still computed ("Maske 1", a sky mask, on two photos), next to the new corrections | +| Re-applying a correction with the same sync id, round after round | Replaced in place: three rounds left one `LrGenius · Sky`, never two | +| `updateAISettings()` after each round | Every mask computed before the render (`needsUpdateAISettings` false), within the plugin's 30 s wait | +| Taking the photo back (the plugin's *Discard*) | Every key the loop wrote returned to its starting value; with own masks, `MaskGroupBasedCorrections` = the own ones alone; **without, an empty list removed the loop's masks** | +| White balance | `Custom` + `Temperature`/`Tint` on raw applied as written. A relative temperature a small model sent for a raw file was dropped by the recipe builder and reported | + +Not covered: a non-raw file (the catalog held none), a straightened or cropped +portrait, Lightroom before 16.0. The `LuaOptions::EVIDENCE` rows still describe the +experiments alone; this run supports `mask_form` for people parts and landscape +classes without changing the writer. + diff --git a/docs/wiki/Dev-Backend-API.md b/docs/wiki/Dev-Backend-API.md index bd171b7a..d053a278 100644 --- a/docs/wiki/Dev-Backend-API.md +++ b/docs/wiki/Dev-Backend-API.md @@ -811,11 +811,13 @@ style not learned, or not better than the default, is said in `warnings` and cha Generates a Lightroom develop recipe for a photo sent as a file upload, using an LLM. -> No plugin workflow calls this any more. *AI Edit Photos* runs on -> `POST /v1/edit/style` alone and never asks for the LLM fallback, so this endpoint -> and `/v1/edit/recipe/base64` are currently reachable only by direct HTTP callers. Both -> are fully supported; `SearchIndexAPI.generateEditRecipePhoto` in the plugin -> still speaks to `/v1/edit/recipe` and is simply not called. +> No plugin workflow calls this any more. *AI Edit Photos* edits with a model through +> the retoucher loop (`POST /v1/edit/sessions`), which looks at Lightroom's render of +> every edit instead of guessing once, and its "My style only" editor runs on +> `POST /v1/edit/style` without the LLM fallback. This endpoint and +> `/v1/edit/recipe/base64` are reachable only by direct HTTP callers. Both are fully +> supported; `SearchIndexAPI.generateEditRecipePhoto` in the plugin still speaks to +> `/v1/edit/recipe` and is simply not called. **Key request fields:** @@ -839,12 +841,14 @@ decides two things: - **How far the guardrails let a recipe push.** Raw files still hold detail behind clipped highlights; a rendered file does not. -- **The unit of `temperature`.** Lightroom's `Temp` is Kelvin for raw and a - relative −100..100 for JPEG/TIFF/PNG. The declared JSON schema follows the +- **The unit of `temperature`.** Lightroom's white balance is Kelvin + (`Temperature`) for raw and a relative −100..100 offset + (`IncrementalTemperature`) for JPEG/TIFF/PNG. The declared JSON schema follows the flag so the model answers in the right unit, and normalization clamps to the matching range. A Kelvin-looking value returned for a non-raw photo is **dropped, not clamped** — clamping 6200 into −100..100 yields +100, a hard - orange cast — and the reason lands in `warnings`. + orange cast — and the reason lands in the recipe's own `warnings` + (`edit.warnings`, mirrored as `edit_warnings`). Absent means unknown, which is treated as raw: that is what every catalog indexed before the flag existed assumed. @@ -857,6 +861,8 @@ mask list, plus: | `guardrail_reasons` | string[] | Machine-readable codes (with the face and expression models on disk, also `face_hard_light`, `face_below_surroundings`, `face_blown`, read off the faces in the frame; if that read fails, `warning` says so and the edit goes on without them) for what the frame allowed or refused, e.g. `hard_light_no_added_contrast`, `flat_light_contrast_allowed`. Empty when the budget changed nothing | | `guardrail_explanations` | string[] | The same, as sentences for the UI | | `edit_warnings` | string[] | Unrelated problems worth surfacing (e.g. no training examples found) | +| `warnings` | string[] | The backend's non-fatal notes about this request, one entry each (on this route, the edit generator's warning). Always present, empty when there is nothing to say | +| `warning` | string | **Legacy.** The same notes joined with `\n`, only when there are any; kept for installed plugins that read the single string. Remove once every supported plugin reads `warnings` | Before the recipe is returned, the backend decodes the image, measures the scene (light hardness, dynamic range, specular fraction, shadow clipping), @@ -875,14 +881,141 @@ scene and time of day, and interpolates their develop settings. Needs at least five stored examples. The result passes through the same guardrail budget as `/v1/edit/recipe` and carries the same `guardrail_reasons`. -`temperature` is blended only from examples whose `is_raw` matches the photo -being edited, because Lightroom's temperature is Kelvin for raw and a relative --100..100 for everything else — averaging the two produces a number that is -meaningless on either scale. When no matched example qualifies the field is -omitted entirely, and either way the reason is in `warning`. Examples saved -before `is_raw` was recorded count as compatible with anything. Every other -develop setting means the same on both kinds of file and is blended normally; -`tint` differs only in range, which the clamp handles. +**White balance** is not blended like the other sliders. It is a mode plus a key +family — Lightroom's `Temperature`/`Tint` are Kelvin and tint on raw files, +`IncrementalTemperature`/`IncrementalTint` are −100..100 offsets on everything +else — so `lrg_analysis::style_engine::blend_white_balance` decides it +separately: + +1. The **whole scored candidate pool** (up to 20 examples, not only the three + blend winners) votes for a mode, weighted by composite score. Examples + without a `WhiteBalance` do not vote; ties go to the more conservative mode + (As Shot, Auto, a preset, Custom). +2. **As Shot** wins: nothing is sent, the photo keeps its own. No warning — that + is the habit the examples show. +3. **Auto** or a **preset** (Daylight, Cloudy, ...) wins: nothing is sent, with + a warning. For a non-raw photo always: Lightroom has no presets for it, and + in the first experiment run (Lightroom 15.6, run from the Library module) + `Auto` alone left the old numbers in the readback within 15 s (E1f/E1g; + the Basic-panel check is open). For a raw photo while + `EMIT_NAMED_WB_MODES` is `false`, until experiment E1 shows that Lightroom + recomputes the temperature for a mode set without numbers on a raw file + (E1e has not run); turning it on reaches raw photos only. +4. **Custom** wins: temperature and tint from up to **3** of the best-scored + Custom examples of the photo's own family, weighted over exactly those, and + only when at least **2** exist and their temperatures lie within **500 K** + (20 for non-raw offsets). Otherwise nothing is sent, with a warning. +5. Raw examples give Kelvin only to raw photos, non-raw examples offsets only to + non-raw photos; an unknown `is_raw` sends nothing. Every "nothing is sent" + the user could act on comes back as an entry in `warnings`. + +Each `warnings` text is constant per cause: the per-photo numbers (the match +confidence, the measured temperature spread) stay in `confidence`, the recipe +summary and the log. The plugin merges warnings by exact text into one +"(N photos)" line per run, and a number in the text would make every photo its +own line. Style-path notes: a low or moderate match confidence, each white +balance that was not transferred, and matching examples that taught nothing +(re-read rows older than PV2012 or with unreadable settings). + +The result travels at **recipe level**, next to `global` and `masks`, never as +`global.temperature`/`global.tint` and never as `global.white_balance` (that +name is the LLM schema's `{temperature, tint}` alias): + +```json +"edit": { + "summary": "...", + "global": { + "exposure": 0.35, "contrast": 12, "sharpen_radius": 1.2, "grain_size": 30, + "hsl": {"blue": {"hue": -5, "saturation": -20, "luminance": -12}, "...": {}}, + "color_grading": {"shadows": {"hue": 220, "saturation": 15}, + "highlights": {"hue": 40, "saturation": 20}, "balance": -10}, + "tone_curve": {"shadows": -8, "shadow_split": 20, "midtone_split": 45, "highlight_split": 70} + }, + "masks": [], + "warnings": [], + "white_balance": {"mode": "Custom", "family": "raw", "temperature": 5450, "tint": 10} +} +``` + +`family` is `"raw"` or `"non_raw"`; `temperature`/`tint` are in that family's +keys' units and precision. Plugins that predate the field ignore it (they read +only `summary`/`global`/`masks`/`warnings`). The response also carries the +top-level `warnings` list and the legacy joined `warning` string described under +`POST /v1/edit/recipe`; a JPEG target with raw examples gets, for example, +*"White balance was not transferred: your training examples are raw files and +this photo is not ..."*. The thresholds are assumptions pinned by tests +(`style_engine.rs`), and the transfer is not switchable in this release. + +The other settings are blended per key over the winners that carry the key (an +example that never touched a slider does not pull it towards 0) and rounded to +the key's registry precision (integers for integer sliders, `Exposure2012` to 2 +decimals, `SharpenRadius` to 1). The learned keys are the registry's `global.*` +recipe aliases: every `global` field the installed plugin's +`DevelopEditManager` already applies (65 keys since step 1f): + +| `global` field(s) | Lightroom keys | +|---|---| +| `exposure`, `contrast`, `highlights`, `shadows`, `whites`, `blacks`, `texture`, `clarity`, `dehaze`, `vibrance`, `saturation` | `Exposure2012`, `Contrast2012`, ..., `Saturation` | +| `sharpening`, `sharpen_radius`, `sharpen_detail`, `sharpen_masking` | `Sharpness`, `SharpenRadius`, `SharpenDetail`, `SharpenEdgeMasking` | +| `noise_reduction`, `noise_reduction_detail`, `noise_reduction_contrast` | `LuminanceSmoothing`, `LuminanceNoiseReductionDetail`, `LuminanceNoiseReductionContrast` | +| `color_noise_reduction`, `color_noise_reduction_detail`, `color_noise_reduction_smoothness` | `ColorNoiseReduction`, `ColorNoiseReductionDetail`, `ColorNoiseReductionSmoothness` | +| `vignette`, `vignette_midpoint`, `vignette_roundness`, `vignette_feather`, `vignette_highlights` | `PostCropVignetteAmount`, `...Midpoint`, `...Roundness`, `...Feather`, `...HighlightContrast` | +| `grain`, `grain_size`, `grain_roughness` | `GrainAmount`, `GrainSize`, `GrainFrequency` | +| `hsl..{hue,saturation,luminance}` | `HueAdjustment`, `SaturationAdjustment`, `LuminanceAdjustment` | +| `color_grading.{shadows,highlights}.{hue,saturation}`, `color_grading.balance` | `SplitToning{Shadow,Highlight}{Hue,Saturation}`, `SplitToningBalance` | +| `tone_curve.{highlights,lights,darks,shadows}`, `tone_curve.{shadow,midtone,highlight}_split` | `Parametric*` | + +Not sent: colour-grading midtones, global, luminance and blending (installed +plugins warn "not supported" and drop them), point curves (averaging curves is +a later step), lens, crop, `Look`, masks. + +Two kinds of key are not plain means: + +- **Toning** (`SplitToning{Shadow,Highlight}{Hue,Saturation}`; registry gate + `CircularHue()` on the hue): each winner's zone is a colour + vector (hue = angle, saturation = length), and the zone gets the + score-weighted mean vector: its angle is the hue, its length the saturation. + So 350° and 10° give 0°, a hue left behind at saturation 0 does not pull, and + tints that disagree fade towards neutral instead of landing at full strength + on a hue none of the examples used (orange 30°/40 and blue 190°/40 give + 110°/7, not 110°/40). Where the tints agree the saturation equals the plain + mean. If the saturations sum to about 0, or the mean vector rounds to + saturation 0 (fully cancelling tints), the hue is left out and saturation 0 + is sent. When the toned winners' agreement (mean resultant length) is below + `MIN_HUE_AGREEMENT` (0.5, i.e. the tint lost more than half its strength) and + the plain mean saturation is at least 5, `warnings` gets *"Color grading of + the shadows was toned down: your matching examples tint the shadows in + clearly different colors."* (or highlights). +- **Curve splits** are blended as one group over the winners that carry all + three, which keeps `shadow_split < midtone_split < highlight_split`; if + integer rounding makes two equal, the three are left out and `warnings` gets + *"The tone curve's region splits were not transferred: your matching + examples' splits could not be averaged into a valid order."* + +Black-and-white examples (`ConvertToGrayscale`) contribute no toning and no +colour mixer: the recipe does not carry the B&W switch, so their sepia or +selenium toning would land on colour photos as a cast. The Basic, curve, +detail and effects keys they carry are still blended. + +`HueAdjustment*` (the colour mixer's hue) is an offset on −100..100, not an +angle, so it is a plain mean. + +The guardrail budget (`edit_budget::measure_and_apply`) acts on contrast, +clarity, dehaze, the parametric curve strength, shadows and whites only. The +new keys need no budget of their own: the splits do not change the curve +strength (a scaled curve keeps them), and HSL saturation, vignette and grain +are not moves the frame measurements (light hardness, dynamic range, clipping) +say anything about — like the global `saturation`/`vibrance`, which were never +budgeted. + +Stored examples carry a `canonical_version` (currently 3). A row written by an +older backend (no version, or a lower one) is re-read from its stored +`develop_settings` blob when it is retrieved — at most `CANDIDATE_POOL` rows per +request, nothing is written back — so examples saved before white balance was +read from the right keys contribute it now, and examples saved before step 1f +contribute the colour mixer, toning, curve splits and detail/effects sliders. A re-read row that teaches nothing (settings older than +PV2012, or a blob that cannot be read) is counted, and the response gets one +`warnings` entry for it. Only examples embedded with the current search model are compared with the photo (`embedding_model` in the example's metadata, written by `POST /v1/edit/training`); a vector @@ -892,10 +1025,11 @@ recorded count as outdated. When *every* stored example is outdated, the endpoin that there is nothing stored, and `/v1/edit/recipe` skips style matching with the same explanation in `warning`. Saving an example again replaces it. -The `/v1/edit/recipe` few-shot path applies the same rule: an example whose raw status -conflicts with the target has its white balance stripped before it reaches the -prompt, so the model is never anchored on a number the schema it must answer in -cannot express. +The `/v1/edit/recipe` few-shot path keeps the two scales apart too: an example +whose raw status conflicts with the target has its whole white-balance family +(`Temperature`, `Tint`, `IncrementalTemperature`, `IncrementalTint`, +`WhiteBalance`) stripped before it reaches the prompt, so the model is never +anchored on a number the schema it must answer in cannot express. ### `POST /v1/edit/score` The aesthetic score of one rendered image, and nothing else: nothing is stored and no @@ -916,6 +1050,77 @@ not an indexing task. | 503 | `{"status": "error", "error": ...}` | The aesthetic model is not downloaded. The message is the instruction ("run "Download AI models" in Plug-in Manager") and is shown as it stands | | 500 | `{"status": "error", "error": ...}` | The image could not be decoded, or the model failed | +### `POST /v1/edit/sessions` +Opens an AI Edit **retoucher session** for one photo and answers with its first +step. The retoucher is a loop: a vision model edits, Lightroom applies and renders +the edit, the model sees the render with its measurements and corrects, until it +calls `finish` or runs out of edits. Lightroom has to sit between the rounds, so +the backend keeps the conversation in memory (`lrg_api::edit_loop`) and the plugin +drives it (`EditLoop.lua`): apply the step's table, render, upload the render to +`…/render`, repeat. Sessions nobody touches for 30 minutes are dropped. + +Multipart: + +| Field | Type | Description | +|---|---|---| +| `photo_id`, `image` | string, file | The photo, rendered as it is now (the plugin sends 1536 px on the long edge; the model sees at most that) | +| `provider`, `model` | string | Any provider the agent chat runs on: `llamacpp`, `mlx`, `ollama`, `lmstudio`, `openai_compatible`, `chatgpt`, `gemini`, `anthropic`. The model must take images and call tools | +| `api_key`, `server_url`, `ollama_base_url`, `lmstudio_base_url`, `llm_*` | string | Connection and local-engine fields, as for `/v1/index/photos` | +| `develop_settings` | JSON | `photo:getDevelopSettings()` before the loop. Tells the model where every slider starts, decides the white-balance family (raw: Kelvin `Temperature`; rendered: `IncrementalTemperature`) and the process version the table is written for | +| `is_raw` | bool | Only a fallback, when the settings carry no white-balance keys | +| `look` | string | `natural` (default), `bright_airy`, `moody`, `cinematic`, `film`, `punchy`, `black_and_white` (`lrg_providers::edit_loop::LOOKS`) | +| `instructions` | string | The user's own words; they win over the look | +| `language` | string | Language of the model's `assessment` and `verdict` | +| `max_edits` | int | Edits the model may apply, 1–5 (default 3) | +| `masks` | bool | Offer AI masks (subject, sky, background, people parts, landscape classes). The plugin sends `true` only on Lightroom 15.3+, where `photo:updateAISettings()` computes them | +| `style_recipe` | JSON | The `edit` of a `/v1/edit/style` answer. When set, the photographer's style is **round 0**: it comes back as the first step without asking the model, and the model's first look is at the original *and* the style's render with its values, told to keep the style and fix the frame. Every edit the model sends is merged onto the style (`lrg_providers::edit_loop::merge_onto`): what it leaves out keeps the style's value, so the steps' tables carry the whole style plus the model's changes | +| `db_path`, `catalog_id` | string | As elsewhere; the finished recipe is stored on the photo's record | + +Each step: + +| Field | Type | Description | +|---|---|---| +| `session_id` | string | For `…/render` and `DELETE` | +| `round` | int | 0 for the style, then 1, 2, … | +| `source` | string | `"style"` or `"model"` | +| `done` | bool | The session has finished; `keep_round` says which round to leave on the photo | +| `develop_settings` | object | The table for `photo:applyDevelopSettings()`, written by `lrg-develop`'s Lua writer: the **complete** edit measured from the starting point, not a change on top of the previous round. Masks are `MaskGroupBasedCorrections` entries named `LrGenius · ` with sync ids derived from the target, so a later round's mask replaces an earlier one. Empty on a `done` step | +| `update_ai_settings` | bool | Call `photo:updateAISettings()` after applying (the table has AI masks) | +| `edit` | object | The recipe in the model's vocabulary (UI units, after the guardrails), for display and the photo's record | +| `assessment` | string | What the model saw and changed this round | +| `verdict`, `keep_round` | string, int | On the `done` step: the model's last word, and the round to keep (the latest unless it named an earlier, better one) | +| `edits_left` | int | | +| `warnings` | string[] | What did not reach the photo as given: a value Lightroom's range clamped, a mask target that does not exist, masks beyond the six an edit may carry, a key the photo's process version lacks, the style's white balance from the other file kind. The guardrail explanations are not repeated here (they are in `guardrail_explanations`); the model gets both with the render | +| `guardrail_reasons`, `guardrail_explanations` | string[] | As for `/v1/edit/recipe`: every round's recipe passes the same budget, measured on the original upload | +| `input_tokens`, `output_tokens` | int | So far in the session | + +| Status | When | +|---|---| +| 400 | No photo or image, unreadable `develop_settings`/`style_recipe`, no model chosen, a model that cannot see images, a missing key or server address | +| 502 | The model call failed, or three answers brought no usable edit. The message names the cause: no tool call at all (a model without tool calling), or the reasons Lightroom could not use the edits it sent | + +### `POST /v1/edit/sessions/{session_id}/render` +The render of the last step's edit (multipart `image`), plus optional +`lightroom_notes`, a JSON list of sentences about what Lightroom saw while applying +it: an AI mask that found nothing, one not computed within 30 s, or a failed +`updateAISettings()` (the plugin also shows these to the user when they concern the +round the photo ends on). The model gets the render, +the values of the edit it shows (`recipe_text`, so a model refining its edit has the +numbers in front of it), its measurements (`lrg_imaging::tone`: luminance +percentiles, histogram bands, clipping, the colour of the near-neutral tones, +midtone saturation), how they moved against the original, and what did not reach +the photo; it answers with the next +step in the shape above. A `done` step ends the session and stores the kept recipe +on the photo's record, as `/v1/edit/style` stores the recipe it generates: what the +model made of the photo, whether or not the user then keeps it in the plugin's +review. When that store fails, the step's `warnings` say so. 404 when the session +has ended or expired; 502 as for the start. + +### `DELETE /v1/edit/sessions/{session_id}` +Ends a session early (the plugin does this when a run is canceled or breaks off). +`{"status": "success", "ended": true|false}`; ending one that already ended is not +an error. + --- ## Face Detection & Persons @@ -1486,14 +1691,34 @@ Applies a list of approved keyword merge pairs to the backend's metadata records ## Style Training ### `POST /v1/edit/training` -Stores one photo's develop settings as a training example. `is_raw` is recorded -with it and decides, later, whether the example may contribute a temperature — -see `POST /v1/edit/style`. The SigLIP2 vector is stored together with the -`embedding_model` that made it, which decides whether the example can still be -matched after the search model changes. - Saves a photo's Lightroom develop settings as a labeled training example. +`develop_settings` is the plugin's JSON.lua encoding of +`photo:getDevelopSettings()`. `"[]"` — how JSON.lua writes an empty table — is +accepted as no settings; any other non-object is a 400. The blob is read with +`lrg_develop::lua::read` and stored three ways: verbatim (`develop_settings`), +as the learnable canonical numbers (`canonical_settings`, the registry's recipe +aliases) and as the typed white balance (`white_balance`, the +`{mode, family, temperature?, tint?}` shape above, numbers only for Custom), +together with `canonical_version`. + +`is_raw` is stored from the white-balance keys when the blob has them +(`Temperature` → raw, `IncrementalTemperature` → non-raw, which also covers a +DNG converted from a JPEG); the plugin's flag is only the fallback, and a +contradiction is logged. A blob that carries both families is ambiguous: its +`white_balance` is not stored whatever the flag says, and the response says the +white balance was not learned. The style engine uses the stored +`white_balance.family`; `is_raw` is the hint for re-deriving older rows and the +filter for the `/v1/edit/recipe` few-shot examples. + +The SigLIP2 vector is stored together with the `embedding_model` that made it, +which decides whether the example can still be matched after the search model +changes. + +**Response:** `{status, photo_id, total_count, warnings: [...]}`, plus the +legacy joined `warning` string when there are warnings (no embedding computed, +process version older than PV2012, both white-balance families in one blob). + ### `GET /v1/edit/training` Lists all stored training examples. @@ -1782,4 +2007,8 @@ All endpoints return: - `error` — a string if an error occurred, `null` otherwise. - `warning` — a non-fatal warning string, `null` otherwise. +Newer endpoints (indexing, faces, `/v1/edit/*`) return a `warnings` array, one +entry per note. Where both exist, `warning` is a legacy newline-joined copy of +`warnings` for installed plugins that read only the string. + On HTTP error responses (4xx/5xx), `error` is always set. diff --git a/docs/wiki/Dev-Capture-One-Port-Plan.md b/docs/wiki/Dev-Capture-One-Port-Plan.md index fa2bd62f..5808480b 100644 --- a/docs/wiki/Dev-Capture-One-Port-Plan.md +++ b/docs/wiki/Dev-Capture-One-Port-Plan.md @@ -239,8 +239,8 @@ Backend work it would have required (none of it done): `mtime`, so it does not survive a backup/restore. Proposed replacement: `file1:` = MD5 over `::`, no mtime. 2. **Separate the training corpus per host.** `POST /v1/edit/training` expects - Lightroom SDK keys and crosswalks them through `LR_TO_CANONICAL` in - `lrg-analysis/src/training.rs`. Capture One's controls are named differently + Lightroom SDK keys and crosswalks them through the `lrg-develop` key + registry (`canonical_keys()` in `lrg-analysis/src/training.rs`). Capture One's controls are named differently *and scale differently* — mixing them would corrupt the existing style profile. 3. **Edit-recipe scale and prompt profile.** `temperature` is Kelvin for RAW and −100..100 otherwise (a Lightroom convention); `prompts.rs` says "senior diff --git a/docs/wiki/Dev-Develop-Model.md b/docs/wiki/Dev-Develop-Model.md new file mode 100644 index 00000000..b103dee9 --- /dev/null +++ b/docs/wiki/Dev-Develop-Model.md @@ -0,0 +1,1560 @@ +# Dev: Native Develop Model (`lrg-develop`) + +> **Status: steps 1a, 1c, 1b and 1g (1d/1f use it; 1b's manual import test in +> the installed Lightroom Classic is pending, see [Hand test](#hand-test)).** The crate has the key +> registry, the typed model, the policy filter, the readers for the Lua table +> form and for XMP (sidecars, develop presets, profiles), the develop-preset +> writer, the mask builders and the Lua writer. The Lua writer's forms are +> **provisional** until experiments E1/E2/E4/E11 are run (see [Writing the +> Lua form](#writing-the-lua-form-provisional)). Background and the research behind it: +> [AI Edit via XMP — Findings and Experiments](Dev-AI-Edit-XMP-Findings). + +`server-rs/crates/lrg-develop` holds the facts and shapes of Lightroom +Classic's develop settings: which keys exist, what they mean, how they are +stored, and what may be learned, shared or written. It is a **leaf crate** — +no image, network or async dependencies — so `cargo test -p lrg-develop` runs +in seconds without an ONNX or LanceDB build. + +What it deliberately does *not* hold: learning and blending (majority votes, +circular means, thresholds) stay in `lrg-analysis::style_engine`, and the LLM +edit-recipe schema stays in `lrg-providers::edit_recipe`. + +Who uses it today: + +- `lrg-analysis::training` reads a training example's `develop_settings` blob + with `lua::read`, learns the registry keys that have a `global.*` recipe + alias, and keeps the white balance as a typed `WbSetting` + (`CANONICAL_VERSION` marks the stored form). A black-and-white example + (`ConvertToGrayscale`) teaches no `color_grading_*`/`hsl_*` keys: the recipe + does not carry the B&W switch, so its toning would reach colour photos. +- `lrg-analysis::style_engine` rounds blends to each key's registry precision, + blends each `CircularHue` key with its saturation as a mean colour vector + (hue = angle, saturation = length), and decides the white balance per key + family. +- `lrg-api` (`routes/training.rs`, `routes/style_edit.rs`) stores and re-reads + that canonical form. +- `lrg-providers` builds AI Edit's retoucher tool schema (`edit_loop.rs`) + from `build::recipe`: the globals it offers, the mask targets and local + adjustments, each with the registry's UI range. A test still requires every + range the older LLM recipe schema (`edit_recipe.rs`) declares for a registry + key to equal the key's UI range. +- `lrg-api` (`edit_loop.rs`) turns each of the retoucher's edits into the + table `applyDevelopSettings()` takes: `build::recipe::settings_from_recipe`, + then `lua::to_lua_value` in apply mode for the photo + ([From an edit recipe](#from-an-edit-recipe)). It is the first production + caller of the Lua writer. + +## Layout + +| Module | What | +|---|---| +| `registry` | One `KeySpec` per develop key (`table.rs`), name families (`patterns.rs`), UI ↔ stored scaling (`ui.rs`, the only place), int/real conversion (`coerce.rs`, the only place) | +| `model` | `DevelopSettings`, `Value`/`Finite`/`Opaque`, corrections and masks (`correction.rs`), white balance (`whitebalance.rs`), the policy filter (`policy.rs`) | +| `lua::read` | `getDevelopSettings()` as JSON.lua encodes it → model + warnings | +| `lua::write` | model → the table `applyDevelopSettings()` takes, as JSON for JSON.lua (`LuaMode::Apply`), or a lossless test rewrite (`LuaMode::TestRoundTrip`, feature `test-roundtrip`); the experiment-decided forms are `LuaOptions` | +| `xmp::read` | an XMP sidecar, preset or profile → `XmpDocument` (kind, header, model, warnings, skipped subtrees) | +| `xmp::write` | model → a develop preset (`WriteMode::Preset`), or a lossless test rewrite (`WriteMode::TestRoundTrip`, feature `test-roundtrip`) | +| `xmp::format` | how the writer spells numbers, booleans, curves, ids, versions; XML escaping | +| `build` | `CorrectionBuilder`: new corrections from UI values and semantic, gradient and luminance-range components, with deterministic sync ids; `build::recipe`: settings from an edit recipe (UI units, recipe aliases, semantic mask targets) and a photo's starting values back in that vocabulary | +| `reader` (crate-private) | what both readers share: typing through the registry, closed sets and ids, curve points, file kind, process-version check, assembly | +| `parse` | `ParseError` (input unusable) and `ParseWarning` (one value, reading went on) | + +## The key registry + +Every key is one row: name, level (global, preset header, correction, mask +tool, or a field of a nested structure), value kind, range **in the stored +unit**, UI scaling, XMP number format, defaults for raw and non-raw files, +policy class, frame scope, file kind (raw only / non-raw only / both), where +it occurs (Lua, XMP, both, or not yet observed), minimum process version and +recipe alias. Lookup +is by `(level, name)`, because names such as `Version` mean different things +on different levels. Families that are never modelled one key at a time are +patterns: `Table_` and `BrushTable_` blobs, `pm_*` patch state, +`UprightTransform_N`/`UprightFourSegments_N`, and everything under +`FilterList`. + +### Maintaining the registry + +The table was seeded from `server-rs/scripts/develop_registry/spec.py` +by `gen_table_rs.py` (same folder; it imports `spec.py` and reads one +committed data file, the non-raw readback in +`server-rs/testdata/develop/defaults/`; never the rendered Markdown, an +inventory or the training dump). **`table.rs` is the source of truth.** +Adding or changing a key is editing a row there by hand, then refreshing the +review snapshot: + +```bash +cd server-rs +LRG_BLESS=1 cargo test -p lrg-develop --test registry_snapshot +``` + +`server-rs/testdata/develop/registry_snapshot.json` is generated from the +table and the patterns and compared on every run, so every registry change is +a readable diff in review. Never edit the snapshot by hand. + +Re-running the generator **overwrites hand edits** to `table.rs`. Do it only +when `spec.py` itself changed, and review the diff: + +```bash +cd server-rs/scripts/develop_registry +python3 gen_table_rs.py --out ../../crates/lrg-develop/src/registry/table.rs --counts +``` + +A new key needs: its level (the same name can mean different things on +different levels, e.g. `Version`), the value kind, the range in the **stored** +unit, the UI scale if it is a slider, raw and non-raw defaults (`Unverified` +if unknown, `NoDefault` if Lightroom fills it per photo or only writes it when +a feature is used), the policy class +(`UNKNOWN` until someone has checked what it does), frame scope, file scope, +where it occurs and the first process version that has it. The invariants +(unique `(level, name)`, ranges contain defaults, `+` signs only on global +keys, raw-only keys absent for non-raw files, unobserved keys only UNKNOWN or +COMPUTED, ...) are unit tests in `registry/tests.rs` and catch many +half-filled rows. `Temp` is not a key: the plugin and the style engine's alias +table used to read and write it by mistake (see *Bugs found in the current +code* in [Dev-AI-Edit-XMP-Findings](Dev-AI-Edit-XMP-Findings)); it resolves to +unknown, and a test pins that. `tests/no_temp_key.rs` keeps it from coming back +as a key literal in `server-rs/crates/*/src` and the plugin's +`DevelopEditManager.lua` (comments excluded; the develop experiments, which +write `Temp` on purpose, are allowlisted, and so is the one constant the Lua +writer refuses it with, `RETIRED_TEMP_KEY` in `src/lua/write.rs`, via +`ALLOWED_LINES`, matched on `/`-joined paths so the Windows release job +agrees). Because it guards a plugin file, CI +also runs it in the unfiltered `format-lint-rust` job of `lint-format.yml`, not +only in `server-rs-tests.yml`, which skips plugin-only PRs. + +Defaults: every training example is a raw file, so the non-raw column comes +from a readback instead. Experiment E13 read Lightroom's full develop table +for one non-raw photo without develop edits (Lightroom Classic 15.6, process +version 15.4, a virtual copy of a TIFF; [Findings: Results](Dev-AI-Edit-XMP-Findings#results-first-run-2026-10-03)), +committed scrubbed as `server-rs/testdata/develop/defaults/non_raw_lrc15.6.json` +(key -> value, plus the Lightroom version, process version and file kind). +`gen_table_rs.py` gives every global row the text leaves `Unverified` for +non-raw files the value read back, if the readback has the key (145 rows), +and stops when a readback contradicts the non-raw default `spec.py` states. +It holds back the keys in `NON_RAW_READBACK_NOT_CONSTANT`: `HDRMaxValue` read +2.3 (raw 4.0) on that one photo and may be per image, so its non-raw default +stays `Unverified`. `registry/tests.rs` (`non_raw_defaults_come_from_the_readback`) +requires every non-raw `Value` to match the file and no row the readback +covers to be left `Unverified`, except the held-back keys. Most equal the raw +default; the ones that differ are `Sharpness` 0 (raw 40), +`ColorNoiseReduction` 0 (raw 25), `CameraProfile` "Embedded" (raw "Adobe +Standard") and `IncrementalTemperature`/`IncrementalTint` 0 (absent for raw). +The policy filter reads this column: a non-raw source's removed value equal +to it is dropped without a report (`non_raw_defaults_are_removed_without_a_report` +in `model/policy.rs`). One virtual copy is thin evidence for a table: the +report's own check (a freshly imported JPEG and raw for the default tables) +is still open. A key the readback does not have stays `Unverified`: +absence can mean "written only when used", so no key was marked `Absent` on +this evidence. `NoDefault` means there is no fixed default for either file +kind (as-shot white balance, digests, `Look`, the Upright solver state), +which a readback does not change (19 readback keys kept it). Hand edits to +`table.rs` are mirrored in `spec.py`, so the generator reproduces the table +and a later readback (a raw default table, another Lightroom version) is one +regeneration plus a reviewed snapshot diff. + +### Policy classes + +| Class | Learn/blend | Shared preset | Apply to one photo | Test round trip | +|---|---|---|---|---| +| LEARN | yes | yes | yes | yes | +| LEARN† (gated) | yes, behind the gate | if the gate holds for every target | if the gate holds for the photo | yes | +| PHOTO | never averaged | no | yes, with the photo's own context | yes | +| COMPUTED | no | no | no | yes | +| NEVER | no | no | no | yes | +| META, global (`ProcessVersion`, `Version`, `Enable*`, ...) | no | no: set by the writer | no: set by the writer | yes | +| META, nested (correction, mask and structure bookkeeping) | no | yes | yes | yes | +| UNKNOWN | no | no | no | yes | + +Gates: `RawOnly`/`NonRawOnly` (white balance), `FileKindDefault` (defaults +differ between raw and non-raw), `OnlyValues` (only some values transfer), +`CameraRestricted` (profiles), and the learning-side gates `Categorical`, +`CircularHue`, `DependsOn`, `ByMaskType`, `NeedsAiUpdate`. + +`CircularHue()` names the saturation that weighs the angle +(`SplitToningShadowHue` → `SplitToningShadowSaturation`, `LocalToningHue` → +`LocalToningSaturation`, same level): the style engine treats each example as +a colour vector of that length and averages the vectors by score (see +`POST /v1/edit/style` in +[Dev-Backend-API](Dev-Backend-API)). A registry test checks that the named key +exists on the same level and cannot be negative. + +### Recipe aliases + +`recipe_alias` is the edit-recipe field a global key is learned and sent as +(`global.exposure`, `global.hsl.red.hue`, `global.color_grading.shadows.hue`, +`global.tone_curve.shadow_split`). Since step 1f every field the installed +plugin's `DevelopEditManager` applies has one: 65 keys (the basic and presence +sliders, detail, vignette, grain, the parametric curve with its splits, the 24 +HSL values, colour grading shadows/highlights hue and saturation plus balance). +Deliberately without an alias: white balance (typed, `WbSetting`), colour +grading midtones/global/luminance/blending (installed plugins warn and drop +them), point curves, lens, crop, `Look`. Tests pin that aliases are unique, +only on learnable global keys, never a prefix of one another, and that the LLM +schema declares the registry's UI range for each (`edit_recipe.rs`). The +aliases live in `gen_table_rs.py`'s `ALIAS` table and are regenerated into +`table.rs` with the rest; adding one means bumping `CANONICAL_VERSION` in +`lrg-analysis::training`, so stored examples are re-read from their blob. + +`CameraRestricted` on `CameraProfile` depends on the value: `Adobe Standard` +and the symbolic `Default Color`/`Default Monochrome`/`Default Profile` exist +for every camera and go everywhere (`PORTABLE_PROFILES`); any other name is +camera-specific and never goes into a shared preset. For one photo +(`ApplyPhoto`) it passes, because the model knows neither the example's camera +nor the photo's make: until the target carries the make, the caller must only +apply settings from the same make. + +### Units + +Lua tables and XMP files both carry the **stored** unit: +`LocalExposure2012` is EV / 4; the other signed `Local*` sliders, +`LocalToningSaturation` and `CorrectionAmount` are UI / 100; +`LocalToningHue` and globals are UI units. The model always holds the stored +unit; `registry::to_ui`/`from_ui` are the only conversion. A builder or blend +uses `from_ui_value`, which returns the model value of the key's kind +(`Value::Int` for `Contrast2012`, as the readers produce), so integer rounding +stays in one place. Keys whose scale is unverified (`LocalHue`, `LocalGrain`) +cannot be converted, so nothing can write them from UI values. + +## The model + +`DevelopSettings` holds global values in a map keyed by registry id (so the +order is the same in every build, whatever `serde_json` features the +workspace unifies). Invariant, enforced by `insert` (the map is private): only +global keys, and never `MaskGroupBasedCorrections` or `Look`, which are the +typed fields `corrections` and `look`. Everything the registry does not know +is kept verbatim in `opaque`, never dropped (from a Lua table sorted by key +name, so every build agrees). + +Typed views exist only where logic hangs: + +- **Corrections and masks.** A `Correction` has its adjustments (`local`, stored + unit), its components in evaluation order, and everything else in `extra`. + Components are recognised **by structure, never by name** (names are + localised): + + | `MaskTool` | Recognised from | + |---|---| + | `Semantic::Subject` / `Sky` | `Mask/Image`, `MaskSubType` 1 / 2 | + | `Semantic::Background` | `Mask/Image`, subtype 0, category 22 | + | `Semantic::PeoplePart(part)` | subtype 3, a people-part category (preset form) | + | `Semantic::Landscape(class)` | subtype 0, category 50001–50008 | + | `Semantic::PersonPartAt { part, point }` | subtype 0, a people-part category, a `ReferencePoint` (one person; photo-specific) | + | `Linear` | `Mask/Gradient` | + | `Radial` | `Mask/CircularGradient` with `Angle` 0 | + | `LuminanceRange` | `Mask/RangeMask`, `CorrectionRangeMask.Type` 2 | + | `Opaque { what }` | anything else: brush aggregates, Select Object, colour/depth ranges, rotated radial gradients — kept for the round trip only | + + How a component combines with the ones before it: + + | `Combine` | `MaskBlendMode` | `MaskValue` | `MaskInverted` | + |---|---|---|---| + | `Add { inverted }` | 0 | 1 | `inverted` | + | `Subtract` | 1 | 0 | false | + | `Intersect` | 1 | 0 | true (A ∩ B = A − ¬B) | + + Any other combination stays as it is (`Combine::Unrecognised`, with a + warning). +- **Look.** The profile is read whole from its source (name, UUID, amount, + camera restriction, and the rest including `Parameters` verbatim). It is + never rebuilt from a profile name. An empty `CameraModelRestriction` means + "no restriction" and stays in the rest as it was. What of it leaves the + model depends on the target (see [Policy filter and frame + scope](#policy-filter-and-frame-scope)). +- **White balance.** `WbSetting { mode, family, temperature, tint }`. The family + (`Temperature`/`Tint` for raw, `IncrementalTemperature`/`IncrementalTint` + for non-raw) comes from the keys the settings carry, via the registry's file + scope, not from the file extension: a DNG converted from a JPEG uses the + non-raw family. The white-balance *policy* (what transfers) belongs to the + style engine (`blend_white_balance`, see `POST /v1/edit/style` in + [Dev-Backend-API](Dev-Backend-API)); what a preset may carry is the policy + filter's: the numbers only next to a `Custom` (or absent) `WhiteBalance`, + never an `As Shot` or `Auto` result turned into a custom white balance + (see [Policy filter and frame scope](#policy-filter-and-frame-scope)). +- **Process version.** `process_version()`; PV2012 (`"6.7"`) is the lowest + version whose settings are learned. + +`Finite` is the float for everything that can reach a writer: it cannot be NaN +or infinite (JSON.lua and `serde_json` would both write `null`), and `-0.0` is +stored as `0.0`. + +### Policy filter and frame scope + +`DevelopSettings::filtered(target)` keeps what may go to a `Target` +(`Preset { mixed_file_kinds }`, `ApplyPhoto { file_kind, process_version, +camera }`, `TestRoundTrip`) and returns every removed value that differs from +its default as `Skipped { path, reason }`, so a caller can say what did not +transfer. A correction goes or stays whole, decided by its most restrictive +component (semantic masks, except a person part at a reference point, and +luminance ranges are LEARN; gradients, Select Object and colour ranges PHOTO; +brushes NEVER). A camera-restricted `Look` only goes to that camera. In a +preset, white-balance numbers (`Temperature`, `Tint`, `Incremental*`) go only +with a custom white balance: next to any other `WhiteBalance` mode (`As +Shot`, `Auto`, `Daylight`, ...) they are what that mode resolved to for the +source photo, so they are dropped and reported as +`SkipReason::WhiteBalanceMode(mode)` instead of pinning one photo's white +balance on every photo (`WhiteBalance` itself is PHOTO; writing `As Shot` +into a preset is a later policy change). +`ApplyPhoto` also drops keys newer than the photo's process version and keys +of the other file kind. A single-kind preset (`Preset { mixed_file_kinds: +false }`) is for photos of the settings' own file kind: it drops the other +kind's keys, and when the settings carry both white-balance families with no +known file kind (the readers warn `ConflictingFileKind`) it drops both +families, since either would make the preset contradict itself. A mask +component whose `MaskBlendMode`/`MaskValue`/`MaskInverted` form no known +combination (`Combine::Unrecognised`) is UNKNOWN, so its correction reaches +neither a preset nor a photo: what it does to the mask is not known. Both +rules came out of the writer's round-trip tests (a preset written from such +settings read back with a warning). + +The verdict is per key at every depth: a kept structure is rebuilt from the +fields that pass at their own registry rows, and each removed field is +reported with its full path (`LensBlur.SampledArea`, +`MaskGroupBasedCorrections[0].CorrectionMasks[1].CorrectionRangeMask.LuminanceDepthSampleInfo`). +So a shared `LensBlur` keeps `Active` and the look sliders but not its sampled +area or focal range, and a luminance range loses its eyedropper sample. A +registry value the reader kept whole (`Value::Opaque`: an `rdf:Bag`, +qualifiers, a Lua table of an unexpected shape) reaches neither a preset nor +a photo and is always reported (`SkipReason::Opaque`). The `Look` goes to one +photo whole (its profile record as the source has it) and to a preset by +reference: `Name`, `UUID`, `Amount` (and the META `Stubbed` when present), +without its COMPUTED fields — `Parameters` (the profile definition, whose +`Table_*` blobs a preset never carries), `Group`, `Cluster`, `Copyright`, +`SortName`, `Supports*`, `isAdobeAdaptive`. Those are copies Lightroom takes +from its profile library, none of Adobe's 118 bundled presets with a `Look` +has `Parameters`, and they are dropped without a report (nothing the user +could act on). Adobe's presets themselves use either the name alone or a +stub (`Stubbed="true"` with the profile's `Group`, `Cluster`, `Copyright` +and `Supports*`); the writer's form, `Name`/`UUID`/`Amount`, is untested in +Lightroom until the E11 experiment. A range mask's `SampleType` is UNKNOWN +(default 0) and is dropped without a report; the XMP writer emits Adobe's +fixed range-mask form (`Version` 3, `SampleType` 0) itself (see [Preset +mode](#preset-mode)). + +Global META values (`ProcessVersion`, `Version`, `CompatibleVersion`, +`ToneCurveName2012`, `HasCrop`, the `Enable*` panel switches, ...) never leave +for a preset or a photo, and are not reported: they describe the example, and +the writer sets them from the target's context. Copying an example's +`ProcessVersion` would silently change the target photo's process version. +Nested META (a correction's name and sync id, a range mask's `Version`) stays +for now. + +`split_by_frame_scope()` sorts values into shareable, per-frame-adjusted, +per-frame-only and not-applicable parts — the basis for sharing one recipe +across a series later. + +## Reading the Lua form + +`lua::from_lua_str(text, hint)` / `from_lua_value(&json, hint)` read the +`develop_settings` blob the plugin sends (JSON.lua's encoding of +`photo:getDevelopSettings()`): + +- a top-level `[]` is the empty table; an empty table (`[]` or `{}`) as the + value of any key, at any depth, means the key is absent (`LensBlur`, + `AILook`, `FilterList`, ...), and an empty table as an element of a list of + objects (corrections, masks, structures) is no element, all without a + warning; +- an integer where a real is expected is fine; a non-integer on an integer key + is rounded half-to-even and warned about; +- booleans and 0/1 are accepted on bool and 0/1-flag keys and normalised; +- a known key with the wrong shape (`null` in an array, a mixed array, text for + a number) is kept verbatim with a `WrongType` warning and never written; +- an unknown key is kept verbatim with an `UnknownKey` warning — never an + error; pattern-family keys are kept verbatim without a warning; +- the file kind comes from the white-balance keys; a caller hint (`is_raw`) + that contradicts them is warned about and loses; +- a process version below 6.7 is warned about (such settings are not learned); +- keys are read in name order, so opaque entries and warnings come out in the + same order whatever `serde_json` features the build unifies. + +`ParseError` is only for input that is unusable as a whole: invalid JSON, +more than 4 MiB, or a top level that is not a table. Warnings are a list with +a path such as `MaskGroupBasedCorrections[2].CorrectionMasks[0].MaskSubType`. + +## Reading XMP + +`xmp::parse(bytes)` reads a sidecar, a develop preset or a profile into an +`XmpDocument { kind, header, develop, warnings, skipped_subtrees }`. It lands +in **the same model the Lua reader produces**: the same key ids, value +variants, correction and mask classification, `Look` and white-balance family. +Both readers only walk their own syntax; typing (through `KeySpec::coerce`), +the checks on closed sets and ids, curve points, the file kind and the +process-version check live once in the crate-private `reader` module, and the +correction/mask classification in `model::correction`. +`tests/xmp_lua_equivalence.rs` holds the two readers to that. What differs by +design, and what those fixtures therefore avoid or strip: + +- global keys of only one format (`Presence::XmpOnly`/`LuaOnly`); +- `PerFormat` keys (`PointColors`, `RetouchInfo`): a `StructList` from Lua, a + `StrList` from XMP; +- `ValueKind::Any` and `Settings` keys (`RGBTables`, `ProfileGainTableMap`, + `Look.Parameters`, ...): `Opaque::Json` from Lua, `Opaque::Xmp` from XMP; +- struct-, correction- and mask-level Lua-only keys (`CorrectionID`, + `CorrectionReferenceX/Y`, `MaskID`, `Look.isAdobeAdaptive`); +- Lightroom writes `PointColors` into every sidecar as a sentinel item + (19 × `-1`) where `getDevelopSettings()` returns `[]`, so an XMP model + reports one point colour that the Lua model does not. + +Pairing real training rows with their sidecars (September 2026, key names +only) found no key present in both formats with a different value variant, +and identical mask tools and combine sequences. It also found `Presence` +rows the data contradicts, a follow-up for the registry (not changed yet): +seen only in XMP although marked `Both` — `Version`, `CompatibleVersion`, +`CameraProfileDigest`, `OverrideLookVignette`, `ColorVariance`, +`AutoToneDigest`, `AutoToneDigestNoSat`, `ToggleStyle*`, `AllowFilters`, +`RangeMaskMapInfo`, `Upright*DependentDigest`; seen only in Lua — the PV2010 +keys (`Exposure`, `Contrast`, `Brightness`, `Shadows`, `FillLight`, +`HighlightRecovery`), the `Auto*` flags and `ExtendedToneCurvePV2012*`. + +Two passes. The first turns RDF into the XMP data model (`XmpNode`), whatever +the syntax: attribute form and element form, structures as a nested +`rdf:Description`, as attributes on the property element or with +`rdf:parseType="Resource"`, `rdf:Seq`/`rdf:Bag`/`rdf:Alt` with empty items, +`xml:lang` and qualifiers (`rdf:value`, as an element or an attribute). Every +`rdf:Description` under every outermost `rdf:RDF` is merged in document +order. Top-level properties outside `crs:` are counted and never built. A +property element that mixes forms RDF does not allow (a container next to +other elements or field attributes, something other than `rdf:li` in a +container) is read as a plain structure of everything it holds, so the +second pass reports it (`WrongType`/`UnknownKey`) and keeps it whole; text +next to elements, which the data model cannot hold, is dropped with one +`MalformedRdf` warning per top-level property (the first place, and how many +more). Namespace URIs are shared (`Arc`), not copied per field, so a +long URI cannot multiply into gigabytes. The second pass types the `crs:` +properties through the registry: + +- the namespace is matched by URI (`http://ns.adobe.com/camera-raw-settings/1.0/`), + never by the `crs:` prefix; +- top-level names resolve at the global level first, then as preset header + keys (`Baseline` is a header key though it looks like a setting); +- `True`/`true`/`False`/`false` are all booleans, `"+15"` is 15, `"1.000000"` + is 1.0; a non-integer on an integer key is rounded half-to-even with a + warning, as in the Lua reader; ids are checked for 32 hex digits; +- global curves (`"x, y"`) and local curves (`"x,y"`) both become + `Value::Curve` — the same value the Lua reader makes of a flat number list + and of `"x,y"` strings; +- an `rdf:Alt` with only `x-default` is `Value::Alt`. One with further + languages, an `rdf:Bag` or `rdf:Alt` where the registry expects an + `rdf:Seq`, and any value with qualifiers are kept whole under their key as + `Opaque::Xmp` (lossless, no warning: valid XMP the model does not + interpret; `XmpNode::alt_default` still finds the default language). Each + such key is counted in `skipped_subtrees.kept_whole`, so the lost typing + is visible; the local sweeps require 0 for Lightroom's own files; +- `crss:` (snapshots) is skipped entirely; `Look.Parameters` (a profile + definition) and `Preset.Parameters` (the last applied preset) are kept + opaque and never evaluated; properties in other namespaces (`dc:`, `xmp:`, + `exif:`, ...) are not part of the model. `skipped_subtrees` counts each; +- an unknown `crs:` key, or a foreign property inside a develop structure, is + kept verbatim (with its namespace) with an `UnknownKey` warning; a known key + of the wrong shape with `WrongType`; a key that appears twice at one level + (Adobe's own bundle has profiles with `Group` twice in one description) + keeps the first occurrence — typed, or opaque if it did not type — and + keeps later ones verbatim with `DuplicateKey`. + +The kind: a **sidecar** has `crs:ProcessVersion` and no `crs:PresetType`; a +**preset** has `PresetType="Normal"`, a **profile** `"Look"` (its settings are +the profile definition); anything else is `NotDevelop`, which is not an error. +The header's common fields are typed (`PresetHeader`: type, UUID, the `x-default` +names, cluster, camera restriction, the four `Supports*` flags); every other +header key stays in `PresetHeader::rest`. + +`ParseError` only when the file is unusable as a whole: more than +`MAX_XMP_BYTES` (4 MiB, the sidecar limit of `lrg-imaging`), not UTF-8 after +an optional byte-order mark, not well-formed XML (a DTD counts as that, and +so do more than 2^18 XML nodes), no `rdf:RDF`, elements nested deeper than +`MAX_XMP_DEPTH` (64) below a description (`TooDeep`; real files nest about +20), or beyond a structural limit (`Limit`): more than `MAX_XMP_ATTRIBUTES` +(1024) attributes on one element, more than `MAX_XMP_NAMESPACES` (256) +namespace declarations, or a namespace URI longer than +`MAX_XMP_NAMESPACE_URI` (1024 bytes); real files stay near 200, 20 and 60. + +The depth and structural limits are enforced by a linear, non-recursive +pre-scan of the text before roxmltree sees it. roxmltree's tokenizer recurses +once per element with no depth limit, so without it a deeply nested file +overflows the stack inside the XML parser — an abort that takes down the +whole backend, not a catchable panic — and its duplicate-attribute check and +namespace resolution are quadratic, so a file under 4 MiB could take minutes +to hours. The reader's own recursion checks the depth a second time. +Filtering `* [conflicted].xmp` sync copies is the caller's job, not the +parser's. + +### Checked against real files + +The fixtures cover the forms; whether the registry covers what Lightroom +actually writes is checked by the local sweeps in `tests/xmp_goldens_local.rs` +(see [Tests](#tests) for how to run them). Last run (September 2026, +Lightroom Classic 15.6): + +| Source | Files | Result | +|---|---|---| +| Lightroom Classic's bundled presets and profiles (`Contents/Resources/Settings`) | 1,045 (446 presets, 599 profiles) | 0 parse errors, 0 unknown keys, 0 values outside a registry range; the only warnings are 6 `DuplicateKey Group` | +| A sidecar corpus of 28,680 `.xmp` files | 10,814 Lightroom sidecars (PV 11.0 and 15.4, 5,523 corrections) | 0 parse errors, 0 warnings of any kind | +| Camera Raw's user folder: its presets and profiles | 23 | 0 parse errors, 0 warnings | + +The `Group` duplicates are Adobe's: some bundled camera profiles carry +`crs:Group` twice in one description, with different values. The reader keeps +the first and warns; the sweep allows exactly that pair (`ADOBE_ALLOWED`) and +nothing else. Camera Raw's folder also holds its own state in `crs:` form +(`Defaults/Preferences.xmp`, `Defaults/Previous.xmp`, `GPU/…`), with keys no +preset or sidecar carries (`JPEGHandling`, `SubsetRetouch`, …); those are not +develop documents, so that sweep checks only presets and profiles and counts +the rest by kind. No registry row had to be added for any of these sources, +and no registry key arrived in a form the model keeps whole +(`kept_whole` is 0 in all three). The corpus is counted as the non-conflicted +files carrying `crs:ProcessVersion` as of September 2026, so it differs from +the photo count in the research. + +October 2026 re-run of the corpus: 28,681 files, 10,815 sidecars parsed +(8,006 at PV 15.4, 2,809 at 11.0; 5,524 corrections). One new sidecar, +written by the Camera Raw 18.7 engine, carried `crs:Glow`, the switch of the +Glow panel whose `GlowRange`/`GlowWarmth`/`GlowSpread`/`GlowStyle` rows +already existed (unobserved); the full sweep failed on it (`UnknownKey +Glow`) until it got its own row (global, `Any`, UNKNOWN, observed in XMP +only so far). With the row the sweep is clean again: 0 parse errors, 0 +warnings, nothing kept whole. The other September counts on this page are +from the 10,814-sidecar run. + +## Writing XMP presets + +`xmp::write(&settings, &WriteMode) -> Result` writes a +develop preset Lightroom Classic imports (Develop → Presets → Import). It is +hand-written and deterministic: the same settings give the same bytes in every +build (registry order for keys, the reader's order for opaque content, never a +`serde_json` map). `Written { xmp, skipped }` returns the document and every +value that did not make it, so a caller can say what was left out. + +```rust +let header = PresetHeader::lrgenius(uuid, "LrGenius · Warm evening"); +let mut spec = PresetSpec::new(header); // mixed_file_kinds: true, no process version +spec.process_version = Some(ProcessVersion::V6); +let Written { xmp, skipped } = xmp::write(&settings, &WriteMode::Preset(spec))?; +``` + +### Preset mode + +`WriteMode::Preset(PresetSpec { header, mixed_file_kinds, process_version })` +is the only production mode: + +1. The settings go through `filtered(Target::Preset { mixed_file_kinds })` + first; only keys whose policy reaches a shared preset are written, and the + filter's `Skipped` reports become `Written::skipped`. A mixed preset (the + default) carries no raw-only or non-raw-only key (`Temperature`, `Tint`, + `Incremental*`), no crop, geometry mask or lens identity; a single-kind + one keeps only its own kind's keys, and white-balance numbers only next to + a `Custom` (or absent) `WhiteBalance`; the `Look` goes by reference, + without its profile definition (see [Policy filter and frame + scope](#policy-filter-and-frame-scope)). +2. The writer sets what describes the file rather than the settings: + - the header: `PresetType="Normal"` (always; `Look` profiles cannot be + written), `UUID` (required, upper-cased), `Name` (required), `ShortName`, + `SortName`, `Group` (`LrGeniusAI` from `PresetHeader::lrgenius`) and + `Description` as `rdf:Alt` with an `x-default` item (empty ones as + ``), `Cluster`, `CameraModelRestriction`, + `Copyright`, `ContactInfo` (empty by default), the four `Supports*` flags + of the header (default `True`), and the values Adobe's newer (Camera + Raw >= 14.4) presets carry: `SupportsHighDynamicRange`, + `SupportsNormalDynamicRange`, `SupportsSceneReferred`, + `SupportsOutputReferred` `True`, `RequiresRGBTables` `False` (163 of the + 446 bundled develop presets have these keys — exactly the ones with + `SupportsAmount2`, every adaptive preset included; the older 283 have + none). `RequiresRGBTables` `False` is right because a preset never + carries an RGB table. `Name` must not be blank + (`WriteError::MissingHeaderField`). Further META header keys in + `PresetHeader::rest` (`ShowInPresets`, ...) are written; anything else + there is skipped and reported; + - `Version="18.5"` (`TARGET_ENGINE`: the oldest engine targeted, + Lightroom Classic 15.5.1's Camera Raw 18.5.1; the installed 15.6 is + newer), `CompatibleVersion` from the feature table below, + `ProcessVersion` only when `PresetSpec::process_version` is set (the + settings' own process version describes the example and never reaches a + preset), `HasSettings="True"`; + - `WhiteBalance="Custom"` whenever white-balance numbers are written + (only a custom or absent source mode lets them through), and + `ToneCurveName2012` (`Linear` or `Custom`) next to a point curve (a + typed one; a curve kept whole never reaches a preset). +3. It completes the fixed form of Adobe's adaptive presets for what the + policy filter removes as per-photo or a builder may leave out (present + values are kept): a correction's `What="Correction"`, `CorrectionAmount` + 1, `CorrectionActive`; a component's `MaskActive`; an AI mask's + `MaskVersion` 1, `ReferencePoint="0.500000 0.500000"`, `ErrorReason` 0; a + luminance range's `CorrectionRangeMask` `Version` 3 and `SampleType` 0. + Builders therefore need not set these. What this form puts back without a + loss is taken out of `Written::skipped` again, so a caller that shows + `skipped` to the user (as step 2 will) does not warn about it: an AI + mask's `ReferencePoint` and `ErrorReason` (Lightroom recomputes both per + photo; Adobe's presets carry the centre and 0 whatever photo they were + made on), a luminance range's `SampleType` 0, and `WhiteBalance="Custom"` + next to its numbers. A people part's (`MaskSubType` 3) `ReferencePoint` + away from the centre stays reported, because the point may pick the + person; a non-zero `SampleType` too. +4. A last guard refuses the never-write list (`NEVER_WRITE`) whatever the + policy says: every `*Digest*` key, `CorrectionID`, `MaskID`, + `CorrectionReferenceX/Y`, `FullMaskSize`, `WholeImageArea`, `Origin`, + `ModelVersion`, `MaskBrushTable*`, `Dabs`, `RetouchAreas`, `RemoveAreas`, + `RetouchInfo`, `RedEyeInfo`, `FilterList`, `AILook`, `DepthMapInfo`, + `orientation`, `AutoWhiteVersion`, `RawFileName`, `AlreadyApplied`, the + global `Enable*` switches, and every Lua-only or never-observed key. Pattern + families (`Table_*`, `pm_*`, ...) are opaque and never reach a preset. A + unit test pins that the policy filter already removes each of them, so the + guard only catches a future filter bug. A registry value kept whole + (`Opaque::Xmp`) is likewise skipped and reported by the writer itself, + should the filter ever let one through. + +Content read from a Lua table (`Opaque::Json`) has no XMP form: the filter +removes it and reports it (a Lua example's `Look.Parameters` is COMPUTED and +goes silently, as from XMP); converting it is step 1g's business. + +`CompatibleVersion` (`crs:CompatibleVersion`, packed `major << 24 | minor << +16`) is the highest row a preset's content needs, capped at the engine, and +absent when no row applies (as in most of Adobe's own presets without masks). +The rows are the lowest values Adobe's bundled presets use for the same +feature (`COMPATIBLE_VERSIONS`): + +| Feature | Version | Evidence | +|---|---|---| +| any mask group, subject, sky | 14.0 | adaptive Subject/Sky presets | +| people parts | 15.0 | adaptive Portrait presets | +| background | 15.0 | not in the bundle; Select Background shipped with the people masks — unverified: the hand test only shows that the installed (newer) Lightroom imports it, and an import into 15.x cannot check a minimum | +| landscape classes | 15.3 | adaptive Landscape presets | +| point curves inside a correction | 15.3 | adaptive presets with `MainCurve`/`BlueCurve` | +| `LensBlur` | 16.0 | Blur Background presets | +| global `CurveRefineSaturation` | 17.0 | presets with that curve | +| `LocalPointColors` | 17.4 | adaptive presets with point colour in a mask | + +The writer computes the version from the *filtered* settings, so two rows +never apply to a preset it writes: `LocalPointColors` (UNKNOWN) and a person +part at a point (PHOTO, the other half of the people-parts row) never pass +the filter. They stay for `compatible_version` on unfiltered settings. + +### Document shape and spelling + +One `rdf:Description` in the `crs:` namespace under `x:xmpmeta` with +`x:xmptk="LrGeniusAI "` (never an Adobe toolkit string), in +Adobe's layout: header attributes, version stamps, global values, +`HasSettings`, then the `rdf:Alt` names, then `rdf:Seq`s and structures as +elements. Simple values are attributes; a structure of simple values only is +written as attributes on its property element or `rdf:li` (mask components), +one with a complex field as a nested `rdf:Description` (corrections, a range +mask's component). `rdf:parseType="Resource"` and `rdf:Bag` are never +written. Numbers and the rest follow `xmp::format`: + +| Rule | Example | +|---|---| +| `NumFmt::Int`: no decimal point | `Temperature` 3569 → `3569` | +| `NumFmt::Fixed(n)`: exactly n decimals | `Exposure2012` 0.5 → `+0.50`, `SharpenRadius` 1 → `+1.0` | +| `NumFmt::Trim6`: up to 6 decimals, trailing zeros trimmed | `LocalExposure2012` 0.0625 → `0.0625`, `CorrectionAmount` 1 → `1` | +| `NumFmt::CompoundFixed6`: `%.6f` each, one space apart | `ReferencePoint` → `0.500000 0.500000`, `LumRange` | +| `+` only on `plus_sign` keys at the global level, only above 0 | `Tint` 6 → `+6`, 0 → `0`, -6 → `-6`; never in a structure | +| never `-0` | -0.001 at 2 decimals → `0.00` | +| booleans by level | `True`/`False` global and header, `true`/`false` in structures | +| curves | global points `x, y`, local points `x,y` | +| ids | 32 upper-case hex digits (`UUID`, `CorrectionSyncID`, `MaskSyncID`) | +| escaping | `&` `<` `>` `"` `'` as entities; tab, line feed, carriage return as character references in attributes (a parser would turn them into spaces), a carriage return also in element text; UTF-8 unchanged; a character XML 1.0 cannot hold is a `WriteError`, never a broken file | + +A value whose variant does not fit its key (text for `Exposure2012`) is a +`WriteError::WrongValue`, not a guess. + +### Test round trip + +`WriteMode::TestRoundTrip(Option)` writes everything the model +and the header hold — opaque, COMPUTED, NEVER and UNKNOWN content, `rdf:Bag`s, +language alternatives, qualifiers and foreign namespaces inside structures — +so XMP → model → XMP → model compares equal. Numbers keep their registry +spelling unless it would change the value; then the shortest exact text is +written (Lightroom never writes such a number). It exists only behind the +crate feature `test-roundtrip`, which only `lrg-develop`'s own +dev-dependency on itself switches on (`[dev-dependencies] lrg-develop = { +path = ".", features = ["test-roundtrip"] }`): `cargo tree -p lrg-server -e +features -i lrg-develop` shows only `default`. Any build that compiles +`lrg-develop`'s tests (`cargo test --workspace`, `cargo clippy --workspace +--all-targets`, so CI and pre-commit) unifies the feature into the +`lrg-develop` every crate links, so CI cannot catch another crate naming +`TestRoundTrip`; only the release build would fail. Never name it outside +`lrg-develop`; `WriteMode` is `#[non_exhaustive]`, so a match elsewhere +always needs a wildcard arm. **Never write a sidecar with +it**: the model drops every namespace but `crs:` (`dc:`, `xmp:`, `exif:`, +`tiff:`, `aux:`, `photoshop:`, `xmpMM:`) and `crss:` snapshots, so a rewritten +sidecar would lose the photo's metadata. Writing into an existing sidecar +needs a merge into that document (later step). + +Round trip 1 of the plan (XMP → model → XMP → model) runs with this mode in +`tests/xmp_roundtrip.rs` over every committed fixture and writer golden, and +in the local sweeps over Adobe's and the maintainer's files (see +[Tests](#tests)). Last local run (September 2026): all 1,045 bundled presets +and profiles, the 23 presets and profiles of Camera Raw's user folder and all +10,814 corpus sidecars write without an error, read back into an equal model +(header, kind and opaque content included, no key differing), and write again +byte-identically. The same sources written as presets (production mode, mixed +and single-kind: the 446 bundled develop presets, the 4 Camera Raw presets, +all 10,814 sidecars, and the 1,294 training-dump rows) write without an error +and read back as presets with no warning, no key outside the preset policy, +nothing kept whole, and exactly the policy-filtered settings (2.3 million +values compared over the corpus, none missing, changed or extra). + +The byte rules against the source (plan §7.4) are part of the same sweeps: +every `crs:` value text of the source (attributes, text-only elements, +`rdf:li` items, outside `crss:` snapshots) is compared with the lossless +output as a multiset per file, and anything but a known variance fails. +Known variances, all keys Adobe spells unusually and rarely: over the +10,814 corpus sidecars the legacy PV2010 `Clarity` (1 value, written with a +`+` the row does not have) and `CropAngle`/`CropBottom`/`CropTop` in two +sidecars that spell them untrimmed at `%.6f` (all never in a preset); in +Adobe's bundle `Look.Amount` (`1.000000` where sidecars and the writer write +`1`, 35 values), legacy `Clarity` and legacy `Exposure` (two decimals, the +row is `Trim6`), `PerspectiveX`/`PerspectiveY` in `Classic/General/Zeroed.xmp` +(`0` against `Fixed(2)`), one `LocalTemperature="0.10"` in an Adobe Sky preset; in +Camera Raw's folder one `LocalShadows2012` with two decimals. The +registry rows keep Lightroom's sidecar spelling. Follow-up for the registry +(round-trip spelling only, both COMPUTED): legacy `Clarity` has no +`plus_sign`, and legacy `Exposure` is `Trim6` where Adobe writes two +decimals. + +## Writing the Lua form (provisional) + +> **Provisional while fields are open.** Experiments E1, E2, E4 and E11 show +> which forms `applyDevelopSettings` and plugin presets accept on the write +> side ([Findings: Running the experiments](Dev-AI-Edit-XMP-Findings#running-the-experiments)). +> The writer decides none of them: every alternative an experiment measures +> that shapes the table, or the call that applies it, is one `LuaOptions` +> field, the defaults sit in one place, `LuaOptions::PROVISIONAL`, and how +> far the evidence carries each of them is `LuaOptions::EVIDENCE` (the table +> below). The first run (Lightroom 15.6, 2026-10-03, one non-raw TIFF, run +> from the Library module; +> [Findings: Results](Dev-AI-Edit-XMP-Findings#results-first-run-2026-10-03)) +> settled the AI-mask forms for subject, sky +> and background and the non-raw mode-only choices (`wb_mode_only`, +> `flatten_auto_now`). `wb_custom_with_numbers` is only supported, because +> `Custom` with the incremental numbers (the writer's non-raw form) was never +> applied, and so is `panel_switches`. People parts and `int_flag_as` are +> open, `look_form` is settled on raw (a stub sets no Look, a complete Look +> does), and the wire goldens are not frozen. + +`lua::to_lua_value(&settings, &LuaMode, &LuaOptions) -> Result` writes the table `photo:applyDevelopSettings()` takes, as the +JSON the plugin's JSON.lua decodes into it. `LuaWritten { table, skipped, +call }`: the table (always a JSON object; `is_empty()` when nothing reaches +the photo), every value that did not make it (as `xmp::Written::skipped`), +and `ApplyCall { update_ai_settings, flatten_auto_now }`, the call +arguments that go with the table. + +```rust +let photo = PhotoContext { file_kind: Some(FileKind::Raw), process_version: Some(ProcessVersion::V6), camera: None }; +let LuaWritten { table, skipped, call } = + lua::to_lua_value(&settings, &LuaMode::Apply(photo), &LuaOptions::default())?; +``` + +Two modes, as for XMP: + +- `LuaMode::Apply(PhotoContext { file_kind, process_version, camera })` is + production. The settings go through `filtered(Target::ApplyPhoto { .. })` + first: PHOTO values pass (the settings are meant for this photo; crop, + gradients, a person's reference point), gates, file-kind keys, `min_pv` + and camera-restricted profiles are checked against the photo, and every + removed non-default value is reported. An unknown `file_kind` gets + no white-balance numbers and no raw-only or non-raw-only key. +- `LuaMode::Preset(PhotoContext)` is the same table for a plugin preset + applied to that one photo (`addDevelopPresetForPlugin`, the E4 route), + plus `SupportsAmount`/`SupportsAmount2` when `preset_amount_flags` asks + for them. It is the only mode that honours that field, so a preset-route + answer stored in `PROVISIONAL` cannot leak into `applyDevelopSettings` + tables. +- `LuaMode::TestRoundTrip` (feature `test-roundtrip`, as for XMP) writes + everything the model holds, opaque content read from a Lua table + included; content kept as XMP has no Lua form and is a + `WireError::NotLua`. Of the options only the encodings apply + (`mask_enum_as`, `int_flag_as`), and the reader reads each of them back + (an integer string on a mask enum is read as that integer, without a + warning), so round trip 2 holds whichever form E2 settles on. + +### Rules (plan §4.1, writer rules 1–10) + +1. Keys without `crs:`; only what the policy filter lets through for the + photo, then a never-write guard (`lua_never_written`): the XMP writer's + `NEVER_WRITE` list (`CorrectionID`, `MaskID`, `CorrectionReferenceX/Y`, + `FullMaskSize`, `WholeImageArea`, `Origin`, `ModelVersion`, retouch and + filter payloads, `orientation`, ...), every key with `Digest` in its + name, `MaskBrushTable*`, the global `Enable*` switches (only + `panel_switches` writes them) and XMP-only keys. The guard's + drops are reported. Global META is set by the writer, never copied: no + `ProcessVersion` (it describes the example), `ToneCurveName2012` derived + from the curve, `WhiteBalance="Custom"` next to numbers. +2. Integer keys as JSON integers (a real on an integer key goes through + `KeySpec::coerce`, the one rounding place; a value no integer key can + take is a `WireError::WrongValue`), real keys as JSON numbers at full + precision. Every number is a `Finite`, so never `null`. +3. Booleans as JSON booleans; 0/1 flags (`IntFlag`) per `int_flag_as`. +4. Numeric-looking strings stay strings: `ProcessVersion`, + `ReferencePoint`, `LumRange`, `FocalRange`, `Look.UUID`, local curve + points. A compound number string keeps its spelling (Lightroom's tables + mix `0` and `0.170630`) but must be a list of numbers + (`WireError::NotCompound`); only what the writer builds itself + (`LumRange` of a luminance range, a person's `ReferencePoint`) is spelled + `%.6f`. Ids are upper-cased and must be 32 hex digits. +5. Global curves as a flat number list `[x, y, x, y, ...]` (integers where + they are), at least two points (`WireError::ShortCurve`); local curves + as a list of `"x,y"` strings. +6. `MaskGroupBasedCorrections` as a list in panel order, `Local*` directly + on the correction, `CorrectionMasks` never empty. Every correction gets + `What`, `CorrectionAmount` 1 and `CorrectionActive` when the model has + none, and (`local_form = AdaptivePreset`) the 23 `Local*` keys of + Adobe's adaptive presets at 0 where the model has no value + (`ADAPTIVE_PRESET_LOCALS`, the list `DevelopExperiments.lua` applies, + pinned against it by a test; its legacy PV2010 keys are written only as + this 0). Every component gets `MaskActive`; `mask_form` decides the + rest. +7. Never an empty container: JSON.lua encodes `{}` as `[]`, and an empty + `MaskGroupBasedCorrections` would delete every mask of the photo. When no + correction reaches the photo the key is absent; a structure left empty + by the filter is absent (the reader reads both as "absent" too). +8. No mixed tables, no sparse arrays: no `null`, one JSON type per list. +9. White balance as a family. After the filter only the photo's family is + left; numbers go with `Custom` or no mode (and get `"Custom"` per + `wb_custom_with_numbers`); numbers next to any other mode (`As Shot`, + `Auto`, `Daylight`, ...) are what that mode resolved to for the source + and are reported as `SkipReason::WhiteBalanceMode`; a named mode + without numbers is written only with `wb_mode_only` (reported otherwise, + `As Shot`, the default, silently). `Custom` without numbers (they + belonged to the other family, or the file kind is unknown) and a mode + outside the registry's list are always skipped and reported: `Custom` + alone would pin whatever the photo has. `Temp` is never written: + `RETIRED_TEMP_KEY` is the one place the name is spelled, refused at any + depth. +10. The forms the experiments decide are `LuaOptions` (below). + +Rules 7–9 are checked once more on the finished table (`check_wire`, public +for the apply layer); a failure is a writer bug and a +`WireError::NotWireSafe`, never a table handed to Lightroom. + +The `Look` follows `look_form`, by default `Full` (E11: only a complete Look +with `Parameters` is applied; a stub sets none). A Look without `Parameters` +is still written but reported as `Look.Parameters`, since Lightroom would +ignore it like a stub; an adaptive Look has none by design and is not +reported. The stub forms (on request) carry `Name`, `UUID`, +`Amount` (and `Stubbed`): the profile's COMPUTED fields (`Parameters`, +`Group`, `Supports*`, `CameraModelRestriction`, `isAdobeAdaptive`, ...) are +copies Lightroom fills in from its profile library when it resolves the +stub, so dropping them is not reported (as in a preset). A camera +restriction is not reported either: the policy filter has already kept a +restricted Look to its own camera, so the stub that reaches the photo loses +nothing. An Adobe Adaptive Look (`isAdobeAdaptive`) sets +`ApplyCall::update_ai_settings` (with `ai_update`) in every form, since its +`AILook` state is never written. `LookForm::Full` writes the whole record, +with `Parameters` only when it was read from a Lua table and passes +`check_wire` — the one place kept-verbatim content reaches Lightroom, and +only on request. No experiment applies a camera-restricted or an adaptive +Look (E11 uses Adobe Raw profiles and rejects adaptive ones). + +Number spelling is not part of the contract: JSON.lua under Lua 5.1 (the +Lightroom runtime and CI's busted) re-encodes with `%.14g`, a local Lua 5.5 +with the shortest exact form. Tests compare decoded values, never encoded +text. + +### Options and what decides them + +Each field's default and status, as `LuaOptions::PROVISIONAL` and +`LuaOptions::EVIDENCE` hold them (a test requires one evidence row per +field). **Settled** means the first run applied the default and it did what +the writer needs, on Lightroom 15.6 with one non-raw TIFF, run from the +Library module; it says nothing about raw files, other versions or the +Develop module. **Supported**: the evidence points to the default without +settling it: the default was not applied as written, or was applied where it +could make no difference. **Open**: no +experiment has answered it. An alternative no experiment applies stays +unmeasured whatever the run says. + +| `LuaOptions` field | Alternatives | Default | Evidence (first run, LrC 15.6, one non-raw TIFF, Library module) | Still open | Status | +|---|---|---|---|---|---| +| `mask_enum_as` | `Number` / `String` (mask enums, `KeySpec::is_mask_enum`: every closed integer set below the global level — `MaskSubType`, `MaskBlendMode`, `ErrorReason`, `CorrectionRangeMask.Type`/`SampleType`, ... — and `MaskSubCategoryID`) | `Number` | E2a–E2d apply mask enums as numbers; the subject, sky and background masks are kept and computed (a hair mask ended `failed`, `ErrorReason` 1). Read back: a number in 655 of 655 masks of the training dump | `String` applied by no experiment (not needed) | settled (E2, non-raw) | +| `int_flag_as` | `Number` (0/1) / `Bool` (`LensProfileEnable`, `AutoLateralCA`, `CropConstrainToWarp`, `HDREditMode`) | `Number` | no experiment writes a flag key; read side only: numbers in 1,294 of 1,294 training rows and in the E13 readback | a write of a flag key, numbers against booleans (cheap evidence: read back photos `DevelopEditManager.lua` applied `AutoLateralCA` to as a boolean) | open | +| `panel_switches` (the plan's `include_panel_switches`) | `None` / `MaskOnly` (`EnableMaskGroupBasedCorrections` next to corrections) / `All` (plus the switch of every panel the table touches, `PANEL_SWITCHES`) | `MaskOnly` (was `None`) | E2 sent the switch, but E13 shows it was already `true` on the source photo (E2's copies inherit it), so the run does not tell `None` from `MaskOnly`; the default is the form E2 applied | `None` against `MaskOnly` (the blocking wire-golden variant, with and without the switch); `All` and the `PANEL_SWITCHES` map are unverified (check them before turning `All` on) | supported (E2, non-raw) | +| `mask_form` | `PresetForm` (an AI mask's `MaskVersion` 1, `ReferencePoint` centre, `ErrorReason` 0; a luminance range's `Version` 3, `SampleType` 0) / `Minimal` | `PresetForm` | E2a: a digest-less subject mask in this form is kept, pending until `updateAISettings()`, then computed; sky and background likewise | people parts: the one applied (E2d hair, `MaskSubType` 3 / `MaskSubCategoryID` 5, the writer's form) ended `failed`, `ErrorReason` 1; form or photo is not known. `Minimal`, and any luminance range, applied by no experiment | settled for subject, sky and background (E2, non-raw) | +| `local_form` | `AdaptivePreset` (the 23 `Local*` keys at 0 unless the model has a value) / `Sparse` (the model's own) | `AdaptivePreset` | E2 applies the zero-filled form, and the corrections work | `Sparse` applied by no experiment | settled (E2, non-raw) | +| `wb_custom_with_numbers` | numbers with `WhiteBalance="Custom"` / alone | `true` | E1b: incremental numbers alone are stored, and leave the mode at "As Shot" in the readback, so the writer keeps writing `Custom` with them; whether As Shot + numbers renders them is the open Basic-panel check. E1d: `"Custom"` is taken as a mode | non-raw: `Custom` + incremental numbers in one call (the writer's non-raw form, never applied); raw: E1b against E1c on a raw file | supported on non-raw; open on raw | +| `wb_mode_only` | a named mode without numbers written / skipped and reported (`Custom` alone is never written) | `false` | E1f/E1g: `"Auto"` alone sets the mode, and the readback showed the previous incremental numbers unchanged within 15 s (the Basic-panel check is open). Never honoured for a non-raw photo: the writer and the style engine refuse a mode alone there before reading the switch. Stays together with the style engine's `EMIT_NAMED_WB_MODES` (off) | raw: E1e (`Daylight`) and Auto on a raw file, where a positive answer turns mode-only on for raw targets only; `"As Shot"` and the other named modes alone are applied by no experiment | settled on non-raw; open on raw | +| `flatten_auto_now` | `ApplyCall::flatten_auto_now` when the table sets `WhiteBalance="Auto"` | `false` | E1f against E1g: `optFlattenAutoNow = true` made no difference to the readback within 15 s | raw (matters only with `wb_mode_only`) | settled on non-raw | +| `ai_update` | `ApplyCall::update_ai_settings` after an AI mask (`Mask/Image`), `LensBlur` or an Adobe Adaptive `Look` | `true` | E2b: without the call the mask is still pending after 30 s in the Library module; E2c: after `photo:updateAISettings()` it is computed (call 0.6 s) | an Adobe Adaptive `Look` (E11 rejects adaptive Looks); people parts (the subType-3 form, on a photo with a visible person) | settled for AI masks (E2, non-raw) | +| `look_form` | `Stub` (`Stubbed = true`) / `BareStub` / `Full` | `Full` | E11b: a complete Look with `Parameters` is honoured; E11a/E11c: a stub sets no Look (raw, 15.6) | camera-restricted and adaptive Looks by no experiment | settled | +| `preset_amount_flags` | `SupportsAmount`/`SupportsAmount2 = true` in a plugin preset's table (`LuaMode::Preset` only) | `false` | E4e: amount 50 and 200 leave the globals and the mask values at the preset's own, with and without the flags (read back) | the look of the amount copies (manual check); the preset route is not chosen (see below) | settled: no effect in 15.6 | + +The default table now equals the one E2 applies for AI masks (preset-form +masks, zero-filled locals, numbers, the mask switch; the switch was already +on in E2's photo). The goldens are still +not frozen: no experiment has applied the writer's own output, see +[Wire goldens](#wire-goldens). + +What the experiments measure that is **not** a writer switch, and why: + +- **E1a/E1d (`Temp` accepted?)**: accepted without an error and ignored + (15.6, non-raw). `Temp` is never written; the family keys are the + documented ones (rule 9). +- **E1c on a non-raw file (`{Temperature = 22}`)**: a cross-family probe. + Lightroom 15.6 stores the raw key on the non-raw photo as a dead value + (the mode and the incremental keys stay as they were); the writer never + writes the other family's keys (rule 9), so the answer changes no table. +- **E2 timing** (update call and mask-ready time): runtime planning for + step 2, no table shape. +- **E4a/E4b (same-name presets), E4f (apply then delete the file)**: the + preset route's lifecycle in the apply layer (step 2); the table is the + same either way. +- **E4c (preset masks merged or replacing)**: replacing (15.6), and the + preset's `updateAI = true` left its sky mask pending for 60 s. The writer + writes exactly `settings.corrections`. Keeping the photo's existing masks + is the apply layer's job on the photo's **raw** tables: the writer drops + digests (rule 1), so an existing AI mask passed through the model would + lose its computed state. Step 2 applies masks through + `applyDevelopSettings` + `updateAISettings()` instead of a preset. +- **E4d (re-apply duplicates or updates by sync id)**: updates in place + (15.6). Sync ids come from + the builders (derived per role, see [Building corrections](#building-corrections)); + the writer keeps them. + +When an experiment has answered, in one change: + +1. change the field in `LuaOptions::PROVISIONAL`, its row in + `LuaOptions::EVIDENCE` and the pinning tests + (`the_provisional_choices_are_pinned`, `the_first_run_statuses_are_pinned`); +2. E1 (mode only): flip `wb_mode_only` together with + `lrg_analysis::style_engine::EMIT_NAMED_WB_MODES` and its const assert + in `a_named_mode_majority_is_not_sent_while_the_switch_is_off`. A positive + raw E1e turns mode-only on for raw targets only: both refuse a mode alone + for a non-raw photo before reading the switch (E1f/E1g), pinned by + `custom_without_numbers_is_never_written_alone` and + `a_non_raw_photo_gets_no_mode_alone_whatever_the_switch`; +3. re-bless the wire goldens and run `busted` ([Wire goldens](#wire-goldens)); + no test or reader change is needed (`every_golden_case_is_clean_under_every_option_flip` + proves that for every single flip); +4. set the field's Status in the table above (and the run's scope: version + and file kind), and correct the field's rustdoc and the `PROVISIONAL` + table in `src/lua/write.rs` if the run settled more or less than the + table says. + The Findings page, `wire/README.md` and the plugin spec point here + instead of restating the values. + +### Round trips 2 and 3 + +Plan §7.3, in `tests/support/lua_round.rs` (shared by `tests/lua_roundtrip.rs` +and the local sweeps): + +- **Round trip 2**, model → Lua → model (`LuaMode::TestRoundTrip`): the + same model back for every `Presence::Both`/`LuaOnly` value, opaque Lua + content and read warnings included, and writing the read-back model again + gives the same table. A model read from XMP goes in without what has no + Lua form (`without_xmp_only`): XMP-only keys (`HasSettings`, `HasCrop`, + ...; a preset header is not part of the model), `PerFormat` values in + their XMP shape, content kept as XMP. Empty containers need no exception: + the reader reads them as "absent". +- **Round trip 3**, Lua → model → XMP preset → model → Lua, mixed and + single-kind preset: the preset's model goes through Lua losslessly and + without a warning, and every value it shares with the source (filtered + for the preset, rounded with `Value::quantized` to the preset writer's + `NumFmt`, then through Lua) is equal. Opaque content is excepted by + definition (format-specific; a preset never carries it). + +| Source | Round trip 2 | Round trip 3 | +|---|---|---| +| committed: Lua fixtures, wire goldens, XMP fixtures (`lua_roundtrip.rs`) | 42 Lua tables under 4 encodings (168), every XMP fixture (22) | 64 sources, 128 presets, 7,374 shared values | +| training dump, 1,294 rows (local) | 311,746 values | 2,588 presets, 301,834 shared values | +| Lightroom bundle, 446 presets (local) | 27,151 values | 892 presets, 48,281 shared values | +| Camera Raw presets, 4 (local) | 310 values | 8 presets, 530 shared values | +| sidecar corpus, all 10,815 (local) | 1,645,988 values | 21,630 presets, 2,267,558 shared values | + +All clean (October 2026): no value changed, lost or warned about, every +second write identical. + +### Wire goldens + +`server-rs/testdata/develop/wire/*.json` are tables the writer produces in +apply mode with the default options (`LuaOptions::PROVISIONAL`; re-blessed +2026-10-03 for `panel_switches = MaskOnly`, which added +`EnableMaskGroupBasedCorrections = true` to the ten goldens with +corrections), one per shape: basic global +sliders, raw Custom white balance, non-raw incremental white balance, global +and parametric tone curves, HSL and colour grading, a subject and a sky +correction, linear and radial gradients, a sky minus subject intersected +with a luminance range, a people part, local point curves on a correction, +the `Look` as a stub and whole, a source whose only correction is a brush +(`MaskGroupBasedCorrections` absent, never `[]`), and five committed +readbacks applied to their own photo: a sky mask, a colour range +(`PointModels`, `ColorAmount`), an area colour range next to AI masks +(`AreaModels`, `ColorRangeMaskAreaSampleInfo`), Select Object polygons +(`Gesture`/`Points`) and the `LensBlur` struct (18 files). +`tests/lua_wire_goldens.rs` writes and compares them, pins each case's +skipped paths and `ApplyCall`, and checks each **file** both ways with the +sweeps' check (`support/lua_apply.rs`): it reads back without a warning, +every value of the filtered settings is in it or reported, and it holds +nothing beyond those and the writer's fixed form. Sources are committed +scrubbed fixtures or models built in the test, with synthetic sync ids; the +files pass both hygiene checks. Their README (`wire/README.md`) says who +reads them and how to re-bless. + +The same test file also + +- writes every case with each `LuaOptions` field flipped to each of its + other values in turn, in apply and preset mode, and requires the same + two-way check to pass (504 tables): flipping a field and re-blessing needs + no test or reader change on the Rust side; +- generates `wire/key_classes.txt` from the registry (blessed with the + goldens): each key name with the classes of its values (`number`, + `integer`, `flag`, `maskenum`, `compound`, ...) or `never`, which the + plugin spec checks every value against, so the spec's key lists cannot + drift from the registry or from `NEVER_WRITE`; +- pins `ADAPTIVE_PRESET_LOCALS` against `LOCAL_KEYS` in + `DevelopExperiments.lua`. + +**Not frozen.** The first run settled the AI-mask forms for subject, sky +and background on one non-raw photo, and the default table is now the form +E2 applied; people parts, `panel_switches` (`None` against `MaskOnly`), +`int_flag_as` and the Basic-panel checks are open. Freezing also needs one thing the +experiments as built do not do: apply the writer's own output. E2 and E4 +apply hand-built tables (the AI-mask preset form, all 23 locals, +`EnableMaskGroupBasedCorrections`), never a golden. Blocking item before the +goldens are frozen: an E2 variant that decodes `masks_subject_sky.json`, +`mask_luminance_intersect.json` and `global_basic.json` with JSON.lua and +passes them unchanged to `applyDevelopSettings` (with and without the mask +switch, on a photo where the switch is off). Variants for numeric-string mask enums, `MaskForm::Minimal`, +`LocalForm::Sparse` and booleans on the flag keys would measure the +alternatives no experiment applies yet. + +To re-bless after an intended writer change, or after an experiment has +flipped a field of `LuaOptions::PROVISIONAL`: + +```bash +cd server-rs +LRG_BLESS=1 cargo test -p lrg-develop --test lua_wire_goldens # writes the files and key_classes.txt +cargo test -p lrg-develop --test lua_wire_goldens --test lua_roundtrip +cd .. && busted # the plugin spec +``` + +The blessing run skips the read-back test (it would race the files being +written), so the second command checks them. A golden no case writes fails +the test: delete a file together with its case. + +**The plugin side.** `plugin/spec/native_wire_format_spec.lua` decodes every +golden with the plugin's own `JSON.lua`, under Lua 5.1 in CI as in +Lightroom (`lua-tests.yml`, which also runs when only +`server-rs/testdata/develop/wire/**` changes), and checks what reaches +`applyDevelopSettings`: a top-level table with string keys; real sequences +(keys 1..n) and no empty, mixed, sparse or `null` value (decoded with a +`null` placeholder so a null cannot hide as a missing key); every value of +the Lua type of its key's registry class (`key_classes.txt`): numbers as +numbers, numeric-looking strings as strings, compound strings as lists of +numbers (`ReferencePoint` 2, `LumRange` and `FocalRange` 4; an empty +string is no list), `CorrectionRangeMask.Type` only inside a range mask, +sync ids 32 hex digits, booleans as booleans and never `"true"`/`"false"`; +no key the registry does not know; global curves flat number lists of even +length ≥ 4, local curves lists of `"x,y"` strings; a non-empty +`CorrectionMasks` on every correction; no never-written key at any depth +(the registry's `never`, `*Digest*`, `MaskBrushTable*`), no `Temp`, no +top-level `ProcessVersion`; one white-balance family, numbers only with +`Custom` or no mode; and the same decoded values after the plugin's own +encode and decode. The encodings the experiments bear on are accepted +either way (mask enums as integers or integer strings, 0/1 flags as 0/1 or +booleans): all 504 flipped tables of the Rust test pass the spec under Lua +5.1 and 5.5. The shapes the checks are about (corrections, global and local +curves, compound strings and lists, booleans, flags, mask enums, numbers) +are counted while the goldens are collected, and a check fails when one is +missing, so a re-bless cannot make them pass vacuously; that check does not +depend on the other tests having run. A block of crafted broken tables (a +number as a string, an empty compound string, a wrong count, a mask enum +that is no integer, an unknown key, ...) proves each check catches what it +is for. Two pin tests hold JSON.lua's own behaviour the rules rest on: +`encode({})` is `"[]"`, and a `null` inside an array decodes to a hole. +Values are compared decoded, numbers within `%.14g`, never as encoded text. + +## Building corrections + +`lrg_develop::build` makes new local corrections: a `CorrectionBuilder` takes +adjustments in UI units and mask components in evaluation order and returns a +`Correction` the preset writer (or, later, the Lua writer) takes as it is. + +```rust +let ns = SyncNamespace::lrgenius(); +let sky = CorrectionBuilder::new("Sky", &ns) // CorrectionName "LrGenius · Sky" + .amount_ui(100.0)? // CorrectionAmount 1 (the default) + .local_ui("LocalHighlights2012", -40.0)? // stored -0.4 + .local_ui("LocalDehaze", 15.0)? // stored 0.15 + .add(Semantic::Sky) + .subtract(Semantic::Subject) + .intersect(LuminanceRange::highs(0.5, 0.2)?) + .build()?; // Result +``` + +- **Adjustments.** `local_ui(key, ui)` converts through the registry's UI + scale (`LocalExposure2012` EV / 4, the other signed sliders / 100, + `LocalToningHue` and `LocalCurveRefineSaturation` as they are); + `local_curve(key, &[(x, y)])` sets `MainCurve`/`RedCurve`/`GreenCurve`/ + `BlueCurve` (2+ points, x strictly increasing). Out of range is + `BuildError::Ui(OutOfRange)`, never clamped. Only learnable adjustments with + a verified UI scale are writable: `LocalHue` and `LocalGrain` (unverified + scales), `LocalPointColors`/`LocalColorVariance` (UNKNOWN), the legacy + `LocalExposure` family (COMPUTED) and every bookkeeping key are + `BuildError::NotWritable`. +- **Components.** `add`, `add_inverted`, `subtract`, `intersect` encode as the + combination table in [The model](#the-model). `build` refuses a correction + without components (`Empty`), one whose first component is not added + (`FirstNotAdd`), one without any adjustment (`NoAdjustments`) and an empty + role (`EmptyRole`). `MaskValue` is 0 exactly when `MaskBlendMode` is 1. A + luminance range's `CorrectionRangeMask.Invert` is set to the component's + `MaskInverted` (not the caller's), as Lightroom writes every range mask — + an intersected range therefore has `Invert="true"`; a range whose `Invert` + disagreed could be read as its opposite. +- **Names.** The correction is `"LrGenius · "`; each component gets a + neutral English `MaskName` close to Adobe's (`Subject`, `Sky`, + `Background`, `Iris and Pupil`, `Vegetation`, `Linear Gradient`, `Radial + Gradient`, `Luminance Range`, ...; Adobe's own Subject and Sky presets say + `Subject 1`/`Sky 1`). Names are display only; nothing reads them back. +- **Sync ids.** `CorrectionSyncID` and every `MaskSyncID` are the first 16 + bytes of SHA-256 over a domain tag and the length-prefixed namespace, role + and slot (the correction, or component *i*), as 32 upper-case hex digits + (`SyncNamespace::id`). The same role gets the same ids on every run, so a + later step can replace a correction rather than stack a second one (whether + Lightroom replaces or duplicates is experiment E4/E5). Two corrections of one + preset therefore need different roles: assemble a preset's corrections with + `build::corrections([builder, ...])`, which builds each and refuses a + repeated role (`BuildError::DuplicateRole`). The preset writer does not + check it, because Lightroom's own sidecars repeat `MaskSyncID`s across the + corrections of one file (133 of 3,465 sidecars with corrections, 12 even + within one correction). Known-answer tests pin three ids, so a change to + the domain tag, the length prefixes or the slot texts — which would stop a + later release from replacing what an earlier one wrote — fails. +- **What is left to the writer.** The builder sets the typed parts only + (name, id, amount, active, adjustments, tool, combination). The preset + writer adds `What`, an AI mask's `MaskVersion`/`ReferencePoint`/ + `ErrorReason` and a range mask's `Version` 3/`SampleType` 0 (see [Preset + mode](#preset-mode)). A radial gradient's `Version` 2 is set by the builder, + because the reader keeps that field in the component's `extra`. + +| Component | Built from | Encoding | Policy | +|---|---|---|---| +| `Semantic::Subject` / `Sky` | — | `Mask/Image`, subtype 1 / 2 | LEARN | +| `Semantic::Background` | — | subtype 0, category 22 | LEARN | +| `Semantic::people_part(PeoplePart)` | `FaceSkin` 2, `IrisAndPupil` 3, `BodySkin` 4, `Hair` 5, `Lips` 6, `FacialHair` 7, `EyeSclera` 8, `Eyebrows` 9, `Clothes` 11, `Teeth` 12 | subtype 3 (preset form; "all people" unconfirmed) | LEARN | +| `Semantic::landscape(LandscapeClass)` | `Architecture` 50001 … `Snow` 50008 | subtype 0 | LEARN | +| `Semantic::person_part_at(part, SensorPoint)` | a part and the person's point | subtype 0 + a real `ReferencePoint` | PHOTO, unverified until E2/E8 | +| `LinearGradient::new(zero, full)` | two different `SensorPoint`s (by the key names no effect at `zero`, full at `full`; the direction is unverified until E6) | `Mask/Gradient` | PHOTO | +| `RadialGradient::new(top, left, bottom, right, feather)` | `top < bottom`, `left < right`, feather 0..=100 | `Mask/CircularGradient`, `Angle` 0, `Midpoint` 50, `Roundness` 0, `Version` 2, `Flipped = !MaskInverted` | PHOTO | +| `LuminanceRange::new([fl, l, h, fh])`, `lows(upto, feather)`, `highs(from, feather)` | `0 ≤ fl ≤ l ≤ h ≤ fh ≤ 1` | `Mask/RangeMask`, `CorrectionRangeMask.Type` 2, `Invert = MaskInverted`; no eyedropper sample (`LuminanceDepthSampleInfo`, which every range in Lightroom's sidecars has) — unverified in a preset until the hand test | LEARN | + +A category outside these tables is `BuildError::InvalidCategory` (whole-person +20036 included); `MaskTool::Opaque` (brush, Select Object, colour or depth +range) and a luminance range carrying further `CorrectionRangeMask` fields +(the eyedropper sample) are `NotBuildable`. A PHOTO component makes its whole +correction PHOTO, so the preset writer leaves it out and reports it in +`Written::skipped`. + +**Coordinates.** A `SensorPoint { x, y }` (and a radial gradient's `Top`, +`Left`, `Bottom`, `Right`) is normalised per axis in the **uncropped image in +sensor orientation**: `x` 0 is the left edge and 1 the right, `y` 0 the top +and 1 the bottom, as the raw file's sensor lies, before crop and before the +photo's rotation. Values may leave 0..1 (a gradient may start outside the +frame). Builders take `SensorPoint`s and normalised numbers only, never pixel +tuples; converting export or display pixels (crop, rotation, orientation) is +`FrameGeometry`, which comes with its first consumer in step 2. Until then the +gradient builders are only usable with points that already are in this frame +(tests, presets). + +**Radial gradients: `Angle` 0 only.** The reader types a radial gradient only +when its `Angle` is 0; a rotated one stays `MaskTool::Opaque` (with every +field, so it still round-trips) until experiment E6 settles the rotation +convention, and the builder has no angle parameter. It also fixes `Midpoint` +50 and `Roundness` 0 and sets `Flipped = !MaskInverted` itself: that is the +relation every radial gradient in the maintainer's sidecar corpus holds +(414 typed ones; the local sweep counts it on every run, next to +`Invert = MaskInverted` for all 207 typed luminance ranges, their `Version` +3 and their `SampleType`: 195 × 0, 12 × 1). + +**Parity with Adobe's adaptive presets** (`lightroom_adaptive_presets_rebuild_with_the_builders` +in `tests/xmp_goldens_local.rs`, local only). Every correction of the bundle's +adaptive presets is rebuilt from the parsed model with the builders (the +amount and adjustments through `to_ui` → `amount_ui`/`local_ui`/`local_curve`, +the same components and combinations, a luminance range without its +`Version`/`SampleType`, which the writer adds) and written as a preset. Last +run (September 2026, Lightroom Classic 15.6's bundle): 38 presets, 109 +corrections, all rebuilt; read back, they equal Adobe's corrections written by +the same writer in every field (names and sync ids apart, which are ours by +design). Against Adobe's files as they are, the only differing fields are ones +the writer also holds back from Adobe's own corrections: the digests +`LocalInputDigest`/`LocalInputDigestVersion` (36), the legacy PV2010 +`LocalBrightness`/`LocalClarity`/`LocalContrast`/`LocalExposure` (109, all at +their default), `LocalCorrectedDepth` (65) and `LocalGrain` (65, unverified +scale, all 0), `LocalHue` (109, unverified scale; one non-zero), and +`LocalPointColors`/`LocalColorVariance` (9, UNKNOWN). The builder refuses +`LocalGrain`, `LocalHue` and `LocalPointColors`/`LocalColorVariance` as +`NotWritable`, which the test counts; `LocalCorrectedDepth`, the digests and +the legacy keys are COMPUTED and never offered to it. The bundle holds only +added AI masks (110 components, all `Add`), so this parity says nothing about +subtract, intersect, luminance ranges, gradients or Select Background; the +corpus sweep and the third hand-test preset cover those forms. `CompatibleVersion` is +compared for the report: equal in 28 presets, ours lower in 10 — 6 carry +point colour (Adobe 17.4, ours 15.3, since we drop it) and 4 are Subject, Sky +or people presets Adobe stamped 15.3 whose only difference from Adobe's own +14.0/15.0 presets of the same content is `LocalCorrectedDepth`/`LocalGrain` +at 0, which we do not write. + +### From an edit recipe + +`build::recipe::settings_from_recipe(&recipe, file_kind)` is how AI Edit's +retoucher reaches Lightroom: the model edits in the recipe's vocabulary and this +turns it into `DevelopSettings` for the Lua writer. + +- **Globals** at their recipe alias paths (`global.exposure`, + `global.hsl.red.hue`, ...), plus `EXTRA_GLOBALS`, which have no alias because + no training example teaches them: the colour-grading midtones, the three + luminances and blending, and the black-and-white mix. Values go through + `from_ui_value`; one outside the key's range is clamped and reported. +- **White balance** (`global.temperature`/`tint`) goes into the photo's own + family with `WhiteBalance = "Custom"`. A number on the other family's scale + (Kelvin for a rendered file, an offset for a raw one) is dropped with a note, + never clamped; an unknown file kind gets no white balance. +- **`treatment`** (`"black_and_white"`/`"color"`) sets `ConvertToGrayscale`. +- **Masks** (`[{target, adjustments}]`): one correction per target from + `MASK_TARGETS` (subject, sky, background, the ten people parts in their preset + form, seven landscape classes — not the landscape sky, which `sky` already + is), built by `CorrectionBuilder` with the role as name (`LrGenius · Sky`). + Their sync ids follow from the role, so the same target in a later round + replaces the earlier correction. Adjustments are `LOCAL_ADJUSTMENTS` + (exposure in EV, the rest ±100). A second mask on one target, an unknown + target or a mask without a non-zero adjustment is left out; the first two + with a note. + +`starting_values(&settings)` is the way back for telling the model where a +photo starts: the basic sliders always, every other recipe global that is not +0, the white balance on the photo's own scale, and whether it is black and +white. + +### Hand test + +The one check no test can do: does Lightroom Classic (15.5.1 or later) +import a written preset and show its masks? It runs on the installed +Lightroom Classic — 15.6 on the maintainer's machine, newer than the 15.5.1 +`TARGET_ENGINE` aims at — and the version actually used goes into the +record below and the PR. An import into a newer Lightroom cannot confirm a +`CompatibleVersion` minimum. `tests/builder_presets.rs` builds three +presets with the real builders and writer in preset mode (`ProcessVersion` +15.4): + +| File | `CompatibleVersion` | Global | Corrections (Masking panel) | +|---|---|---|---| +| `LrGenius Handtest Subject+Sky.xmp` | 14.0 | Contrast +15 | "LrGenius · Subject" (Subject: Exposure +0.30); "LrGenius · Sky" (Sky: Highlights −40, Dehaze +15) | +| `LrGenius Handtest Landscape+People.xmp` | 15.3 | — | "LrGenius · Vegetation" (Vegetation: Saturation +20); "LrGenius · Eyes" (Iris and Pupil of all people: Saturation +30) | +| `LrGenius Handtest Combinations+Background.xmp` | 15.0 | — | "LrGenius · Bright sky" (Sky, minus Subject, intersected with a luminance range 0.3/0.5/1/1: Highlights −30); "LrGenius · Background" (Background: Exposure −0.50, Saturation −30) | + +The third preset carries what no preset of Adobe's does: a subtract, an +intersect (with the range's `Invert="true"` mirroring `MaskInverted`), a +luminance range without the eyedropper sample every range in Lightroom's +sidecars has, and Select Background. + +```bash +LRG_HANDTEST_DIR=/some/dir cargo test -p lrg-develop --test builder_presets +``` + +writes all three there. In Lightroom Classic: Develop → Presets panel → `+` → +Import Presets…, pick the files; they appear in the group "LrGeniusAI". +Apply the first and third to a photo with a person, sky and some bright +background, the second to one with vegetation and to one with **two or more +people** (does the people part select everyone, or one person?). Expected: +the global Contrast moves to +15; the Masks panel lists the named masks, +each with the components the table names (Lightroom computes the masks on +apply, which can take a few seconds; the people and landscape masks may ask +for an update); the mask sliders show the values above; the "Bright sky" +mask covers only the bright sky around the subject. What to note if it +fails: an import error, masks listed but empty, a mask missing, a +component shown inverted (a range selecting the shadows), or a people part +on one person only. Until the run is recorded here, Select Background's +15.0 row, the luminance range and the subtract/intersect encodings in a +preset, and "all people" are unverified. The same presets with synthetic +ids are the byte goldens in `testdata/develop/written/` (see [Fixtures and +hygiene](#fixtures-and-hygiene)). + +Record (Lightroom version, date, per preset: imported / masks shown / +values / notes): *pending*. + +## Fixtures and hygiene + +Test data lives in `server-rs/testdata/develop/` (layout and scrubbing rules +in its README): + +- `lua/*.json` listed in `lua/manifest.json` are **generated** by + `server-rs/scripts/develop_registry/extract_fixtures.py` from the + maintainer's edits and replaced on every run. Do not edit them. +- Shapes the source data does not have (booleans as 0/1, non-integers on + integer keys, `null` in arrays, mixed arrays, non-raw white balance, both + white-balance families, PV2010) are **hand-written** as `lua/hand_*.json`; + the generator never touches them. Add a new one there, and give it its own + test in `tests/lua_read.rs` naming the warnings it must produce. +- Any other `*.json` in `lua/` is a hygiene failure. +- `xmp/*.xmp` are **self-authored**, one file per XMP form or develop shape + (no Adobe file, no copy or excerpt of one, no real sidecar); the list is in + the folder's README and in `FIXTURES` in `tests/xmp_fixtures.rs`. +- `wire/*.json` are the **Lua writer's wire goldens** (see [Wire + goldens](#wire-goldens)): not frozen while `LuaOptions` fields are open, regenerated with + `LRG_BLESS=1 cargo test -p lrg-develop --test lua_wire_goldens`, read by + Rust and the plugin's busted spec. `wire/key_classes.txt`, generated by + the same run from the registry, holds key names and value classes only. +- `defaults/non_raw_lrc15.6.json` is E13's readback of one non-raw photo + without develop edits (a virtual copy of a TIFF; key -> value, plus + Lightroom version, process version and file kind), the source of the + registry's non-raw default column (see + [Maintaining the registry](#maintaining-the-registry)). +- `written/*.xmp` are the **writer's byte goldens**: the three hand-test presets + exactly as the preset writer emits them, with synthetic ids in place of the + derived sync ids (a SHA-256 id cannot be told from a catalog's by the + hygiene check) and the development toolkit string. Regenerate after an + intended format change with + `LRG_BLESS=1 cargo test -p lrg-develop --test builder_presets`. + +The hygiene rules keep private data out of the repository: no photo ids, +paths, file or image names, dates, real 32-hex ids or GUIDs, profile +definitions or lookup tables (`Look.Parameters` is at most a stub of +`ConvertToGrayscale`/`ProcessVersion`), and `Look` names/UUIDs only from the +verified Adobe profiles or synthetic. The Python check +(`extract_fixtures.py --check-only`, in pre-commit and CI) and the Rust test +`tests/fixture_hygiene.rs` implement the same rules and must stay in step: +both match patterns against whole strings with ASCII digits, and both run the +same crafted files through the file-level rules +(`extract_fixtures.py --self-test`, and +`dispatch_rules_flag_exactly_the_crafted_files` in the Rust test). +`registry_snapshot.json` holds key names and their metadata, no settings, so +it gets the text rules without the table-key substrings, plus the id check. +An XMP file must be well-formed with an `rdf:RDF`, its `rdf:Alt` items need a +valid `xml:lang`, and its data model (`xmp_as_tree`, the same conversion on +both sides) gets the rules of a hand-written JSON file, so a `Look.Parameters` +beyond the stub, a real id or a date in an XMP fixture fails the same way. +The table-key substrings (`Table_`, `LookTable`, `RGBTable`) are forbidden as +text, with one exception on both sides: the preset-header key +`RequiresRGBTables` as a whole word, which Adobe's newer presets (the 163 of +446 bundled ones that carry `SupportsAmount2`, every adaptive preset +included) and every preset the writer produces carry (the writer goldens and +`xmp/preset_header.xmp` hold it). It is removed before the text rules run, so `RGBTable_` or +`RequiresRGBTablesX` still fail; crafted self-test files pin both cases. + +Everything under `server-rs/testdata/develop/` is checked out with LF line +endings on every platform (`.gitattributes`), and the snapshot test compares +content, not line endings, so a Windows checkout passes too. + +## Tests + +- **Unit tests** next to the code: registry invariants, scaling, coercion, + `Finite`, the settings invariant, combine/semantic tables, the policy filter, + the frame split, every reader rule. +- **`tests/lua_read.rs`**: every fixture in `server-rs/testdata/develop/lua/` + parses without errors or unknown keys; generated fixtures without any + warning; every shape a fixture's `manifest.json` entry claims is recognised + in the model; each hand-written `hand_*.json` shape produces exactly its + expected warnings. +- **`tests/fixture_hygiene.rs`**: mirrors `extract_fixtures.py --check-only` + (rules in `server-rs/testdata/develop/README.md`): no photo ids, paths, file + names, dates, real ids, profile definitions or lookup tables in the test + data. +- **`tests/xmp_fixtures.rs`**: every fixture in `testdata/develop/xmp/` + parses; only `unknown_keys.xmp` has `UnknownKey` warnings and only it and + `coercion_warnings.xmp` any warning; only `bag.xmp` (1) and + `alt_second_language.xmp` (2) keep registry keys whole (`kept_whole`); no + fixture uses a Lua-only key; each form has its own assertions (attribute + and element form, and plain and `parseType="Resource"` structures, read + into equal models). The pre-scan, the limits, mixed-form elements, + duplicates and `rdf:value` attributes are unit tests in `xmp/read.rs` + (deep nesting is parsed on a 2 MiB-stack thread). +- **`tests/xmp_lua_equivalence.rs`**: for the curve, mask, look and number + fixtures, the same settings hand-written in the Lua table form read into an + equal model. Removed before comparing: global keys of only one format + (`Presence::XmpOnly`/`LuaOnly`) and `Look.Parameters`, which each reader + keeps opaque in its own format. The other by-design differences listed + under [Reading XMP](#reading-xmp) (`PerFormat` and `Any` keys, nested + Lua-only keys, the `PointColors` sentinel) are kept out of the fixtures + instead. +- **`xmp::format` and `xmp::write` unit tests**: every spelling rule above, + the document shape against Adobe's layout, the preset header defaults and + stamps, policy filtering and the never-write guard (with every + `NEVER_WRITE` key checked against its policy), the `CompatibleVersion` + table, errors instead of broken files, byte determinism (twice the same + bytes, insertion order irrelevant), the `Look` by reference without its + profile definition or tables, values kept whole never reaching a preset, + white-balance numbers only with a custom mode, the recomputed AI-mask + fields not reported, a blank name refused, and — with the `test-roundtrip` + feature — the lossless mode's own rules (numbers the format would round, + foreign namespaces and qualifiers, Lua content refused). +- **`tests/xmp_roundtrip.rs`** (plan §7.3): round trip 1 over every fixture in + `testdata/develop/xmp/` and every golden in `testdata/develop/written/` + (model, header, kind and opaque content equal; writing the read-back model + gives the same bytes); the key enumeration — every LEARN and PHOTO key with + an XMP form, at every level (global, correction, mask component, `Look`, + `LensBlur`, range-mask, area-model and gesture fields), with its minimum, + maximum, midpoint, default, a negative value for a signed range, a value + with more digits than any format keeps, and every member of a closed set, + survives the lossless mode exactly (789 values), and every LEARN value a + preset can carry by itself (global values, a correction's adjustments and + amount, `LensBlur` fields, the `Look` amount) survives preset mode, mixed + and single-kind, after rounding to its `NumFmt` (`Value::quantized`; 1,073 + checks; a LEARN† key may be held back by its gate — the raw-only and + non-raw-only white balance, the file-kind-default `Sharpness` and + `ColorNoiseReduction` in a mixed preset, `LensProfileSetup` outside its + values — and what is held back is pinned per key, gate and mode; a plain + LEARN key never is). Mask fields are typed and go through presets in the + builder tests. And a preset written from every Lua and XMP fixture, mixed + and single-kind, is byte-deterministic and reads back as a preset with no + warning, nothing kept whole, no key outside LEARN, LEARN† and META (apart + from the writer's own fixed values: `WhiteBalance="Custom"`, an AI mask's + `ReferencePoint`/`ErrorReason`, a range mask's `SampleType`), and exactly + the policy-filtered settings, each value rounded as written (the writer's + stamps and fixed form apart). Both checks live in `tests/support/preset.rs` + and are shared with the local sweeps; a test pins that they catch a lost, + changed or extra value, a PHOTO key and content kept whole. +- **`build` unit tests**: every semantic variant with its encoding and name, + unknown categories, the combination table and `MaskValue 0 ⇔ MaskBlendMode + 1`, first-component-added, empty correction, `Flipped = !MaskInverted`, + a range's `Invert = MaskInverted` under every combination, radial and + linear checks, luminance-range ordering, UI scaling and the not-writable + keys, local curves, sync-id determinism, uniqueness per namespace, role and + slot, and known answers, `corrections` refusing a repeated role; through + the writer: built semantic corrections read back unchanged from a preset + (an intersected range with `Invert="true"`), PHOTO corrections are skipped + and reported, output is byte-deterministic, and (feature `test-roundtrip`) + every built tool round-trips exactly. +- **`lua::write` unit tests**: one per writer rule — keys and policy, + the guard against every `NEVER_WRITE` key, computed mask bookkeeping + never written, JSON types of integers/reals/booleans/flags, coercion and + wrong variants, numeric-looking strings, compound strings and ids, + curves (flat, local strings, one point refused), corrections in panel + order with their fixed form, gradients, no empty containers (no + corrections, a brush-only source, an emptied structure), `check_wire` + against `null`, empty and mixed lists and `Temp`, the white-balance + family (raw, non-raw, unknown kind, other modes, mode only, `Custom` + never alone, flatten, `Custom` stamp), every `LuaOptions` alternative + (the adaptive-preset locals and the sparse form, the panel switches + `MaskOnly` and `All`, the amount flags only in preset mode), the `Look` + forms, a camera restriction and an adaptive Look's AI update, + `PANEL_SWITCHES` naming registry keys, read-back and determinism, and the + provisional choices pinned. +- **`tests/lua_wire_goldens.rs`**: the wire goldens above, every case under + every single option flip, `key_classes.txt`, and the adaptive-preset + locals against the experiments' list. +- **`plugin/spec/native_wire_format_spec.lua`** (busted, from the repo + root): the same goldens decoded by the plugin's JSON.lua (see [Wire + goldens](#wire-goldens)). +- **`tests/lua_roundtrip.rs`** (plan §7.3, see [Round trips 2 and + 3](#round-trips-2-and-3)): round trip 2 (model → Lua → model, lossless) + over every Lua fixture and wire golden, warnings included, under all four + encodings (mask enums as numbers or strings × flags as numbers or + booleans; 42 tables each), and over every XMP fixture without what has no + Lua form; round trip 3 (Lua → model → XMP preset → model → Lua, mixed and + single-kind) over the Lua fixtures, the wire goldens and the XMP + fixtures, agreeing on every shared value after `Value::quantized` (7,374 + values; a failure names the source); the key enumeration + (`tests/support/enumerate.rs`, shared with `xmp_roundtrip.rs`) through + the lossless mode (792 values) and through apply mode for a raw and a + non-raw photo, where every global, correction, `LensBlur` and + `Look.Amount` value reaches the photo exactly or is reported or is its + default (1,299 written), with what is held back pinned per key and file + kind (the other white-balance family, a mode without numbers, + `LensProfileSetup` outside its values, the guard's `LensProfileDigest` and + `orientation`); and the sweeps' apply-mode check over every committed Lua + and XMP fixture (profiles excluded), with the provisional options and + every single flip, in apply and preset mode (45 sources, 1,350 tables, + 130,792 values compared, nothing lost, changed or added) — the check that + covers the mask-tool, range-mask and gesture values the enumeration does + not place. +- **`tests/builder_presets.rs`**: the three hand-test presets (see [Hand + test](#hand-test)) build, write without skips and read back unchanged, and + (with synthetic ids) match their byte goldens in `testdata/develop/written/`; + `LRG_HANDTEST_DIR` writes them out for Lightroom. +- **`tests/registry_snapshot.rs`**: the snapshot above. +- **`tests/support/`**: helpers the local sweeps share (mask tool and + combination counts, the mask forms the builders rely on — `Flipped`, + a range's `Invert`, `Version` and `SampleType` —, the registry-range check, + warning paths without indices), all reducing a model to key names and + counts; `support/flat.rs` flattens a model into `path → value` with the + typed parts expanded back into the XMP keys they stand for, so two models + are compared key by key; `support/preset.rs` holds the two checks every + preset-writing test applies (and the quantization round trip 3 uses); + `support/enumerate.rs` the key enumeration both writers' round trips + share; `support/lua_apply.rs` the two-way apply-mode check of the Lua + writer (every filtered value written or reported, nothing added beyond + the writer's fixed form) and the option flips, shared by the local + sweeps, `lua_roundtrip.rs` and `lua_wire_goldens.rs`; `support/lua_round.rs` round trips 2 and 3, counted, + for `lua_roundtrip.rs` and the local sweeps (all included by path from + the tests that use them). +- **`tests/training_dump_local.rs`** — local only: parses every row of the + maintainer's private training dump and requires zero parse errors, zero + warnings of any kind and every number within its key's registry range, + printing counts per warning kind, mask tool, combination and out-of-range + key (never values or photo ids; failing rows are named by position). Every + row is also written as a preset, mixed and single-kind — the production + path, since learned settings come from Lua — and must pass the same two + preset checks as the fixtures; and every row goes through the Lua writer: + round trips 2 and 3 (`tests/support/lua_round.rs`; 311,746 and 301,834 + values) and apply mode for its own photo (`tests/support/lua_apply.rs`: a + table that passes `check_wire`, reads back without a warning, holds + every filtered value exactly or reports it, and holds nothing beyond + those and the writer's fixed form; 1,294 rows, 213,826 values compared, + none changed, lost or added, October 2026): + + ```bash + LRG_DEVELOP_TRAINING_DUMP=/path/to/training_rows.json \ + cargo test -p lrg-develop --test training_dump_local -- --nocapture + ``` + + Without the variable it skips through the shared golden gate + (`crates/lrg-ml/tests/common`) as family `training-dump`, which is + local-only: `LRG_REQUIRE_GOLDENS=all` leaves it out, and only + `LRG_REQUIRE_GOLDENS=training-dump` makes a missing dump a failure. Setting + the variable makes a missing file a failure: a typo or an unmounted volume + must not pass as a skipped run. +- **`tests/xmp_goldens_local.rs`** — local only: the XMP reader and writer + over files that can never be in the repository or CI, one test and one + gate family per source: + + | Variable | Family | Parses | Must hold | + |---|---|---|---| + | `LRG_LRC_PRESETS_DIR` | `lrc-presets` | every `*.xmp` below it (Lightroom's `Contents/Resources/Settings`) | 0 parse errors, 0 `UnknownKey`, no warning but `DuplicateKey Group`, 0 keys kept whole; presets and profiles only | + | `LRG_ACR_PRESETS_DIR` | `acr-presets` | the presets and profiles among the `*.xmp` below it (Camera Raw's user folder: its presets and profiles) | as the bundle; keys kept whole only printed (a user preset may have a localized name) | + | `LRG_XMP_CORPUS_DIR` | `xmp-corpus` | the sidecars below it with `crs:ProcessVersion`; `LRG_XMP_CORPUS_SAMPLE=N` takes a fixed-seed sample of N | 0 parse errors, no warning at all, 0 keys kept whole, every file a `Sidecar` | + + Every sweep also fails on an unrecognised mask combination, on a mask + component against a relation the builders rely on (`Flipped` other than + `!MaskInverted`, a range's `Invert` other than `MaskInverted`, a range + `Version` other than 3), and on a number outside its key's registry range, + and writes every parsed document back: losslessly (round trip 1: no write + error, no key differing after reading back, the same model, header and + kind, idempotent bytes, and every `crs:` value spelled as the source spelled + it, apart from a per-sweep allow-list of known Adobe variances with their + reasons; mismatches are counted per key with indices removed) and as a + preset, mixed and single-kind (no write error, reads back as a preset with + no warning, no key outside the preset policy, nothing kept whole, and + exactly the policy-filtered settings; what the policy holds back is + printed per mode, key and reason, pattern families folded to + `Table_*`/`UprightTransform_*`). Every document but a profile also goes + through the Lua writer in apply mode for its own photo, with the same + check as the training dump (October 2026, both ways — every filtered + value written or reported, nothing added beyond the writer's fixed form: + the 446 bundled presets (24,283 values), the 4 Camera Raw presets (267) + and all 10,815 corpus sidecars (1,376,155) clean), and, without what has no Lua form, through round trips + 2 and 3 (counts in [Round trips 2 and 3](#round-trips-2-and-3)). A fourth test, on `LRG_LRC_PRESETS_DIR`, + is the builder parity with Adobe's adaptive presets (see [Building + corrections](#building-corrections)). The corpus walk skips `* [conflicted].xmp` + sync copies, darktable's `..xmp` sidecars (no `crs:` at all) and + metadata-only sidecars, and prints how many of each. Output is counts per + document kind, warning kind, process version, skipped subtree, mask tool + and combination, plus key names; never a value, and corpus or Camera Raw + files are named by ordinal only. + + ```bash + LRG_LRC_PRESETS_DIR="/Applications/Adobe Lightroom Classic/Adobe Lightroom Classic.app/Contents/Resources/Settings" \ + LRG_ACR_PRESETS_DIR="$HOME/Library/Application Support/Adobe/CameraRaw" \ + LRG_XMP_CORPUS_DIR=/path/to/photos \ + cargo test -p lrg-develop --test xmp_goldens_local -- --nocapture + ``` + + The full corpus above (October 2026: 28,681 files, 10,815 parsed) takes + about 150 s in a debug build with all write-backs and checks; + `LRG_XMP_CORPUS_SAMPLE=3000` about 25 s. + Gating works as for the training dump: an unset variable skips, the + families are local-only (`all` leaves them out, only naming one makes its + absence a failure), and a set variable that is not a directory, or a + directory without a single file to check, fails. + + A new unknown key found here goes into the registry as a hand-written row + in `registry/table.rs`, followed by + `LRG_BLESS=1 cargo test -p lrg-develop --test registry_snapshot` (see + [Maintaining the registry](#maintaining-the-registry)), never into an + allow-list. diff --git a/docs/wiki/Dev-Plugin-README.md b/docs/wiki/Dev-Plugin-README.md index b8ea2f1c..f188e794 100644 --- a/docs/wiki/Dev-Plugin-README.md +++ b/docs/wiki/Dev-Plugin-README.md @@ -73,10 +73,17 @@ The plugin is designed to work with local and cloud providers, while keeping Lig ### AI Develop Edits -- Generate a Develop recipe per photo and apply it non-destructively, either to - the photo itself or to a virtual copy -- No LLM involved: the backend matches the photo against the edits you saved - yourself and interpolates theirs +- Edit each photo and apply the result non-destructively, either to the photo + itself or to a virtual copy +- **AI retoucher**: a vision model edits, Lightroom renders the edit, the model + looks at the render with its measurements and corrects it, round by round + (`EditLoop.lua` with the backend's `/v1/edit/sessions`). Looks, your own + instructions, and Lightroom's AI masks (subject, sky, background, people + parts, landscape classes; Lightroom Classic 15.3+), applied through + `applyDevelopSettings` and `updateAISettings` +- **My style only**: no AI model; the backend matches the photo against the + edits you saved yourself and interpolates theirs. The retoucher can start + from this blend and only fix what does not suit the frame - Recipes are constrained to what the frame can take: the backend measures the image and caps contrast, clarity, shadow lift and whites accordingly, and reports why @@ -84,7 +91,8 @@ The plugin is designed to work with local and cloud providers, while keeping Lig ### Style Training - Save your own Develop settings as labeled training examples -- The sole input to AI Develop Edits, which needs at least five of them +- The sole input to *My style only*, which needs at least five of them, and the + AI retoucher's optional starting point ### Keyword Dedup & Declutter diff --git a/docs/wiki/Dev-Project-README.md b/docs/wiki/Dev-Project-README.md index c0b3f1e4..230206f4 100644 --- a/docs/wiki/Dev-Project-README.md +++ b/docs/wiki/Dev-Project-README.md @@ -28,7 +28,7 @@ Whether you prefer running local models to ensure maximum privacy or want to lev ## ✨ Core Features - **🤖 AI-Powered Tagging & Describing:** Uses advanced LLMs to accurately recognize image content, generate metadata, and provide detailed descriptions of your photos. -- **🎛️ AI Lightroom Edit (Develop) *(beta)*:** Generates a Lightroom edit recipe per photo and applies it in Develop, either to the photo itself or to a new virtual copy. No language model is involved: the recipe is interpolated from the edits you saved yourself (see *Style Training*), so at least five training examples are required. Per-photo review is on by default. +- **🎛️ AI Lightroom Edit (Develop) *(beta)*:** Edits each photo and applies the result as ordinary develop settings, to the photo itself or to a new virtual copy. The **AI retoucher** works like a person at the Develop panel: a vision model edits, Lightroom renders the edit, the model looks at the render with its measurements and corrects it — with looks (natural, moody, cinematic, film, black & white, …), your own instructions, and Lightroom's AI masks for sky, subject, background, skin, eyes and more. With five or more saved edits it starts from your own style and only fixes what does not suit the frame. **My style only** needs no language model at all: the recipe is interpolated from the edits you saved yourself (see *Style Training*). Per-photo review is on by default. - **🔍 Semantic Free-Text Search (Advanced Search):** Find images naturally through descriptive queries (e.g., *"Red sports car parked in front of a garage"* or *"Sunset over the mountains"*). LrGeniusAI automatically creates a relevance-sorted Collection in Lightroom based on your prompt. - **📸 Image Culling *(beta)*:** Group similar photos into bursts or near-duplicate stacks, automatically pick the strongest frames, and create Lightroom collections for picks, alternates, reject candidates, and optional duplicates. - **👥 People & Faces:** Detect and cluster faces, assign names to persons, browse person collections, and find similar faces across your catalog. @@ -63,13 +63,13 @@ Whether you prefer running local models to ensure maximum privacy or want to lev 4. **Pick an AI model.** Either enter a cloud API key in the **Plug-in Manager**, or stay fully local: the **Local AI Model** section offers a curated list of vision models (Gemma 4, Ministral 3, Qwen3-VL, Qwen2.5-VL) — choose one and click *Download*. The section shows the engine your platform ships: **MLX** on macOS, **llama.cpp** on Windows. See [Local AI Models](https://github.com/LrGenius/LrGeniusAI/wiki/Help-Local-AI-Models). 5. Select photos in the library and choose one of the AI actions from **Library -> Plug-in Extras**: - **Analyze & Index Photos...** — AI tagging, descriptions, search index, faces, and optional species identification - - **AI Edit Photos...** *(beta)* — generate and apply Lightroom develop edits learned from your own edits + - **AI Edit Photos...** *(beta)* — an AI retoucher edits, looks at the result and refines it; or apply edits learned from your own - **Advanced Search...** — semantic free-text search - **Cull Similar Photos...** *(beta)* — burst grouping and auto-ranking - **People...** — face clusters and named person collections - **Find Similar Images...** — find near-duplicates or visually similar photos - **Deduplicate Keyword Synonyms...** — clean up synonym sprawl in your catalog (in the **Help** menu) -6. For AI Edit, first teach it your style: edit a few photos by hand and run **Save Edits as AI Training Examples...** (in the **Help** menu). AI Edit builds every recipe from those examples and needs at least five of them — it calls no LLM. Keep **Review each proposed edit before applying it** enabled while you validate the results. +6. For AI Edit, pick a vision model for the **AI retoucher** in its dialog (larger models edit better). To have it keep your own look, edit a few photos by hand and run **Save Edits as AI Training Examples...** (in the **Help** menu); with five or more it starts from your style, and **My style only** uses nothing else. Keep **Review each edit** enabled while you validate the results. *For comprehensive details, model setup guides, and tips, please visit [lrgenius.com/help](http://lrgenius.com/help/).* diff --git a/docs/wiki/Dev-Server-README.md b/docs/wiki/Dev-Server-README.md index 8407ed71..1be6d18e 100644 --- a/docs/wiki/Dev-Server-README.md +++ b/docs/wiki/Dev-Server-README.md @@ -12,9 +12,14 @@ endpoint reference. Workspace layout: `crates/lrg-common`, `lrg-store` (LanceDB), `lrg-imaging`, `lrg-ml` (ONNX Runtime via `ort`), `lrg-analysis`, `lrg-providers` (LLM -clients), `lrg-llama` (in-process llama.cpp, behind the `llamacpp` feature), +clients, edit-recipe schema), `lrg-develop` (the native Lightroom develop +model: key registry, typed settings, Lua/JSON and XMP readers, the +develop-preset writer, the Lua writer for `applyDevelopSettings` (provisional +until experiments E1/E2/E4/E11) and correction builders; a leaf crate), +`lrg-llama` (in-process llama.cpp, behind the `llamacpp` feature), `lrg-mlx` (supervises the Apple silicon MLX sidecar), `lrg-api` (axum -routers, including the agent bridge to the plugin), `lrg-mcp` (the MCP endpoint +routers, including the agent bridge to the plugin and AI Edit's retoucher +sessions at `/v1/edit/sessions`), `lrg-mcp` (the MCP endpoint AI agents connect to at `/mcp`, and the `geniusai-server mcp` stdio proxy — see [`docs/wiki/Dev-Agent-Bridge.md`](../docs/wiki/Dev-Agent-Bridge.md)), `lrg-chat` (the in-app agent chat at `/v1/chat/*` and its page, a local or @@ -27,7 +32,9 @@ providers. They are independent — a build can have either, both, or neither and both are served by the same `LocalEngine` trait in `lrg-providers/src/local.rs`. Besides indexing photos in batches (`generate`), the trait answers a whole conversation with one turn, -streaming its text as it is decoded (`chat`) — the agent chat is built on that. +streaming its text as it is decoded (`chat`) — the agent chat is built on that, +and so is AI Edit's retoucher, which edits through two tools (`apply_edit`, +`finish`) and sees each of Lightroom's renders as a tool result. The chat itself talks to a model-neutral `ChatModel` (`lrg-providers/src/chat.rs`); `local_chat.rs` puts a local engine behind it, calling tools by decoding against a JSON Schema with one branch per tool, since llama.cpp cannot render @@ -139,13 +146,23 @@ so a job that provisions assets can prove it used them: ```bash LRG_REQUIRE_GOLDENS=face cargo test --workspace # one family LRG_REQUIRE_GOLDENS=face,siglip cargo test --workspace # a subset -LRG_REQUIRE_GOLDENS=all cargo test --workspace # every family +LRG_REQUIRE_GOLDENS=all cargo test --workspace # every family except the local-only ones ``` Family names are `face`, `auraface`, `facestate`, `subject`, `siglip`, `aesthetic` and `bioclip`. Per-family rather than a single switch because they differ in cost by more than an order of magnitude (89 MB, 261 MB, 7 MB, 123 MB, 2.2 GB, 868 MB and 834 MB respectively). +Local-only families can never be provisioned in CI, because their data is +private or not redistributable: `all` leaves them out, and only naming one +makes its absence a failure. They are lrg-develop's local sweeps, each reading +a path from an environment variable: `training-dump` +(`LRG_DEVELOP_TRAINING_DUMP`, the maintainer's training dump), `lrc-presets` +(`LRG_LRC_PRESETS_DIR`, Lightroom Classic's bundled presets and profiles), +`acr-presets` (`LRG_ACR_PRESETS_DIR`, Camera Raw's user folder: its presets and +profiles) and `xmp-corpus` (`LRG_XMP_CORPUS_DIR`, a private sidecar corpus). See +[Dev-Develop-Model.md](../docs/wiki/Dev-Develop-Model.md). + CI uses both halves: `server-rs-tests.yml` downloads the face pair on every PR and sets `LRG_REQUIRE_GOLDENS=face`, while the nightly `golden-tests-full.yml` fetches every family (AuraFace from its pinned Hugging Face commit, the @@ -408,6 +425,11 @@ It runs with `--no-project` on purpose — `facenet-pytorch` pins `torch<2.3` and `numpy<2.0`, which `scripts/pyproject.toml` cannot satisfy, so the script carries its own PEP 723 dependency header. +`scripts/develop_registry/` is a separate, stdlib-only `uv` project for the +native Lightroom develop model: the `crs:` key inventory and registry +generator, and the extractor that produces the scrubbed fixtures in +`testdata/develop/`. See its README. + Then point the server at the files: ```bash diff --git a/docs/wiki/Help-AI-Edit.md b/docs/wiki/Help-AI-Edit.md index 19ad9a8f..e284f317 100644 --- a/docs/wiki/Help-AI-Edit.md +++ b/docs/wiki/Help-AI-Edit.md @@ -1,16 +1,130 @@ # Help: AI Edit Photos *(beta)* -> **Beta feature.** AI Edit generates generally good results but can occasionally produce edits that are too aggressive or miss the mark on specific scenes. Always use **Review each proposed edit** when running on photos you haven't tested this feature on before, and be ready to skip or undo individual edits. Feedback and bug reports via GitHub Issues are very welcome. +> **Beta feature.** Always use **Review each edit** when running on photos you +> haven't tried this feature on before, and be ready to discard individual edits. +> Feedback and bug reports via GitHub Issues are very welcome. -The **AI Edit Photos** workflow generates a structured Lightroom develop recipe for each photo and can apply it directly — without leaving Lightroom Classic. +**AI Edit Photos** edits your photos in Lightroom Classic and applies the result +directly, as ordinary develop settings you can keep working on. ## How to start `Library → Plug-in Extras → AI Edit Photos...` +AI Edit has two editors; pick one under **Editor**: + +- **AI retoucher** *(recommended)* — an AI model edits each photo, looks at what + Lightroom renders, and corrects it, like a person at the Develop panel. With + enough saved edits it starts from your own style. +- **My style only** — no AI model: every recipe is a blend of your own saved + edits on similar photos. Offline and free, but it never looks at the result. + +--- + +## The AI retoucher + +### Look, edit, look again + +A vision model is poor at guessing slider values for a photo it has never seen +rendered — "+0.4 exposure" means something different on every file and profile — +and good at judging a picture and saying what is wrong with it. So the retoucher +works in rounds: + +1. Lightroom renders the photo as it is. The model sees it with **measurements** + of its tones — brightness percentiles, how much is clipped, the colour cast of + the neutral tones, saturation — and the values every slider starts from. +2. The model sends a complete edit: white balance, exposure, the tonal range, + presence, HSL, colour grading, effects, and **AI masks** where global sliders + would hurt the rest of the photo (a darker, richer sky; a lifted subject; a + calmer background; smoother skin; brighter eyes). +3. Lightroom applies it, computes the masks and renders the result. The model + sees the render next to the original, with fresh measurements and what moved + ("median 38 → 47, clipped highlights 0.4 % → 1.9 %"), and corrects. +4. It stops when another round would not clearly help — or goes back to an + earlier round that looked better. + +Every round is the whole edit measured from where the photo started, so a round +can undo anything the one before did. The measurements keep the model honest +about what the eye misjudges: clipping, levels and casts. + +### Your style as the starting point + +Tick **Start from my own style** (it needs five saved training examples, see +[My style only](#my-style-only)) and round 0 is your style: AI Edit blends your +saved edits for the photo, applies the blend and shows the model the original +and your style side by side, with your style's values. The model is told to keep +your look — colour, contrast, choices — and to fix only what does not suit this +frame: exposure, white balance, clipping, emphasis, local work. Its edits are +made on top of your style: a setting it does not touch keeps your style's value, +so your colour mix, grading and detail settings survive every round. It can also +decide your style was already right and keep it unchanged. + +### Look and your own words + +- **Look** sets the direction: *Natural* (a better version of the photo, the + default), *Bright & airy*, *Moody*, *Cinematic*, *Analog film*, + *Vivid & punchy* or *Black & white* (converts to monochrome and shapes the tones + with the black-and-white mix). +- **Your instructions** are free text for the run, e.g. *keep skin warm, no + vignette*. They win over the look where the two disagree. +- **Edits per photo** caps the rounds (default 3). One round is the old + single-shot edit; most photos settle in two or three. + +### AI masks + +With **Use AI masks** the model can use Lightroom's own AI masks: subject, sky, +background, the parts of people (face skin, body skin, hair, lips, teeth, iris +and pupil, eye whites, eyebrows, facial hair, clothes — of everyone in the +photo), and landscape classes (architecture, mountains, vegetation, water, +natural and artificial ground, snow). Lightroom finds the regions itself, at +full resolution, whatever the size of the image the model saw. + +The masks are named *LrGenius · Sky*, *LrGenius · Subject*, … in the Masks panel. +A later round, or a later AI Edit run, replaces them instead of stacking new +ones; masks you made yourself are left as they are. A mask that finds nothing +(a sky mask on an indoor photo) is reported back to the model, which then +drops it; if the edit you end up with still has one, the review and the run's +summary say so. + +AI masks need **Lightroom Classic 15.3 or later**; on older versions the option +is greyed out and the retoucher edits with global sliders only. + +### Which model + +Any provider set up in Plug-in Manager works — a cloud model (Google Gemini, +OpenAI, Anthropic Claude), an AI server (Ollama, LM Studio, the Other AI server) +or a model on this computer — as long as it **sees images** and can **call +tools**. The dialog defaults to the model chosen for Analyze & Index. + +Editing asks more of a model than tagging. Large cloud models edit noticeably +better than small local ones; a small local model may not manage the tool calls +at all, and the run then says so. Cost and time per photo are one model call +per round, each with the photo at 1536 px plus the latest render, and one +Lightroom render per round — with a cloud model typically a few seconds per +round and a few cents per photo for the larger models; the run's summary lists +the tokens used. + +> **Privacy.** With a cloud model, renders of every photo you edit go to that +> provider, as they do for Analyze & Index. Pick a model on this computer to keep +> them here. + +### Review, keep or discard + +With **Review each edit** on, every finished edit is shown with the photo, the +round that was kept, the model's assessment of each round, what the frame +allowed, and anything that did not reach the photo as asked. **Keep** keeps it, +**Discard** takes the photo back to exactly where it was (sliders and masks), +**Keep all** keeps this one and the rest of the run without asking. + +Every round is a step in the photo's Edit History (*AI Edit: round 1*, …), so +you can also step through them there. Tick **Apply the edit to a new virtual +copy** to keep the original's history clean. + --- -## It edits like you do — and only like you do +## My style only + +### It edits like you do — and only like you do AI Edit does not ask a language model what a good edit looks like. It builds every recipe from **your own saved edits**: the photo is matched against the @@ -26,24 +140,63 @@ The match is scored on four things: | Scene tags | 15% | | Time of day | 10% | -The upshot: **AI Edit needs training examples to work at all.** With fewer than -five saved examples the task stops before it uploads anything and points you at -*Save Edits as AI Training Examples*. Five is the floor, not the target — the +The upshot: ***My style only* needs training examples to work at all.** With +fewer than five saved examples the task stops before it uploads anything and +points you at *Save Edits as AI Training Examples*. Five is the floor, not the target — the style profile is reported as *Building up* below ten examples and *Active* from fifty. See [Help: Train from Edits](Help-Train-From-Edits). Because the recipe comes from your own edits, there is no prompt, no model -choice, no style preset and no API key involved. AI Edit works with every cloud +choice, no look and no API key involved. *My style only* works with every cloud provider switched off. --- -## What it generates - -A develop recipe of **global adjustments** — exposure, white balance, -highlights, shadows, whites, blacks, contrast, texture, clarity, dehaze, -vibrance, saturation, tone curve, sharpening, noise reduction, vignette, grain -— averaged from the matching examples. +### What it generates + +A develop recipe of **global adjustments** averaged from the matching +examples: + +- **Basic:** exposure, contrast, highlights, shadows, whites, blacks, texture, + clarity, dehaze, vibrance, saturation. +- **Tone curve:** the four parametric sliders (highlights, lights, darks, + shadows) and the three region splits. +- **Color mixer (HSL):** hue, saturation and luminance of all eight colors. +- **Color grading:** hue and saturation of the shadows and highlights, and the + balance. Tints are averaged as colors (a red at 350° and one at 10° average + to red, not cyan), and a tint you set at zero saturation does not count. + When your matching examples tint the shadows (or highlights) in clearly + different colors, the tints partly cancel: that zone is toned down towards + neutral (fully opposite tints leave no toning at all), and the end-of-run + summary says so. Toning from black-and-white examples is not carried over, + so a sepia black-and-white example does not tint a color photo. Midtones, + global grading, luminance and blending are not carried over yet. +- **Detail:** sharpening amount, radius, detail and masking; luminance noise + reduction with its detail and contrast; color noise reduction with its detail + and smoothness. +- **Effects:** post-crop vignette (amount, midpoint, roundness, feather, + highlights) and grain (amount, size, roughness). + +White balance is decided separately; see *How white balance is carried over* +below. Point curves, lens corrections, the profile and crop are not carried +over. + +A setting only some of the matching examples carry (a black-and-white example +has no color mixer, for instance) is averaged over the ones that have it, so it +is not pulled towards zero. Examples you saved before this release take part +with all of these settings without being saved again. + +The color mixer, color grading, curve splits and the detail and effects +settings above came with a backend update alone: the plug-in already knew how +to apply them, so you do not need a new plug-in version to get them. + +These values replace the photo's own settings in those panels, including a +color mixer, color grading, curve splits or detail and effects settings it +already had (earlier versions left those alone), usually with values near 0 +when your examples did not use a panel. To keep an edited photo's own look, +turn on *Apply the edit to a new virtual copy*. The review dialog lists +`hsl`, `color_grading` and `tone_curve` whenever they are sent, even when every +value in them is 0. Local masks are not part of a style-engine edit; every recipe carries an empty mask list. @@ -53,9 +206,9 @@ outside Lightroom; all results are reversible via Lightroom's Edit History. --- -## Refine with the aesthetic score *(optional)* +### Refine with the aesthetic score *(optional)* -Tick **Refine each edit with the aesthetic score** in the options and AI Edit asks +Tick **Refine my style with the aesthetic score** in the options and AI Edit asks a second opinion before it settles on an edit: it applies the recipe, renders the photo, and has the aesthetic model score the result. If a gentler version scores clearly better, it steps the strength down — 100%, 75%, 50%, 25% — and keeps the @@ -87,11 +240,15 @@ without refinement. --- +--- + ## What the frame allows Your habitual +25 contrast was learnt on the frames you shot, and this frame may -have nothing in common with them. So before a recipe reaches your photo, the -backend measures the image itself — how hard the light is, how many stops of +have nothing in common with them; an AI model, for its part, likes clarity and +contrast more than most photographs do. So before any recipe reaches your photo +— from your style or from the retoucher, every round — the backend measures the +image itself — how hard the light is, how many stops of dynamic range there are, how much of the frame is specular highlight, how far the shadows are already clipped — and works out how much contrast, clarity, shadow lift and whites this particular frame can take. Values above that ceiling @@ -110,7 +267,55 @@ balance can be carried over at all — Lightroom's temperature is Kelvin on a ra file and a relative −100…100 value on everything else, and the two cannot be averaged together. The plugin tells the backend which it is, since the photo is exported to JPEG before upload and the original encoding is otherwise invisible -from the server side. +from the server side. It asks the photo's own develop settings rather than the +file extension, so a DNG converted from a JPEG counts as a JPEG here — which is +how Lightroom treats its white balance. + +A white balance that does not fit the photo is left out, never squeezed onto the +other scale: the photo keeps its own, and the run's summary says why. + +### How white balance is carried over + +White balance is not averaged like the sliders. Up to twenty of your matching +examples vote on *how* you set it, weighted by how well each one matches: + +- **Mostly As Shot:** the photo keeps its own white balance. That is what your + examples do, so nothing is reported. +- **Mostly Custom:** the temperature and tint of your best-matching Custom + examples of the same file type as the photo (up to three) are applied — but + only when at least two of them exist and their temperatures are within 500 K + of each other. If they disagree, or there is only one, the photo keeps its + own and the run's summary says so. A JPEG or TIFF photo is the exception, + see below. +- **Mostly Auto or a preset such as Daylight:** not carried over yet; the + summary says so. (Whether Lightroom recalculates the temperature when only + the mode is set is still being tested.) +- **A JPEG or TIFF photo when Custom wins:** training examples are saved from + raw and DNG files only, so their Kelvin temperature means nothing on a JPEG. + The photo keeps its own white balance, with a note in the end-of-run + warnings. With an As Shot habit a JPEG simply keeps its own, with no note. + (The one exception is an example saved from a DNG converted from a JPEG: it + has the JPEG kind of white balance and can pass it on to JPEG photos.) + +These thresholds are a first setting and may be tuned. In this release white +balance transfer cannot be switched off separately; undo it in Lightroom's +Edit History if a photo should keep its own. + +--- + +## Warnings at the end of a run + +Anything that did not go as planned — a white balance left out, a low +style-match confidence, a recipe that could not be applied — is listed in the +dialog at the end of the run. A cause that hit many photos is listed once with a +count, for example *"White balance was not transferred … (12 photos)"*, so one +run-wide reason does not push every other report out of view. The five that +affected the most photos are shown; the dialog says how many more there were, +and the plug-in log has all of them. + +With **Review each edit** on, the review dialog lists the same notes for the +photo in front of you, so you see why a white balance was left out before you +decide. --- @@ -122,67 +327,63 @@ from the server side. - **Current view** — all photos in the currently visible folder or collection. - **All photos in catalog** — everything. +### Editor + +*AI retoucher* or *My style only*, see above. The AI retoucher's settings — +model, look, instructions, edits per photo, your style as the starting point, +AI masks — are greyed out for *My style only*. + ### Style profile Read-only. Shows how many training examples the backend holds and what that means for the match quality, so you can tell an unconvincing result caused by a thin style profile from one caused by an unusual photo. -### Review each proposed edit before applying it - -When enabled, a **review dialog** opens for each photo before the edit is applied. You see: +### Review each edit -- The proposed develop values, plus how confident the style match was and which of your saved edits it drew on. -- Any guardrail explanations — what the frame allowed, and what got capped. With - the face models downloaded these include the faces in the frame: hard light across - a face leaves clarity alone, a face well below the frame around it keeps contrast - from pushing it darker, and blown skin holds the white point back. -- Options to **Apply** or **Skip**. +For the **AI retoucher**: the finished edit is shown to keep or discard, see +[Review, keep or discard](#review-keep-or-discard). -**Recommended for first use.** Disable only after you've validated the results for your shooting style. +For **My style only**: a review dialog opens for each photo *before* the edit +is applied, with the proposed develop values, how confident the style match was +and which of your saved edits it drew on, and the guardrail explanations — with +the face models downloaded these include the faces in the frame. Options to +**Apply** or **Skip**. -> **No before/after preview yet.** The review dialog lists values; it does not render a comparison. A rendered before/after is planned. Until then, Lightroom's own History panel is the fastest way to judge a result — a plain run is a single undo step named *Apply AI Lightroom develop settings* (a refined run adds one per strength tried). +**Recommended for first use.** Disable only after you've validated the results +for your shooting style. ### Apply the edit to a new virtual copy -Off by default. When enabled, each photo that is actually going to be edited -gets a virtual copy named *AI Edit* first, and the recipe is applied to that -copy — your original keeps whatever settings it had. +Off by default. When enabled, each photo gets a virtual copy named *AI Edit* +and the edit goes onto the copy — your original keeps whatever settings it had. -The copy is created *after* the review dialog, so a photo you skip leaves -nothing behind. If Lightroom refuses to create the copy, the photo is reported -as an error and skipped; the edit is never redirected onto the original. +For *My style only* the copy is created *after* the review dialog, so a photo +you skip leaves nothing behind. The AI retoucher edits for real while it works, +so its copy comes first; a copy whose edit you discard stays in the catalog with +the original's settings, and the run's summary says how many. Lightroom does not +let plugins delete photos: remove them with *Photo → Remove Photo*. If Lightroom +refuses to create a copy, the photo is reported as an error and skipped; the +edit is never redirected onto the original. Two side effects come from Lightroom's own API here: copying works on the current selection, so your grid selection changes as the run walks through the photos, and if a photo is not part of the folder or collection you are looking at, the plugin switches the source to *All Photographs* to reach it. -### Refine each edit with the aesthetic score +### Refine my style with the aesthetic score -Off by default. Tries gentler strengths of the recipe and keeps the one the aesthetic -model scores best — see [Refine with the aesthetic score](#refine-with-the-aesthetic-score-optional) -above. Slower, and each try is a step in the photo's Edit History; pair it with the -virtual copy option to keep the original's history clean. +*My style only*. Off by default. Tries gentler strengths of the recipe and keeps +the one the aesthetic model scores best — see +[Refine with the aesthetic score](#refine-with-the-aesthetic-score-optional) +above. Slower, and each try is a step in the photo's Edit History; pair it with +the virtual copy option to keep the original's history clean. --- ## Tips for best results -- Start with **Review each proposed edit** enabled — review the first batch before applying to hundreds of photos. -- Train on the kind of photo you are about to edit. The style profile is matched per photo, so a profile built only from bright studio work has little to offer a night shot. -- Keep feeding it: run **Save Edits as AI Training Examples** after a manual editing session. Match quality improves with the number and variety of examples. -- A low confidence in the review dialog means the photo did not resemble anything you have trained on. That is the moment to edit it by hand — and then save that edit as a training example. - ---- - -## Where the LLM went - -Earlier versions offered a second, prompt-driven path: pick a provider and -model, write a system instruction, choose a look preset, and let a vision LLM -propose the develop settings. That path is no longer reachable from the plugin. -The backend still implements it (`POST /v1/edit/recipe`), so it can come back, but the AI -Edit dialog no longer configures it and no run reaches an LLM. - -Model choice now only affects [Analyze & Index](Help-Analyze-and-Index) — -tagging, descriptions and keywords. +- Start with **Review each edit** enabled — review the first batch before applying to hundreds of photos. +- Give the retoucher a look and, where it matters, a sentence of your own: *keep the blue hour cool*, *skin natural, no smoothing*. +- Save your best edits as training examples and tick **Start from my own style**: the retoucher then keeps your look and fixes the frame, which is usually the best of both. +- For *My style only*: train on the kind of photo you are about to edit. The style profile is matched per photo, so a profile built only from bright studio work has little to offer a night shot. A low confidence in the review dialog means the photo did not resemble anything you have trained on — edit it by hand and save that edit as a training example. diff --git a/docs/wiki/Help-Train-From-Edits.md b/docs/wiki/Help-Train-From-Edits.md index 31634592..d29aec64 100644 --- a/docs/wiki/Help-Train-From-Edits.md +++ b/docs/wiki/Help-Train-From-Edits.md @@ -16,6 +16,8 @@ The **Save Edits as AI Training Examples** workflow lets you teach LrGeniusAI yo The plugin reads the current develop settings from Lightroom and sends them to the backend, where they are stored as labeled training examples. +Only **raw and DNG** files are saved. JPEGs, TIFFs and other formats in the chosen scope are skipped, and if nothing is left the workflow says so. + --- ## Dialog options @@ -75,20 +77,44 @@ hiding it. ## Raw and non-raw examples are kept apart Only for white balance, and only because Lightroom forces the issue: its -temperature slider is Kelvin on a raw file and a relative −100..+100 on a JPEG. -Averaging an example edited from a raw with one edited from a JPEG would produce -a number that means nothing on either scale — and it would land on a real photo. - -So when the backend blends a white balance, it uses only the examples whose file -type matches the photo you are editing. If none of the matched examples qualify, -it leaves white balance alone and tells you why. Everything else — exposure, -contrast, presence, colour, curves — means the same on both, and is blended from -all matched examples as usual. - -Practically: if you shoot raw and JPEG both, keep saving examples from both. -Nothing is wasted; the pool simply narrows for that one field. Examples you saved -before this existed count as compatible with anything, so nothing you already -have stops working. +temperature slider is Kelvin on a raw file and a relative −100..+100 offset on a +JPEG. Averaging the two would produce a number that means nothing on either +scale — and it would land on a real photo. + +"File type" here means the kind of white balance Lightroom stores for a photo, +read from its develop settings, not the file extension. Since this workflow +saves raw and DNG files only, nearly every example is the raw kind. The +exception is a DNG converted from a JPEG: Lightroom keeps the JPEG kind of white +balance for it, and so does the backend. + +When the backend carries a white balance over, it uses only the examples whose +file type matches the photo you are editing. For a JPEG or TIFF photo that +usually means none qualify: it keeps its own white balance, and the run's +summary tells you why. Everything else — exposure, contrast, presence, the +color mixer, color grading of shadows and highlights, the parametric tone curve, +detail, vignette and grain — is blended from all matched examples as usual (see +[What it generates](Help-AI-Edit#what-it-generates)). Most of these mean the +same on both file types; sharpening amount and color noise reduction do not +quite: Lightroom starts them from different defaults on raw and JPEG files, and +they are still averaged across both for now. + +Examples you saved earlier are read again from their stored develop settings, +so they take part with the right file type — and now with their white balance — +without being saved again. The exception is an example still on a process +version older than PV2012: it contributes nothing, and AI Edit's end-of-run +warnings say so when such an example was among the matches. Update it in the +Develop module and save it again. + +Saving an example reports the same kind of problem right away — an old process +version, or develop settings that carry both kinds of white balance at once, in +which case its white balance is not learned. The summary at the end lists each +cause once, with the number of examples it hit. + +What is learned from an example's white balance is how you set it — As Shot, +Auto, a preset or Custom — and, for Custom only, its temperature and tint. The +Kelvin an As Shot or Auto example happens to have belongs to that photo's light +and is never copied. See [Help: AI Edit](Help-AI-Edit) for how the examples' +choices decide what a photo gets. --- diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 7a5240d0..75e65a02 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -54,6 +54,8 @@ Welcome to the project wiki. - [Dev: Culling — what the other tools judge](Dev-Image-Culling-Competitive-Analysis) — Lightroom, Aftershoot, Narrative, FilterPixel, Imagen, Excire, Evoto - [Dev: Culling — the public benchmark](Dev-Image-Culling-Benchmark) — defects made on purpose on free-licence photos, and what it measured - [Dev: Capture One Port — Findings and Plan](Dev-Capture-One-Port-Plan) — what Capture One can and cannot do; investigated 2026-09-10, not pursued +- [Dev: AI Edit via XMP — Findings and Experiments](Dev-AI-Edit-XMP-Findings) — what `crs:` XMP can express, mask coordinate frames, SDK apply paths, and the in-Lightroom experiments that settle the rest; researched 2026-09-26 +- [Dev: Native Develop Model](Dev-Develop-Model) — `lrg-develop`: the key registry, the typed develop model, the policy filter and the Lua/JSON reader; how to add a key ### Auto-generated from README files @@ -68,7 +70,7 @@ Welcome to the project wiki. LrGeniusAI is an AI extension for Lightroom Classic. It runs a local backend server and connects it to the Lightroom plugin to provide: - **AI metadata generation** — keywords, titles, captions, alt text, via cloud APIs or models the backend runs locally itself -- **AI develop edits** *(beta)* — per-photo Lightroom develop recipes interpolated from your own saved edits, no LLM involved +- **AI develop edits** *(beta)* — an AI retoucher that edits, looks at Lightroom's render and corrects it, with looks, your own instructions and AI masks; or recipes interpolated from your own saved edits, with no LLM involved - **Semantic free-text search** — find photos by describing them in natural language - **Species identification** — name the animals, plants and fungi in your photos down to species, on your own machine, with no cloud account - **Image culling** *(beta)* — burst grouping, scoring, Picks/Alternates/Rejects collections diff --git a/plugin/LrGeniusAI.lrdevplugin/APISearchIndex.lua b/plugin/LrGeniusAI.lrdevplugin/APISearchIndex.lua index 61d3861e..8305ef72 100644 --- a/plugin/LrGeniusAI.lrdevplugin/APISearchIndex.lua +++ b/plugin/LrGeniusAI.lrdevplugin/APISearchIndex.lua @@ -235,6 +235,7 @@ local ENDPOINTS = { EDIT_BASE64 = "/v1/edit/recipe/base64", STYLE_EDIT = "/v1/edit/style", EDIT_SCORE = "/v1/edit/score", + EDIT_SESSIONS = "/v1/edit/sessions", -- suffix //render; DELETE / TRAINING_ADD = "/v1/edit/training", TRAINING_LIST = "/v1/edit/training", TRAINING_CLEAR = "/v1/edit/training", -- DELETE /v1/edit/training (all) @@ -1655,9 +1656,10 @@ function SearchIndexAPI.analyzeAndIndexPhoto(photoId, filepath, options) if type(options.original_filename) == "string" and options.original_filename ~= "" then table.insert(mimeChunks, { name = "original_filename", value = options.original_filename }) end - -- Whether the original is raw. Stored with the photo so later work — style - -- training above all, where raw and rendered Temp values are on different - -- scales — does not have to re-derive it. Absent when unknown. + -- Whether the original is raw. Stored with the photo so later work does not + -- have to re-derive it: the edit guardrails, and the style engine, which + -- keeps raw (Kelvin `Temperature`) and rendered (`IncrementalTemperature` + -- offset) white balances apart. Absent when unknown. if type(options.is_raw) == "boolean" then table.insert(mimeChunks, { name = "is_raw", value = tostring(options.is_raw) }) end @@ -2569,9 +2571,10 @@ local function buildPhotoOptions(photo, photoId, options) end -- Stored with the photo so later work does not have to ask the catalog -- again: the same file can be edited from a different session, and the - -- style training in particular needs to keep raw and rendered originals - -- apart because their Temp values are on different scales. Absent when the - -- format could not be read; see Util.isRawPhoto. + -- style training in particular keeps raw and rendered originals apart + -- because their white balances live in different keys on different + -- scales (Kelvin `Temperature` vs an `IncrementalTemperature` offset). + -- Absent when the format could not be read; see Util.isRawPhoto. local isRaw = Util.isRawPhoto(photo) if isRaw ~= nil then photoOptions.is_raw = isRaw @@ -5748,9 +5751,12 @@ function SearchIndexAPI.addTrainingExample(photoId, filepath, developSettings, o table.insert(mimeChunks, { name = "shutter_speed", value = tostring(options.shutter_speed) }) end -- Recorded with the example so the style engine can keep raw and rendered - -- sources apart when it blends a white balance: Lightroom's Temp is Kelvin - -- for one and a relative -100..100 for the other, and averaging the two - -- produces a number that means nothing on either scale. + -- sources apart when it blends a white balance: a raw file stores Kelvin in + -- `Temperature`, a rendered one a -100..100 offset in + -- `IncrementalTemperature`, and averaging the two produces a number that + -- means nothing on either scale. The backend reads the family from those + -- keys in the develop settings first; this is the fallback when neither + -- is present. if type(options.is_raw) == "boolean" then table.insert(mimeChunks, { name = "is_raw", value = tostring(options.is_raw) }) end @@ -6083,6 +6089,104 @@ function SearchIndexAPI.scoreEditedPhoto(filepath) return nil, response.error or "Unexpected response" end +--- +-- Opens an AI Edit retoucher session for one photo (`POST /v1/edit/sessions`). +-- +-- The backend holds the conversation with the AI model; the answer is the +-- first step: a develop table to apply, or the photographer's style as round +-- 0 when `fields.style_recipe` is set. See EditLoop.lua. +-- @param photoId string +-- @param filepath string The photo rendered as it is now. +-- @param fields table Text fields: provider, model, api_key, server_url, +-- look, instructions, language, max_edits, masks, is_raw, develop_settings +-- (JSON), style_recipe (JSON), the llm_* engine settings. +-- @return boolean ok, table|string the step, or what went wrong. +--- +function SearchIndexAPI.startEditSession(photoId, filepath, fields) + if not photoId or photoId == "" then + return false, "No photo ID provided" + end + if not filepath or not LrFileUtils.exists(filepath) then + return false, "No rendered image to start from" + end + local mimeChunks = { { name = "photo_id", value = photoId } } + local catalogId = getCatalogId() + if catalogId then + table.insert(mimeChunks, { name = "catalog_id", value = catalogId }) + end + for key, value in pairs(fields or {}) do + if value ~= nil and tostring(value) ~= "" then + table.insert(mimeChunks, { name = key, value = tostring(value) }) + end + end + table.insert(mimeChunks, { + name = "image", + fileName = LrPathUtils.leafName(filepath), + filePath = filepath, + contentType = "image/jpeg", + }) + -- A local model can take minutes for one answer. + local response, err = _requestMultipart(SearchIndexAPI.url("EDIT_SESSIONS"), mimeChunks, 900) + if not response then + return false, err or "Unknown error" + end + if response.status == "success" then + return true, response + end + return false, response.error or "Unexpected response" +end + +--- +-- Hands the render of the last step back to the session and gets the next +-- step (`POST /v1/edit/sessions/{id}/render`). +-- @param sessionId string +-- @param filepath string The photo rendered with the step applied. +-- @param notes table|nil What Lightroom saw while applying it (an AI mask that +-- found nothing), told to the model. +-- @return boolean ok, table|string the next step, or what went wrong. +--- +function SearchIndexAPI.sendEditRender(sessionId, filepath, notes) + if not filepath or not LrFileUtils.exists(filepath) then + return false, "No rendered image to send" + end + local mimeChunks = {} + if type(notes) == "table" and #notes > 0 then + table.insert(mimeChunks, { name = "lightroom_notes", value = JSON:encode(notes) }) + end + table.insert(mimeChunks, { + name = "image", + fileName = LrPathUtils.leafName(filepath), + filePath = filepath, + contentType = "image/jpeg", + }) + local url = SearchIndexAPI.url("EDIT_SESSIONS", sessionId, "render") + local response, err = _requestMultipart(url, mimeChunks, 900) + if not response then + return false, err or "Unknown error" + end + if response.status == "success" then + return true, response + end + return false, response.error or "Unexpected response" +end + +--- +-- Ends an AI Edit session early (`DELETE /v1/edit/sessions/{id}`). Ending one +-- that has already ended is not an error. +-- @param sessionId string +-- @return boolean ok, string|nil error +--- +function SearchIndexAPI.endEditSession(sessionId) + if not sessionId then + return true, nil + end + local response, err = _request("DELETE", SearchIndexAPI.url("EDIT_SESSIONS", sessionId), nil, 30) + if not response then + return false, err or "Unknown error" + end + return response.status == "success", response.error +end + --- Sends the update manifest and plugin path to the backend to perform a code-only update. --- @param manifest table --- @return boolean success, string|table responseOrError diff --git a/plugin/LrGeniusAI.lrdevplugin/DevelopEditManager.lua b/plugin/LrGeniusAI.lrdevplugin/DevelopEditManager.lua index 6c1b4e5b..2774e521 100644 --- a/plugin/LrGeniusAI.lrdevplugin/DevelopEditManager.lua +++ b/plugin/LrGeniusAI.lrdevplugin/DevelopEditManager.lua @@ -7,8 +7,9 @@ local GLOBAL_KEY_MAP = { shadows = "Shadows2012", whites = "Whites2012", blacks = "Blacks2012", - temperature = "Temp", - tint = "Tint", + -- No temperature/tint here: which Lightroom keys carry a white balance + -- depends on the photo (see buildWhiteBalanceSettings), so a fixed name + -- cannot be right for every file. texture = "Texture", clarity = "Clarity2012", dehaze = "Dehaze", @@ -41,7 +42,7 @@ local MASK_KEY_CANDIDATES = { shadows = { "local_Shadows", "Shadows2012", "Shadows" }, whites = { "local_Whites", "Whites2012", "Whites" }, blacks = { "local_Blacks", "Blacks2012", "Blacks" }, - temperature = { "local_Temperature", "Temperature", "Temp" }, + temperature = { "local_Temperature", "Temperature" }, tint = { "local_Tint", "Tint" }, texture = { "local_Texture", "Texture" }, clarity = { "local_Clarity", "Clarity2012", "Clarity" }, @@ -83,7 +84,7 @@ local HSL_LABELS = { -- edit therefore double-counts that edit. -- -- This used to be an additive merge for most keys, which was catastrophic for --- `Temp` (as-shot 5500 K + a recommended 5600 K produced 11100 K) and silently +-- the white balance (as-shot 5500 K + a recommended 5600 K produced 11100 K) and silently -- wrong wherever Lightroom's default is non-zero: `ColorNoiseReduction` (25), -- `SharpenDetail` (25), `GrainSize`, the vignette midpoints, and the wrapping -- `SplitToning*Hue` angles. @@ -100,11 +101,17 @@ local DEVELOP_VALUE_BOUNDS = { Shadows2012 = { min = -100, max = 100 }, Whites2012 = { min = -100, max = 100 }, Blacks2012 = { min = -100, max = 100 }, - -- Raw only. Lightroom's Temp is Kelvin for raw files and a relative - -- -100..100 for JPEG/TIFF/PNG; see TEMP_BOUNDS_RELATIVE below and - -- `temperature_range` in `edit_recipe.rs`, which has to agree. - Temp = { min = 2000, max = 50000 }, + -- White balance lives in two families of keys, never in `Temp`, which is + -- not what `getDevelopSettings` reports (none of the saved training + -- examples carries it). A raw file carries Kelvin in + -- `Temperature` and an absolute `Tint`; a rendered file (JPEG/TIFF/PNG, and + -- a DNG converted from one) carries offsets from its own rendering in + -- `IncrementalTemperature`/`IncrementalTint`. Each key has one scale, so the + -- bounds no longer depend on the file. + Temperature = { min = 2000, max = 50000 }, Tint = { min = -150, max = 150 }, + IncrementalTemperature = { min = -100, max = 100 }, + IncrementalTint = { min = -100, max = 100 }, Texture = { min = -100, max = 100 }, Clarity2012 = { min = -100, max = 100 }, Dehaze = { min = -100, max = 100 }, @@ -147,16 +154,20 @@ local DEVELOP_VALUE_BOUNDS = { CropAngle = { min = -45, max = 45 }, } --- Non-raw originals use a relative white balance. Clamping such a photo's Temp --- with the Kelvin bounds above turned a sensible `+10` into 2000 — maximum --- cool — because the clamp raised it to the Kelvin minimum. -local TEMP_BOUNDS_RELATIVE = { min = -100, max = 100 } - -- A value this large on the relative scale can only be Kelvin that reached us -- anyway: an older backend, or a model that ignored the schema. Clamping it -- would apply maximum warmth to every affected photo, so it is dropped --- instead. Mirrors `KELVIN_LOOKING` in `edit_recipe.rs`. -local TEMP_KELVIN_LOOKING = 500 +-- instead. Mirrors `KELVIN_LOOKING` in `edit_recipe.rs`. The same threshold +-- read the other way catches a relative value aimed at a raw file, which the +-- Kelvin clamp would otherwise turn into 2000 K, maximum cool. +local KELVIN_LOOKING = 500 + +-- The develop keys that carry a white balance, per family. See the note in +-- DEVELOP_VALUE_BOUNDS. +local WHITE_BALANCE_KEYS = { + raw = { temperature = "Temperature", tint = "Tint" }, + non_raw = { temperature = "IncrementalTemperature", tint = "IncrementalTint" }, +} for _, label in pairs(HSL_LABELS) do DEVELOP_VALUE_BOUNDS["HueAdjustment" .. label] = { min = -100, max = 100 } @@ -461,16 +472,11 @@ local function mergeSettings(target, source) end end --- `isRaw` is only consulted for `Temp`; every other key means the same thing on --- both kinds of file. `nil` keeps the historical Kelvin behaviour. -local function normalizeDevelopValue(key, value, isRaw) +local function normalizeDevelopValue(key, value) if type(value) ~= "number" then return value end local bounds = DEVELOP_VALUE_BOUNDS[key] - if key == "Temp" and isRaw == false then - bounds = TEMP_BOUNDS_RELATIVE - end if not bounds then return value end @@ -527,7 +533,7 @@ local function scaleDevelopSettings(baseline, settings, strength) return scaled end -local function mergeGlobalDevelopSettings(currentSettings, aiSettings, isRaw) +local function mergeGlobalDevelopSettings(currentSettings, aiSettings) local merged = {} -- Start with existing settings to preserve all state, including linked keys -- like crop coordinates and tone curves that aren't being touched by the AI. @@ -541,11 +547,264 @@ local function mergeGlobalDevelopSettings(currentSettings, aiSettings, isRaw) -- key the recipe carries replaces the current value rather than adding to it. -- Keys the recipe does not mention keep whatever the photo already had. for key, value in pairs(aiSettings or {}) do - merged[key] = normalizeDevelopValue(key, value, isRaw) + merged[key] = normalizeDevelopValue(key, value) end return merged end +--- +-- Which family of white-balance keys a photo's develop settings use. +-- +-- Lightroom answers this itself: a raw file's settings carry `Temperature` +-- (Kelvin), a rendered file's carry `IncrementalTemperature` (an offset from +-- its own rendering). The file format is only a fallback, because it is wrong +-- for a DNG converted from a JPEG: Lightroom counts that as a DNG, yet its +-- white balance is the rendered kind. +-- +-- @param settings table|nil The photo's `getDevelopSettings()`, or nil when +-- they could not be read. +-- @param isRawFallback boolean|nil `Util.isRawPhoto(photo)`, used only when the +-- settings carry neither key. +-- @return string|nil "raw", "non_raw", or nil when neither source knows. +-- +function DevelopEditManager.whiteBalanceFamily(settings, isRawFallback) + if type(settings) == "table" then + if settings.Temperature ~= nil then + return "raw" + end + if settings.IncrementalTemperature ~= nil then + return "non_raw" + end + end + if isRawFallback == true then + return "raw" + end + if isRawFallback == false then + return "non_raw" + end + return nil +end + +--- +-- The white-balance family of a photo in the catalog. Needs an async task, +-- because it reads the develop settings. Sent to the backend as `is_raw`, so +-- the style engine picks examples of the same family that this plug-in will +-- later write the result into. +-- +-- @param photo LrPhoto +-- @return string|nil "raw", "non_raw", or nil when unknown. +-- +function DevelopEditManager.photoWhiteBalanceFamily(photo) + if photo == nil then + return nil + end + local ok, settingsOrErr = LrTasks.pcall(function() + return photo:getDevelopSettings() + end) + local settings = nil + if ok and type(settingsOrErr) == "table" then + settings = settingsOrErr + else + log:warn( + "DevelopEditManager.photoWhiteBalanceFamily: develop settings unavailable: " .. tostring(settingsOrErr) + ) + end + return DevelopEditManager.whiteBalanceFamily(settings, Util.isRawPhoto(photo)) +end + +local function familyLabel(family) + if family == "raw" then + return "raw files" + end + return "non-raw files (JPEG, TIFF, PNG, or a DNG converted from one)" +end + +-- One family's temperature/tint pair as develop settings, with +-- `WhiteBalance = "Custom"` so Lightroom shows the numbers it was given rather +-- than a preset label that no longer describes them. Returns an empty table +-- (and says why) when the numbers are on the other family's scale. +local function mapWhiteBalanceValues(family, temperature, tint, warnings) + local keys = WHITE_BALANCE_KEYS[family] + local settings = {} + if not keys then + return settings + end + if type(temperature) == "number" then + local looksKelvin = math.abs(temperature) > KELVIN_LOOKING + if family == "non_raw" and looksKelvin then + -- Clamping 6200 into -100..100 gives +100: a hard orange cast. + -- Leaving the photo's own white balance alone is the lesser harm, + -- and the tint that came with it is on the raw scale too. + appendWarning( + warnings, + "White balance was not applied: the suggestion is a Kelvin value, but this photo is not a raw file, " + .. "where Lightroom expects a relative -100 to +100 value instead." + ) + return {} + end + if family == "raw" and not looksKelvin then + appendWarning( + warnings, + "White balance was not applied: the suggestion is a relative value, but this photo is a raw file, " + .. "where Lightroom expects a temperature in Kelvin." + ) + return {} + end + settings[keys.temperature] = temperature + end + if type(tint) == "number" then + settings[keys.tint] = tint + end + if next(settings) ~= nil then + settings.WhiteBalance = "Custom" + end + return settings +end + +local function warnUnknownFamily(warnings) + appendWarning( + warnings, + "White balance was not applied: Lightroom did not report whether this photo is a raw file, " + .. "so it is unclear which white-balance scale it uses." + ) +end + +-- Develop settings for the recipe's white balance, written into the keys of +-- the photo's own family (`targetFamily`, from whiteBalanceFamily). +-- +-- Two wire forms reach this: +-- * `recipe.white_balance = { mode, family, temperature?, tint? }`, next to +-- `global`/`masks`: what the style engine sends. `family` says which +-- scale the numbers are on; a mismatch with the photo drops them, since a +-- Kelvin value has no meaning as an offset and vice versa. +-- * `recipe.global.temperature`/`tint` (or the LLM's `global.white_balance` +-- alias object): an older backend, or the LLM fallback. These carry no +-- family, so they are read on the photo's own scale. +-- +-- Never writes `Temp`: it is not a key `getDevelopSettings` reports, and its +-- scale changes with the file, which is how a Kelvin value used to land on a +-- JPEG. (TaskDevelopExperiments writes it on purpose, to find out what +-- Lightroom makes of it.) +local function buildWhiteBalanceSettings(recipe, targetFamily, warnings) + local wb = type(recipe) == "table" and recipe.white_balance or nil + if type(wb) == "table" then + local mode = wb.mode + if mode ~= nil and mode ~= "Custom" then + -- A mode without numbers ("Auto", "Daylight", ...) is meant to be + -- re-evaluated by Lightroom for this frame. Whether + -- applyDevelopSettings does that is not verified yet (experiment + -- E1), and the backend does not send such modes until it is; if + -- one arrives anyway it is reported rather than applied blind. + appendWarning( + warnings, + "White balance '" + .. tostring(mode) + .. "' was not applied: this plug-in version only transfers a custom white balance. " + .. "Update the plug-in to transfer this white-balance mode." + ) + return {} + end + if targetFamily == nil then + warnUnknownFamily(warnings) + return {} + end + local family = wb.family + if not WHITE_BALANCE_KEYS[family] then + appendWarning( + warnings, + "White balance was not applied: the backend sent an unrecognised white-balance scale (" + .. tostring(family) + .. "). Update the plug-in and the backend together." + ) + return {} + end + if family ~= targetFamily then + appendWarning( + warnings, + "White balance was not applied: it was learned from " + .. familyLabel(family) + .. ", and this photo is one of the " + .. familyLabel(targetFamily) + .. "." + ) + return {} + end + return mapWhiteBalanceValues(targetFamily, wb.temperature, wb.tint, warnings) + end + + local globalSettings = type(recipe) == "table" and type(recipe.global) == "table" and recipe.global or {} + local temperature = globalSettings.temperature + local tint = globalSettings.tint + if temperature == nil and tint == nil and type(globalSettings.white_balance) == "table" then + temperature = globalSettings.white_balance.temperature + tint = globalSettings.white_balance.tint + end + if type(temperature) ~= "number" and type(tint) ~= "number" then + return {} + end + if targetFamily == nil then + warnUnknownFamily(warnings) + return {} + end + return mapWhiteBalanceValues(targetFamily, temperature, tint, warnings) +end + +local function roundNumber(value) + if value >= 0 then + return math.floor(value + 0.5) + end + return -math.floor(-value + 0.5) +end + +local function signedNumber(value) + local rounded = roundNumber(value) + if rounded > 0 then + return "+" .. tostring(rounded) + end + return tostring(rounded) +end + +--- +-- A white balance as one readable phrase, for the review dialog. +-- +-- @param wb table `{ mode?, family?, temperature?, tint? }`. +-- @return string|nil e.g. "Custom 5600 K, tint +5"; nil for a non-table. +-- +function DevelopEditManager.formatWhiteBalance(wb) + if type(wb) ~= "table" then + return nil + end + local parts = {} + local head = wb.mode ~= nil and tostring(wb.mode) or nil + if type(wb.temperature) == "number" then + local temperature + if wb.family == "raw" then + temperature = tostring(roundNumber(wb.temperature)) .. " K" + elseif wb.family == "non_raw" then + temperature = "temperature " .. signedNumber(wb.temperature) + else + temperature = "temperature " .. tostring(roundNumber(wb.temperature)) + end + if head and wb.family == "raw" then + head = head .. " " .. temperature + elseif head then + table.insert(parts, temperature) + else + head = temperature + end + end + if type(wb.tint) == "number" then + table.insert(parts, "tint " .. signedNumber(wb.tint)) + end + if head then + table.insert(parts, 1, head) + end + if #parts == 0 then + return "unchanged" + end + return table.concat(parts, ", ") +end + local function formatGlobalSettings(globalSettings) local lines = {} for _, key in ipairs(sortedKeys(globalSettings or {})) do @@ -555,10 +814,23 @@ local function formatGlobalSettings(globalSettings) and key ~= "tone_curve" and key ~= "lens_corrections" and key ~= "crop" + and key ~= "white_balance" then - table.insert(lines, "- " .. tostring(key) .. ": " .. tostring(globalSettings[key])) + local value = globalSettings[key] + if type(value) == "table" then + -- A group this dialog has no dedicated line for (a newer + -- backend may send one). Its address is no use to anyone. + table.insert(lines, "- " .. tostring(key) .. ": " .. tostring(tableCount(value)) .. " setting(s)") + else + table.insert(lines, "- " .. tostring(key) .. ": " .. tostring(value)) + end end end + -- The LLM's `{ temperature, tint }` alias; the style engine's white balance + -- sits next to `global` and is shown by formatRecipeDetails. + if type(globalSettings.white_balance) == "table" then + table.insert(lines, "- white_balance: " .. DevelopEditManager.formatWhiteBalance(globalSettings.white_balance)) + end if type(globalSettings.hsl) == "table" then table.insert(lines, "- hsl: " .. tostring(tableCount(globalSettings.hsl)) .. " channel(s)") end @@ -626,6 +898,9 @@ function DevelopEditManager.formatRecipeDetails(response) local globalSettings = recipe.global or {} table.insert(lines, "Global adjustments") local globalLines = formatGlobalSettings(globalSettings) + if type(recipe.white_balance) == "table" then + table.insert(globalLines, 1, "- White balance: " .. DevelopEditManager.formatWhiteBalance(recipe.white_balance)) + end if #globalLines == 0 then table.insert(lines, "- none") else @@ -647,19 +922,49 @@ function DevelopEditManager.formatRecipeDetails(response) end table.insert(lines, "") + -- The recipe's own notes, then the response's (the style engine puts its + -- white-balance and confidence notes there, not in the recipe), so the + -- user sees why a setting was left out before deciding to apply. table.insert(lines, "Warnings") - local warnings = recipe.warnings or {} + local warnings = {} + local seen = {} + local function addWarning(text) + text = text ~= nil and tostring(text) or "" + if text ~= "" and not seen[text] then + seen[text] = true + table.insert(warnings, text) + end + end + for _, warning in ipairs(type(recipe.warnings) == "table" and recipe.warnings or {}) do + addWarning(warning) + end + for _, warning in ipairs(Util.responseWarnings(response)) do + addWarning(warning) + end if #warnings == 0 then table.insert(lines, "- none") else for _, warning in ipairs(warnings) do - table.insert(lines, "- " .. tostring(warning)) + table.insert(lines, "- " .. warning) end end return table.concat(lines, "\n") end +--- The warning for a record persistEditRecipe could not write: one wording, +-- so the run's summary counts every photo it hit under one entry. +function DevelopEditManager.recordFailureWarning(err) + return "The AI Edit record on the photo could not be saved in the catalog (" .. tostring(err) .. ")." +end + +--- +-- Records an edit on the photo's plug-in metadata (aiEditRecipe, aiEditSummary, +-- ...), the catalog's own note of what AI Edit did. +-- @return boolean ok, string|nil why the record could not be written. The +-- edit itself is unaffected either way; callers report a failure as a +-- warning, since a log line is not a report. +--- function DevelopEditManager.persistEditRecipe(photo, response, warnings, status) log:trace("DevelopEditManager.persistEditRecipe: start status=" .. tostring(status)) local okRecipe, recipeOrErr = LrTasks.pcall(function() @@ -667,12 +972,12 @@ function DevelopEditManager.persistEditRecipe(photo, response, warnings, status) end) if not okRecipe then log:error("DevelopEditManager.persistEditRecipe: getRecipeFromResponse failed: " .. tostring(recipeOrErr)) - return + return false, tostring(recipeOrErr) end local recipe = recipeOrErr if not photo or not recipe then log:error("DevelopEditManager.persistEditRecipe: missing photo or recipe") - return + return false, "no recipe to record" end log:trace("DevelopEditManager.persistEditRecipe: recipe resolved, building warnings") @@ -729,12 +1034,15 @@ function DevelopEditManager.persistEditRecipe(photo, response, warnings, status) end) if not okWrite then log:error("DevelopEditManager.persistEditRecipe: catalog write failed: " .. tostring(writeErr)) - return + return false, tostring(writeErr) end log:trace("DevelopEditManager.persistEditRecipe: done warningsCount=" .. tostring(#allWarnings)) + return true, nil end -local function buildDevelopSettings(recipe, warnings, isRaw) +-- `whiteBalanceFamily` is the photo's own family ("raw", "non_raw" or nil), +-- from DevelopEditManager.whiteBalanceFamily. +local function buildDevelopSettings(recipe, warnings, whiteBalanceFamily) local developSettings = {} local globalSettings = recipe.global or {} @@ -745,20 +1053,11 @@ local function buildDevelopSettings(recipe, warnings, isRaw) end end - -- Dropped here rather than clamped in normalizeDevelopValue, because the - -- merge starts from the photo's current settings: removing the key later - -- would take the photo's own white balance with it, whereas never adding it - -- simply leaves the existing value alone. - if isRaw == false and type(developSettings.Temp) == "number" then - if math.abs(developSettings.Temp) > TEMP_KELVIN_LOOKING then - appendWarning( - warnings, - "Ignored the proposed white balance: it is a Kelvin value, and this photo is not a raw file, " - .. "where Lightroom expects a relative -100 to +100 value instead." - ) - developSettings.Temp = nil - end - end + -- A white balance that does not fit the photo is left out here rather than + -- removed after the merge: the merge starts from the photo's current + -- settings, so never adding a key leaves the photo's own white balance + -- alone, whereas removing it later would take that with it. + mergeSettings(developSettings, buildWhiteBalanceSettings(recipe, whiteBalanceFamily, warnings)) -- Respect the RAW profile (Adobe Color, Camera Neutral, ...) so the baseline the -- recipe was solved against is the one Lightroom actually renders. @@ -788,6 +1087,7 @@ DevelopEditManager.internal = { normalizeDevelopValue = normalizeDevelopValue, mergeGlobalDevelopSettings = mergeGlobalDevelopSettings, buildDevelopSettings = buildDevelopSettings, + buildWhiteBalanceSettings = buildWhiteBalanceSettings, scaleDevelopSettings = scaleDevelopSettings, } @@ -830,11 +1130,18 @@ end -- the whole recipe. See `scaleDevelopSettings` for what a partial strength means. local function applyGlobalDevelopSettings(photo, recipe, warnings, scaling) log:trace("DevelopEditManager.applyGlobalDevelopSettings: start") - -- Read from the catalog rather than passed in: the backend was told the + local okCurrent, currentOrErr = LrTasks.pcall(function() + return photo:getDevelopSettings() + end) + local currentSettings = nil + if okCurrent and type(currentOrErr) == "table" then + currentSettings = currentOrErr + end + -- Read from the photo rather than passed in: the backend was told the -- same thing when the recipe was generated, but a recipe can also be - -- re-applied later, and the file is the authority either way. - local isRaw = Util.isRawPhoto(photo) - local developSettings = buildDevelopSettings(recipe, warnings, isRaw) + -- re-applied later, and the photo is the authority either way. + local wbFamily = DevelopEditManager.whiteBalanceFamily(currentSettings, Util.isRawPhoto(photo)) + local developSettings = buildDevelopSettings(recipe, warnings, wbFamily) if scaling and (tonumber(scaling.strength) or 1) < 1 then developSettings = scaleDevelopSettings(scaling.baseline, developSettings, tonumber(scaling.strength)) end @@ -870,21 +1177,20 @@ local function applyGlobalDevelopSettings(photo, recipe, warnings, scaling) return true end - local mergedSettings = developSettings - local okCurrent, currentOrErr + local mergedSettings + -- Merge over the photo as it was before any candidate went on, not over + -- what it carries now: a setting a stronger candidate set and this one + -- skips would otherwise stay behind from the stronger one. + local mergeBase = currentSettings if scaling and type(scaling.baseline) == "table" then - -- Merge over the photo as it was before any candidate went on, not over - -- what it carries now: a setting a stronger candidate set and this one - -- skips would otherwise stay behind from the stronger one. - okCurrent, currentOrErr = true, scaling.baseline - else - okCurrent, currentOrErr = LrTasks.pcall(function() - return photo:getDevelopSettings() - end) + mergeBase = scaling.baseline end - if okCurrent and type(currentOrErr) == "table" then - mergedSettings = mergeGlobalDevelopSettings(currentOrErr, developSettings, isRaw) + if mergeBase then + mergedSettings = mergeGlobalDevelopSettings(mergeBase, developSettings) else + -- Merged onto nothing rather than used as-is, so the values are still + -- clamped to Lightroom's slider ranges. + mergedSettings = mergeGlobalDevelopSettings({}, developSettings) appendWarning( warnings, "Could not read current develop settings for additive merge; AI edits were applied directly." @@ -1481,7 +1787,7 @@ function DevelopEditManager.showValidationDialog(context, photo, response, optio local bind = LrView.bind local share = LrView.share local props = LrBinding.makePropertyTable(context) - props.applyGlobal = next(recipe.global or {}) ~= nil + props.applyGlobal = next(recipe.global or {}) ~= nil or type(recipe.white_balance) == "table" props.applyMasks = (options and options.applyMasks ~= false) and ((recipe.masks and #recipe.masks > 0) or false) props.details = DevelopEditManager.formatRecipeDetails(response) props.engineTypeDisplay = recipe.engine_type or "Style Engine" @@ -1654,7 +1960,10 @@ function DevelopEditManager.applyRecipe(photo, response, options) applyMaskEdits(photo, recipe, warnings) end - DevelopEditManager.persistEditRecipe(photo, response, warnings, "applied") + local recorded, recordErr = DevelopEditManager.persistEditRecipe(photo, response, warnings, "applied") + if not recorded then + appendWarning(warnings, DevelopEditManager.recordFailureWarning(recordErr)) + end log:trace( "DevelopEditManager.applyRecipe: done globalApplied=" .. tostring(globalApplied) diff --git a/plugin/LrGeniusAI.lrdevplugin/DevelopExperiments.lua b/plugin/LrGeniusAI.lrdevplugin/DevelopExperiments.lua new file mode 100644 index 00000000..33a61281 --- /dev/null +++ b/plugin/LrGeniusAI.lrdevplugin/DevelopExperiments.lua @@ -0,0 +1,1815 @@ +--- Pure helpers for `TaskDevelopExperiments.lua`. +-- +-- The task runs controlled experiments inside Lightroom to settle the open +-- questions from the AI Edit XMP research (docs/wiki/Dev-AI-Edit-XMP-Findings.md): +-- which develop keys `applyDevelopSettings` accepts, whether it can add AI masks +-- and transfer a profile `Look`, how plugin presets behave, in which frame the +-- SDK reports dimensions, and what a photo's full develop-settings table holds. +-- +-- Everything in here is free of Lightroom calls so it can be unit tested with +-- busted: building the develop-settings tables the experiments apply, reading +-- the results back into plain data, and rendering the report. + +DevelopExperiments = {} + +local HEX_DIGITS = "0123456789ABCDEF" + +--- Generates a 32-digit upper-case hex id, the format of `CorrectionSyncID` +-- and `MaskSyncID`. +-- +-- The ids must differ between runs: Lightroom may match a preset correction to +-- an existing one by its sync id, and a repeated id would turn "add a mask" +-- into "update the mask from the previous run". +-- +-- @param random function|nil `random(1, 16)` source, `math.random` by default. +-- @return string +function DevelopExperiments.newSyncId(random) + random = random or math.random + local digits = {} + for i = 1, 32 do + local n = random(1, 16) + digits[i] = HEX_DIGITS:sub(n, n) + end + return table.concat(digits) +end + +--- `LocalExposure2012` is stored as EV/4, so +1 EV is 0.25. Every other signed +-- local slider is stored as UI value / 100. +function DevelopExperiments.localExposureFromStops(stops) + return stops / 4 +end + +-- The local keys Adobe's own Adaptive presets write for every correction. The +-- experiment corrections mirror that shape exactly, so a rejected correction +-- cannot be blamed on a key the proven form would have carried. +local LOCAL_KEYS = { + "LocalExposure", + "LocalHue", + "LocalSaturation", + "LocalContrast", + "LocalClarity", + "LocalSharpness", + "LocalBrightness", + "LocalToningHue", + "LocalToningSaturation", + "LocalExposure2012", + "LocalContrast2012", + "LocalHighlights2012", + "LocalShadows2012", + "LocalWhites2012", + "LocalBlacks2012", + "LocalClarity2012", + "LocalDehaze", + "LocalLuminanceNoise", + "LocalMoire", + "LocalDefringe", + "LocalTemperature", + "LocalTint", + "LocalTexture", +} + +--- The AI mask kinds the experiments exercise, in the "compute me" form +-- Adobe's Adaptive presets use: no digests, no geometry, only what to select. +DevelopExperiments.MASKS = { + subject = { subType = 1, maskName = "Subject 1" }, + sky = { subType = 2, maskName = "Sky 1" }, + background = { subType = 0, subCategoryId = 22, maskName = "Background 1" }, + -- People part in preset form: 3 reportedly means "every person" + -- (unconfirmed), 5 = hair. + hair = { subType = 3, subCategoryId = 5, maskName = "Hair" }, +} + +--- Builds one AI mask tool (a `CorrectionMasks` entry). +-- @param mask table One of `DevelopExperiments.MASKS`. +-- @param syncId string The tool's `MaskSyncID`. +function DevelopExperiments.aiMaskTool(mask, syncId) + local tool = { + What = "Mask/Image", + MaskActive = true, + MaskName = mask.maskName, + MaskBlendMode = 0, + MaskInverted = false, + MaskSyncID = syncId, + MaskValue = 1, + MaskVersion = 1, + MaskSubType = mask.subType, + ReferencePoint = "0.500000 0.500000", + ErrorReason = 0, + } + if mask.subCategoryId ~= nil then + tool.MaskSubCategoryID = mask.subCategoryId + end + return tool +end + +--- Builds a `MaskGroupBasedCorrections` entry holding one AI mask. +-- +-- Runtime ids (`CorrectionID`, `MaskID`) and computed fields (`MaskDigest`, +-- `InputDigest`, `FullMaskSize`, ...) are deliberately absent: they are what +-- Lightroom adds when it computes the mask, and their appearance is what the +-- experiments look for. +-- +-- @param spec table `{ name, mask, exposureStops, syncId?, maskSyncId? }`. +-- @param newId function|nil Id source for missing sync ids. +function DevelopExperiments.aiMaskCorrection(spec, newId) + newId = newId or DevelopExperiments.newSyncId + local correction = { + What = "Correction", + CorrectionAmount = 1, + CorrectionActive = true, + CorrectionName = spec.name, + CorrectionSyncID = spec.syncId or newId(), + } + for _, key in ipairs(LOCAL_KEYS) do + correction[key] = 0 + end + correction.LocalExposure2012 = DevelopExperiments.localExposureFromStops(spec.exposureStops or 1) + correction.CorrectionMasks = { + DevelopExperiments.aiMaskTool(spec.mask, spec.maskSyncId or newId()), + } + return correction +end + +--- Builds a `MaskGroupBasedCorrections` entry holding one linear gradient. +-- +-- Gradients need no AI computation, and third-party plugins already write them +-- through `applyDevelopSettings`, so they make a dependable "existing mask" for +-- experiments that ask what happens to masks a photo already has. +-- +-- @param spec table `{ name, exposureStops, zeroX, zeroY, fullX, fullY }`, +-- coordinates normalised to the uncropped sensor-oriented image. +-- @param newId function|nil Id source for the sync ids. +function DevelopExperiments.gradientCorrection(spec, newId) + newId = newId or DevelopExperiments.newSyncId + local correction = { + What = "Correction", + CorrectionAmount = 1, + CorrectionActive = true, + CorrectionName = spec.name, + CorrectionSyncID = spec.syncId or newId(), + } + for _, key in ipairs(LOCAL_KEYS) do + correction[key] = 0 + end + correction.LocalExposure2012 = DevelopExperiments.localExposureFromStops(spec.exposureStops or -1) + correction.CorrectionMasks = { + { + What = "Mask/Gradient", + MaskActive = true, + MaskName = "Linear Gradient 1", + MaskBlendMode = 0, + MaskInverted = false, + MaskSyncID = newId(), + MaskValue = 1, + ZeroX = spec.zeroX or 0.5, + ZeroY = spec.zeroY or 0.6, + FullX = spec.fullX or 0.5, + FullY = spec.fullY or 0.2, + }, + } + return correction +end + +--- Returns a new array: the existing corrections, unchanged, followed by the +-- additions. `applyDevelopSettings` replaces a top-level key wholesale, so the +-- array handed to it has to carry every correction the photo should keep. +function DevelopExperiments.appendCorrections(existing, additions) + local out = {} + if type(existing) == "table" then + for _, correction in ipairs(existing) do + table.insert(out, correction) + end + end + for _, correction in ipairs(additions or {}) do + table.insert(out, correction) + end + return out +end + +--- Finds every correction carrying the given `CorrectionSyncID`. +-- @return table Array of matching corrections (empty when there is none). +function DevelopExperiments.findCorrections(groups, syncId) + local found = {} + if type(groups) ~= "table" then + return found + end + for _, correction in ipairs(groups) do + if type(correction) == "table" and correction.CorrectionSyncID == syncId then + table.insert(found, correction) + end + end + return found +end + +local function isNonZero(value) + if value == nil then + return false + end + local number = tonumber(value) + if number == nil then + return true + end + return number ~= 0 +end + +--- Classifies one mask tool: +-- * "computed" - Lightroom attached a mask bitmap (`MaskDigest`) +-- * "failed" - computed, but nothing was found (`ErrorReason` 1) +-- * "pending" - still only the definition we wrote +-- * "geometric" - not an AI mask at all +function DevelopExperiments.toolState(tool) + if type(tool) ~= "table" or tool.What ~= "Mask/Image" then + return "geometric" + end + if tool.MaskDigest ~= nil and tool.MaskDigest ~= "" then + return "computed" + end + if isNonZero(tool.ErrorReason) then + return "failed" + end + return "pending" +end + +--- Aggregates the states of a correction's AI tools. Returns "missing" for a +-- nil correction so a poll loop can tell "not there" from "not computed yet". +function DevelopExperiments.correctionState(correction) + if type(correction) ~= "table" then + return "missing" + end + local sawPending, sawFailed, sawComputed = false, false, false + for _, tool in ipairs(correction.CorrectionMasks or {}) do + local state = DevelopExperiments.toolState(tool) + if state == "pending" then + sawPending = true + elseif state == "failed" then + sawFailed = true + elseif state == "computed" then + sawComputed = true + end + end + if sawPending then + return "pending" + end + if sawFailed then + return "failed" + end + if sawComputed then + return "computed" + end + return "no-ai-mask" +end + +--- The first non-zero `ErrorReason` among the tools of summarised corrections. +local function firstErrorReason(summary) + for _, correction in ipairs(summary or {}) do + for _, tool in ipairs(correction.tools or {}) do + if isNonZero(tool.errorReason) then + return tool.errorReason + end + end + end + return nil +end + +--- Renders a poll result for a verdict line. "failed" is how Lightroom marks a +-- mask it computed and found nothing for (no person, no sky): the definition +-- was accepted, so the wording must not suggest it was rejected. +-- @param poll table `{ state, seconds, timedOut, summary }` as `pollCorrection` builds it. +function DevelopExperiments.describePoll(poll) + if type(poll) ~= "table" then + return "not polled" + end + local seconds = tostring(poll.seconds) + if poll.state == "unreadable" then + return "unknown - the develop settings could not be read back after " .. seconds .. " s" + end + if poll.state == "canceled" then + return "not finished (run canceled after " .. seconds .. " s)" + end + if poll.state == "failed" then + return string.format( + "computed, nothing found (ErrorReason=%s) after %s s", + tostring(firstErrorReason(poll.summary) or "?"), + seconds + ) + end + if poll.state == "no-ai-mask" then + return "no longer an AI mask - the correction's Mask/Image tool is gone after " .. seconds .. " s" + end + if poll.timedOut then + return "still " .. tostring(poll.state) .. " after " .. seconds .. " s" + end + return tostring(poll.state) .. " after " .. seconds .. " s" +end + +--- Reduces `MaskGroupBasedCorrections` to the fields the report needs. +function DevelopExperiments.summarizeCorrections(groups) + local summary = {} + if type(groups) ~= "table" then + return summary + end + for _, correction in ipairs(groups) do + if type(correction) == "table" then + local tools = {} + for _, tool in ipairs(correction.CorrectionMasks or {}) do + table.insert(tools, { + what = tool.What, + name = tool.MaskName, + subType = tool.MaskSubType, + subCategoryId = tool.MaskSubCategoryID, + state = DevelopExperiments.toolState(tool), + errorReason = tool.ErrorReason, + fullMaskSize = tool.FullMaskSize, + referencePoint = tool.ReferencePoint, + hasMaskId = tool.MaskID ~= nil, + }) + end + table.insert(summary, { + name = correction.CorrectionName, + syncId = correction.CorrectionSyncID, + amount = correction.CorrectionAmount, + localExposure2012 = correction.LocalExposure2012, + hasCorrectionId = correction.CorrectionID ~= nil, + state = DevelopExperiments.correctionState(correction), + tools = tools, + }) + end + end + return summary +end + +--- Copies the listed keys out of a settings table. Missing keys stay missing, +-- so "Lightroom dropped the key" and "the key is 0" remain distinguishable. +function DevelopExperiments.pick(settings, keys) + local out = {} + if type(settings) ~= "table" then + return out + end + for _, key in ipairs(keys) do + if settings[key] ~= nil then + out[key] = settings[key] + end + end + return out +end + +--- Lists the keys whose values differ between two picked subsets, as +-- `{ key = { before = ..., after = ... } }`. A key present on only one side +-- counts as changed. +function DevelopExperiments.diff(before, after, keys) + local changes = {} + for _, key in ipairs(keys) do + local a = before and before[key] + local b = after and after[key] + if a ~= b then + changes[key] = { before = a, after = b } + end + end + return changes +end + +--- Parses Lightroom's dimensions in either form the SDK uses: the raw +-- `{ width = 2304, height = 3072 }` table or the formatted "3072 x 2304". +-- @return number|nil, number|nil width, height +function DevelopExperiments.parseDimensions(value) + if type(value) == "table" then + local w, h = tonumber(value.width), tonumber(value.height) + if w and h then + return w, h + end + return nil, nil + end + if type(value) == "string" then + local w, h = value:match("(%d+)%s*[xX×]%s*(%d+)") + if w and h then + return tonumber(w), tonumber(h) + end + end + return nil, nil +end + +--- Predicts the cropped size from the crop settings and compares it with what +-- Lightroom reports, under every combination of "which way round" the two +-- dimension values are. +-- +-- The model is the one the XMP corpus established (570/570 angled crops): +-- CropLeft/Top and CropRight/Bottom are opposite corners of a rectangle rotated +-- by +CropAngle, normalised to the *sensor-oriented* uncropped image. Here it +-- answers which orientation `getRawMetadata('dimensions')` and +-- `'croppedDimensions'` use at runtime; the corpus only proved it for the +-- catalog columns. +-- +-- @param dims table `{ w, h }` as reported for the whole image. +-- @param cropped table `{ w, h }` as reported for the cropped image. +-- @param crop table `{ left, top, right, bottom, angle }` from the develop settings. +-- @return table Array of `{ dimensions, cropped, predictedW, predictedH, errorPx }`, +-- best (smallest error) first. +function DevelopExperiments.cropModelCheck(dims, cropped, crop) + local left = tonumber(crop.left) or 0 + local top = tonumber(crop.top) or 0 + local right = tonumber(crop.right) or 1 + local bottom = tonumber(crop.bottom) or 1 + local theta = math.rad(tonumber(crop.angle) or 0) + + local results = {} + local dimsVariants = { + { label = "as reported", w = dims.w, h = dims.h }, + { label = "swapped", w = dims.h, h = dims.w }, + } + local croppedVariants = { + { label = "as reported", w = cropped.w, h = cropped.h }, + { label = "swapped", w = cropped.h, h = cropped.w }, + } + for _, d in ipairs(dimsVariants) do + local dx = (right - left) * d.w + local dy = (bottom - top) * d.h + local predictedW = math.cos(theta) * dx + math.sin(theta) * dy + local predictedH = -math.sin(theta) * dx + math.cos(theta) * dy + for _, c in ipairs(croppedVariants) do + local errorPx = math.max(math.abs(predictedW - c.w), math.abs(predictedH - c.h)) + table.insert(results, { + dimensions = d.label, + cropped = c.label, + predictedW = predictedW, + predictedH = predictedH, + errorPx = errorPx, + }) + end + end + -- Deterministic on ties: Lua 5.1's sort is not stable, and a tie is exactly + -- the case `describeCropModel` has to recognise instead of picking one. + table.sort(results, function(a, b) + if a.errorPx ~= b.errorPx then + return a.errorPx < b.errorPx + end + return a.dimensions .. "/" .. a.cropped < b.dimensions .. "/" .. b.cropped + end) + return results +end + +-- Lightroom's orientation codes, as the catalog stores them, mapped to EXIF +-- orientation numbers. Only AB/BC/CD/DA/CB have been seen in real catalogs; +-- the mirrored ones are the natural completion and are unverified. +local ORIENTATION_TO_EXIF = { + AB = 1, + BA = 2, + CD = 3, + DC = 4, + CB = 5, + BC = 6, + AD = 7, + DA = 8, +} + +--- @return number|nil The EXIF orientation for a Lightroom orientation code. +function DevelopExperiments.exifOrientation(code) + if type(code) == "number" then + return code + end + if type(code) ~= "string" then + return nil + end + return ORIENTATION_TO_EXIF[code:upper()] +end + +--- True when the orientation turns the image by 90° (EXIF 5-8), i.e. when +-- sensor and display width/height are swapped. +function DevelopExperiments.isQuarterTurn(code) + local exif = DevelopExperiments.exifOrientation(code) + return exif ~= nil and exif >= 5 and exif <= 8 +end + +--- Interprets `cropModelCheck` for the report. Only a quarter-turned photo can +-- tell sensor from display orientation, and only when the best hypothesis +-- matches to within a few pixels. +-- @return string +function DevelopExperiments.describeCropModel(results, orientationCode, tolerancePx) + tolerancePx = tolerancePx or 3 + local best = results and results[1] + if not best then + return "no crop model result" + end + if best.errorPx > tolerancePx then + return string.format( + "no hypothesis matches (best error %.1f px) - the crop model or the reported sizes are not what we think", + best.errorPx + ) + end + if not DevelopExperiments.isQuarterTurn(orientationCode) then + return string.format( + "crop model matches (error %.1f px); orientation %s cannot tell sensor from display frame", + best.errorPx, + tostring(orientationCode) + ) + end + -- Decide each frame only from the hypotheses that fit. With no crop, or an + -- unrotated crop that keeps the image's aspect ratio, "both as reported" + -- and "both swapped" fit equally well, and picking one would state a frame + -- the data does not show. + local dimsLabels, croppedLabels = {}, {} + for _, result in ipairs(results) do + if result.errorPx <= tolerancePx then + dimsLabels[result.dimensions] = true + croppedLabels[result.cropped] = true + end + end + local function frameOf(labels) + if labels["as reported"] and labels["swapped"] then + return nil + end + return labels["as reported"] and "sensor" or "display" + end + local dimsFrame, croppedFrame = frameOf(dimsLabels), frameOf(croppedLabels) + if not dimsFrame or not croppedFrame then + return string.format( + "ambiguous (error %.1f px): no crop, or an unrotated crop that keeps the aspect ratio, fits sensor and display frames equally - straighten the crop or change its aspect ratio", + best.errorPx + ) + end + return string.format( + "dimensions are reported in %s orientation, croppedDimensions in %s orientation (error %.1f px)", + dimsFrame, + croppedFrame, + best.errorPx + ) +end + +--- True when the running Lightroom is at least `major.minor`. +function DevelopExperiments.versionAtLeast(versionTable, major, minor) + if type(versionTable) ~= "table" then + return false + end + local vMajor = tonumber(versionTable.major) or 0 + local vMinor = tonumber(versionTable.minor) or 0 + if vMajor ~= major then + return vMajor > major + end + return vMinor >= (minor or 0) +end + +local function isArray(t) + local count = 0 + for _ in pairs(t) do + count = count + 1 + end + for i = 1, count do + if t[i] == nil then + return false + end + end + return true +end + +--- Converts any value into plain data the JSON encoder and the Markdown +-- renderer can handle: tables become arrays or string-keyed maps, anything +-- else that is not a string, number or boolean becomes its `tostring`. +function DevelopExperiments.plainValue(value, depth) + depth = depth or 0 + local kind = type(value) + if kind == "string" or kind == "number" or kind == "boolean" or kind == "nil" then + return value + end + if kind ~= "table" then + return tostring(value) + end + if depth >= 8 then + return "" + end + local out = {} + if isArray(value) then + for i, item in ipairs(value) do + out[i] = DevelopExperiments.plainValue(item, depth + 1) + end + return out + end + for key, item in pairs(value) do + out[tostring(key)] = DevelopExperiments.plainValue(item, depth + 1) + end + return out +end + +--- The keys of a table, sorted by their string form. +function DevelopExperiments.sortedKeys(t) + local keys = {} + for key in pairs(t or {}) do + table.insert(keys, key) + end + table.sort(keys, function(a, b) + return tostring(a) < tostring(b) + end) + return keys +end + +local sortedKeys = DevelopExperiments.sortedKeys + +--- Compact, deterministic one-line rendering of plain data for the Markdown +-- report (the JSON file carries the same data in full). +function DevelopExperiments.inlineValue(value, maxLength) + maxLength = maxLength or 2000 + local function render(v) + local kind = type(v) + if kind == "string" then + return string.format("%q", v) + end + if kind ~= "table" then + return tostring(v) + end + local parts = {} + if isArray(v) then + for _, item in ipairs(v) do + table.insert(parts, render(item)) + end + return "[" .. table.concat(parts, ", ") .. "]" + end + for _, key in ipairs(sortedKeys(v)) do + table.insert(parts, tostring(key) .. "=" .. render(v[key])) + end + return "{" .. table.concat(parts, ", ") .. "}" + end + local text = render(value) + if #text > maxLength then + text = text:sub(1, maxLength) .. " ... (truncated, see JSON)" + end + return text +end + +local function isStringList(value) + if type(value) ~= "table" or #value == 0 or not isArray(value) then + return false + end + for _, item in ipairs(value) do + if type(item) ~= "string" then + return false + end + end + return true +end + +--------------------------------------------------------------------------- +-- E1: white balance mode without values +--------------------------------------------------------------------------- + +-- The two white-balance key families. Which one a photo uses decides where a +-- recomputed value has to show up. +local WB_FAMILY_KEYS = { + raw = { temperature = "Temperature", tint = "Tint" }, + ["non-raw"] = { temperature = "IncrementalTemperature", tint = "IncrementalTint" }, +} + +--- The white-balance family of a develop-settings table: "raw" when it holds +-- `Temperature`, "non-raw" when it holds `IncrementalTemperature`, nil when it +-- holds neither. Unlike the file format this is right for a DNG converted from +-- a JPEG, which Lightroom treats as non-raw. +function DevelopExperiments.settingsFamily(settings) + if type(settings) ~= "table" then + return nil + end + if settings.Temperature ~= nil then + return "raw" + end + if settings.IncrementalTemperature ~= nil then + return "non-raw" + end + return nil +end + +-- A Custom white balance no camera or Auto result is likely to land on. A +-- mode-only variant first puts its copy on this, in a history step of its own, +-- so any recomputation shows as a change, and "already on this mode" or "the +-- new mode happens to give the same values" cannot happen. +local WB_PRECONDITIONS = { + raw = { WhiteBalance = "Custom", Temperature = 3000, Tint = 40 }, + ["non-raw"] = { WhiteBalance = "Custom", IncrementalTemperature = -40, IncrementalTint = 40 }, +} + +--- The precondition a mode-only variant writes first, for a white-balance +-- family ("raw" or "non-raw"). A fresh table each call. +function DevelopExperiments.wbPrecondition(family) + local out = {} + for key, value in pairs(WB_PRECONDITIONS[family] or WB_PRECONDITIONS.raw) do + out[key] = value + end + return out +end + +--- True when the readback shows the precondition as written. +function DevelopExperiments.preconditionHeld(precondition, readback) + if type(precondition) ~= "table" or type(readback) ~= "table" then + return false + end + for key, value in pairs(precondition) do + if readback[key] ~= value then + return false + end + end + return true +end + +--- True when the family's temperature or tint differs between two readbacks. +function DevelopExperiments.wbPairChanged(family, before, after) + local keys = WB_FAMILY_KEYS[family] or WB_FAMILY_KEYS.raw + before, after = before or {}, after or {} + return before[keys.temperature] ~= after[keys.temperature] or before[keys.tint] ~= after[keys.tint] +end + +--- Describes what writing only a white-balance mode (no Temperature/Tint) did. +-- +-- @param mode string The `WhiteBalance` value that was written. +-- @param family string "raw" or "non-raw": which key pair to look at. +-- @param before table White-balance keys of the copy before the write. +-- @param after table The same keys at the end of the wait. +-- @param opts table|nil `{ immediate, settle, flatten }`: `immediate` is the +-- readback right after the call; `settle` is `{ seconds, changed, canceled }` +-- from the wait for a change of the pair (`seconds` is when it changed, or +-- how long nothing did); `flatten` says optFlattenAutoNow was passed. +-- @return string +function DevelopExperiments.describeWbModeOutcome(mode, family, before, after, opts) + before = before or {} + after = after or {} + opts = opts or {} + local immediate, settle = opts.immediate, opts.settle + local keys = WB_FAMILY_KEYS[family] or WB_FAMILY_KEYS.raw + local pair = { keys.temperature, keys.tint } + + local changes = {} + for _, key in ipairs(pair) do + if before[key] ~= after[key] then + table.insert(changes, string.format("%s %s -> %s", key, tostring(before[key]), tostring(after[key]))) + end + end + + local parts = {} + local flattened = opts.flatten and after.WhiteBalance ~= mode and #changes > 0 + if after.WhiteBalance == mode then + table.insert(parts, "WhiteBalance = " .. tostring(mode)) + elseif flattened then + -- Resolving Auto synchronously is what the flag asks for; a mode that + -- reads Custom afterwards is that resolution, not a rejection. + table.insert( + parts, + string.format("%s was flattened to WhiteBalance = %s", tostring(mode), tostring(after.WhiteBalance)) + ) + else + table.insert(parts, "WhiteBalance was not taken (reads " .. tostring(after.WhiteBalance) .. ")") + end + + local values + if #changes > 0 then + values = (flattened and "with " or "Lightroom recomputed ") .. table.concat(changes, ", ") + if type(settle) == "table" and settle.changed then + if (tonumber(settle.seconds) or 0) <= 0 then + values = values .. " (already in the readback right after the call)" + else + values = values .. " after " .. tostring(settle.seconds) .. " s" + end + end + else + local within = "" + if type(settle) == "table" then + within = " within " .. tostring(settle.seconds) .. " s" + if settle.canceled then + within = within .. " (run canceled)" + end + end + values = string.format( + "%s/%s not recomputed%s (still %s / %s)", + keys.temperature, + keys.tint, + within, + tostring(before[keys.temperature]), + tostring(before[keys.tint]) + ) + end + if before.WhiteBalance == mode then + values = values .. " - the copy was already on " .. tostring(mode) .. ", so this proves nothing" + end + table.insert(parts, values) + + if type(immediate) == "table" and next(DevelopExperiments.diff(immediate, after, pair)) ~= nil then + table.insert( + parts, + string.format( + "the readback right after the call still showed %s = %s, so Lightroom resolves the mode asynchronously", + keys.temperature, + tostring(immediate[keys.temperature]) + ) + ) + end + + -- The other family appearing would mean Lightroom changed its mind about + -- what kind of file this is; worth a line of its own. + local other = family == "non-raw" and WB_FAMILY_KEYS.raw or WB_FAMILY_KEYS["non-raw"] + for _, key in ipairs({ other.temperature, other.tint }) do + if before[key] == nil and after[key] ~= nil then + table.insert(parts, string.format("%s appeared (%s)", key, tostring(after[key]))) + end + end + return table.concat(parts, "; ") +end + +--------------------------------------------------------------------------- +-- E13: full develop-settings readback +--------------------------------------------------------------------------- + +-- Small all-scalar tables (tone curves, the LensBlur block) are copied with +-- their values: they are defaults worth having. Anything larger is described +-- by its shape only. +local MAX_INLINE_TABLE_ITEMS = 64 + +local function countKeys(t) + local count = 0 + for _ in pairs(t) do + count = count + 1 + end + return count +end + +local function isScalar(value) + local kind = type(value) + return kind == "string" or kind == "number" or kind == "boolean" +end + +--- Describes a table by its shape: `{ type = "table", shape = "array" | +-- "map" | "empty", length, keyCount, values? }`. `values` is present only for +-- small tables whose entries are all scalars. +function DevelopExperiments.describeTable(t) + local keyCount = countKeys(t) + local shape = keyCount == 0 and "empty" or (isArray(t) and "array" or "map") + local out = { type = "table", shape = shape, length = #t, keyCount = keyCount } + if keyCount > 0 and keyCount <= MAX_INLINE_TABLE_ITEMS then + local allScalar = true + for _, value in pairs(t) do + if not isScalar(value) then + allScalar = false + break + end + end + if allScalar then + out.values = DevelopExperiments.plainValue(t) + end + end + return out +end + +--- Summarises a `Look` without copying its `Parameters` (Adobe's profile +-- definition): name, UUID, amount, whether `Parameters` exist and how many +-- keys they have, and the Look's own key list. +-- @return table|nil nil when `look` is not a table. +function DevelopExperiments.summarizeLook(look) + if type(look) ~= "table" then + return nil + end + local params = look.Parameters + local keys = {} + for _, key in ipairs(sortedKeys(look)) do + table.insert(keys, tostring(key)) + end + return { + Name = look.Name, + UUID = look.UUID, + Amount = look.Amount, + Stubbed = look.Stubbed, + isAdobeAdaptive = look.isAdobeAdaptive, + CameraModelRestriction = look.CameraModelRestriction, + hasParameters = type(params) == "table" and next(params) ~= nil, + parameterKeyCount = type(params) == "table" and countKeys(params) or 0, + keys = keys, + } +end + +--- Every top-level key of a `getDevelopSettings()` table: scalar values +-- verbatim, tables summarised (`describeTable`), `Look` via `summarizeLook` +-- and `MaskGroupBasedCorrections` via `summarizeCorrections`. Read from an +-- unedited photo this is Lightroom's default table for its file kind. +function DevelopExperiments.summarizeSettings(settings) + local out = {} + if type(settings) ~= "table" then + return out + end + for key, value in pairs(settings) do + local name = tostring(key) + if isScalar(value) then + out[name] = value + elseif type(value) ~= "table" then + out[name] = tostring(value) + elseif name == "Look" then + local summary = DevelopExperiments.summarizeLook(value) + summary.type = "Look" + out[name] = summary + elseif name == "MaskGroupBasedCorrections" then + out[name] = { + type = "table", + length = #value, + corrections = DevelopExperiments.summarizeCorrections(value), + } + else + out[name] = DevelopExperiments.describeTable(value) + end + end + return out +end + +--------------------------------------------------------------------------- +-- E11: Look transfer +--------------------------------------------------------------------------- + +--- Adobe Raw profiles for the stubbed-Look variants (E11a, E11c), tried in +-- order; the first whose name differs from the photo's current Look is used, +-- so a result cannot be "unchanged" merely because it was already there. +DevelopExperiments.STUB_LOOKS = { + { Name = "Adobe Vivid", UUID = "EA1DE074F188405965EF399C72C221D9" }, + { Name = "Adobe Landscape", UUID = "6F9C877E84273F4E8271E6B91BEB36A1" }, + { Name = "Adobe Color", UUID = "B952C231111CD8E0ECCF14B86BAA7077" }, +} + +--- A stubbed Look without `Parameters`. By default in the form Lightroom's +-- own presets carry it, with `Stubbed = true` (every Look stub in the bundled +-- presets has it); `bare` leaves the flag out, so the difference between the +-- two forms is an answer of its own. +function DevelopExperiments.stubLook(candidate, bare) + local look = { Name = candidate.Name, UUID = candidate.UUID, Amount = 1 } + if not bare then + look.Stubbed = true + end + return look +end + +--- The base profile E11 writes under its Looks. Adobe Raw Looks sit on an +-- "Adobe Standard" profile; a master already on one ("Adobe Standard v2") +-- keeps it, so the Look is the only thing that changes. +function DevelopExperiments.e11CameraProfile(current) + if type(current) == "string" and current:find("^Adobe Standard") then + return current + end + return "Adobe Standard" +end + +--- Why a Look cannot serve as E11b's full Look for a photo, or nil when it +-- can. +-- @param look table|nil The candidate Look. +-- @param currentName string|nil The photo's current `Look.Name`. +-- @param donorCamera string|nil Camera model of the photo the Look comes from. +-- @param targetCamera string|nil Camera model of the photo it would go to. +-- @return string|nil +function DevelopExperiments.lookRejection(look, currentName, donorCamera, targetCamera) + if not DevelopExperiments.isFullLook(look) then + return "no full Look (no Parameters)" + end + if look.Name == currentName then + return "same Look as the photo (" .. tostring(currentName) .. ")" + end + if look.isAdobeAdaptive then + return "an Adobe Adaptive Look, which also needs AILook and an AI update" + end + -- The restriction names Adobe's camera id, not the EXIF model ("Canon EOS + -- R6 Mark II" vs "Canon EOS R6m2"), so it cannot be matched directly. A + -- donor of the same camera model is known to satisfy it. + if look.CameraModelRestriction ~= nil and (donorCamera == nil or donorCamera ~= targetCamera) then + return string.format( + "restricted to %s, and the photo is from %s", + tostring(look.CameraModelRestriction), + tostring(targetCamera) + ) + end + return nil +end + +--- The first donor whose Look passes `lookRejection`. +-- @param donors table Array of `{ photo, camera, look }`. +-- @return table|nil, table the donor, and `{ { photo, reason } }` for every +-- donor passed over. +function DevelopExperiments.pickDonorLook(donors, currentName, targetCamera) + local rejected = {} + for _, donor in ipairs(donors or {}) do + local reason = DevelopExperiments.lookRejection(donor.look, currentName, donor.camera, targetCamera) + if reason == nil then + return donor, rejected + end + table.insert(rejected, { photo = donor.photo, reason = reason }) + end + return nil, rejected +end + +--- The reason E11b's preset fallback found nothing, including presets whose +-- settings could not be read: a failed read must not pass for "no preset has +-- one". +-- @param scan table `{ scanned, failedReads, firstError, found }`. +-- @return string +function DevelopExperiments.describePresetScan(scan) + scan = scan or {} + local text = string.format( + "none of the %d develop presets read carries a usable full Look (Lightroom's bundled presets store Looks as stubs)", + tonumber(scan.scanned) or 0 + ) + if (tonumber(scan.failedReads) or 0) > 0 then + text = text + .. string.format( + "; %d preset(s) could not be read, first error: %s", + scan.failedReads, + tostring(scan.firstError) + ) + end + return text +end + +--- True for a Look that carries its full profile definition: a name and a +-- non-empty `Parameters` table. +function DevelopExperiments.isFullLook(look) + return type(look) == "table" + and type(look.Name) == "string" + and look.Name ~= "" + and type(look.Parameters) == "table" + and next(look.Parameters) ~= nil +end + +--- The first candidate whose Look name differs from `currentName`. +-- @param candidates table Array of candidates. +-- @param currentName string|nil The photo's current `Look.Name`. +-- @param nameOf function|nil Reads a candidate's name; `candidate.Name` by default. +-- @return table|nil +function DevelopExperiments.pickLook(candidates, currentName, nameOf) + nameOf = nameOf or function(candidate) + return candidate.Name + end + for _, candidate in ipairs(candidates or {}) do + if nameOf(candidate) ~= currentName then + return candidate + end + end + return nil +end + +--- Renders a Look state (`{ CameraProfile, look = summarizeLook(...) }`) for +-- a verdict line. +function DevelopExperiments.describeLookState(state) + state = state or {} + local look = state.look + local lookText + if look == nil then + lookText = "no Look" + else + lookText = string.format( + "Look %s (%s, Parameters: %s)", + tostring(look.Name), + tostring(look.UUID), + look.hasParameters and "yes" or "no" + ) + end + return string.format("CameraProfile %s, %s", tostring(state.CameraProfile), lookText) +end + +--- Judges one Look write: "honoured", "ignored", "changed to something else" +-- or "error". +-- +-- @param applied table `{ CameraProfile, look = summarizeLook(written Look) }`. +-- @param before table Look state of the copy before the write. +-- @param after table Look state after the write. +-- @param ok boolean Whether the write gate ran without error. +-- @param err string|nil The error when it did not. +-- @param readErr string|nil A readback error, which makes the result unknown. +-- @return string +function DevelopExperiments.describeLookOutcome(applied, before, after, ok, err, readErr) + if not ok then + return "error: " .. tostring(err) + end + if readErr then + return "applied, but the result is unknown - " .. tostring(readErr) + end + applied, before, after = applied or {}, before or {}, after or {} + local wanted = applied.look or {} + local got = after.look + local was = before.look + + local parts = {} + if got ~= nil and got.Name == wanted.Name and (wanted.UUID == nil or got.UUID == wanted.UUID) then + table.insert(parts, "honoured") + if wanted.hasParameters then + table.insert(parts, got.hasParameters and "Parameters kept" or "Parameters dropped") + elseif got.hasParameters then + table.insert(parts, "Lightroom filled in the profile's Parameters") + else + table.insert(parts, "the Look still has no Parameters - check the profile browser") + end + elseif (got and got.Name) == (was and was.Name) and (got and got.UUID) == (was and was.UUID) then + table.insert(parts, "ignored - the Look is unchanged") + else + table.insert(parts, "changed to something else") + end + if after.CameraProfile ~= applied.CameraProfile then + table.insert(parts, "CameraProfile reads " .. tostring(after.CameraProfile)) + end + table.insert(parts, "now " .. DevelopExperiments.describeLookState(after)) + return table.concat(parts, "; ") +end + +--------------------------------------------------------------------------- +-- E2: timing +--------------------------------------------------------------------------- + +local function seconds(value) + if type(value) ~= "number" then + return "?" + end + return string.format("%.1f", value) +end + +-- "no-ai-mask" is deliberately absent: it means the correction lost its AI +-- tool, which is not a mask being ready and must not enter the runtimes. +local TERMINAL_MASK_STATES = { computed = true, failed = true } + +--- Renders one photo's E2 timing: +-- * source "auto" - Lightroom computed the mask without being asked +-- * source "update" - after an explicit `updateAISettings()` +-- * source "none" - not measured, with `reason` +-- +-- @param timing table `{ source, reason?, state?, updateOk?, +-- updateCallSeconds?, gateWaitSeconds?, updateSeconds?, readySeconds?, +-- waitedBeforeUpdate?, lockedDuringAutoWait? }`. `updateCallSeconds` is the +-- `updateAISettings()` call itself, `gateWaitSeconds` the wait for write +-- access before it, `updateSeconds` both together. `readySeconds` runs from +-- the start of the call (or the end of the apply, for "auto") to the final +-- mask state. `waitedBeforeUpdate` is how long the photo was locked before +-- the update could start, `lockedDuringAutoWait` whether it was ever locked +-- during the unasked wait; either means Lightroom was already computing, so +-- the time is only a lower bound. +-- @return string +function DevelopExperiments.describeTiming(timing) + if type(timing) ~= "table" or timing.source == "none" or timing.source == nil then + return "not measured (" .. tostring(type(timing) == "table" and timing.reason or "no data") .. ")" + end + local state = timing.state + if timing.source == "auto" then + if state == "failed" then + return "without updateAISettings() the mask was computed (nothing found) after " + .. seconds(timing.readySeconds) + .. " s" + end + if state == "no-ai-mask" then + return "without updateAISettings() the AI mask tool disappeared after " + .. seconds(timing.readySeconds) + .. " s" + end + return "without updateAISettings() the mask was ready after " .. seconds(timing.readySeconds) .. " s" + end + + local callSeconds = timing.updateCallSeconds + if type(callSeconds) ~= "number" then + callSeconds = timing.updateSeconds + end + local text = "update call " .. seconds(callSeconds) .. " s" + if type(timing.gateWaitSeconds) == "number" and timing.gateWaitSeconds >= 0.5 then + text = text .. " (after " .. seconds(timing.gateWaitSeconds) .. " s waiting for write access)" + end + if not timing.updateOk then + return text .. ", and it failed" + end + local outcome + if state == "computed" then + outcome = ", mask ready after " .. seconds(timing.readySeconds) .. " s" + elseif state == "failed" then + outcome = ", mask computed (nothing found) after " .. seconds(timing.readySeconds) .. " s" + elseif state == "no-ai-mask" then + return text .. ", the AI mask tool disappeared after " .. seconds(timing.readySeconds) .. " s" + elseif state == "canceled" then + return text .. ", run canceled " .. seconds(timing.readySeconds) .. " s after the update started" + elseif state == "unreadable" then + return text .. ", mask state unreadable" + else + outcome = ", mask still " .. tostring(state) .. " after " .. seconds(timing.readySeconds) .. " s" + end + local waited = tonumber(timing.waitedBeforeUpdate) or 0 + if waited > 0 or timing.lockedDuringAutoWait then + local why = waited > 0 + and ("the photo was locked for " .. seconds(waited) .. " s before the update could start") + or "the photo was locked during the unasked wait" + outcome = outcome .. " - a lower bound: Lightroom was already computing before the update (" .. why .. ")" + end + return text .. outcome +end + +--- True when a timing entry reached a final mask state that counts as a +-- mask runtime ("computed" or "failed" - not "no-ai-mask"). +function DevelopExperiments.timingIsTerminal(timing) + return type(timing) == "table" and TERMINAL_MASK_STATES[timing.state] == true +end + +--- The run-level timing line: one `label: describeTiming(...)` per photo. +-- The first measured photo is marked: the first AI detection in a Lightroom +-- session also loads the model, and E2 is the run's first AI work. +-- @param timings table Array of timing entries, each with a `photo` label. +function DevelopExperiments.describeTimings(timings) + local parts = {} + local tagged = false + for _, timing in ipairs(timings or {}) do + local text = tostring(timing.photo) .. ": " .. DevelopExperiments.describeTiming(timing) + if not tagged and type(timing) == "table" and timing.source ~= "none" and timing.source ~= nil then + text = text .. " (includes loading the AI model if this was the first AI use this session)" + tagged = true + end + table.insert(parts, text) + end + if #parts == 0 then + return "E2 timing: no photo got as far as a mask" + end + return "E2 timing per photo - " .. table.concat(parts, "; ") +end + +--------------------------------------------------------------------------- +-- E4f: deleting a plugin preset file after applying it +--------------------------------------------------------------------------- + +local PRESET_FILE_EXTENSIONS = { xmp = true, lrtemplate = true } + +--- Splits a path at its last "/" or "\". +-- @return string|nil, string the directory (nil when there is no separator) +-- and the leaf name. +function DevelopExperiments.splitPath(path) + local dir, leaf = path:match("^(.*)[/\\]([^/\\]*)$") + if dir == nil then + return nil, path + end + return dir, leaf +end + +local function isWindowsAbsolute(path) + return path:match("^%a:[/\\]") ~= nil or path:match("^\\\\") ~= nil +end + +--- The form paths are compared in: "/" separators, no trailing separator, and +-- lower case for Windows paths, whose file system ignores case. +local function comparablePath(path) + local out = path:gsub("\\", "/"):gsub("/+$", "") + if isWindowsAbsolute(path) then + out = out:lower() + end + return out +end + +--- Decides whether E4f may delete `path`, the file Lightroom reported for the +-- preset it just created. E4f deletes a file in a folder Lightroom shares with +-- every catalog, so the rule is narrow on purpose: +-- * an absolute path without "." or ".." components; +-- * a preset file (.xmp or .lrtemplate) whose name is `presetName`, alone +-- or followed by a suffix that starts with a non-alphanumeric character +-- ("E4f 2" matches "E4f", "E4fx" does not); +-- * not one of `knownFiles`, the preset files the other E4 steps created; +-- * in the same directory as one of `knownFiles`, or inside a folder named +-- "Plugin Develop Presets", where the SDK documents plugin presets live. +-- The caller still has to check that the path is a file, not a directory: +-- `LrFileUtils.delete` removes a directory with everything in it. +-- @return boolean, string|nil true, or false and why not. +function DevelopExperiments.presetFileDeletable(path, presetName, knownFiles) + if type(path) ~= "string" or path == "" or path:find("^error:") then + return false, "Lightroom reported no preset file path (" .. tostring(path) .. ")" + end + if type(presetName) ~= "string" or presetName == "" then + return false, "there is no preset name to match the file against" + end + if path:sub(1, 1) ~= "/" and not isWindowsAbsolute(path) then + return false, "the preset file path is not absolute: " .. path + end + local slashed = path:gsub("\\", "/") + for component in slashed:gmatch("[^/]+") do + if component == "." or component == ".." then + return false, "the preset file path contains a relative component: " .. path + end + end + + local dir, leaf = DevelopExperiments.splitPath(path) + local base, extension = leaf:match("^(.+)%.([^.]+)$") + if base == nil or not PRESET_FILE_EXTENSIONS[extension:lower()] then + return false, "the file is not a preset file (.xmp or .lrtemplate): " .. leaf + end + local rest = base:sub(#presetName + 1) + if base:sub(1, #presetName) ~= presetName or (rest ~= "" and rest:match("^%w")) then + return false, string.format("the file name %q does not match the preset name %q", leaf, presetName) + end + + local target = comparablePath(path) + local targetDir = comparablePath(dir) + local nextToKnown = false + for _, known in ipairs(knownFiles or {}) do + if type(known) == "string" and known ~= "" then + if comparablePath(known) == target then + return false, "the path is the preset file of another experiment step: " .. path + end + local knownDir = DevelopExperiments.splitPath(known) + if knownDir ~= nil and comparablePath(knownDir) == targetDir then + nextToKnown = true + end + end + end + local inPluginFolder = false + for component in dir:gsub("\\", "/"):gmatch("[^/]+") do + if component:lower() == "plugin develop presets" then + inPluginFolder = true + end + end + if not nextToKnown and not inPluginFolder then + return false, + "the file is neither next to the other experiment preset files nor inside a 'Plugin Develop Presets' folder: " + .. path + end + return true, nil +end + +local function yesNo(value) + return value and "yes" or "no" +end + +--- Reads one numeric develop setting out of a preset file's text: the XMP +-- attribute (`crs:Contrast2012="+15"`), the XMP element +-- (`+15`) or the `.lrtemplate` field +-- (`Contrast2012 = 15,`). The key must stand on its own, so `Contrast2012` +-- does not match `crs:LocalContrast2012`. +-- @return number|nil nil when the key is not in the text. +function DevelopExperiments.presetFileSetting(content, key) + if type(content) ~= "string" or type(key) ~= "string" or key == "" then + return nil + end + local text = " " .. content + local value = text:match("[:%s]" .. key .. '%s*=%s*"?%s*([%+%-]?[%d%.]+)') + or text:match("%s*([%+%-]?[%d%.]+)%s*") + return value and tonumber(value) or nil +end + +--- Whether a mask state counts as computed: "computed", or "failed", which is +-- how Lightroom marks a mask it computed and found nothing for. +local function isComputedState(state) + return state == "computed" or state == "failed" +end + +local function maskWord(found) + return found and "present" or "missing" +end + +--- Joins paths for a verdict line, without repeats and at most three of them. +local function listPaths(paths) + local unique, seen = {}, {} + for _, path in ipairs(paths) do + if not seen[path] then + seen[path] = true + table.insert(unique, path) + end + end + local shown = {} + for index = 1, math.min(#unique, 3) do + table.insert(shown, unique[index]) + end + local text = table.concat(shown, ", ") + if #unique > 3 then + text = text .. string.format(" and %d more", #unique - 3) + end + return text +end + +local function describeDelete(d) + if d.skipped then + return "was not deleted - " .. tostring(d.skipped) + end + if not d.ok then + return "could not be deleted - " .. tostring(d.error) + end + if d.existsAfter then + return "is still there, although LrFileUtils.delete reported success" + end + return "was deleted" +end + +local function describeSecondApply(s) + if s.copyError then + return "not tested (no second virtual copy: " .. tostring(s.copyError) .. ")" + end + if not s.ok then + return "fails - " .. tostring(s.error) + end + if s.readError then + return "no error, but the result is unknown - " .. tostring(s.readError) + end + local text + if s.contrast == s.expectedContrast and s.maskFound then + text = string.format("still works (Contrast2012 %s, subject mask present)", tostring(s.contrast)) + elseif s.contrast == s.expectedContrast then + text = string.format("works partially (Contrast2012 yes (%s), subject mask missing)", tostring(s.contrast)) + else + text = string.format( + "has no effect (Contrast2012 %s, the preset holds %s; subject mask %s)", + tostring(s.contrast), + tostring(s.expectedContrast), + maskWord(s.maskFound) + ) + end + if s.fileBack then + text = text .. ", and it wrote the preset file again" + end + return text +end + +--- What the presets of the E4f name listed before this run say. The three +-- cases answer different questions: a listed preset without its file is the +-- evidence that Lightroom keeps a deleted plugin preset somewhere else (when it +-- was restarted since the delete); one with its file is a leftover or a file +-- written back at quit; none at all after a restart means Lightroom forgot it. +local function describeBefore(r, add) + local before = r.before + if type(before) ~= "table" then + return + end + local name = tostring(r.name) + local missing, present, noPath = {}, {}, 0 + for _, entry in ipairs(before) do + local file = type(entry) == "table" and entry.file or nil + if type(file) ~= "string" or file == "" or file:find("^error:") then + noPath = noPath + 1 + elseif entry.fileExists then + table.insert(present, file) + else + table.insert(missing, file) + end + end + if #missing + #present + noPath == 0 then + add( + string.format( + "E4f no preset named %q was listed before this run. If an earlier E4f run deleted its file and Lightroom was restarted since, Lightroom forgot the deleted preset at the restart.", + name + ) + ) + return + end + if #missing > 0 then + add( + string.format( + "E4f %d preset(s) named %q were listed before this run although no file exists at their path (%s). If an earlier E4f run deleted that file and Lightroom was restarted since, Lightroom keeps deleted plugin presets somewhere other than the file; without a restart in between it is only the list Lightroom holds in memory.", + #missing, + name, + listPaths(missing) + ) + ) + end + if #present > 0 then + add( + string.format( + "E4f %d preset(s) named %q were listed before this run with their file present (%s): left over from an earlier run that stopped before its delete, or written back by Lightroom at quit. This run writes and deletes a file of the same name, so that file may be gone afterwards; the paths are recorded here and in the report.", + #present, + name, + listPaths(present) + ) + ) + end + if noPath > 0 then + add( + string.format( + "E4f %d preset(s) named %q were listed before this run without a readable file path (preset:getFile() failed).", + noPath, + name + ) + ) + end +end + +--- The VIABLE / NOT viable line, judged on what actually arrived: the +-- contrast when it arrived, the subject mask when it arrived. +local function describeViability(r, a, first) + local contrastArrived = a.contrast == r.expectedContrast + local maskComputedBefore = isComputedState(a.maskPollState) + local contrastKept = not contrastArrived or first.contrast == a.contrast + -- A mask that was computed before the delete and is only a definition + -- (or gone) after it lost its computation. + local maskKept = not a.maskFound + or ( + first.maskFound + and not (maskComputedBefore and first.maskState ~= nil and not isComputedState(first.maskState)) + ) + local states = string.format( + "mask state before the delete: %s, after: %s", + tostring(a.maskState or "not polled"), + tostring(first.maskState or "not read") + ) + if contrastKept and maskKept then + local text = string.format( + "E4f apply then delete is VIABLE within this session (restart behaviour is a manual check): the preset file is gone and the first copy kept its edit (Contrast2012 %s, subject mask %s; %s).", + tostring(first.contrast), + maskWord(first.maskFound), + states + ) + if a.maskFound and not maskComputedBefore then + text = text + .. string.format( + " The subject mask was still pending, not computed, when the file was deleted (%s), so the verdict covers the settings only; it does not show that a computed mask survives the delete.", + tostring(a.maskState or "not polled") + ) + end + return text + end + return string.format( + "E4f apply then delete is NOT viable: deleting the preset file changed the copy it was applied to (Contrast2012 %s -> %s, subject mask %s -> %s, %s).", + tostring(a.contrast), + tostring(first.contrast), + maskWord(a.maskFound), + maskWord(first.maskFound), + states + ) +end + +local function describeReadd(r, readd) + local text + if not readd.ok then + return "E4f re-adding the same name after the delete failed - " .. tostring(readd.error) .. "." + end + local expected = readd.expectedContrast + local fileText + if readd.fileExists then + text = string.format( + "E4f re-adding the same name created a file again at %s (same path as the deleted file: %s; same uuid: %s)", + tostring(readd.file), + yesNo(readd.samePath), + yesNo(readd.sameUuid) + ) + if readd.fileContrast ~= nil then + fileText = "the file holds " .. tostring(readd.fileContrast) + else + fileText = "no Contrast2012 found in the file" + end + else + text = string.format( + "E4f re-adding the same name returned a preset, but no file exists at its path %s (same uuid: %s)", + tostring(readd.file), + yesNo(readd.sameUuid) + ) + end + if expected ~= nil then + local holdsNew = readd.contrast == expected and readd.fileHasNewContrast ~= false + text = text + .. string.format( + "; holds the new settings (Contrast2012 %s, the deleted preset held %s): %s (getSetting reads %s%s)", + tostring(expected), + tostring(r.expectedContrast), + yesNo(holdsNew), + tostring(readd.contrast), + fileText and (", " .. fileText) or "" + ) + end + if readd.namedCount ~= nil then + text = text .. string.format("; presets of that name now listed: %s", tostring(readd.namedCount)) + end + if readd.fileExists and type(readd.cleanup) == "table" then + local cleanup = readd.cleanup + local deletedAgain = not cleanup.skipped and cleanup.ok and not cleanup.existsAfter + text = text .. "; the re-added file " .. describeDelete(cleanup) + if not deletedAgain then + text = text .. " and stays in the list of preset files to delete by hand" + end + end + return text .. "." +end + +--- Everything `describeE4f` says except the note on a canceled run. +local function describeE4fParts(r) + local lines = {} + local function add(line) + table.insert(lines, line) + end + describeBefore(r, add) + if type(r.create) ~= "table" then + return lines + end + if not r.create.ok then + add("E4f not run: the preset could not be created - " .. tostring(r.create.error)) + return lines + end + local a = r.apply + if type(a) ~= "table" then + return lines + end + if not a.ok then + add("E4f not run: applying the preset to the first copy failed - " .. tostring(a.error)) + return lines + end + if a.readError then + add("E4f inconclusive: the first copy could not be read back after the apply - " .. tostring(a.readError)) + return lines + end + + local d = r.delete + local deleted = type(d) == "table" and not d.skipped and d.ok and not d.existsAfter + local after = r.after + local first = deleted and type(after) == "table" and type(after.first) == "table" and after.first or nil + local judged = first ~= nil and not first.readError + + -- A preset that changed nothing leaves no edit for the delete to keep, so + -- an unchanged copy afterwards proves nothing. + local contrastArrived = a.contrast == r.expectedContrast + local applied = contrastArrived or a.maskFound + local noEffect = string.format( + "the preset had no visible effect on the first copy (Contrast2012 %s, subject mask missing)", + tostring(a.contrast) + ) + if not applied and not judged then + add("E4f " .. noEffect .. ".") + elseif applied and not contrastArrived then + add( + string.format( + "E4f the preset's Contrast2012 did not arrive on the first copy (read back %s, the preset holds %s); the subject mask did, so the verdict rests on the mask alone.", + tostring(a.contrast), + tostring(r.expectedContrast) + ) + ) + end + + if type(d) ~= "table" then + return lines + end + if not deleted then + add( + "E4f inconclusive: the preset file " + .. describeDelete(d) + .. ". It stays in the list of preset files to delete by hand." + ) + end + + if first ~= nil then + if first.readError then + add( + "E4f inconclusive: the first copy could not be read back after the delete - " + .. tostring(first.readError) + ) + elseif not applied then + add("E4f inconclusive: " .. noEffect .. ", so there was no edit for the delete to preserve.") + else + add(describeViability(r, a, first)) + end + end + + if deleted and type(after) == "table" then + local setting = after.setting or {} + local settingText + if setting.ok then + settingText = "works (Contrast2012 " .. tostring(setting.contrast) .. ")" + else + settingText = "fails - " .. tostring(setting.error) + end + local secondText = "not tested (run stopped)" + if type(r.second) == "table" then + local second = {} + for key, value in pairs(r.second) do + second[key] = value + end + second.expectedContrast = r.expectedContrast + secondText = describeSecondApply(second) + end + add( + string.format( + "E4f until Lightroom restarts, after the file was deleted: getDevelopPresetsForPlugin still lists it: %s (%s preset(s) of that name, same uuid listed: %s); lookup by uuid: %s; preset:getSetting(): %s; applying the deleted preset to a second copy: %s. What a restart changes is a manual check: the 'Plugin Develop Presets' folder, and E4 run again.", + yesNo(after.listedByUuid or (tonumber(after.namedCount) or 0) > 0), + tostring(after.namedCount), + yesNo(after.listedByUuid), + tostring(after.lookup), + settingText, + secondText + ) + ) + end + + if type(r.readd) == "table" then + add(describeReadd(r, r.readd)) + end + return lines +end + +--- Turns what E4f observed into verdict lines. +-- +-- @param r table Built by the task step by step; a part is nil when the run +-- stopped (canceled or failed) before it: +-- `{ name, expectedContrast, +-- before = { { uuid, file, fileExists } }, -- presets of that name listed first +-- create = { ok, error }, +-- apply = { ok, error, readError, contrast, maskFound, maskState, +-- maskPollState }, -- maskPollState: the raw poll state +-- delete = { skipped, ok, error, existsAfter }, +-- after = { namedCount, listedByUuid, lookup, setting = { ok, error, +-- contrast }, first = { readError, contrast, maskFound, maskState } }, +-- second = { copyError, ok, error, readError, contrast, maskFound, fileBack }, +-- readd = { ok, error, file, fileExists, samePath, sameUuid, contrast, +-- expectedContrast, fileContrast, fileHasNewContrast, namedCount, +-- cleanup = { skipped, ok, error, existsAfter } }, +-- canceled }` +-- @return table Array of verdict strings. +function DevelopExperiments.describeE4f(r) + if type(r) ~= "table" then + return {} + end + local lines = describeE4fParts(r) + if r.canceled then + table.insert( + lines, + "E4f not finished: the run was canceled. A preset file it created and did not delete is in the list of files to delete by hand." + ) + end + return lines +end + +local HEADLINE_WORDS = { "VIABLE", "NOT viable", "inconclusive", "not run", "not finished" } + +--- Whether a verdict is an experiment's answer that the final dialog must +-- show even past its cap: today the E4f outcome, which E4 always produces +-- after a dozen other verdicts. +function DevelopExperiments.isHeadlineVerdict(verdict) + if type(verdict) ~= "string" or verdict:sub(1, 4) ~= "E4f " then + return false + end + for _, word in ipairs(HEADLINE_WORDS) do + if verdict:find(word, 1, true) then + return true + end + end + return false +end + +--- Picks the verdicts the final dialog shows: the first `cap`, plus every +-- headline verdict after them. +-- @return table, number The verdicts to show and how many are left out. +function DevelopExperiments.summaryVerdicts(verdicts, cap) + local shown, hidden = {}, 0 + for index, verdict in ipairs(verdicts or {}) do + if index <= cap or DevelopExperiments.isHeadlineVerdict(verdict) then + table.insert(shown, verdict) + else + hidden = hidden + 1 + end + end + return shown, hidden +end + +--- Renders the collected results as Markdown. +-- +-- @param report table `{ meta = {...}, experiments = { { id, title, question, +-- verdicts = {}, manualChecks = {}, steps = { { photo, label, ok, error, data } } } } }` +-- @return string +function DevelopExperiments.renderMarkdown(report) + local lines = {} + local function add(line) + table.insert(lines, line or "") + end + + add("# LrGeniusAI develop-settings experiments") + add() + for _, key in ipairs(sortedKeys(report.meta or {})) do + local value = report.meta[key] + if isStringList(value) then + -- Lists of names and paths (photos, preset files to delete) are + -- rendered verbatim: quoting doubles Windows backslashes, and + -- truncation would cut the very paths the user has to act on. + add(string.format("- **%s:**", tostring(key))) + for _, item in ipairs(value) do + add(" - " .. item) + end + else + add(string.format("- **%s:** %s", tostring(key), DevelopExperiments.inlineValue(value, 400))) + end + end + + for _, experiment in ipairs(report.experiments or {}) do + add() + add(string.format("## %s - %s", experiment.id, experiment.title)) + add() + add("**Question:** " .. tostring(experiment.question)) + add() + add("### Verdicts") + add() + if #(experiment.verdicts or {}) == 0 then + add("- (none - see the steps below)") + end + for _, verdict in ipairs(experiment.verdicts or {}) do + add("- " .. verdict) + end + if #(experiment.manualChecks or {}) > 0 then + add() + add("### Check by hand in Lightroom") + add() + for _, check in ipairs(experiment.manualChecks) do + add("- [ ] " .. check) + end + end + add() + add("### Steps") + for _, step in ipairs(experiment.steps or {}) do + add() + local status = step.ok and "ok" or "FAILED" + add(string.format("#### %s - %s (%s)", tostring(step.photo), tostring(step.label), status)) + if step.error then + add() + add("Error: `" .. tostring(step.error) .. "`") + end + if step.data ~= nil then + add() + add("```") + add(DevelopExperiments.inlineValue(step.data)) + add("```") + end + end + end + add() + return table.concat(lines, "\n") +end + +return DevelopExperiments diff --git a/plugin/LrGeniusAI.lrdevplugin/EditLoop.lua b/plugin/LrGeniusAI.lrdevplugin/EditLoop.lua new file mode 100644 index 00000000..132e987a --- /dev/null +++ b/plugin/LrGeniusAI.lrdevplugin/EditLoop.lua @@ -0,0 +1,535 @@ +-- AI Edit's retoucher loop, Lightroom side. +-- +-- The backend holds the conversation with the AI model (`/v1/edit/sessions`, +-- server-rs/crates/lrg-api/src/edit_loop.rs). Lightroom does what only it can: +-- apply each step's develop table, compute the AI masks in it, render the +-- result and hand the render back, until the model says the photo is done. +-- +-- Every step the backend sends is the complete edit measured from the photo's +-- starting point, not a change on top of the previous step. So each round is +-- applied as: the starting value of every key an earlier round set and this +-- one does not, then this round's values. Masks follow the same rule by +-- `CorrectionSyncID`: the backend derives the id from the mask's target, so +-- "LrGenius · Sky" in round 2 replaces round 1's, and the photo's own masks +-- (the ones it had before) are kept as Lightroom gave them to us — passing +-- them back raw keeps their computed state, which the backend's writer could +-- not. +-- +-- Masks are written into the table and computed by `photo:updateAISettings()` +-- (Lightroom 15.3 and later), the route the develop-settings experiments +-- settled (docs/wiki/Dev-AI-Edit-XMP-Findings.md, E2). + +EditLoop = {} + +-- Long edge of every render the model sees, the original's included, so the +-- measurements of one compare with the next. The backend scales anything +-- larger down to this anyway. +EditLoop.RENDER_EDGE = 1536 + +-- How long a round waits for Lightroom to compute its AI masks. +EditLoop.MASK_WAIT_SECONDS = 30 + +-- How long a round waits for a photo another AI operation holds. +EditLoop.AVAILABLE_WAIT_SECONDS = 30 + +local MASKS_KEY = "MaskGroupBasedCorrections" + +--- +-- Whether this Lightroom can write AI masks through develop settings: +-- `photo:updateAISettings()` arrived with SDK 15.3. +-- @param versionTable table|nil `LrApplication.versionTable()`. +-- @return boolean +--- +function EditLoop.supportsMasks(versionTable) + if type(versionTable) ~= "table" then + return false + end + local major = tonumber(versionTable.major) or 0 + local minor = tonumber(versionTable.minor) or 0 + return major > 15 or (major == 15 and minor >= 3) +end + +--- +-- The table to apply for one round. Pure. +-- +-- @param baseline table The photo's `getDevelopSettings()` before the loop. +-- @param roundTable table The step's `develop_settings`. +-- @param state table `{ touched = {key = true}, ownSyncIds = {id = true} }`, +-- carried from round to round and updated here: the keys and the mask ids +-- any round so far has written. +-- @return table What `applyDevelopSettings` gets. +--- +function EditLoop.composeApply(baseline, roundTable, state) + baseline = baseline or {} + roundTable = roundTable or {} + state.touched = state.touched or {} + state.ownSyncIds = state.ownSyncIds or {} + + local out = {} + for key in pairs(state.touched) do + if roundTable[key] == nil and baseline[key] ~= nil then + out[key] = baseline[key] + end + end + for key, value in pairs(roundTable) do + if key ~= MASKS_KEY then + out[key] = value + state.touched[key] = true + end + end + + local roundMasks = type(roundTable[MASKS_KEY]) == "table" and roundTable[MASKS_KEY] or {} + for _, correction in ipairs(roundMasks) do + if type(correction) == "table" and correction.CorrectionSyncID then + state.ownSyncIds[correction.CorrectionSyncID] = true + end + end + if next(state.ownSyncIds) ~= nil then + local merged = {} + local own = type(baseline[MASKS_KEY]) == "table" and baseline[MASKS_KEY] or {} + for _, correction in ipairs(own) do + if type(correction) ~= "table" or not state.ownSyncIds[correction.CorrectionSyncID] then + table.insert(merged, correction) + end + end + for _, correction in ipairs(roundMasks) do + table.insert(merged, correction) + end + out[MASKS_KEY] = merged + end + return out +end + +--- +-- The table that takes the photo back to where it started: the starting +-- value of every key the loop wrote, and the photo's own masks. Pure. +-- @param baseline table +-- @param state table As for composeApply. +-- @return table +--- +function EditLoop.composeRevert(baseline, state) + return EditLoop.composeApply(baseline, {}, state) +end + +--- +-- What Lightroom has made of one AI mask correction. Pure. +-- +-- Read the way the experiments read it: a component with a `MaskDigest` has +-- its bitmap, one with a non-zero `ErrorReason` was computed and found +-- nothing, anything else is still only the definition we wrote. +-- @param correction table|nil One entry of `MaskGroupBasedCorrections`. +-- @return string "computed", "nothing_found", "pending" or "missing". +--- +function EditLoop.maskState(correction) + if type(correction) ~= "table" then + return "missing" + end + local pending, failed, computed = false, false, false + for _, mask in ipairs(correction.CorrectionMasks or {}) do + if type(mask) == "table" and mask.What == "Mask/Image" then + if mask.MaskDigest ~= nil and mask.MaskDigest ~= "" then + computed = true + elseif tonumber(mask.ErrorReason) ~= nil and tonumber(mask.ErrorReason) ~= 0 then + failed = true + else + pending = true + end + end + end + if pending then + return "pending" + end + if failed and not computed then + return "nothing_found" + end + return "computed" +end + +--- +-- The state of each of this round's masks on the photo. Pure. +-- @param settings table The photo's `getDevelopSettings()` after applying. +-- @param roundTable table The step's `develop_settings`. +-- @return table `{ { name = "LrGenius · Sky", state = "computed" }, ... }` +--- +function EditLoop.roundMaskStates(settings, roundTable) + local byId = {} + local onPhoto = type(settings) == "table" and settings[MASKS_KEY] or nil + for _, correction in ipairs(type(onPhoto) == "table" and onPhoto or {}) do + if type(correction) == "table" and correction.CorrectionSyncID then + byId[correction.CorrectionSyncID] = correction + end + end + local states = {} + local roundMasks = type(roundTable) == "table" and roundTable[MASKS_KEY] or nil + for _, correction in ipairs(type(roundMasks) == "table" and roundMasks or {}) do + table.insert(states, { + name = correction.CorrectionName or "a mask", + state = EditLoop.maskState(byId[correction.CorrectionSyncID]), + }) + end + return states +end + +--- +-- What the model should hear about this round's masks. Pure. +-- @param states table From roundMaskStates. +-- @return table List of sentences. +--- +function EditLoop.maskNotes(states) + local notes = {} + for _, entry in ipairs(states or {}) do + local name = tostring(entry.name):gsub("^LrGenius · ", "") + if entry.state == "nothing_found" then + table.insert( + notes, + "Lightroom's " .. name .. " mask found nothing in this photo, so its adjustments do nothing." + ) + elseif entry.state == "pending" or entry.state == "missing" then + table.insert( + notes, + "Lightroom had not finished computing the " .. name .. " mask when this render was made." + ) + end + end + return notes +end + +-- Lightroom-facing from here on. + +local function sleepUntil(seconds, check) + local waited = 0 + while waited < seconds do + if check() then + return true + end + LrTasks.sleep(0.5) + waited = waited + 0.5 + end + return check() +end + +local function readSettings(photo) + local ok, settingsOrErr = LrTasks.pcall(function() + return photo:getDevelopSettings() + end) + if ok and type(settingsOrErr) == "table" then + return settingsOrErr, nil + end + return nil, tostring(settingsOrErr) +end + +local function waitUntilAvailable(photo) + return sleepUntil(EditLoop.AVAILABLE_WAIT_SECONDS, function() + local ok, available = LrTasks.pcall(function() + return photo:isAvailableForEditing() + end) + -- Older Lightroom has no such call and no such lock. + return not ok or available ~= false + end) +end + +--- +-- Applies a table in one history step and, when it carries AI masks, has +-- Lightroom compute them. +-- @return boolean ok, string|nil error, table notes for the model +--- +local function applyTable(photo, tbl, updateAI, historyName, roundTable) + if not waitUntilAvailable(photo) then + return false, "the photo stayed locked by another AI operation in Lightroom", {} + end + local catalog = LrApplication.activeCatalog() + local ok, statusOrErr = LrTasks.pcall(function() + return catalog:withWriteAccessDo(historyName, function() + photo:applyDevelopSettings(tbl, historyName) + end, Defaults.catalogWriteAccessOptions) + end) + if not ok then + return false, tostring(statusOrErr), {} + end + if statusOrErr == "aborted" then + return false, "Lightroom did not grant catalog write access in time", {} + end + if not updateAI then + return true, nil, {} + end + + local okUpdate, updateErr = LrTasks.pcall(function() + catalog:withWriteAccessDo("AI Edit: compute masks", function() + photo:updateAISettings() + end, Defaults.catalogWriteAccessOptions) + end) + if not okUpdate then + log:warn("EditLoop: updateAISettings failed: " .. tostring(updateErr)) + return true, + nil, + { "Lightroom could not compute this round's AI masks, so they do nothing (" .. tostring(updateErr) .. ")." } + end + local states + sleepUntil(EditLoop.MASK_WAIT_SECONDS, function() + local settings = readSettings(photo) + states = EditLoop.roundMaskStates(settings, roundTable) + for _, entry in ipairs(states) do + if entry.state == "pending" or entry.state == "missing" then + return false + end + end + return true + end) + return true, nil, EditLoop.maskNotes(states) +end + +local function render(photo) + local paths, failures = SearchIndexAPI.exportPhotosForIndexing({ photo }, { + LR_size_maxHeight = EditLoop.RENDER_EDGE, + }) + local path = paths[photo] + if path then + return path, nil + end + return nil, tostring(failures[photo] or "Lightroom could not render the photo") +end + +local function removeFile(path) + LrTasks.pcall(function() + if path and LrFileUtils.exists(path) then + LrFileUtils.delete(path) + end + end) +end + +local function appendAll(list, items) + for _, item in ipairs(items or {}) do + if type(item) == "string" and item ~= "" then + table.insert(list, item) + end + end +end + +--- +-- Runs the loop on one photo. +-- +-- Leaves the photo carrying the round the session ended on. When nothing +-- could be applied the photo is untouched; when the session broke off after +-- a round was applied, that round stays and the error is a warning. +-- +-- @param args table +-- `photo`, `photoId`, +-- `fields` (the session's request fields, see SearchIndexAPI.startEditSession), +-- `styleRecipe` (table|nil, the style engine's `edit` for round 0), +-- `isCanceled` (function -> boolean), `onProgress` (function(caption)). +-- @return table `{ applied = boolean, error = string|nil, canceled = boolean, +-- keptRound = n|nil, verdict = string|nil, rounds = { {round, source, +-- assessment} ... }, recipe = table|nil, warnings = {...}, +-- guardrailExplanations = {...}, baseline = table, state = table, +-- inputTokens, outputTokens }`. `baseline` and `state` are what +-- EditLoop.revert needs. +--- +function EditLoop.run(args) + local photo = args.photo + local result = { + applied = false, + canceled = false, + rounds = {}, + warnings = {}, + guardrailExplanations = {}, + state = { touched = {}, ownSyncIds = {} }, + } + local progress = args.onProgress or function() end + local isCanceled = args.isCanceled or function() + return false + end + + local baseline, baselineErr = readSettings(photo) + if not baseline then + result.error = "could not read the photo's develop settings (" .. tostring(baselineErr) .. ")" + return result + end + result.baseline = baseline + + progress("rendering the original") + local originalPath, renderErr = render(photo) + if not originalPath then + result.error = "could not render the photo: " .. renderErr + return result + end + + local fields = {} + for key, value in pairs(args.fields or {}) do + fields[key] = value + end + fields.develop_settings = JSON:encode(baseline) + if args.styleRecipe then + fields.style_recipe = JSON:encode(args.styleRecipe) + end + + progress("asking the AI model for a first edit") + local okCall, ok, step = LrTasks.pcall(function() + return SearchIndexAPI.startEditSession(args.photoId, originalPath, fields) + end) + removeFile(originalPath) + if not okCall then + result.error = tostring(ok) + return result + end + if not ok then + result.error = Util.userFacingError(tostring(step)) + return result + end + + local sessionId = step.session_id + local tables = {} + local currentRound = nil + -- What Lightroom made of each round's masks. The model hears it with the + -- render; the user hears it for the round the photo ends on. + local roundNotes = {} + + local function fail(message) + if currentRound ~= nil then + table.insert(result.warnings, message .. "; kept round " .. tostring(currentRound)) + result.keptRound = currentRound + else + result.error = message + end + end + + while true do + appendAll(result.warnings, step.warnings) + appendAll(result.guardrailExplanations, step.guardrail_explanations) + result.inputTokens = step.input_tokens + result.outputTokens = step.output_tokens + + if step.done then + local keep = tonumber(step.keep_round) + result.verdict = step.verdict + if keep == currentRound then + result.recipe = step.edit + end + if keep ~= nil and keep ~= currentRound and tables[keep] then + progress("going back to round " .. tostring(keep)) + local tbl = EditLoop.composeApply(baseline, tables[keep].table, result.state) + local applied, err, backNotes = applyTable( + photo, + tbl, + tables[keep].updateAI, + "AI Edit: back to round " .. tostring(keep), + tables[keep].table + ) + if applied then + currentRound = keep + result.recipe = step.edit + roundNotes[keep] = backNotes + else + table.insert( + result.warnings, + "could not go back to round " + .. tostring(keep) + .. " (" + .. tostring(err) + .. "); kept round " + .. tostring(currentRound) + ) + end + end + result.keptRound = currentRound + break + end + + if isCanceled() then + result.canceled = true + break + end + + local round = tonumber(step.round) or 0 + table.insert(result.rounds, { + round = round, + source = step.source, + assessment = step.assessment, + }) + tables[round] = { table = step.develop_settings or {}, updateAI = step.update_ai_settings == true } + progress( + step.source == "style" and "applying your style" + or ( + "applying round " + .. tostring(round) + .. " of up to " + .. tostring(round + (tonumber(step.edits_left) or 0)) + ) + ) + local tbl = EditLoop.composeApply(baseline, tables[round].table, result.state) + local historyName = step.source == "style" and "AI Edit: your style" or ("AI Edit: round " .. tostring(round)) + local applied, applyErr, notes = + applyTable(photo, tbl, tables[round].updateAI, historyName, tables[round].table) + if not applied then + fail("could not apply round " .. tostring(round) .. ": " .. tostring(applyErr)) + break + end + currentRound = round + result.applied = true + result.recipe = step.edit + roundNotes[round] = notes + + if isCanceled() then + result.canceled = true + break + end + + progress("rendering round " .. tostring(round)) + local renderPath, err = render(photo) + if not renderPath then + fail("could not render round " .. tostring(round) .. ": " .. tostring(err)) + break + end + progress("the AI model is looking at round " .. tostring(round)) + local okSend, sent, nextStep = LrTasks.pcall(function() + return SearchIndexAPI.sendEditRender(sessionId, renderPath, notes) + end) + removeFile(renderPath) + if not okSend then + fail("the AI model's answer was lost: " .. tostring(sent)) + break + end + if not sent then + fail("the AI model did not answer: " .. Util.userFacingError(tostring(nextStep))) + break + end + step = nextStep + end + + -- A mask of the round the photo ends on that found nothing, or was not + -- computed in time, is on the photo doing nothing: the user has to know. + if result.keptRound ~= nil and not result.canceled then + appendAll(result.warnings, roundNotes[result.keptRound]) + end + + -- A session that finished ended itself on the backend; any other one is + -- ended here so it does not wait out its expiry. Not reported when that + -- fails: the backend drops an idle session after 30 minutes anyway, and + -- nothing on the photo depends on it. + if not step.done then + local okEnd, ended, endErr = LrTasks.pcall(function() + return SearchIndexAPI.endEditSession(sessionId) + end) + if not okEnd or not ended then + log:warn("EditLoop: could not end session: " .. tostring(okEnd and endErr or ended)) + end + end + return result +end + +--- +-- Takes the photo back to where the loop found it. +-- @param photo LrPhoto +-- @param result table From EditLoop.run. +-- @return boolean ok, string|nil error +--- +function EditLoop.revert(photo, result) + if not result or not result.baseline then + return false, "nothing to go back to" + end + local tbl = EditLoop.composeRevert(result.baseline, result.state) + if next(tbl) == nil then + return true, nil + end + local ok, err = applyTable(photo, tbl, false, "AI Edit: discarded", nil) + return ok, err +end diff --git a/plugin/LrGeniusAI.lrdevplugin/Info.lua b/plugin/LrGeniusAI.lrdevplugin/Info.lua index 3f4e31f7..21998101 100644 --- a/plugin/LrGeniusAI.lrdevplugin/Info.lua +++ b/plugin/LrGeniusAI.lrdevplugin/Info.lua @@ -140,6 +140,10 @@ return { title = LOC("$$$/LrGeniusAI/Menu/DeduplicateKeywords=Deduplicate Keyword Synonyms..."), file = "TaskDeduplicateKeywords.lua", }, + { + title = "Developer: Run Develop-Settings Experiments...", + file = "TaskDevelopExperiments.lua", + }, }, LrShutdownApp = "ShutdownApp.lua", diff --git a/plugin/LrGeniusAI.lrdevplugin/TaskAiEditPhotos.lua b/plugin/LrGeniusAI.lrdevplugin/TaskAiEditPhotos.lua index 9ba2f28b..065d2ea6 100644 --- a/plugin/LrGeniusAI.lrdevplugin/TaskAiEditPhotos.lua +++ b/plugin/LrGeniusAI.lrdevplugin/TaskAiEditPhotos.lua @@ -1,19 +1,25 @@ require("DevelopEditManager") require("AestheticRefine") +require("EditLoop") --- AI Edit runs on the Style Engine only. The LLM edit path (`/v1/edit/recipe`, provider --- and model choice, prompts, intent presets, creative controls, per-photo --- instructions) is gone from this task: none of it reached the style engine, --- which builds a recipe purely by matching the photo against the training --- examples the user saved from their own edits. The backend still serves --- `/v1/edit/recipe`, and `SearchIndexAPI.generateEditRecipePhoto` still speaks to it — --- nothing here calls either. +-- AI Edit has two editors. +-- +-- The AI retoucher (the default) is a loop with a vision model: the model +-- edits, Lightroom renders the result, the model looks at it next to the +-- original with its measurements and corrects, until it is done. The backend +-- holds the conversation (`/v1/edit/sessions`); EditLoop.lua applies each +-- step, computes its AI masks and renders it. With enough training examples +-- the loop starts from the photographer's own style (the style engine's +-- recipe as round 0) and the model only corrects what does not suit the frame. +-- +-- "My style only" is the style engine alone: offline, no model, every recipe +-- built from the user's saved edits (`/v1/edit/style`), with the optional +-- aesthetic-score refinement. -- Mirrors `MIN_TRAINING_EXAMPLES` in -- `server-rs/crates/lrg-analysis/src/style_engine.rs`. Below it the style --- engine declines to answer, and with the LLM fallback no longer requested --- there is nothing behind it — so the run is stopped here rather than --- exporting and uploading every photo for a guaranteed 422. +-- engine declines to answer, so "My style only" stops before it uploads +-- anything, and the retoucher starts from the photo instead of a style. local MIN_TRAINING_EXAMPLES = 5 local function copyOptions(source) @@ -67,6 +73,11 @@ end -- `DevelopEditManager` does the same); only creating the copy needs one. The -- switch to All Photographs is the same fallback that function uses, for the -- same reason: the scope may reach outside the folder or collection on screen. +local function isOnlySelected(catalog, photo) + local selected = catalog:getTargetPhotos() + return type(selected) == "table" and #selected == 1 and selected[1] == photo +end + local function selectOnly(catalog, photo) local ok = LrTasks.pcall(function() catalog:setSelectedPhotos(photo, { photo }) @@ -74,10 +85,17 @@ local function selectOnly(catalog, photo) if not ok then return false end - local selected = catalog:getTargetPhotos() - return type(selected) == "table" and #selected == 1 and selected[1] == photo + return isOnlySelected(catalog, photo) +end + +local function localIdOf(photo) + return photo and tostring(photo.localIdentifier) or "nil" end +-- Returns `copy, nil, 0` on success, or `nil, err, strayCount` on failure. +-- `strayCount` is how many copies Lightroom may already have created before +-- the failure was detected: the SDK cannot delete a photo, so those copies stay +-- in the catalog and the user has to be told to remove them. local function createVirtualCopyFor(photo) local catalog = LrApplication.activeCatalog() @@ -87,30 +105,56 @@ local function createVirtualCopyFor(photo) LrTasks.sleep(0.2) end) if not switched or not selectOnly(catalog, photo) then - return nil, "the photo could not be selected in Lightroom" + return nil, "the photo could not be selected in Lightroom", 0 end end local copies + local selectionChanged = false local ok, err = LrTasks.pcall(function() catalog:withWriteAccessDo( LOC("$$$/LrGeniusAI/TaskAiEditPhotos/VirtualCopyUndo=Create virtual copy for AI edit"), function() + -- Waiting for write access can take up to the gate's timeout, + -- and the user may click in the grid meanwhile. The copy is + -- made from whatever is selected *now*, so check again here. + if not isOnlySelected(catalog, photo) then + selectionChanged = true + return + end copies = catalog:createVirtualCopies(LOC("$$$/LrGeniusAI/TaskAiEditPhotos/VirtualCopyName=AI Edit")) end, Defaults.catalogWriteAccessOptions ) end) if not ok then - return nil, tostring(err) + return nil, tostring(err), type(copies) == "table" and #copies or 0 + end + if selectionChanged then + return nil, "the selection in Lightroom changed before the copy could be made", 0 end if type(copies) ~= "table" or #copies ~= 1 then - return nil, "Lightroom did not return exactly one virtual copy" + return nil, "Lightroom did not return exactly one virtual copy", type(copies) == "table" and #copies or 0 end - if copies[1]:getRawMetadata("masterPhoto") ~= photo then - return nil, "Lightroom copied a different photo" + -- A copy of a virtual copy belongs to that copy's master, not to the copy. + local isVirtualCopy = photo:getRawMetadata("isVirtualCopy") + local sourceMaster = isVirtualCopy and photo:getRawMetadata("masterPhoto") or nil + local copyMaster = copies[1]:getRawMetadata("masterPhoto") + if not Util.isVirtualCopyOf(copyMaster, photo, isVirtualCopy, sourceMaster) then + -- Catalog-local ids only, so a failure can be diagnosed from the log. + log:error( + "Virtual copy check failed: source=" + .. localIdOf(photo) + .. " isVirtualCopy=" + .. tostring(isVirtualCopy) + .. " sourceMaster=" + .. localIdOf(sourceMaster) + .. " copyMaster=" + .. localIdOf(copyMaster) + ) + return nil, "Lightroom copied a different photo than the one selected", 1 end - return copies[1], nil + return copies[1], nil, 0 end -- Refinement scores every candidate with the aesthetic model, which is a @@ -146,19 +190,63 @@ local function refinementSummary(refinedCount, reducedCount) .. " of them at a gentler strength than the full recipe." end -local function showAiEditDialog(ctx, stats) +-- The looks the retoucher offers. The ids are the backend's +-- (`lrg_providers::edit_loop::LOOKS`), which says what each means to the model. +local LOOKS = { + { title = "Natural — a better version of the photo", value = "natural" }, + { title = "Bright & airy", value = "bright_airy" }, + { title = "Moody", value = "moody" }, + { title = "Cinematic", value = "cinematic" }, + { title = "Analog film", value = "film" }, + { title = "Vivid & punchy", value = "punchy" }, + { title = "Black & white", value = "black_and_white" }, +} + +local EDIT_ROUNDS = { + { title = "1 — edit once, no second look", value = 1 }, + { title = "2", value = 2 }, + { title = "3 (recommended)", value = 3 }, + { title = "4", value = 4 }, + { title = "5", value = 5 }, +} + +local function showAiEditDialog(ctx, stats, models) log:trace("showAiEditDialog: start") local f = LrView.osFactory() local bind = LrView.bind local share = LrView.share local props = LrBinding.makePropertyTable(ctx) + local readinessText, readinessColor, trainingCount = styleReadinessDisplay(stats) + local styleAvailable = (trainingCount or 0) >= MIN_TRAINING_EXAMPLES + local masksSupported = EditLoop.supportsMasks(LrApplication.versionTable()) + props.scope = prefs.aiEditScope or "selected" + props.engine = prefs.aiEditEngine or "retoucher" + props.modelKey = AiProviders.initialKey(models.items, models.savedKey) + props.look = prefs.aiEditLook or "natural" + props.instructions = prefs.aiEditInstructions or "" + props.maxEdits = tonumber(prefs.aiEditMaxEdits) or 3 + props.useStyle = styleAvailable and prefs.aiEditUseStyle ~= false + props.useMasks = masksSupported and prefs.aiEditUseMasks ~= false props.reviewBeforeApply = prefs.aiEditReviewBeforeApply ~= false props.createVirtualCopy = prefs.aiEditCreateVirtualCopy == true props.refineWithAesthetic = prefs.aiEditRefineAesthetic == true - local readinessText, readinessColor, trainingCount = styleReadinessDisplay(stats) + -- Which controls apply depends on the editor; they stay visible, so the + -- dialog does not change size, and are switched off when they do not. + local function updateEnabled() + local retoucher = props.engine == "retoucher" + props.isRetoucher = retoucher + props.styleEnabled = retoucher and styleAvailable + props.masksEnabled = retoucher and masksSupported + props.isStyleOnly = not retoucher + end + updateEnabled() + props:addObserver("engine", updateEnabled) + + local labelWidth = share("labelWidth") + local grey = LrColor(0.5, 0.5, 0.5) local contents = f:column({ bind_to_object = props, @@ -169,7 +257,7 @@ local function showAiEditDialog(ctx, stats) f:row({ f:static_text({ title = LOC("$$$/LrGeniusAI/common/ApplyTo=Apply to:"), - width = share("labelWidth"), + width = labelWidth, }), f:popup_menu({ value = bind("scope"), @@ -182,13 +270,108 @@ local function showAiEditDialog(ctx, stats) }), }), }), + f:group_box({ + title = "Editor", + fill_horizontal = 1, + f:row({ + f:static_text({ title = "Edit with:", width = labelWidth }), + f:popup_menu({ + value = bind("engine"), + width = 300, + items = { + { title = "AI retoucher (recommended)", value = "retoucher" }, + { title = "My style only (offline, no AI model)", value = "style" }, + }, + }), + }), + f:row({ + f:static_text({ + title = "The AI retoucher edits each photo, looks at what Lightroom renders and corrects\n" + .. "it, like a person at the Develop panel. My style only blends your saved edits.", + text_color = grey, + }), + }), + }), + f:group_box({ + title = "AI retoucher", + fill_horizontal = 1, + f:row({ + f:static_text({ title = "AI model:", width = labelWidth, enabled = bind("isRetoucher") }), + f:column({ + f:popup_menu({ + value = bind("modelKey"), + items = models.items, + width = 300, + enabled = bind("isRetoucher"), + }), + models.warnings and f:static_text({ + title = models.warnings, + text_color = LrColor(0.8, 0, 0), + size = "small", + wrap = true, + width = 300, + }) or nil, + }), + }), + f:row({ + f:static_text({ title = "", width = labelWidth }), + f:static_text({ + title = "It has to see images: a vision model. Larger models edit better.", + text_color = grey, + }), + }), + f:row({ + f:static_text({ title = "Look:", width = labelWidth, enabled = bind("isRetoucher") }), + f:popup_menu({ + value = bind("look"), + items = LOOKS, + width = 300, + enabled = bind("isRetoucher"), + }), + }), + f:row({ + f:static_text({ title = "Your instructions:", width = labelWidth, enabled = bind("isRetoucher") }), + f:edit_field({ + value = bind("instructions"), + width_in_chars = 40, + height_in_lines = 2, + enabled = bind("isRetoucher"), + placeholder_string = "optional, e.g. keep skin warm, no vignette", + }), + }), + f:row({ + f:static_text({ title = "Edits per photo:", width = labelWidth, enabled = bind("isRetoucher") }), + f:popup_menu({ + value = bind("maxEdits"), + items = EDIT_ROUNDS, + width = 300, + enabled = bind("isRetoucher"), + }), + }), + f:row({ + f:checkbox({ value = bind("useStyle"), enabled = bind("styleEnabled") }), + f:static_text({ + title = styleAvailable and "Start from my own style (the training examples below)" + or ("Start from my own style (needs " .. MIN_TRAINING_EXAMPLES .. " training examples)"), + enabled = bind("styleEnabled"), + }), + }), + f:row({ + f:checkbox({ value = bind("useMasks"), enabled = bind("masksEnabled") }), + f:static_text({ + title = masksSupported and "Use AI masks (sky, subject, background, skin, eyes, …)" + or "Use AI masks (needs Lightroom Classic 15.3 or later)", + enabled = bind("masksEnabled"), + }), + }), + }), f:group_box({ title = LOC("$$$/LrGeniusAI/TaskAiEditPhotos/StyleProfile=Style profile"), fill_horizontal = 1, f:row({ f:static_text({ title = LOC("$$$/LrGeniusAI/TaskAiEditPhotos/TrainingExamples=Training examples:"), - width = share("labelWidth"), + width = labelWidth, }), f:static_text({ title = trainingCount and tostring(trainingCount) or "—", @@ -197,20 +380,13 @@ local function showAiEditDialog(ctx, stats) f:row({ f:static_text({ title = LOC("$$$/LrGeniusAI/TaskAiEditPhotos/StyleStatus=Status:"), - width = share("labelWidth"), + width = labelWidth, }), f:static_text({ title = readinessText, text_color = readinessColor, }), }), - f:row({ - f:static_text({ - title = LOC( - "$$$/LrGeniusAI/TaskAiEditPhotos/StyleProfileHint=Edits are built from your own saved edits — the more examples, the closer the match." - ), - }), - }), }), f:group_box({ title = LOC("$$$/LrGeniusAI/common/Options=Options"), @@ -220,9 +396,7 @@ local function showAiEditDialog(ctx, stats) value = bind("reviewBeforeApply"), }), f:static_text({ - title = LOC( - "$$$/LrGeniusAI/TaskAiEditPhotos/ReviewProposed=Review each proposed edit before applying it" - ), + title = "Review each edit (the retoucher shows the finished edit to keep or discard)", }), }), f:row({ @@ -238,15 +412,17 @@ local function showAiEditDialog(ctx, stats) f:row({ f:checkbox({ value = bind("refineWithAesthetic"), + enabled = bind("isStyleOnly"), }), f:static_text({ - title = "Refine each edit with the aesthetic score (slower: tries gentler strengths)", + title = "Refine my style with the aesthetic score (slower: tries gentler strengths)", + enabled = bind("isStyleOnly"), }), }), f:row({ f:static_text({ - title = "Every try is a step in the Edit History; a virtual copy keeps the original's clean.", - text_color = LrColor(0.5, 0.5, 0.5), + title = "Every try and every round is a step in the Edit History; a virtual copy keeps the original's clean.", + text_color = grey, }), }), }), @@ -264,15 +440,30 @@ local function showAiEditDialog(ctx, stats) end prefs.aiEditScope = props.scope + prefs.aiEditEngine = props.engine + prefs.aiEditModelKey = props.modelKey + prefs.aiEditLook = props.look + prefs.aiEditInstructions = props.instructions + prefs.aiEditMaxEdits = props.maxEdits + prefs.aiEditUseStyle = props.useStyle + prefs.aiEditUseMasks = props.useMasks prefs.aiEditReviewBeforeApply = props.reviewBeforeApply prefs.aiEditCreateVirtualCopy = props.createVirtualCopy prefs.aiEditRefineAesthetic = props.refineWithAesthetic return { scope = props.scope, + engine = props.engine, + modelKey = props.modelKey, + look = props.look, + instructions = props.instructions, + maxEdits = props.maxEdits, + useStyle = props.useStyle and styleAvailable, + useMasks = props.useMasks and masksSupported, reviewBeforeApply = props.reviewBeforeApply, createVirtualCopy = props.createVirtualCopy, - refineWithAesthetic = props.refineWithAesthetic, + refineWithAesthetic = props.engine == "style" and props.refineWithAesthetic, + trainingCount = trainingCount, } end @@ -296,430 +487,841 @@ local function enrichPhotoOptions(photo, baseOptions) -- longer visible in the bytes it gets. Lightroom knows, and the edit -- guardrails need it: blown highlights are recoverable on a raw file and -- gone on a rendered one, which changes how far the white point may go. It - -- also decides whether a training example's Kelvin temperature may be - -- carried over at all. - photoOptions.is_raw = Util.isRawPhoto(photo) + -- also decides which training examples' white balance may be carried over. + -- + -- Taken from the photo's develop settings, not only its file format, so it + -- matches the white-balance keys DevelopEditManager will write: a DNG + -- converted from a JPEG is a DNG, yet has a rendered file's white balance. + -- Left absent when neither source knows. + local wbFamily = DevelopEditManager.photoWhiteBalanceFamily(photo) + if wbFamily ~= nil then + photoOptions.is_raw = wbFamily == "raw" + end return photoOptions end -LrTasks.startAsyncTask(function() - LrFunctionContext.callWithContext("AiEditPhotosTask", function(ctx) - LrDialogs.attachErrorDialogToFunctionContext(ctx) - log:info("AI Edit task started") - - -- No `requireProviders` any more: the style engine needs no LLM - -- provider, so a missing API key must not block this task. - if not Util.waitForServerDialog() then - log:warn("AI Edit task aborted: backend server unavailable") - return - end - - -- A backend that could not answer is not a backend reporting zero: fall - -- through and let the per-photo errors say what actually went wrong, - -- rather than telling the user their style profile is empty when it may - -- be full. - local stats, statsErr = SearchIndexAPI.getTrainingStats() - if not stats then - log:warn("AI Edit could not read the style profile stats: " .. tostring(statsErr)) - end - local trainingCount = (stats and tonumber(stats.count)) or 0 - if stats and trainingCount < MIN_TRAINING_EXAMPLES then - log:warn("AI Edit task aborted: only " .. tostring(trainingCount) .. " training example(s) available") - LrDialogs.message( - LOC("$$$/LrGeniusAI/TaskAiEditPhotos/NoStyleProfileTitle=Style profile not ready"), - LOC( - "$$$/LrGeniusAI/TaskAiEditPhotos/NoStyleProfile=AI Edit builds every recipe from your own saved edits, and it needs at least ^1 training examples to do that. You currently have ^2.\n\nEdit a handful of photos the way you like them, then run Help → Save Edits as AI Training Examples and start AI Edit again.", - tostring(MIN_TRAINING_EXAMPLES), - tostring(trainingCount) - ), - "info" - ) - return +--- +-- "My style only": the style engine's recipe for each photo, applied as it +-- came or refined with the aesthetic score. +--- +local function runStyleEdits(ctx, photos, options, progressScope, run) + for index, photo in ipairs(photos) do + if progressScope:isCanceled() then + run.canceled = true + break end - local options = showAiEditDialog(ctx, stats) - if not options then - log:info("AI Edit task canceled by user in options dialog") - return - end - log:trace( - "AI Edit options selected: scope=" - .. tostring(options.scope) - .. " review=" - .. tostring(options.reviewBeforeApply) - .. " virtualCopy=" - .. tostring(options.createVirtualCopy) - .. " refine=" - .. tostring(options.refineWithAesthetic) + local fileName = photo:getFormattedMetadata("fileName") or "Photo" + progressScope:setCaption( + "Processing " .. fileName .. " (" .. tostring(index) .. " of " .. tostring(#photos) .. ")" ) + progressScope:setPortionComplete(index - 1, #photos) + local continueProcessing = true - if options.refineWithAesthetic then - local answer = aestheticModelPreflight() - if answer == "cancel" then - log:info("AI Edit task canceled at the aesthetic model preflight") - return - elseif answer == "other" then - options.refineWithAesthetic = false - end - end + log:trace("AI Edit photo loop start: index=" .. tostring(index) .. " photo=" .. tostring(fileName)) - local photos = PhotoSelector.getPhotosInScope(options.scope) - if not photos or #photos == 0 then - LrDialogs.message( - LOC("$$$/LrGeniusAI/common/NoPhotosTitle=No Photos"), - LOC("$$$/LrGeniusAI/common/NoPhotosInScope=No photos found in the selected scope."), - "info" - ) - log:warn("AI Edit task found no photos in scope: " .. tostring(options.scope)) - return + local photoId, photoIdErr = SearchIndexAPI.getPhotoIdForPhoto(photo) + if not photoId then + log:error("Failed to resolve photo ID for " .. fileName .. ": " .. tostring(photoIdErr)) + table.insert(run.errorMessages, fileName .. ": " .. tostring(photoIdErr)) + run.errorCount = run.errorCount + 1 + continueProcessing = false + else + log:trace("Resolved photo ID for " .. fileName .. ": " .. tostring(photoId)) end - local progressScope = LrProgressScope({ - title = LOC("$$$/LrGeniusAI/TaskAiEditPhotos/ProgressTitle=Generating AI Lightroom edits..."), - functionContext = ctx, - }) - progressScope:setPortionComplete(0, #photos) - - local successCount = 0 - local skippedCount = 0 - local errorCount = 0 - local errorMessages = {} - local backendWarnings = {} - local refinedCount = 0 - local reducedCount = 0 - - local canceled = false - - for index, photo in ipairs(photos) do - if progressScope:isCanceled() then - canceled = true - break - end - - local fileName = photo:getFormattedMetadata("fileName") or "Photo" - progressScope:setCaption( - "Processing " .. fileName .. " (" .. tostring(index) .. " of " .. tostring(#photos) .. ")" - ) - progressScope:setPortionComplete(index - 1, #photos) - local continueProcessing = true - - log:trace("AI Edit photo loop start: index=" .. tostring(index) .. " photo=" .. tostring(fileName)) - - local photoId, photoIdErr = SearchIndexAPI.getPhotoIdForPhoto(photo) - if not photoId then - log:error("Failed to resolve photo ID for " .. fileName .. ": " .. tostring(photoIdErr)) - table.insert(errorMessages, fileName .. ": " .. tostring(photoIdErr)) - errorCount = errorCount + 1 + local response = nil + local baselineScore = nil + if continueProcessing then + local photoOptions = enrichPhotoOptions(photo, options) + local exportedPath = SearchIndexAPI.exportPhotoForIndexing(photo) + if not exportedPath then + log:error("Failed to export photo for AI edit generation: " .. fileName) + table.insert(run.errorMessages, fileName .. ": export failed") + run.errorCount = run.errorCount + 1 continueProcessing = false - else - log:trace("Resolved photo ID for " .. fileName .. ": " .. tostring(photoId)) end - local response = nil - local baselineScore = nil if continueProcessing then - local photoOptions = enrichPhotoOptions(photo, options) - local exportedPath = SearchIndexAPI.exportPhotoForIndexing(photo) - if not exportedPath then - log:error("Failed to export photo for AI edit generation: " .. fileName) - table.insert(errorMessages, fileName .. ": export failed") - errorCount = errorCount + 1 - continueProcessing = false - end - - if continueProcessing then - log:trace("AI Edit calling API for " .. fileName .. " exportedPath=" .. tostring(exportedPath)) - local ok, apiOk, apiResponse = LrTasks.pcall(function() - return SearchIndexAPI.styleEdit(photoId, exportedPath, photoOptions) + log:trace("AI Edit calling API for " .. fileName .. " exportedPath=" .. tostring(exportedPath)) + local ok, apiOk, apiResponse = LrTasks.pcall(function() + return SearchIndexAPI.styleEdit(photoId, exportedPath, photoOptions) + end) + -- The export is the photo as it is before the edit, so its score is what the + -- refinement has to beat. A photo that cannot be scored is still edited, just + -- not refined, and says so. + if ok and apiOk and options.refineWithAesthetic then + local okScore, score, scoreErr = LrTasks.pcall(function() + return SearchIndexAPI.scoreEditedPhoto(exportedPath) end) - -- The export is the photo as it is before the edit, so its score is what the - -- refinement has to beat. A photo that cannot be scored is still edited, just - -- not refined, and says so. - if ok and apiOk and options.refineWithAesthetic then - local okScore, score, scoreErr = LrTasks.pcall(function() - return SearchIndexAPI.scoreEditedPhoto(exportedPath) - end) - if okScore and type(score) == "number" then - baselineScore = score - else - local reason = okScore and Util.userFacingError(tostring(scoreErr)) or tostring(score) - log:warn("AI Edit could not score " .. fileName .. " before editing: " .. reason) - table.insert(backendWarnings, fileName .. ": not refined — " .. reason) - end - end - LrTasks.pcall(function() - if exportedPath and LrFileUtils.exists(exportedPath) then - LrFileUtils.delete(exportedPath) - end - end) - if not ok then - log:error("AI edit generation threw for " .. fileName .. ": " .. tostring(apiOk)) - table.insert(errorMessages, fileName .. ": exception thrown: " .. tostring(apiOk)) - errorCount = errorCount + 1 - continueProcessing = false + if okScore and type(score) == "number" then + baselineScore = score else - response = apiResponse - if response and response.warning then - table.insert(backendWarnings, fileName .. ": " .. tostring(response.warning)) - end + local reason = okScore and Util.userFacingError(tostring(scoreErr)) or tostring(score) + log:warn("AI Edit could not score " .. fileName .. " before editing: " .. reason) + Util.tallyWarnings(run.warningTally, { "Not refined — " .. reason }, fileName, index) end - if - continueProcessing - and (not apiOk or not response or type(response) ~= "table" or response.status ~= "success") - then - local errMsg = "Unknown error" - if not apiOk then - errMsg = Util.userFacingError(tostring(apiResponse)) - elseif type(response) == "string" then - errMsg = response - elseif response and response.error then - errMsg = response.error - end - log:error( - "AI edit generation failed for " - .. fileName - .. ": apiOk=" - .. tostring(apiOk) - .. " responseType=" - .. tostring(type(response)) - .. " response=" - .. tostring(response) - ) - table.insert(errorMessages, fileName .. ": " .. errMsg) - errorCount = errorCount + 1 - continueProcessing = false - else - log:trace( - "AI edit generation succeeded for " - .. fileName - .. " responseStatus=" - .. tostring(response and response.status) + end + LrTasks.pcall(function() + if exportedPath and LrFileUtils.exists(exportedPath) then + LrFileUtils.delete(exportedPath) + end + end) + if not ok then + log:error("AI edit generation threw for " .. fileName .. ": " .. tostring(apiOk)) + table.insert(run.errorMessages, fileName .. ": exception thrown: " .. tostring(apiOk)) + run.errorCount = run.errorCount + 1 + continueProcessing = false + else + response = apiResponse + local responseWarnings = Util.responseWarnings(response) + if #responseWarnings > 0 then + log:warn( + "AI edit backend warnings for " .. fileName .. ": " .. table.concat(responseWarnings, " | ") ) + Util.tallyWarnings(run.warningTally, responseWarnings, fileName, index) end end - end - - if continueProcessing and response then - local applyOptions = { - applyGlobal = true, - applyMasks = true, - } - - if options.reviewBeforeApply then - log:trace("Showing review dialog for " .. fileName) - local result, validated = DevelopEditManager.showValidationDialog(ctx, photo, response, options) - log:trace("Review dialog result for " .. fileName .. ": " .. tostring(result)) - if result == "cancel" then - skippedCount = skippedCount + 1 - continueProcessing = false - elseif validated then - applyOptions = validated + if + continueProcessing + and (not apiOk or not response or type(response) ~= "table" or response.status ~= "success") + then + local errMsg = "Unknown error" + if not apiOk then + errMsg = Util.userFacingError(tostring(apiResponse)) + elseif type(response) == "string" then + errMsg = response + elseif response and response.error then + errMsg = response.error end + log:error( + "AI edit generation failed for " + .. fileName + .. ": apiOk=" + .. tostring(apiOk) + .. " responseType=" + .. tostring(type(response)) + .. " response=" + .. tostring(response) + ) + table.insert(run.errorMessages, fileName .. ": " .. errMsg) + run.errorCount = run.errorCount + 1 + continueProcessing = false + else + log:trace( + "AI edit generation succeeded for " + .. fileName + .. " responseStatus=" + .. tostring(response and response.status) + ) end + end + end - if continueProcessing and not applyOptions.applyGlobal and not applyOptions.applyMasks then - skippedCount = skippedCount + 1 + if continueProcessing and response then + local applyOptions = { + applyGlobal = true, + applyMasks = true, + } + + if options.reviewBeforeApply then + log:trace("Showing review dialog for " .. fileName) + local result, validated = DevelopEditManager.showValidationDialog(ctx, photo, response, options) + log:trace("Review dialog result for " .. fileName .. ": " .. tostring(result)) + if result == "cancel" then + run.skippedCount = run.skippedCount + 1 continueProcessing = false + elseif validated then + applyOptions = validated end + end - -- Only now, once the edit is actually going to be applied: a - -- photo the user skipped in the review dialog must not leave a - -- virtual copy behind. - local target = photo - if continueProcessing and options.createVirtualCopy then - local copy, copyErr = createVirtualCopyFor(photo) - if copy then - target = copy - log:trace("Created virtual copy for " .. fileName) - else - -- Deliberately not falling back to the original: the - -- user asked for it to stay untouched. - log:error("Failed to create virtual copy for " .. fileName .. ": " .. tostring(copyErr)) - table.insert( - errorMessages, - fileName .. ": could not create virtual copy: " .. tostring(copyErr) - ) - errorCount = errorCount + 1 - continueProcessing = false - end - end + if continueProcessing and not applyOptions.applyGlobal and not applyOptions.applyMasks then + run.skippedCount = run.skippedCount + 1 + continueProcessing = false + end - if continueProcessing then - log:trace("Persisting generated recipe for " .. fileName) - local okPersist, persistErr = LrTasks.pcall(function() - DevelopEditManager.persistEditRecipe(target, response, nil, "generated") - end) - if not okPersist then - log:error("Persist generated recipe threw for " .. fileName .. ": " .. tostring(persistErr)) - table.insert(errorMessages, fileName .. ": could not persist recipe: " .. tostring(persistErr)) - errorCount = errorCount + 1 - continueProcessing = false - end + -- Only now, once the edit is actually going to be applied: a + -- photo the user skipped in the review dialog must not leave a + -- virtual copy behind. + local target = photo + if continueProcessing and options.createVirtualCopy then + local copy, copyErr, strayCount = createVirtualCopyFor(photo) + if copy then + target = copy + log:trace("Created virtual copy for " .. fileName) + else + -- Deliberately not falling back to the original: the + -- user asked for it to stay untouched. + log:error("Failed to create virtual copy for " .. fileName .. ": " .. tostring(copyErr)) + table.insert( + run.errorMessages, + fileName .. ": could not create virtual copy: " .. tostring(copyErr) + ) + run.errorCount = run.errorCount + 1 + continueProcessing = false + run.strayCopyCount = run.strayCopyCount + strayCount end + end - if continueProcessing then - log:trace( - "Applying recipe for " - .. fileName - .. " applyGlobal=" - .. tostring(applyOptions.applyGlobal) - .. " applyMasks=" - .. tostring(applyOptions.applyMasks) + if continueProcessing then + log:trace("Persisting generated recipe for " .. fileName) + local okPersist, recorded, recordErr = LrTasks.pcall(function() + return DevelopEditManager.persistEditRecipe(target, response, nil, "generated") + end) + local persistErr = recorded + if okPersist and not recorded then + -- Written to the catalog or not, the edit goes on: the + -- record is AI Edit's note, not the edit. + Util.tallyWarnings( + run.warningTally, + { DevelopEditManager.recordFailureWarning(recordErr) }, + fileName, + index ) - local applied, warnings, refined - local refineWarnings = nil - if baselineScore and applyOptions.applyGlobal then - applied, warnings, refined = AestheticRefine.run({ - photo = target, - response = response, - baselineScore = baselineScore, - applyOptions = applyOptions, - isCanceled = function() - return progressScope:isCanceled() - end, - onTrial = function(strength) - local percent = math.floor(strength * 100 + 0.5) - progressScope:setCaption("Refining " .. fileName .. " (trying " .. percent .. "%)") - end, - }) - if applied == nil then - -- Nothing was touched; the plain recipe still goes on. - refineWarnings = warnings - elseif refined then - refinedCount = refinedCount + 1 - if refined.strength < 1 then - reducedCount = reducedCount + 1 - end - end + end + if not okPersist then + log:error("Persist generated recipe threw for " .. fileName .. ": " .. tostring(persistErr)) + table.insert(run.errorMessages, fileName .. ": could not persist recipe: " .. tostring(persistErr)) + run.errorCount = run.errorCount + 1 + continueProcessing = false + if target ~= photo then + run.strayCopyCount = run.strayCopyCount + 1 end + end + end + + if continueProcessing then + log:trace( + "Applying recipe for " + .. fileName + .. " applyGlobal=" + .. tostring(applyOptions.applyGlobal) + .. " applyMasks=" + .. tostring(applyOptions.applyMasks) + ) + local applied, warnings, refined + local refineWarnings = nil + if baselineScore and applyOptions.applyGlobal then + applied, warnings, refined = AestheticRefine.run({ + photo = target, + response = response, + baselineScore = baselineScore, + applyOptions = applyOptions, + isCanceled = function() + return progressScope:isCanceled() + end, + onTrial = function(strength) + local percent = math.floor(strength * 100 + 0.5) + progressScope:setCaption("Refining " .. fileName .. " (trying " .. percent .. "%)") + end, + }) if applied == nil then - applied, warnings = DevelopEditManager.applyRecipe(target, response, applyOptions) - for _, warning in ipairs(refineWarnings or {}) do - table.insert(warnings, warning) + -- Nothing was touched; the plain recipe still goes on. + refineWarnings = warnings + elseif refined then + run.refinedCount = run.refinedCount + 1 + if refined.strength < 1 then + run.reducedCount = run.reducedCount + 1 end end - log:trace( - "Apply result for " - .. fileName - .. ": applied=" - .. tostring(applied) - .. " warningsCount=" - .. tostring(type(warnings) == "table" and #warnings or 0) - ) - if applied then - successCount = successCount + 1 - if warnings and #warnings > 0 then - table.insert( - backendWarnings, - fileName .. ": the edit applied, but " .. table.concat(warnings, "; ") - ) - end - else - errorCount = errorCount + 1 - table.insert(errorMessages, fileName .. ": failed to apply recipe") + end + if applied == nil then + applied, warnings = DevelopEditManager.applyRecipe(target, response, applyOptions) + for _, warning in ipairs(refineWarnings or {}) do + table.insert(warnings, warning) end - if warnings and #warnings > 0 then - log:warn("AI edit warnings for " .. fileName .. ": " .. table.concat(warnings, " | ")) + end + log:trace( + "Apply result for " + .. fileName + .. ": applied=" + .. tostring(applied) + .. " warningsCount=" + .. tostring(type(warnings) == "table" and #warnings or 0) + ) + if applied then + run.successCount = run.successCount + 1 + else + run.errorCount = run.errorCount + 1 + table.insert(run.errorMessages, fileName .. ": failed to apply recipe") + if target ~= photo then + run.strayCopyCount = run.strayCopyCount + 1 end end + -- For a failed apply too: its warnings carry the cause + -- ("Failed to apply global develop settings: ..."), and a + -- log line is not a report. + Util.tallyWarnings(run.warningTally, warnings, fileName, index) + if warnings and #warnings > 0 then + log:warn("AI edit warnings for " .. fileName .. ": " .. table.concat(warnings, " | ")) + end end end + end +end - progressScope:done() - - if errorCount > 0 or #backendWarnings > 0 then - local uniqueErrors = {} - local errorList = {} - for _, msg in ipairs(errorMessages) do - if not uniqueErrors[msg] then - uniqueErrors[msg] = true - table.insert(errorList, "- " .. msg) - if #errorList >= 5 then - break - end +-- The run's completion dialog, for either editor. +local function showCompletion(run) + local warningCount = Util.warningTallySize(run.warningTally) + if run.errorCount > 0 or warningCount > 0 then + local uniqueErrors = {} + local errorList = {} + for _, msg in ipairs(run.errorMessages) do + if not uniqueErrors[msg] then + uniqueErrors[msg] = true + table.insert(errorList, "- " .. msg) + if #errorList >= 5 then + break end end + end - local combinedReport = - LOC("$$$/LrGeniusAI/TaskAiEditPhotos/Summary=Applied edits to ^1 photo(s).", tostring(successCount)) - if refinedCount > 0 then - combinedReport = combinedReport .. "\n" .. refinementSummary(refinedCount, reducedCount) - end - if skippedCount > 0 then + local combinedReport = + LOC("$$$/LrGeniusAI/TaskAiEditPhotos/Summary=Applied edits to ^1 photo(s).", tostring(run.successCount)) + if run.refinedCount > 0 then + combinedReport = combinedReport .. "\n" .. refinementSummary(run.refinedCount, run.reducedCount) + end + for _, line in ipairs(run.summaryLines or {}) do + combinedReport = combinedReport .. "\n" .. line + end + if run.canceled then + combinedReport = combinedReport .. "\n" .. "Canceled before all photos were processed." + end + if run.skippedCount > 0 then + combinedReport = combinedReport + .. "\n" + .. LOC("$$$/LrGeniusAI/common/Skipped=Skipped: ^1", tostring(run.skippedCount)) + end + if run.errorCount > 0 then + combinedReport = combinedReport + .. "\n" + .. LOC("$$$/LrGeniusAI/common/Errors=Errors: ^1", tostring(run.errorCount)) + end + + if #errorList > 0 then + combinedReport = combinedReport + .. "\n\n" + .. LOC("$$$/LrGeniusAI/common/ErrorDetails=Error details:") + .. "\n" + .. table.concat(errorList, "\n") + if #run.errorMessages > 5 then combinedReport = combinedReport .. "\n" - .. LOC("$$$/LrGeniusAI/common/Skipped=Skipped: ^1", tostring(skippedCount)) + .. LOC("$$$/LrGeniusAI/common/MoreErrors=... and ^1 more errors", tostring(#run.errorMessages - 5)) end - if errorCount > 0 then - combinedReport = combinedReport - .. "\n" - .. LOC("$$$/LrGeniusAI/common/Errors=Errors: ^1", tostring(errorCount)) + end + + if run.strayCopyCount > 0 then + -- Said once for the run, not in every error line: the user acts + -- on it once, after the run. + combinedReport = combinedReport + .. "\n\n" + .. string.format( + 'Failed attempts may have left %d virtual %s named "AI Edit" without the edit,\n' + .. "or with only part of it. Lightroom does not let plugins delete photos: select\n" + .. "them in the Library grid and remove them with Photo > Remove Photo.", + run.strayCopyCount, + run.strayCopyCount == 1 and "copy" or "copies" + ) + end + + if warningCount > 0 then + combinedReport = combinedReport + .. "\n\n" + .. "Warnings:" + .. "\n" + .. table.concat(Util.formatWarningTally(run.warningTally, 5), "\n") + end + + if run.errorCount > 0 and run.successCount == 0 then + ErrorHandler.handleError( + LOC("$$$/LrGeniusAI/TaskAiEditPhotos/CompletionTitle=AI Edit Completed"), + combinedReport + ) + else + LrDialogs.message( + LOC("$$$/LrGeniusAI/TaskAiEditPhotos/CompletionTitle=AI Edit Completed"), + combinedReport, + "warning" + ) + end + else + local summary = LOC( + "$$$/LrGeniusAI/TaskAiEditPhotos/SuccessSummary=Applied edits to ^1 photo(s).\nSkipped: ^2", + tostring(run.successCount), + tostring(run.skippedCount) + ) + if run.refinedCount > 0 then + summary = summary .. "\n" .. refinementSummary(run.refinedCount, run.reducedCount) + end + for _, line in ipairs(run.summaryLines or {}) do + summary = summary .. "\n" .. line + end + if run.canceled then + summary = summary .. "\n\n" .. "Canceled before all photos were processed." + end + LrDialogs.message(LOC("$$$/LrGeniusAI/TaskAiEditPhotos/SuccessTitle=AI Lightroom Edit"), summary, "info") + end +end + +local function removeFile(path) + LrTasks.pcall(function() + if path and LrFileUtils.exists(path) then + LrFileUtils.delete(path) + end + end) +end + +-- The request fields every session of a run shares: the model and its +-- connection, the look, the user's words, and the local engine's settings. +-- @return table|nil fields, string|nil why the model cannot be used. +local function retoucherFields(options) + local provider, model = AiProviders.splitModelKey(options.modelKey) + if Util.nilOrEmpty(provider) or Util.nilOrEmpty(model) then + return nil, "Choose an AI model for the retoucher in the AI Edit dialog." + end + local connection, problem = AiProviders.connectionOptions(provider, Secrets.connectionFields(prefs)) + if not connection then + return nil, problem + end + return { + provider = provider, + model = model, + api_key = connection.api_key, + server_url = connection.server_url, + look = options.look, + instructions = options.instructions, + language = prefs.generateLanguage or "English", + max_edits = tostring(options.maxEdits or 3), + masks = tostring(options.useMasks == true), + llm_n_ctx = prefs.llmContextSize and tostring(prefs.llmContextSize) or nil, + llm_n_parallel = prefs.llmParallel and tostring(prefs.llmParallel) or nil, + llm_gpu_layers = prefs.llmGpuLayers and tostring(prefs.llmGpuLayers) or nil, + }, + nil +end + +-- What the retoucher did with one photo, in words, for the review dialog. +local function retouchReport(result) + local lines = {} + local modelRounds = 0 + for _, round in ipairs(result.rounds or {}) do + if round.source ~= "style" then + modelRounds = modelRounds + 1 + end + end + if result.keptRound == 0 then + table.insert(lines, "Kept your own style, unchanged by the AI model.") + elseif result.keptRound ~= nil then + table.insert(lines, string.format("Kept round %d of %d.", result.keptRound, modelRounds)) + end + if not Util.nilOrEmpty(result.verdict) then + table.insert(lines, "The AI model: " .. tostring(result.verdict)) + end + table.insert(lines, "") + for _, round in ipairs(result.rounds or {}) do + if round.source == "style" then + table.insert(lines, "Round 0: your style from your saved edits.") + elseif not Util.nilOrEmpty(round.assessment) then + table.insert(lines, "Round " .. tostring(round.round) .. ": " .. tostring(round.assessment)) + end + end + if #(result.guardrailExplanations or {}) > 0 then + table.insert(lines, "") + table.insert(lines, "What the frame allowed:") + local seen = {} + for _, text in ipairs(result.guardrailExplanations) do + if not seen[text] then + seen[text] = true + table.insert(lines, "- " .. text) end + end + end + if #(result.warnings or {}) > 0 then + table.insert(lines, "") + table.insert(lines, "Notes:") + local seen = {} + for _, text in ipairs(result.warnings) do + if not seen[text] then + seen[text] = true + table.insert(lines, "- " .. text) + end + end + end + return table.concat(lines, "\n") +end - if #errorList > 0 then - combinedReport = combinedReport - .. "\n\n" - .. LOC("$$$/LrGeniusAI/common/ErrorDetails=Error details:") - .. "\n" - .. table.concat(errorList, "\n") - if #errorMessages > 5 then - combinedReport = combinedReport - .. "\n" - .. LOC("$$$/LrGeniusAI/common/MoreErrors=... and ^1 more errors", tostring(#errorMessages - 5)) +-- Shows a finished edit, already on the photo, to keep or discard. +-- @return string "ok" keep, "cancel" discard, "other" keep this one and the +-- rest without asking. +local function reviewRetouch(photo, fileName, result) + local f = LrView.osFactory() + local report = retouchReport(result) + -- A static_text clips to its height instead of growing (see + -- Defaults.copyrightLineCount), so count the lines it will wrap into. + local lineCount = 0 + for line in (report .. "\n"):gmatch("([^\n]*)\n") do + lineCount = lineCount + math.max(1, math.ceil(#line / 60)) + end + local contents = f:row({ + spacing = f:control_spacing(), + f:catalog_photo({ + photo = photo, + width = 380, + height = 380, + }), + f:scrolled_view({ + width = 420, + height = 380, + horizontal_scroller = false, + f:static_text({ + title = report, + width = 390, + height_in_lines = lineCount, + wrap = true, + }), + }), + }) + return LrDialogs.presentModalDialog({ + title = "AI Edit: " .. fileName, + contents = contents, + actionVerb = "Keep", + cancelVerb = "Discard", + otherVerb = "Keep all", + }) +end + +--- +-- The AI retoucher: one session per photo, see EditLoop.lua. +--- +local function runRetoucherEdits(photos, options, progressScope, run) + local fields, problem = retoucherFields(options) + if not fields then + run.errorCount = run.errorCount + 1 + table.insert(run.errorMessages, problem) + return + end + local reviewing = options.reviewBeforeApply + local modelRounds, inputTokens, outputTokens = 0, 0, 0 + + local function editPhoto(index, photo) + local fileName = photo:getFormattedMetadata("fileName") or "Photo" + local function caption(text) + progressScope:setCaption( + fileName .. " (" .. tostring(index) .. " of " .. tostring(#photos) .. "): " .. tostring(text) + ) + end + caption("starting") + progressScope:setPortionComplete(index - 1, #photos) + + local photoId, photoIdErr = SearchIndexAPI.getPhotoIdForPhoto(photo) + if not photoId then + run.errorCount = run.errorCount + 1 + table.insert(run.errorMessages, fileName .. ": " .. tostring(photoIdErr)) + return + end + local photoOptions = enrichPhotoOptions(photo, {}) + + local styleRecipe = nil + if options.useStyle then + caption("matching your style") + local path = SearchIndexAPI.exportPhotoForIndexing(photo) + local why + if path then + local ok, apiOk, response = LrTasks.pcall(function() + return SearchIndexAPI.styleEdit(photoId, path, photoOptions) + end) + removeFile(path) + if ok and apiOk and type(response) == "table" and type(response.edit) == "table" then + styleRecipe = response.edit + Util.tallyWarnings(run.warningTally, Util.responseWarnings(response), fileName, index) + elseif ok and apiOk then + why = "the style engine returned no recipe" + elseif ok then + why = Util.userFacingError(tostring(response)) + else + why = tostring(apiOk) end + else + why = "the photo could not be rendered" + end + if why then + Util.tallyWarnings( + run.warningTally, + { "Your style could not be matched, so the AI model edited without it: " .. why }, + fileName, + index + ) end + end - if #backendWarnings > 0 then - combinedReport = combinedReport - .. "\n\n" - .. LOC("$$$/LrGeniusAI/common/BackendWarnings=Warnings:") - .. "\n" - for i = 1, math.min(5, #backendWarnings) do - combinedReport = combinedReport .. "- " .. backendWarnings[i] .. "\n" + -- The loop edits for real, so the copy comes first. + local target = photo + if options.createVirtualCopy then + local copy, copyErr, strayCount = createVirtualCopyFor(photo) + if not copy then + run.errorCount = run.errorCount + 1 + table.insert(run.errorMessages, fileName .. ": could not create virtual copy: " .. tostring(copyErr)) + run.strayCopyCount = run.strayCopyCount + strayCount + return + end + target = copy + end + + local sessionFields = copyOptions(fields) + if photoOptions.is_raw ~= nil then + sessionFields.is_raw = tostring(photoOptions.is_raw) + end + local result = EditLoop.run({ + photo = target, + photoId = photoId, + fields = sessionFields, + styleRecipe = styleRecipe, + isCanceled = function() + return progressScope:isCanceled() + end, + onProgress = caption, + }) + inputTokens = inputTokens + (tonumber(result.inputTokens) or 0) + outputTokens = outputTokens + (tonumber(result.outputTokens) or 0) + for _, round in ipairs(result.rounds) do + if round.source ~= "style" then + modelRounds = modelRounds + 1 + end + end + + Util.tallyWarnings(run.warningTally, result.warnings, fileName, index) + if result.canceled then + -- A photo left halfway is worse than one left alone. + local reverted, revertErr = EditLoop.revert(target, result) + if not reverted then + table.insert( + run.errorMessages, + fileName .. ": canceled halfway, and the edit could not be taken back: " .. tostring(revertErr) + ) + run.errorCount = run.errorCount + 1 + end + if target ~= photo then + run.strayCopyCount = run.strayCopyCount + 1 + end + run.canceled = true + return + end + if not result.applied then + log:error("AI Edit retoucher failed for " .. fileName .. ": " .. tostring(result.error)) + run.errorCount = run.errorCount + 1 + table.insert( + run.errorMessages, + fileName .. ": " .. tostring(result.error or "the edit could not be applied") + ) + if target ~= photo then + run.strayCopyCount = run.strayCopyCount + 1 + end + return + end + + if reviewing then + local answer = reviewRetouch(target, fileName, result) + if answer == "cancel" then + local reverted, revertErr = EditLoop.revert(target, result) + if reverted then + run.skippedCount = run.skippedCount + 1 + else + run.errorCount = run.errorCount + 1 + table.insert( + run.errorMessages, + fileName .. ": the edit could not be discarded: " .. tostring(revertErr) + ) end - if #backendWarnings > 5 then - combinedReport = combinedReport - .. LOC( - "$$$/LrGeniusAI/common/MoreWarnings=... and ^1 more warnings", - tostring(#backendWarnings - 5) - ) + if target ~= photo then + run.strayCopyCount = run.strayCopyCount + 1 end + return + elseif answer == "other" then + reviewing = false end + end - if errorCount > 0 and successCount == 0 then - ErrorHandler.handleError( - LOC("$$$/LrGeniusAI/TaskAiEditPhotos/CompletionTitle=AI Edit Completed"), - combinedReport - ) - else + local okPersist, recorded, recordErr = LrTasks.pcall(function() + return DevelopEditManager.persistEditRecipe( + target, + { edit = result.recipe or {}, edit_model = fields.model }, + result.warnings, + "generated" + ) + end) + if not okPersist or not recorded then + local why = okPersist and recordErr or recorded + log:error("Recording the retoucher's edit failed for " .. fileName .. ": " .. tostring(why)) + Util.tallyWarnings(run.warningTally, { DevelopEditManager.recordFailureWarning(why) }, fileName, index) + end + run.successCount = run.successCount + 1 + end + + for index, photo in ipairs(photos) do + if progressScope:isCanceled() then + run.canceled = true + break + end + editPhoto(index, photo) + if run.canceled then + break + end + end + + run.summaryLines = { + string.format( + "AI retoucher: %s, %d edit round(s) in total; %d input and %d output tokens.", + tostring(fields.model), + modelRounds, + inputTokens, + outputTokens + ), + } +end + +LrTasks.startAsyncTask(function() + LrFunctionContext.callWithContext("AiEditPhotosTask", function(ctx) + LrDialogs.attachErrorDialogToFunctionContext(ctx) + log:info("AI Edit task started") + + if not Util.waitForServerDialog() then + log:warn("AI Edit task aborted: backend server unavailable") + return + end + + -- A backend that could not answer is not a backend reporting zero: the + -- dialog says "unavailable", and a style-only run falls through to let + -- the per-photo errors say what actually went wrong. + local stats, statsErr = SearchIndexAPI.getTrainingStats() + if not stats then + log:warn("AI Edit could not read the style profile stats: " .. tostring(statsErr)) + end + + local modelsResp, modelsErr = SearchIndexAPI.getModels({ includeCloud = true }) + local savedKey = AiProviders.resolveSavedKey(modelsResp, prefs.aiEditModelKey or prefs.modelKey) + local models = { + items = AiProviders.modelItems(modelsResp, savedKey), + savedKey = savedKey, + warnings = SearchIndexAPI.condenseMessages(modelsResp and modelsResp.warnings), + } + if not modelsResp then + models.warnings = "The list of AI models could not be loaded: " .. tostring(modelsErr or "no answer") + end + + local options = showAiEditDialog(ctx, stats, models) + if not options then + log:info("AI Edit task canceled by user in options dialog") + return + end + log:trace( + "AI Edit options selected: engine=" + .. tostring(options.engine) + .. " scope=" + .. tostring(options.scope) + .. " model=" + .. tostring(options.modelKey) + .. " look=" + .. tostring(options.look) + .. " edits=" + .. tostring(options.maxEdits) + .. " style=" + .. tostring(options.useStyle) + .. " masks=" + .. tostring(options.useMasks) + ) + + if options.engine == "style" then + local trainingCount = (stats and tonumber(stats.count)) or 0 + if stats and trainingCount < MIN_TRAINING_EXAMPLES then + log:warn("AI Edit task aborted: only " .. tostring(trainingCount) .. " training example(s) available") LrDialogs.message( - LOC("$$$/LrGeniusAI/TaskAiEditPhotos/CompletionTitle=AI Edit Completed"), - combinedReport, - "warning" + LOC("$$$/LrGeniusAI/TaskAiEditPhotos/NoStyleProfileTitle=Style profile not ready"), + "My style only builds every recipe from your own saved edits, and it needs at least " + .. tostring(MIN_TRAINING_EXAMPLES) + .. " training examples to do that. You currently have " + .. tostring(trainingCount) + .. ".\n\nUse the AI retoucher instead, or edit a handful of photos the way you like them, " + .. "run Help → Save Edits as AI Training Examples and start AI Edit again.", + "info" ) + return + end + if options.refineWithAesthetic then + local answer = aestheticModelPreflight() + if answer == "cancel" then + log:info("AI Edit task canceled at the aesthetic model preflight") + return + elseif answer == "other" then + options.refineWithAesthetic = false + end end else - local summary = LOC( - "$$$/LrGeniusAI/TaskAiEditPhotos/SuccessSummary=Applied edits to ^1 photo(s).\nSkipped: ^2", - tostring(successCount), - tostring(skippedCount) - ) - if refinedCount > 0 then - summary = summary .. "\n" .. refinementSummary(refinedCount, reducedCount) + if Util.nilOrEmpty(options.modelKey) then + LrDialogs.showError("Choose an AI model for the retoucher in the AI Edit dialog.") + return end - if canceled then - summary = summary .. "\n\n" .. "Canceled before all photos were processed." + local unavailable = AiProviders.unavailableReason(modelsResp, options.modelKey) + if unavailable then + LrDialogs.showError(unavailable) + return end - LrDialogs.message(LOC("$$$/LrGeniusAI/TaskAiEditPhotos/SuccessTitle=AI Lightroom Edit"), summary, "info") end + + local photos = PhotoSelector.getPhotosInScope(options.scope) + if not photos or #photos == 0 then + LrDialogs.message( + LOC("$$$/LrGeniusAI/common/NoPhotosTitle=No Photos"), + LOC("$$$/LrGeniusAI/common/NoPhotosInScope=No photos found in the selected scope."), + "info" + ) + log:warn("AI Edit task found no photos in scope: " .. tostring(options.scope)) + return + end + + local progressScope = LrProgressScope({ + title = LOC("$$$/LrGeniusAI/TaskAiEditPhotos/ProgressTitle=Generating AI Lightroom edits..."), + functionContext = ctx, + }) + progressScope:setPortionComplete(0, #photos) + + local run = { + successCount = 0, + skippedCount = 0, + errorCount = 0, + errorMessages = {}, + -- One entry per distinct text with a photo count: a cause that hits + -- every photo in the run (white balance on JPEGs, say) is reported + -- once, not once per photo. + warningTally = Util.newWarningTally(), + refinedCount = 0, + reducedCount = 0, + -- "AI Edit" virtual copies left in the catalog by an attempt that + -- then failed or was discarded: the SDK cannot delete photos. + strayCopyCount = 0, + canceled = false, + summaryLines = {}, + } + + if options.engine == "style" then + runStyleEdits(ctx, photos, options, progressScope, run) + else + runRetoucherEdits(photos, options, progressScope, run) + end + + progressScope:done() + showCompletion(run) log:info( - "AI Edit task completed. success=" - .. tostring(successCount) + "AI Edit task completed. engine=" + .. tostring(options.engine) + .. " success=" + .. tostring(run.successCount) .. " skipped=" - .. tostring(skippedCount) + .. tostring(run.skippedCount) .. " errors=" - .. tostring(errorCount) + .. tostring(run.errorCount) ) end) end) diff --git a/plugin/LrGeniusAI.lrdevplugin/TaskDevelopExperiments.lua b/plugin/LrGeniusAI.lrdevplugin/TaskDevelopExperiments.lua new file mode 100644 index 00000000..fceb585b --- /dev/null +++ b/plugin/LrGeniusAI.lrdevplugin/TaskDevelopExperiments.lua @@ -0,0 +1,2612 @@ +--- Developer task: controlled develop-settings experiments. +-- +-- The AI Edit XMP research (docs/wiki/Dev-AI-Edit-XMP-Findings.md) settled most +-- questions from files on disk, but these decide the architecture and can only +-- be answered by Lightroom itself: +-- +-- E13 In which orientation does the SDK report `dimensions` and +-- `croppedDimensions`, and what does `orientation` look like at runtime? +-- Also records every top-level develop key of each photo and whether +-- the photo has develop edits; on an unedited raw and JPEG that is +-- Lightroom's raw and non-raw default table. (read-only) +-- E1 Does `applyDevelopSettings` accept the `Temp` key AI Edit writes today, +-- or only `Temperature` / `IncrementalTemperature`? And does writing only +-- a mode (`WhiteBalance="Daylight"` / `"Auto"`) make Lightroom recompute +-- the temperature and tint, and how soon? +-- E11 Does an Adobe Raw profile `Look` transfer through +-- `applyDevelopSettings`: as a stub in the form presets carry it +-- (`Stubbed = true`), as a bare stub, and as the full table with +-- `Parameters` taken from a photo? Raw photos only. +-- E2 Can `applyDevelopSettings` add a new AI mask (subject, sky, background, +-- people part), and does Lightroom compute it - on its own or after +-- `updateAISettings()`? How long does the update call take, and how long +-- until the mask is ready? +-- E4 How do plugin presets behave: same-name re-adds, where the file lives +-- and what it holds, merging with existing masks, re-applying, amount. +-- E4f: can a preset be applied and its file deleted right after, and +-- what does Lightroom still report about it until a restart? +-- +-- Everything that writes goes to virtual copies named "LrG Exp ...", never to +-- the selected photos themselves. E4 additionally leaves hidden plugin presets +-- behind: they are files in Lightroom's preset folder, shared by every catalog, +-- and the SDK has no call to delete a preset. E4f deletes its own preset files +-- with LrFileUtils.delete - only paths that pass +-- `DevelopExperiments.presetFileDeletable` - and the final dialog lists every +-- preset file that still exists. +-- +-- The result is a Markdown report plus the same data as JSON next to it (a +-- numbered name if a .json of that name already exists). Nothing is judged +-- beyond what the readback shows; a few things (history step names, what a mask +-- covers) are listed as manual checks because the SDK cannot read them. + +require("DevelopExperiments") + +local X = DevelopExperiments + +local MAX_PHOTOS = 4 +-- The first AI detection in a session loads the model. A short wait here would +-- turn "Lightroom computes on its own, slowly" into "only after +-- updateAISettings()", so the automatic window gets a comparable budget. +local AUTO_COMPUTE_WAIT_SECONDS = 30 +local AI_POLL_SECONDS = 60 +local AVAILABLE_WAIT_SECONDS = 60 +local PRESET_NAME = "LrGenius Experiment E4" +local AMOUNT_PRESET_NAME = "LrGenius Experiment E4 Amount" +local DELETE_PRESET_NAME = "LrGenius Experiment E4f" +local DELETE_PRESET_CONTRAST = 25 +local READD_PRESET_CONTRAST = 15 +-- A white-balance mode may be resolved after `applyDevelopSettings` returns, +-- and Auto on a raw needs the pixels decoded first. E1's mode-only variants +-- read back right away, then every WB_POLL_INTERVAL seconds until the +-- temperature or tint changes or WB_POLL_SECONDS pass. +local WB_POLL_SECONDS = 15 +local WB_POLL_INTERVAL = 0.5 +-- E11b's preset fallback needs one full Look whose name differs from the +-- photo's; two distinct names always leave one that does. +local FULL_LOOKS_WANTED = 2 + +local WB_KEYS = { "WhiteBalance", "Temperature", "Tint", "IncrementalTemperature", "IncrementalTint", "Temp" } + +local CONTEXT_KEYS = { + "orientation", + "CropLeft", + "CropTop", + "CropRight", + "CropBottom", + "CropAngle", + "HasCrop", + "ProcessVersion", + "CameraProfile", + "PerspectiveUpright", + "LensProfileEnable", + "EnableLensCorrections", + "EnableMaskGroupBasedCorrections", + "WhiteBalance", + "Temperature", + "Tint", + "IncrementalTemperature", + "IncrementalTint", + "Temp", + "Exposure2012", + "Contrast2012", +} + +local function round(value, digits) + local factor = 10 ^ (digits or 1) + return math.floor(value * factor + 0.5) / factor +end + +local function photoLabel(photo) + local ok, label = LrTasks.pcall(function() + local name = photo:getFormattedMetadata("fileName") or "?" + local copyName = photo:getFormattedMetadata("copyName") + if copyName and copyName ~= "" then + return name .. " [" .. copyName .. "]" + end + return name + end) + return ok and label or "?" +end + +--- Reads the develop settings. On failure returns an empty table *and* the +-- error, so readbacks stay safe to index while writers can refuse to act on +-- a state they could not read. +local function readSettings(photo) + local ok, settings = LrTasks.pcall(function() + return photo:getDevelopSettings() + end) + if ok and type(settings) == "table" then + return settings, nil + end + return {}, "could not read develop settings: " .. tostring(settings) +end + +local function correctionsOf(photo) + local settings, err = readSettings(photo) + local groups = settings.MaskGroupBasedCorrections + if type(groups) ~= "table" then + return {}, err + end + return groups, err +end + +--- Runs `fn` inside a write gate. Returns ok, error text. +-- +-- With timeout parameters the SDK does not throw when write access is not +-- granted in time: it returns "aborted" and never runs `fn`. Treating that as +-- success would record "Lightroom ignored the setting" for a call that never +-- happened. +local function withWrite(catalog, actionName, fn) + local status + local ok, err = LrTasks.pcall(function() + status = catalog:withWriteAccessDo(actionName, fn, Defaults.catalogWriteAccessOptions) + end) + if not ok then + return false, tostring(err) + end + if status ~= nil and status ~= "executed" then + return false, + string.format( + "Lightroom did not grant catalog write access within %s s (%s), so nothing was applied. Close other plug-in tasks and run the experiment again.", + tostring(Defaults.catalogWriteAccessOptions.timeout), + tostring(status) + ) + end + return true, nil +end + +--- `needsUpdateAISettings` is 15.3+; on older builds the error text is the answer. +local function needsUpdateAI(photo) + local ok, result = LrTasks.pcall(function() + return photo:needsUpdateAISettings() + end) + if ok then + return result + end + return "unavailable: " .. tostring(result) +end + +local function isAvailableForEditing(photo) + local ok, result = LrTasks.pcall(function() + return photo:isAvailableForEditing() + end) + if ok then + return result + end + return "unavailable" +end + +local function originalIsOnline(photo) + local ok, result = LrTasks.pcall(function() + return photo:checkPhotoAvailability() + end) + return ok and result == true +end + +local function currentModule() + local ok, name = LrTasks.pcall(function() + return LrApplicationView.getCurrentModuleName() + end) + return ok and name or "unknown" +end + +--- Waits while an AI job holds the photo (15.3+). Writing to a locked photo +-- would fail or be skipped, and the next readback would blame the experiment. +-- @return boolean, number, string|nil true when the photo is (or cannot be +-- told to be anything but) available; the seconds waited; and when it is +-- not, why: "canceled" or "locked". +local function waitUntilAvailable(photo, progress) + local started = LrDate.currentTime() + while true do + local available = isAvailableForEditing(photo) + local waited = round(LrDate.currentTime() - started) + if available ~= false then + return true, waited, nil + end + if progress:isCanceled() then + return false, waited, "canceled" + end + if waited >= AVAILABLE_WAIT_SECONDS then + return false, waited, "locked" + end + LrTasks.sleep(1) + end +end + +-- A step skipped because the user canceled is not a failure of Lightroom; +-- `countFailedSteps` leaves it out. +local CANCELED_MESSAGE = "not run (canceled)" + +local function unavailableMessage(waited, why) + if why == "canceled" then + return CANCELED_MESSAGE + end + return "the photo stayed locked by another AI job for " .. tostring(waited) .. " s" +end + +-- Same approach as `createVirtualCopyFor` in TaskAiEditPhotos.lua: +-- `createVirtualCopies` copies whatever is selected, so select exactly this +-- photo first and verify the result before trusting it. +local function selectOnly(catalog, photo) + local ok = LrTasks.pcall(function() + catalog:setSelectedPhotos(photo, { photo }) + end) + if not ok then + return false + end + local selected = catalog:getTargetPhotos() + return type(selected) == "table" and #selected == 1 and selected[1] == photo +end + +local function createVirtualCopy(catalog, photo, copyName) + if not selectOnly(catalog, photo) then + local switched = LrTasks.pcall(function() + catalog:setActiveSources({ catalog.kAllPhotos }) + LrTasks.sleep(0.2) + end) + if not switched or not selectOnly(catalog, photo) then + return nil, "the photo could not be selected in Lightroom" + end + end + + local copies + local ok, err = withWrite(catalog, "LrGenius experiments: create virtual copy", function() + copies = catalog:createVirtualCopies(copyName) + end) + if not ok then + return nil, err + end + if type(copies) ~= "table" or #copies ~= 1 then + return nil, "Lightroom did not return exactly one virtual copy" + end + -- A copy of a virtual copy belongs to the same master, not to the copy. + local expectedMaster = photo + if photo:getRawMetadata("isVirtualCopy") then + expectedMaster = photo:getRawMetadata("masterPhoto") + end + if copies[1]:getRawMetadata("masterPhoto") ~= expectedMaster then + return nil, "Lightroom copied a different photo" + end + return copies[1], nil +end + +--- Polls until the correction with `syncId` reaches a terminal state +-- ("computed", "failed", "no-ai-mask"), the run is canceled, or +-- `timeoutSeconds` pass. A missing correction is polled too: an applied preset +-- may only show up later. The timeline records every change of mask state, +-- `needsUpdateAISettings` and `isAvailableForEditing`, so a flip during the +-- wait is visible as evidence of an automatic computation. `endedAt` is the +-- absolute time of the last reading, for timings anchored elsewhere. +local function pollCorrection(photo, syncId, timeoutSeconds, progress) + local started = LrDate.currentTime() + local timeline = {} + local last = {} + local state, matches, elapsed, readErr, endedAt + while true do + local groups + groups, readErr = correctionsOf(photo) + matches = X.findCorrections(groups, syncId) + -- An unreadable state is terminal: polling on would report "still + -- missing" for a mask that may well be there. + state = readErr and "unreadable" or X.correctionState(matches[1]) + endedAt = LrDate.currentTime() + elapsed = endedAt - started + local needs = needsUpdateAI(photo) + local available = isAvailableForEditing(photo) + if state ~= last.state or needs ~= last.needs or available ~= last.available then + table.insert(timeline, { + seconds = round(elapsed), + state = state, + needsUpdateAISettings = needs, + isAvailableForEditing = available, + }) + last = { state = state, needs = needs, available = available } + end + if + state == "computed" + or state == "failed" + or state == "no-ai-mask" + or state == "unreadable" + or elapsed >= timeoutSeconds + then + break + end + if progress:isCanceled() then + state = "canceled" + break + end + LrTasks.sleep(1) + end + return { + state = state, + seconds = round(elapsed), + endedAt = endedAt, + budgetSeconds = timeoutSeconds, + timedOut = state ~= "computed" + and state ~= "failed" + and state ~= "no-ai-mask" + and state ~= "canceled" + and state ~= "unreadable", + readError = readErr, + matchingCorrections = #matches, + timeline = timeline, + summary = X.summarizeCorrections(matches), + needsUpdateAISettings = needsUpdateAI(photo), + isAvailableForEditing = isAvailableForEditing(photo), + } +end + +--- Creates an experiment record and registers it in `sink` (the report's +-- experiment list) right away, so an experiment that throws halfway still +-- shows up in the report with the steps it got through. +local function newExperiment(sink, id, title, question) + local exp = { id = id, title = title, question = question, verdicts = {}, manualChecks = {}, steps = {} } + table.insert(sink, exp) + return exp +end + +local function addStep(experiment, photo, label, ok, err, data) + table.insert(experiment.steps, { + photo = photo, + label = label, + ok = ok and true or false, + error = err, + data = X.plainValue(data), + }) +end + +local function countFailedSteps(experiments) + local failed = 0 + for _, experiment in ipairs(experiments) do + for _, step in ipairs(experiment.steps) do + if not step.ok and step.error ~= CANCELED_MESSAGE then + failed = failed + 1 + end + end + end + return failed +end + +--- An offline original cannot feed an AI computation, so every mask result on +-- it would read as "Lightroom does not compute". Say so up front. +local function noteIfOffline(experiment, photo, label) + if not originalIsOnline(photo) then + table.insert( + experiment.verdicts, + label .. ": the original file is offline - AI mask results for this photo are inconclusive" + ) + end +end + +--------------------------------------------------------------------------- +-- E13: runtime formats (read-only) +--------------------------------------------------------------------------- + +-- A successful call can return false or nil, and both are answers here: the +-- `ok and v or err` idiom would report them as errors. +local function readMetadataValue(fn) + local ok, value = LrTasks.pcall(fn) + if not ok then + return "error: " .. tostring(value) + end + if value == nil then + return "(nil)" + end + return X.plainValue(value) +end + +local function metadataKeys(fn) + local ok, all = LrTasks.pcall(fn) + if ok and type(all) == "table" then + return X.sortedKeys(all) + end + return ok and "(not a table)" or ("error: " .. tostring(all)) +end + +--- Whether the photo has develop edits, per Lightroom's own "Has +-- Adjustments" search. The search is narrowed to the file name so it stays +-- cheap in a large catalog, and is only trusted when the name search alone +-- finds the photo. +-- @return boolean|string true, false, or "unknown: ..." +local function hasDevelopAdjustments(catalog, photo) + local ok, result = LrTasks.pcall(function() + local name = photo:getFormattedMetadata("fileName") + local byName = { criteria = "filename", operation = "all", value = name } + local function contains(found) + for _, candidate in ipairs(found or {}) do + if candidate == photo then + return true + end + end + return false + end + if not contains(catalog:findPhotos({ searchDesc = byName })) then + return "unknown: a file-name search does not find the photo" + end + return contains(catalog:findPhotos({ + searchDesc = { + { criteria = "hasAdjustments", operation = "isTrue", value = true }, + byName, + combine = "intersect", + }, + })) + end) + if ok then + return result + end + return "unknown: " .. tostring(result) +end + +local function describeAdjustments(hasAdjustments) + if hasAdjustments == true then + return "has develop edits - not a default table" + end + if hasAdjustments == false then + return "no develop edits" + end + return "could not tell whether it has develop edits (" .. tostring(hasAdjustments) .. ")" +end + +local function runE13(catalog, photos, sink) + local exp = newExperiment( + sink, + "E13", + "Runtime formats of orientation, dimensions and crop", + "In which orientation does the SDK report dimensions and croppedDimensions, and what does the develop setting `orientation` look like?" + ) + for _, photo in ipairs(photos) do + local label = photoLabel(photo) + local raw = {} + for _, key in ipairs({ + "fileFormat", + "dimensions", + "croppedDimensions", + "isCropped", + "orientation", + "width", + "height", + "aspectRatio", + "isVirtualCopy", + "editCount", + "lastEditTime", + }) do + raw[key] = readMetadataValue(function() + return photo:getRawMetadata(key) + end) + end + local formatted = {} + for _, key in ipairs({ "dimensions", "croppedDimensions", "fileType" }) do + formatted[key] = readMetadataValue(function() + return photo:getFormattedMetadata(key) + end) + end + + local settings, readErr = readSettings(photo) + local develop = X.pick(settings, CONTEXT_KEYS) + if type(settings.Look) == "table" then + develop.LookName = settings.Look.Name + end + develop.maskGroups = X.summarizeCorrections(settings.MaskGroupBasedCorrections) + + local cropModel + local w, h = X.parseDimensions(raw.dimensions) + local cw, ch = X.parseDimensions(raw.croppedDimensions) + if w and cw then + cropModel = X.cropModelCheck({ w = w, h = h }, { w = cw, h = ch }, { + left = settings.CropLeft, + top = settings.CropTop, + right = settings.CropRight, + bottom = settings.CropBottom, + angle = settings.CropAngle, + }) + table.insert( + exp.verdicts, + string.format( + "%s: orientation = %s; %s", + label, + X.inlineValue(X.plainValue(settings.orientation)), + X.describeCropModel(cropModel, settings.orientation) + ) + ) + else + table.insert( + exp.verdicts, + label + .. ": could not parse dimensions " + .. X.inlineValue(raw.dimensions) + .. " / " + .. X.inlineValue(raw.croppedDimensions) + ) + end + + addStep(exp, label, "read metadata and develop settings", readErr == nil, readErr, { + rawMetadata = raw, + formattedMetadata = formatted, + -- Settles whether `orientation` is a raw key at all: the SDK + -- returns every available field for a nil key. + rawMetadataKeys = metadataKeys(function() + return photo:getRawMetadata(nil) + end), + develop = develop, + cropModel = cropModel, + }) + + -- The complete top-level key set, for the raw and non-raw default + -- tables. It goes into the step data only: the JSON has it in full, + -- the verdict just says where to look. + if readErr == nil then + local full = X.summarizeSettings(settings) + local family = X.settingsFamily(settings) + local look = X.summarizeLook(settings.Look) + local keyCount = #X.sortedKeys(full) + local hasAdjustments = hasDevelopAdjustments(catalog, photo) + addStep(exp, label, "full develop-settings readback", true, nil, { + fileFormat = raw.fileFormat, + whiteBalanceFamily = family, + hasAdjustments = hasAdjustments, + editCount = raw.editCount, + lastEditTime = raw.lastEditTime, + keyCount = keyCount, + settings = full, + }) + table.insert( + exp.verdicts, + string.format( + "%s: %s; full readback has %d top-level keys, white-balance family %s, %s (every key in the JSON, step 'full develop-settings readback')", + label, + describeAdjustments(hasAdjustments), + keyCount, + tostring(family or "unknown"), + look + and string.format( + "Look %s (Parameters: %s)", + tostring(look.Name), + look.hasParameters and "yes" or "no" + ) + or "no Look" + ) + ) + end + end + table.insert( + exp.manualChecks, + "The frame verdict needs a portrait-orientation photo (rotated in camera) whose crop is straightened or changes the aspect ratio; otherwise it says 'ambiguous'." + ) + table.insert( + exp.manualChecks, + "The full readback shows Lightroom's defaults only for a photo without develop edits (the verdict says which ones have edits) and only if Preferences > Presets > Raw Defaults is 'Adobe Default': use a freshly imported raw and JPEG for the default tables." + ) + return exp +end + +--------------------------------------------------------------------------- +-- E1: white balance keys +--------------------------------------------------------------------------- + +--- Raw or not, decided by the photo's white-balance key family where it has +-- one (a DNG converted from a JPEG is non-raw to Lightroom), by the file +-- format otherwise. +-- @return boolean, string|nil, string|nil isRaw, file format, family +local function isRawFile(photo) + local ok, format = LrTasks.pcall(function() + return photo:getRawMetadata("fileFormat") + end) + format = ok and format or nil + local settings = readSettings(photo) + local family = X.settingsFamily(settings) + if family then + return family == "raw", format, family + end + return format == "RAW" or format == "DNG", format, nil +end + +--- Every variant gets a fresh virtual copy, so each starts from the master's +-- white balance instead of whatever the previous variant left behind. +-- `modeOnly` variants write a mode without values and ask whether Lightroom +-- recomputes the temperature and tint; they first put the copy on a +-- distinctive Custom white balance (`X.wbPrecondition`) so a recomputation +-- cannot hide behind values that happen to match. `flattenAuto` passes +-- optFlattenAutoNow = true, which the SDK documents as resolving Auto +-- settings synchronously. +local function e1Variants(isRaw) + if isRaw then + return { + { id = "E1a", settings = { Temp = 7000 } }, + { id = "E1b", settings = { Temperature = 7100 } }, + { id = "E1c", settings = { WhiteBalance = "Custom", Temperature = 7200, Tint = 12 } }, + -- A no-op under "As Shot" must not be mistaken for the key being + -- rejected: try `Temp` again with the mode set in the same call. + { id = "E1d", settings = { WhiteBalance = "Custom", Temp = 6500 }, probe = "Temperature", expect = 6500 }, + { id = "E1e", settings = { WhiteBalance = "Daylight" }, modeOnly = true }, + { id = "E1f", settings = { WhiteBalance = "Auto" }, modeOnly = true }, + { id = "E1g", settings = { WhiteBalance = "Auto" }, modeOnly = true, flattenAuto = true }, + } + end + return { + { id = "E1a", settings = { Temp = 20 } }, + { id = "E1b", settings = { IncrementalTemperature = 21, IncrementalTint = 11 } }, + { id = "E1c", settings = { Temperature = 22 } }, + { + id = "E1d", + settings = { WhiteBalance = "Custom", Temp = 15 }, + probe = "IncrementalTemperature", + expect = 15, + }, + -- Non-raw files have no named light sources, only As Shot, Auto and + -- Custom; E1e (Daylight) is raw-only. + { id = "E1f", settings = { WhiteBalance = "Auto" }, modeOnly = true }, + { id = "E1g", settings = { WhiteBalance = "Auto" }, modeOnly = true, flattenAuto = true }, + } +end + +local function e1Outcome(variant, ok, err, readErr, after, changed, context) + if not ok then + return "raised an error: " .. tostring(err) + end + if readErr then + return "applied, but the result is unknown - " .. tostring(readErr) + end + if variant.modeOnly then + local text = X.describeWbModeOutcome(variant.settings.WhiteBalance, context.family, context.before, after, { + immediate = context.immediate, + settle = context.settle, + flatten = variant.flattenAuto == true, + }) + if not context.preconditionHeld then + text = text + .. string.format( + " (the Custom precondition did not take%s, so the copy started from the master's white balance)", + context.preconditionError and (": " .. tostring(context.preconditionError)) or "" + ) + end + return text + end + if variant.probe then + if after[variant.probe] == variant.expect then + return string.format("Temp honoured: %s = %s", variant.probe, tostring(after[variant.probe])) + end + return string.format( + "Temp ignored even with WhiteBalance=Custom in the same call (%s = %s, WhiteBalance = %s)", + variant.probe, + tostring(after[variant.probe]), + tostring(after.WhiteBalance) + ) + end + if next(changed) == nil then + return "accepted without error, but changed nothing" + end + return "changed " .. X.inlineValue(X.plainValue(changed), 400) +end + +--- Reads a mode-only copy back right away, then polls until its +-- temperature or tint differs from `before` or WB_POLL_SECONDS pass. +-- @return table, string|nil, table, table after, read error, the immediate +-- readback, and `{ seconds, changed, canceled }` - `seconds` is when the +-- pair changed (0 = already in the immediate readback) or how long it did not. +local function waitForWbChange(copy, family, before, progress) + local started = LrDate.currentTime() + local settings, err = readSettings(copy) + local immediate = X.pick(settings, WB_KEYS) + if err then + return immediate, err, nil, nil + end + if X.wbPairChanged(family, before, immediate) then + return immediate, nil, immediate, { seconds = 0, changed = true } + end + local after = immediate + while true do + local elapsed = LrDate.currentTime() - started + local canceled = progress:isCanceled() + if elapsed >= WB_POLL_SECONDS or canceled then + return after, nil, immediate, { seconds = round(elapsed), changed = false, canceled = canceled or nil } + end + LrTasks.sleep(WB_POLL_INTERVAL) + settings, err = readSettings(copy) + if err then + return after, err, immediate, nil + end + after = X.pick(settings, WB_KEYS) + if X.wbPairChanged(family, before, after) then + return after, nil, immediate, { seconds = round(LrDate.currentTime() - started), changed = true } + end + end +end + +local function runE1(catalog, photos, progress, sink) + local exp = newExperiment( + sink, + "E1", + "White balance keys via applyDevelopSettings", + "Does applyDevelopSettings accept the `Temp` key AI Edit writes today, or only `Temperature` (raw) / `IncrementalTemperature` (non-raw)? And does writing only a mode (Daylight, Auto) make Lightroom recompute the temperature and tint?" + ) + for _, photo in ipairs(photos) do + local label = photoLabel(photo) + local isRaw, format, family = isRawFile(photo) + for _, variant in ipairs(e1Variants(isRaw)) do + if progress:isCanceled() then + break + end + progress:setCaption("E1: " .. label .. " " .. variant.id) + local copy, copyErr = createVirtualCopy(catalog, photo, "LrG Exp " .. variant.id) + if not copy then + addStep(exp, label, variant.id .. " create virtual copy", false, copyErr, nil) + table.insert(exp.verdicts, string.format("%s %s: skipped - %s", label, variant.id, tostring(copyErr))) + else + local copyLabel = photoLabel(copy) + local startFamily = family or (isRaw and "raw" or "non-raw") + local precondition, preconditionOk, preconditionErr + if variant.modeOnly then + precondition = X.wbPrecondition(startFamily) + preconditionOk, preconditionErr = withWrite( + catalog, + "LrGenius experiment " .. variant.id .. " precondition", + function() + copy:applyDevelopSettings(precondition, "LrGenius " .. variant.id .. " precondition", false) + end + ) + end + local beforeSettings, beforeErr = readSettings(copy) + local before = X.pick(beforeSettings, WB_KEYS) + local held = precondition ~= nil and preconditionOk and X.preconditionHeld(precondition, before) + if precondition ~= nil and preconditionOk and not held and not beforeErr then + preconditionErr = "it reads back as " .. X.inlineValue(before) + end + -- A mode-only write judges the family of the copy itself, not + -- of the file format. + local copyFamily = X.settingsFamily(beforeSettings) or startFamily + local flatten = variant.flattenAuto == true + local ok, err = withWrite(catalog, "LrGenius experiment " .. variant.id, function() + copy:applyDevelopSettings(variant.settings, "LrGenius " .. variant.id, flatten) + end) + local after, afterErr, immediate, settle + if variant.modeOnly and ok then + after, afterErr, immediate, settle = waitForWbChange(copy, copyFamily, before, progress) + else + local afterSettings + afterSettings, afterErr = readSettings(copy) + after = X.pick(afterSettings, WB_KEYS) + end + local readErr = beforeErr or afterErr + local changed = X.diff(before, after, WB_KEYS) + local applied = X.inlineValue(variant.settings) + if flatten then + applied = applied .. " with optFlattenAutoNow=true" + end + -- A precondition that could not be written is a failed step; one + -- that wrote but read back differently is only noted. + local preconditionFailed = precondition ~= nil and not preconditionOk + local stepErr = err + or readErr + or (preconditionFailed and ("precondition: " .. tostring(preconditionErr))) + or nil + addStep( + exp, + copyLabel, + variant.id .. " apply " .. applied, + ok and not readErr and not preconditionFailed, + stepErr, + { + fileFormat = format, + whiteBalanceFamily = copyFamily, + precondition = precondition, + preconditionHeld = precondition ~= nil and held or nil, + preconditionError = preconditionErr, + applied = variant.settings, + flattenAutoNow = flatten, + before = before, + afterImmediately = immediate, + settle = settle, + after = after, + changed = changed, + } + ) + table.insert( + exp.verdicts, + string.format( + "%s (%s) %s %s from WhiteBalance=%s: %s", + label, + tostring(format), + variant.id, + applied, + tostring(before.WhiteBalance), + e1Outcome(variant, ok, err, readErr, after, changed, { + family = copyFamily, + before = before, + immediate = immediate, + settle = settle, + preconditionHeld = held, + preconditionError = preconditionErr, + }) + ) + ) + end + end + end + table.insert( + exp.manualChecks, + "History panel of each 'LrG Exp E1a' ... 'E1g' copy: is the variant's step named 'LrGenius E1x' (optHistoryName honoured) or a generic 'Multiple Settings'?" + ) + table.insert(exp.manualChecks, "Basic panel of each 'LrG Exp E1x' copy: does it show that variant's white balance?") + table.insert( + exp.manualChecks, + "Basic panel of the 'LrG Exp E1e/E1f/E1g' copies: does the WB menu show Daylight/Auto, and do the Temp and Tint sliders show the values the report read back? Their history starts with an 'LrGenius E1x precondition' step (a Custom white balance) before the mode itself." + ) + return exp +end + +--------------------------------------------------------------------------- +-- E2: AI masks through applyDevelopSettings +--------------------------------------------------------------------------- + +local function applyCorrections(catalog, photo, additions, actionName, historyName) + local existing, readErr = correctionsOf(photo) + if readErr then + -- Writing the additions onto an unread state would drop every mask the + -- copy inherited from its master. + return false, readErr, 0 + end + local ok, err = withWrite(catalog, actionName, function() + photo:applyDevelopSettings({ + EnableMaskGroupBasedCorrections = true, + MaskGroupBasedCorrections = X.appendCorrections(existing, additions), + }, historyName, false) + end) + return ok, err, #existing +end + +--- Calls `updateAISettings()` in a write gate and times it. +-- @return boolean, string|nil, table ok, error, and `{ updateSeconds, +-- updateCallSeconds, gateWaitSeconds, callStartedAt }`: the time around the +-- write gate, the call itself, the wait for write access before it, and the +-- absolute time the call started (the last three nil when the gate never +-- ran the function). +local function updateAI(catalog, photo, actionName) + local started = LrDate.currentTime() + local callStartedAt, callSeconds + local ok, err = withWrite(catalog, actionName, function() + callStartedAt = LrDate.currentTime() + photo:updateAISettings() + callSeconds = LrDate.currentTime() - callStartedAt + end) + return ok, + err, + { + updateSeconds = round(LrDate.currentTime() - started, 2), + updateCallSeconds = callSeconds and round(callSeconds, 2), + gateWaitSeconds = callStartedAt and round(callStartedAt - started, 2), + callStartedAt = callStartedAt, + } +end + +--- True when any reading of an unasked-wait timeline found the photo locked: +-- Lightroom was computing on its own. +local function lockedDuringWait(poll) + for _, entry in ipairs(type(poll) == "table" and poll.timeline or {}) do + if entry.isAvailableForEditing == false then + return true + end + end + return false +end + +local function runE2Extras(exp, catalog, copy, copyLabel, progress) + local extras = { + { + correction = X.aiMaskCorrection({ name = "LrGenius E2 Sky -1 EV", mask = X.MASKS.sky, exposureStops = -1 }), + needs = "visible sky", + }, + { + correction = X.aiMaskCorrection({ + name = "LrGenius E2 Background -1 EV", + mask = X.MASKS.background, + exposureStops = -1, + }), + needs = "a clear subject", + }, + { + correction = X.aiMaskCorrection({ name = "LrGenius E2 Hair +1 EV", mask = X.MASKS.hair, exposureStops = 1 }), + needs = "a visible person", + }, + } + local corrections = {} + for _, extra in ipairs(extras) do + table.insert(corrections, extra.correction) + end + + local available, waited, why = waitUntilAvailable(copy, progress) + local okExtra, errExtra = false, unavailableMessage(waited, why) + if available then + okExtra, errExtra = applyCorrections( + catalog, + copy, + corrections, + "LrGenius experiment E2d", + "LrGenius E2d sky, background, hair masks" + ) + end + local okUpdate, errUpdate, updateTiming = false, errExtra, nil + if okExtra then + okUpdate, errUpdate, updateTiming = updateAI(catalog, copy, "LrGenius experiment E2d update") + end + addStep(exp, copyLabel, "E2d apply sky, background, hair and updateAISettings()", okExtra and okUpdate, errUpdate, { + applied = okExtra, + applyError = errExtra, + updated = okUpdate, + updateError = errUpdate, + updateSeconds = updateTiming and updateTiming.updateSeconds, + updateCallSeconds = updateTiming and updateTiming.updateCallSeconds, + gateWaitSeconds = updateTiming and updateTiming.gateWaitSeconds, + }) + + for _, extra in ipairs(extras) do + local name = extra.correction.CorrectionName + if not okExtra then + table.insert(exp.verdicts, copyLabel .. ": E2d " .. name .. " not applied: " .. tostring(errExtra)) + elseif not okUpdate then + table.insert( + exp.verdicts, + copyLabel .. ": E2d " .. name .. " applied, but updateAISettings() failed: " .. tostring(errUpdate) + ) + else + local polled = pollCorrection(copy, extra.correction.CorrectionSyncID, AI_POLL_SECONDS, progress) + addStep(exp, copyLabel, "E2d poll " .. name, true, nil, polled) + local verdict = copyLabel .. ": E2d " .. name .. " is " .. X.describePoll(polled) + if polled.state == "failed" then + verdict = verdict .. " - only meaningful on a photo with " .. extra.needs + end + table.insert(exp.verdicts, verdict) + end + end +end + +--- Runs E2a-E2d on one photo. +-- @return table The photo's timing entry for `X.describeTimings`: how long +-- the subject mask took to be ready, and how long the update call took. +local function runE2OnPhoto(exp, catalog, photo, progress) + local label = photoLabel(photo) + local copy, copyErr = createVirtualCopy(catalog, photo, "LrG Exp E2") + if not copy then + addStep(exp, label, "create virtual copy", false, copyErr, nil) + table.insert(exp.verdicts, label .. ": skipped - " .. tostring(copyErr)) + return { photo = label, source = "none", reason = "no virtual copy" } + end + local copyLabel = photoLabel(copy) + local function notMeasured(reason) + return { photo = copyLabel, source = "none", reason = reason } + end + local module = currentModule() + noteIfOffline(exp, photo, copyLabel) + local needsBefore = needsUpdateAI(copy) + + -- E2a: one subject mask, +1 EV so it is obvious on screen. + local subject = X.aiMaskCorrection({ + name = "LrGenius E2 Subject +1 EV", + mask = X.MASKS.subject, + exposureStops = 1, + }) + local availableFirst, waitedFirst, whyFirst = waitUntilAvailable(copy, progress) + local ok, err, countBefore = false, unavailableMessage(waitedFirst, whyFirst), 0 + if availableFirst then + ok, err, countBefore = + applyCorrections(catalog, copy, { subject }, "LrGenius experiment E2a", "LrGenius E2a subject mask") + end + local after = correctionsOf(copy) + local found = X.findCorrections(after, subject.CorrectionSyncID) + addStep(exp, copyLabel, "E2a applyDevelopSettings with a new subject mask", ok, err, { + module = module, + correctionsBefore = countBefore, + correctionsAfter = #after, + foundBySyncId = #found, + added = X.summarizeCorrections(found), + needsUpdateAISettingsBefore = needsBefore, + needsUpdateAISettingsAfter = needsUpdateAI(copy), + }) + if not ok then + table.insert(exp.verdicts, copyLabel .. ": E2a not applied: " .. tostring(err)) + return notMeasured(err == CANCELED_MESSAGE and "canceled" or "the subject mask was not applied") + end + if #found == 0 then + table.insert( + exp.verdicts, + string.format( + "%s: E2a applyDevelopSettings returned without error but dropped the new subject mask (corrections %d -> %d)", + copyLabel, + countBefore, + #after + ) + ) + return notMeasured("Lightroom dropped the subject mask") + end + table.insert( + exp.verdicts, + string.format( + "%s: E2a new subject mask %s (corrections %d -> %d, run from the %s module)", + copyLabel, + #found == 1 and "was kept" or ("found " .. #found .. " times"), + countBefore, + #after, + tostring(module) + ) + ) + + -- E2b: does Lightroom compute it without being asked? + local auto = pollCorrection(copy, subject.CorrectionSyncID, AUTO_COMPUTE_WAIT_SECONDS, progress) + addStep(exp, copyLabel, "E2b wait without updateAISettings", true, nil, auto) + table.insert( + exp.verdicts, + string.format( + "%s: E2b without updateAISettings (waited up to %d s) the subject mask is %s", + copyLabel, + AUTO_COMPUTE_WAIT_SECONDS, + X.describePoll(auto) + ) + ) + local timing + if auto.state == "canceled" then + return notMeasured("canceled") + elseif auto.state == "unreadable" then + timing = notMeasured("the mask state could not be read back") + elseif not auto.timedOut then + -- Computed without being asked: the time from the apply to that. + timing = { photo = copyLabel, source = "auto", state = auto.state, readySeconds = auto.seconds } + end + if progress:isCanceled() then + return timing or notMeasured("canceled") + end + + -- E2c: explicit update. + if auto.timedOut then + local available, waited, why = waitUntilAvailable(copy, progress) + local okUpdate, errUpdate, updateTiming = false, unavailableMessage(waited, why), nil + if available then + okUpdate, errUpdate, updateTiming = updateAI(catalog, copy, "LrGenius experiment E2c") + end + local polled = okUpdate and pollCorrection(copy, subject.CorrectionSyncID, AI_POLL_SECONDS, progress) or nil + if updateTiming and updateTiming.callStartedAt then + -- Anchored at the start of the call itself, so waiting for write + -- access is not counted as compute time. + timing = { + photo = copyLabel, + source = "update", + updateOk = okUpdate, + updateSeconds = updateTiming.updateSeconds, + updateCallSeconds = updateTiming.updateCallSeconds, + gateWaitSeconds = updateTiming.gateWaitSeconds, + waitedBeforeUpdate = waited, + lockedDuringAutoWait = lockedDuringWait(auto), + state = polled and polled.state, + readySeconds = polled and polled.endedAt and round(polled.endedAt - updateTiming.callStartedAt), + } + elseif updateTiming then + timing = notMeasured("write access was not granted: " .. tostring(errUpdate)) + else + timing = notMeasured(errUpdate == CANCELED_MESSAGE and "canceled" or tostring(errUpdate)) + end + addStep(exp, copyLabel, "E2c photo:updateAISettings() then poll", okUpdate, errUpdate, { + updateSeconds = updateTiming and updateTiming.updateSeconds, + updateCallSeconds = updateTiming and updateTiming.updateCallSeconds, + gateWaitSeconds = updateTiming and updateTiming.gateWaitSeconds, + waitedBeforeUpdate = waited, + lockedDuringAutoWait = lockedDuringWait(auto), + maskReadySeconds = X.timingIsTerminal(timing) and timing.readySeconds or nil, + timing = X.describeTiming(timing), + poll = polled, + }) + local outcome = okUpdate and ("the subject mask is " .. X.describePoll(polled)) + or ("the call failed: " .. tostring(errUpdate)) + table.insert( + exp.verdicts, + copyLabel .. ": E2c after updateAISettings() " .. outcome .. " (" .. X.describeTiming(timing) .. ")" + ) + end + if progress:isCanceled() then + return timing + end + + -- E2d: the other AI kinds, including a people-part category that + -- LrDevelopController.createNewMask cannot express. + runE2Extras(exp, catalog, copy, copyLabel, progress) + return timing +end + +local function runE2(catalog, photos, progress, lrVersion, sink) + local exp = newExperiment( + sink, + "E2", + "New AI masks via applyDevelopSettings", + "Can applyDevelopSettings add a digest-less AI mask definition, and does Lightroom compute it on its own or only after photo:updateAISettings()? How long do the update call and the mask take?" + ) + if not X.versionAtLeast(lrVersion, 15, 3) then + table.insert( + exp.verdicts, + "Lightroom is older than 15.3: needsUpdateAISettings/isAvailableForEditing are unavailable, so only the mask state is observed." + ) + end + local timings = {} + for _, photo in ipairs(photos) do + if progress:isCanceled() then + break + end + progress:setCaption("E2: " .. photoLabel(photo)) + table.insert(timings, runE2OnPhoto(exp, catalog, photo, progress)) + end + -- One line with every photo's numbers: the basis for any runtime estimate + -- of AI masks in a batch. + table.insert(exp.verdicts, X.describeTimings(timings)) + table.insert( + exp.manualChecks, + "Open an 'LrG Exp E2' copy in Develop > Masks: are the LrGenius masks listed, and does each cover what its name says (subject and hair brighter, sky and background darker)?" + ) + table.insert(exp.manualChecks, "Did Lightroom show an AI progress dialog during E2c/E2d?") + table.insert( + exp.manualChecks, + "History panel of an 'LrG Exp E2' copy: how many steps did E2a add, and how are they named?" + ) + return exp +end + +--------------------------------------------------------------------------- +-- E4: plugin presets +--------------------------------------------------------------------------- + +local function pluginPresets() + local ok, presets = LrTasks.pcall(function() + return LrApplication.getDevelopPresetsForPlugin(_PLUGIN) + end) + if ok and type(presets) == "table" then + return presets + end + return {} +end + +local function presetCall(preset, method) + local ok, value = LrTasks.pcall(function() + return preset[method](preset) + end) + if ok then + return value + end + return "error: " .. tostring(value) +end + +local function countPresetsNamed(name) + local total, named = 0, 0 + for _, preset in ipairs(pluginPresets()) do + total = total + 1 + if presetCall(preset, "getName") == name then + named = named + 1 + end + end + return total, named +end + +local function describePreset(preset) + if preset == nil then + return nil + end + local info = { + name = presetCall(preset, "getName"), + uuid = presetCall(preset, "getUuid"), + file = presetCall(preset, "getFile"), + } + local ok, setting = LrTasks.pcall(function() + return preset:getSetting() + end) + if ok and type(setting) == "table" then + info.settingKeys = X.sortedKeys(setting) + info.Contrast2012 = setting.Contrast2012 + info.Exposure2012 = setting.Exposure2012 + info.SupportsAmount = setting.SupportsAmount + info.SupportsAmount2 = setting.SupportsAmount2 + info.maskGroups = X.summarizeCorrections(setting.MaskGroupBasedCorrections) + else + info.settingError = tostring(setting) + end + if type(info.file) == "string" and LrFileUtils.exists(info.file) == "file" then + local okRead, content = LrTasks.pcall(function() + return LrFileUtils.readFile(info.file) + end) + if okRead and type(content) == "string" then + info.fileSize = #content + info.fileHead = content:sub(1, 3000) + info.fileMentionsSupportsAmount = content:find("SupportsAmount", 1, true) ~= nil + info.fileContrast2012 = X.presetFileSetting(content, "Contrast2012") + end + end + return info +end + +--- The SDK does not say whether addDevelopPresetForPlugin needs a write gate; +-- try without first and record which way worked. +local function addPluginPreset(catalog, name, value) + local ok, preset = LrTasks.pcall(function() + return LrApplication.addDevelopPresetForPlugin(_PLUGIN, name, value) + end) + if ok and preset ~= nil then + return preset, "outside a write gate" + end + local outsideError = ok and "returned nil" or tostring(preset) + local insidePreset + local okInside, errInside = withWrite(catalog, "LrGenius experiment: add plugin preset", function() + insidePreset = LrApplication.addDevelopPresetForPlugin(_PLUGIN, name, value) + end) + if okInside and insidePreset ~= nil then + return insidePreset, "inside a write gate (outside: " .. outsideError .. ")" + end + return nil, "outside: " .. outsideError .. "; inside: " .. tostring(errInside or "returned nil") +end + +local function applyPreset(catalog, photo, preset, amount, withAI, actionName) + return withWrite(catalog, actionName, function() + photo:applyDevelopPreset(preset, _PLUGIN, amount, withAI) + end) +end + +--- Remembers every preset file the run created, for the cleanup instructions. +local function recordPresetFile(exp, info) + if type(info) ~= "table" or type(info.file) ~= "string" or info.file:find("^error:") then + return + end + for _, known in ipairs(exp.presetFiles) do + if known == info.file then + return + end + end + table.insert(exp.presetFiles, info.file) +end + +-- E4c/E4d on one photo: seed an existing mask, apply v2 with AI update, then +-- apply it again. +local function runE4ApplyOnPhoto(exp, catalog, photo, p2, sky2, subject1, progress) + local label = photoLabel(photo) + local copy, copyErr = createVirtualCopy(catalog, photo, "LrG Exp E4") + if not copy then + addStep(exp, label, "create virtual copy", false, copyErr, nil) + table.insert(exp.verdicts, label .. ": skipped - " .. tostring(copyErr)) + return + end + local copyLabel = photoLabel(copy) + noteIfOffline(exp, photo, copyLabel) + + -- A gradient needs no AI computation and third-party plugins already write + -- it this way, so it is a dependable "mask the photo already has". Whether + -- the preset keeps it answers merge vs replace. + local seed = X.gradientCorrection({ name = "LrGenius E4 existing gradient", exposureStops = -0.5 }) + local seedAvailable, seedWaited, seedWhy = waitUntilAvailable(copy, progress) + local seedOk, seedErr = false, unavailableMessage(seedWaited, seedWhy) + if seedAvailable then + seedOk, seedErr = + applyCorrections(catalog, copy, { seed }, "LrGenius experiment E4 seed", "LrGenius E4 seed gradient") + end + local seeded = seedOk and #X.findCorrections(correctionsOf(copy), seed.CorrectionSyncID) > 0 + + local countBefore = #correctionsOf(copy) + local available, waited, why = waitUntilAvailable(copy, progress) + local ok, err = false, unavailableMessage(waited, why) + if available then + ok, err = applyPreset(catalog, copy, p2, 100, true, "LrGenius experiment E4c") + end + local settings, settingsErr = readSettings(copy) + local polled = ok and pollCorrection(copy, sky2.CorrectionSyncID, AI_POLL_SECONDS, progress) or nil + local seedKept = #X.findCorrections(correctionsOf(copy), seed.CorrectionSyncID) > 0 + local v1Found = #X.findCorrections(correctionsOf(copy), subject1.CorrectionSyncID) + addStep(exp, copyLabel, "E4c seed a gradient, then applyDevelopPreset(v2, amount 100, updateAI true)", ok, err, { + module = currentModule(), + seeded = seeded, + seedError = seedErr, + seedKept = seedKept, + Contrast2012 = settings.Contrast2012, + correctionsBefore = countBefore, + correctionsAfter = #correctionsOf(copy), + v1SubjectFound = v1Found, + sky = polled, + }) + if not ok then + table.insert(exp.verdicts, copyLabel .. ": E4c applyDevelopPreset failed: " .. tostring(err)) + return + end + if settingsErr then + table.insert(exp.verdicts, copyLabel .. ": E4c applied, but the result is unknown - " .. settingsErr) + return + end + local mergeVerdict + if not seeded then + mergeVerdict = "merge vs replace inconclusive (the seed gradient did not persist: " + .. tostring(seedErr or "dropped by applyDevelopSettings") + .. ")" + elseif seedKept then + mergeVerdict = "the existing mask was kept, so preset masks are ADDED" + else + mergeVerdict = "the existing mask is gone, so preset masks REPLACE the photo's masks" + end + table.insert( + exp.verdicts, + string.format( + "%s: E4c Contrast2012 = %s (v1 30, v2 -30); %s; v1's subject mask came along with v2: %s; v2 sky mask %s", + copyLabel, + tostring(settings.Contrast2012), + mergeVerdict, + tostring(v1Found > 0), + polled and X.describePoll(polled) or "?" + ) + ) + + if progress:isCanceled() then + return + end + local availableAgain, waitedAgain, whyAgain = waitUntilAvailable(copy, progress) + local okAgain, errAgain = false, unavailableMessage(waitedAgain, whyAgain) + if availableAgain then + okAgain, errAgain = applyPreset(catalog, copy, p2, 100, true, "LrGenius experiment E4d") + end + local groupsAfter, readAgainErr = correctionsOf(copy) + local skyCount = #X.findCorrections(groupsAfter, sky2.CorrectionSyncID) + addStep(exp, copyLabel, "E4d apply the same preset again", okAgain and not readAgainErr, errAgain or readAgainErr, { + skyCorrectionsWithSameSyncId = skyCount, + correctionsAfter = #groupsAfter, + }) + local outcome + if not okAgain then + outcome = "failed: " .. tostring(errAgain) + elseif readAgainErr then + outcome = "applied, but the result is unknown - " .. readAgainErr + elseif skyCount == 0 then + outcome = "left no mask with the preset's sync id at all" + elseif skyCount == 1 then + outcome = "updated the mask in place" + else + outcome = string.format("duplicated the mask (%d corrections with the preset's sync id)", skyCount) + end + table.insert(exp.verdicts, copyLabel .. ": E4d re-apply " .. outcome) +end + +local function amountVerdict(variantKey, amount, ok, err, data) + if not ok then + return string.format("E4e %s amount %d failed: %s", variantKey, amount, tostring(err)) + end + return string.format( + "E4e %s amount %d: Contrast2012 %s -> %s (preset 40), Exposure2012 %s -> %s (preset 0.5), mask CorrectionAmount = %s, LocalExposure2012 = %s (preset 0.25)", + variantKey, + amount, + tostring(data.Contrast2012Before), + tostring(data.Contrast2012), + tostring(data.Exposure2012Before), + tostring(data.Exposure2012), + tostring(data.maskCorrectionAmount), + tostring(data.maskLocalExposure2012) + ) +end + +-- E4e: amount semantics, with and without the SupportsAmount flags, so "plugin +-- presets ignore the amount" cannot be confused with "the preset was not +-- marked as scalable". +local function runE4Amount(exp, catalog, photo, progress) + local variants = { + { key = "plain", name = AMOUNT_PRESET_NAME, flags = {} }, + { + key = "flagged", + name = AMOUNT_PRESET_NAME .. " Flagged", + flags = { SupportsAmount = true, SupportsAmount2 = true }, + }, + } + local label = photoLabel(photo) + for _, variant in ipairs(variants) do + if progress:isCanceled() then + return + end + local subject = + X.aiMaskCorrection({ name = "LrGenius E4 Amount Subject", mask = X.MASKS.subject, exposureStops = 1 }) + local value = { + Contrast2012 = 40, + Exposure2012 = 0.5, + EnableMaskGroupBasedCorrections = true, + MaskGroupBasedCorrections = { subject }, + } + for key, flag in pairs(variant.flags) do + value[key] = flag + end + local preset, how = addPluginPreset(catalog, variant.name, value) + local info = describePreset(preset) + recordPresetFile(exp, info) + local createLabel = "E4e create amount preset (" .. variant.key .. ")" + addStep(exp, "(global)", createLabel, preset ~= nil, preset == nil and how or nil, { how = how, preset = info }) + if preset == nil then + table.insert( + exp.verdicts, + "E4e " .. variant.key .. " amount preset could not be created: " .. tostring(how) + ) + else + table.insert( + exp.verdicts, + string.format( + "E4e %s preset: getSetting SupportsAmount=%s, SupportsAmount2=%s; file mentions SupportsAmount: %s", + variant.key, + tostring(info.SupportsAmount), + tostring(info.SupportsAmount2), + tostring(info.fileMentionsSupportsAmount) + ) + ) + for _, amount in ipairs({ 50, 200 }) do + if progress:isCanceled() then + return + end + local copyName = "LrG Exp E4 amount " .. variant.key .. " " .. tostring(amount) + local amountCopy, amountCopyErr = createVirtualCopy(catalog, photo, copyName) + if not amountCopy then + addStep(exp, label, copyName .. ": create virtual copy", false, amountCopyErr, nil) + table.insert(exp.verdicts, copyName .. ": skipped - " .. tostring(amountCopyErr)) + else + -- The amount may blend from the photo's current values rather + -- than from zero, so the "before" is part of the answer. + local before, beforeErr = readSettings(amountCopy) + local actionName = "LrGenius experiment E4e " .. variant.key .. " " .. tostring(amount) + local applied, applyErr = applyPreset(catalog, amountCopy, preset, amount, false, actionName) + local settings, afterErr = readSettings(amountCopy) + local ok = applied and not beforeErr and not afterErr + local err = applyErr or beforeErr or afterErr + local mask = X.findCorrections(correctionsOf(amountCopy), subject.CorrectionSyncID)[1] + local data = { + variant = variant.key, + amount = amount, + Contrast2012Before = before.Contrast2012, + Exposure2012Before = before.Exposure2012, + Contrast2012 = settings.Contrast2012, + Exposure2012 = settings.Exposure2012, + maskCorrectionAmount = mask and mask.CorrectionAmount, + maskLocalExposure2012 = mask and mask.LocalExposure2012, + } + local stepLabel = "E4e " .. variant.key .. " amount " .. tostring(amount) + addStep(exp, photoLabel(amountCopy), stepLabel, ok, err, data) + table.insert(exp.verdicts, amountVerdict(variant.key, amount, ok, err, data)) + end + end + end + end +end + +local function pathExists(path) + if type(path) ~= "string" then + return false + end + local kind = LrFileUtils.exists(path) + return kind == "file" or kind == "directory" +end + +--- Takes `file` off the cleanup list once it no longer exists on disk, so the +-- final dialog only names files the user still has to delete. +local function forgetDeletedPresetFile(exp, file) + if type(file) ~= "string" or pathExists(file) then + return + end + for index = #exp.presetFiles, 1, -1 do + if exp.presetFiles[index] == file then + table.remove(exp.presetFiles, index) + end + end +end + +--- Deletes one E4f preset file - only a path `X.presetFileDeletable` accepts, +-- and only when it is a file: `LrFileUtils.delete` would remove a directory +-- with everything in it. +-- @param knownFiles table The preset files of the other E4 steps. +-- @return table `{ skipped, ok, error, existsAfter, returned }` for `X.describeE4f`. +local function deletePresetFile(file, knownFiles) + local allowed, why = X.presetFileDeletable(file, DELETE_PRESET_NAME, knownFiles) + if not allowed then + return { skipped = why } + end + local kind = LrFileUtils.exists(file) + if kind ~= "file" then + return { skipped = "there is no file at " .. file .. " (LrFileUtils.exists: " .. tostring(kind) .. ")" } + end + local deleted, reason + local ok, err = LrTasks.pcall(function() + deleted, reason = LrFileUtils.delete(file) + end) + local result = { existsAfter = pathExists(file), returned = tostring(deleted) } + if not ok then + result.ok = false + result.error = tostring(err) + elseif deleted ~= true and result.existsAfter then + result.ok = false + result.error = tostring(reason or ("LrFileUtils.delete returned " .. tostring(deleted))) + else + -- The file is gone even if the return value was not `true`, or the + -- call claimed success; `existsAfter` tells the two cases apart. + result.ok = true + end + return result +end + +local function deleteStepError(result) + return result.skipped or result.error or (result.existsAfter and "the file is still there" or nil) +end + +-- E4f, steps; `r` collects what `X.describeE4f` turns into verdicts and is +-- filled step by step, so a stop halfway leaves the later parts nil. +local function runE4DeleteSteps(exp, catalog, photo, progress, r) + progress:setCaption("E4f: apply a preset, then delete its file") + -- A snapshot taken before E4f records its own file: the path check + -- compares against the files the other E4 steps created. + local knownFiles = {} + for _, known in ipairs(exp.presetFiles) do + table.insert(knownFiles, known) + end + -- The presets of this name that are already listed, each with whether its + -- file exists: the only programmatic trace of an earlier run after a + -- restart, recorded before this run's add can overwrite that file. + r.before = {} + for _, listed in ipairs(pluginPresets()) do + if presetCall(listed, "getName") == DELETE_PRESET_NAME then + local listedFile = presetCall(listed, "getFile") + table.insert(r.before, { + uuid = presetCall(listed, "getUuid"), + file = listedFile, + fileExists = pathExists(listedFile), + }) + end + end + + local subject = X.aiMaskCorrection({ name = "LrGenius E4f Subject", mask = X.MASKS.subject, exposureStops = 0.5 }) + local preset, how = addPluginPreset(catalog, DELETE_PRESET_NAME, { + Contrast2012 = DELETE_PRESET_CONTRAST, + EnableMaskGroupBasedCorrections = true, + MaskGroupBasedCorrections = { subject }, + }) + local info = describePreset(preset) + -- Listed right away, so a run that stops before the delete still names + -- the file in the cleanup list. + recordPresetFile(exp, info) + r.create = { ok = preset ~= nil, error = preset == nil and how or nil } + addStep(exp, "(global)", "E4f addDevelopPresetForPlugin", preset ~= nil, preset == nil and how or nil, { + how = how, + preset = info, + before = r.before, + }) + if preset == nil then + return + end + local file = info.file + + -- Plugin presets never appear in Develop > Presets (SDK reference, + -- addDevelopPresetForPlugin), so the check looks at the folder itself. + local presetFolder = type(file) == "string" and not file:find("^error:") and X.splitPath(file) or nil + table.insert( + exp.manualChecks, + "Quit and restart Lightroom, then open the 'Plugin Develop Presets' folder " + .. (presetFolder and ("(" .. presetFolder .. ")") or "(the folder of the E4f preset path in the report)") + .. " in Finder or Explorer: is a file named '" + .. DELETE_PRESET_NAME + .. "' back? Plugin presets never show in Develop > Presets, so that panel tells nothing either way." + ) + table.insert( + exp.manualChecks, + "After that restart, run E4 again: its first E4f verdict then says whether presets named '" + .. DELETE_PRESET_NAME + .. "' are still listed, and for each whether its file is missing (kept somewhere else) or present (left over or written back at quit)." + ) + table.insert( + exp.manualChecks, + "After the restart, on the 'LrG Exp E4f' copy in Develop: does it still show the preset's contrast and subject mask, and how is the preset step named in the History panel?" + ) + + local function stopIfCanceled(label) + if progress:isCanceled() then + addStep(exp, "(global)", label, false, CANCELED_MESSAGE, { file = file }) + r.canceled = true + return true + end + return false + end + + -- Apply to a fresh copy and read it back. + local label = photoLabel(photo) + local copy, copyErr = createVirtualCopy(catalog, photo, "LrG Exp E4f") + if not copy then + addStep(exp, label, "E4f create virtual copy", false, copyErr, nil) + r.apply = { ok = false, error = "no virtual copy: " .. tostring(copyErr) } + return + end + local copyLabel = photoLabel(copy) + noteIfOffline(exp, photo, copyLabel) + local applyLabel = "E4f applyDevelopPreset(amount 100, updateAI true)" + local available, waited, why = waitUntilAvailable(copy, progress) + if not available and why == "canceled" then + addStep(exp, copyLabel, applyLabel, false, CANCELED_MESSAGE, { file = file }) + r.canceled = true + return + end + local ok, err = false, unavailableMessage(waited, why) + if available then + ok, err = applyPreset(catalog, copy, preset, 100, true, "LrGenius experiment E4f") + end + local polled = ok and pollCorrection(copy, subject.CorrectionSyncID, AI_POLL_SECONDS, progress) or nil + local settings, readErr = readSettings(copy) + r.apply = { + ok = ok, + error = err, + readError = ok and readErr or nil, + contrast = settings.Contrast2012, + maskFound = #X.findCorrections(settings.MaskGroupBasedCorrections, subject.CorrectionSyncID) > 0, + maskState = polled and X.describePoll(polled) or nil, + maskPollState = polled and polled.state or nil, + } + addStep(exp, copyLabel, applyLabel, ok and not readErr, err or readErr, { + Contrast2012 = settings.Contrast2012, + presetContrast2012 = DELETE_PRESET_CONTRAST, + subjectMaskFound = r.apply.maskFound, + subject = polled, + file = file, + }) + if not ok or readErr then + return + end + + -- Delete the preset's file. + if stopIfCanceled("E4f delete the preset file") then + return + end + r.delete = deletePresetFile(file, knownFiles) + forgetDeletedPresetFile(exp, file) + local deleteErr = deleteStepError(r.delete) + addStep(exp, "(global)", "E4f delete the preset file with LrFileUtils.delete", deleteErr == nil, deleteErr, { + file = file, + result = r.delete, + }) + if deleteErr ~= nil then + return + end + + -- What Lightroom still reports about the deleted preset. + local namedAfter, listedByUuid = 0, false + for _, listed in ipairs(pluginPresets()) do + if presetCall(listed, "getName") == DELETE_PRESET_NAME then + namedAfter = namedAfter + 1 + end + if info.uuid ~= nil and presetCall(listed, "getUuid") == info.uuid then + listedByUuid = true + end + end + local okLookup, found = LrTasks.pcall(function() + return LrApplication.getDevelopPresetsForPlugin(_PLUGIN, info.uuid) + end) + local lookup + if not okLookup then + lookup = "error: " .. tostring(found) + elseif found == nil then + lookup = "nothing" + else + lookup = "found " .. tostring(presetCall(found, "getName")) + end + local okSetting, setting = LrTasks.pcall(function() + return preset:getSetting() + end) + local settingResult = { ok = okSetting and type(setting) == "table" } + if settingResult.ok then + settingResult.contrast = setting.Contrast2012 + else + settingResult.error = okSetting and ("returned " .. type(setting)) or tostring(setting) + end + -- Deleting a preset must not undo an edit it made. + local firstSettings, firstErr = readSettings(copy) + r.after = { + namedCount = namedAfter, + listedByUuid = listedByUuid, + lookup = lookup, + setting = settingResult, + first = { + readError = firstErr, + contrast = firstSettings.Contrast2012, + maskFound = #X.findCorrections(firstSettings.MaskGroupBasedCorrections, subject.CorrectionSyncID) > 0, + -- Found only proves the definition is there; the state says + -- whether the computed mask is too. + maskState = X.correctionState( + X.findCorrections(firstSettings.MaskGroupBasedCorrections, subject.CorrectionSyncID)[1] + ), + }, + } + addStep(exp, copyLabel, "E4f what Lightroom reports after the delete", firstErr == nil, firstErr, r.after) + + -- Apply the preset object whose file is gone to a second fresh copy. + if stopIfCanceled("E4f apply the deleted preset to a second copy") then + return + end + local secondLabel = "E4f apply the deleted preset to a second copy" + local copy2, copy2Err = createVirtualCopy(catalog, photo, "LrG Exp E4f after delete") + if not copy2 then + addStep(exp, label, secondLabel, false, copy2Err, nil) + r.second = { copyError = copy2Err } + else + local copy2Label = photoLabel(copy2) + local available2, waited2, why2 = waitUntilAvailable(copy2, progress) + if not available2 and why2 == "canceled" then + addStep(exp, copy2Label, secondLabel, false, CANCELED_MESSAGE, { file = file }) + r.canceled = true + return + end + local ok2, err2 = false, unavailableMessage(waited2, why2) + if available2 then + ok2, err2 = applyPreset(catalog, copy2, preset, 100, true, "LrGenius experiment E4f after delete") + end + local settings2, readErr2 = readSettings(copy2) + r.second = { + ok = ok2, + error = err2, + readError = ok2 and readErr2 or nil, + contrast = settings2.Contrast2012, + maskFound = #X.findCorrections(settings2.MaskGroupBasedCorrections, subject.CorrectionSyncID) > 0, + fileBack = pathExists(file), + } + if r.second.fileBack then + -- Applying brought the file back: it is a file to clean up again. + recordPresetFile(exp, info) + end + -- Failing is a possible answer here, not a broken step: the step is + -- only failed when the copy could not be read back. + addStep(exp, copy2Label, secondLabel, readErr2 == nil, readErr2, r.second) + end + + -- Re-add the same name: does Lightroom write a file again, and where? + if stopIfCanceled("E4f re-add the same name") then + return + end + local readdPreset, readdHow = addPluginPreset(catalog, DELETE_PRESET_NAME, { + Contrast2012 = READD_PRESET_CONTRAST, + }) + local readdInfo = describePreset(readdPreset) + local readd = { + ok = readdPreset ~= nil, + error = readdPreset == nil and readdHow or nil, + expectedContrast = READD_PRESET_CONTRAST, + } + if readdInfo then + readd.file = readdInfo.file + readd.fileExists = pathExists(readdInfo.file) + readd.samePath = readdInfo.file == file + readd.sameUuid = readdInfo.uuid == info.uuid + -- New settings, or stale ones from the deleted preset of that name? + readd.contrast = readdInfo.Contrast2012 + if readd.fileExists then + readd.fileContrast = readdInfo.fileContrast2012 + if readd.fileContrast ~= nil then + readd.fileHasNewContrast = readd.fileContrast == READD_PRESET_CONTRAST + end + end + recordPresetFile(exp, readdInfo) + forgetDeletedPresetFile(exp, readdInfo.file) + end + -- Does every add-then-delete cycle grow the list until a restart? AI Edit + -- would run one per photo. + if readd.ok then + readd.namedCount = select(2, countPresetsNamed(DELETE_PRESET_NAME)) + end + r.readd = readd + addStep(exp, "(global)", "E4f re-add the same name", readd.ok, readd.error, { + how = readdHow, + preset = readdInfo, + result = readd, + }) + -- Clean up the re-added file too, so the restart check above looks at a + -- preset with no file left at all. + if readd.fileExists and not stopIfCanceled("E4f delete the re-added preset file") then + readd.cleanup = deletePresetFile(readdInfo.file, knownFiles) + forgetDeletedPresetFile(exp, readdInfo.file) + local cleanupErr = deleteStepError(readd.cleanup) + addStep(exp, "(global)", "E4f delete the re-added preset file", cleanupErr == nil, cleanupErr, { + file = readdInfo.file, + result = readd.cleanup, + }) + end +end + +-- E4f: the SDK has no call to delete a preset, so AI Edit could only clean up +-- a per-edit preset by deleting its file. Applies a preset, deletes the file, +-- and records what Lightroom still reports until it restarts. +local function runE4Delete(exp, catalog, photo, progress) + local r = { name = DELETE_PRESET_NAME, expectedContrast = DELETE_PRESET_CONTRAST } + runE4DeleteSteps(exp, catalog, photo, progress, r) + for _, line in ipairs(X.describeE4f(r)) do + table.insert(exp.verdicts, line) + end +end + +-- `presetFiles` is owned by the caller, so the cleanup list survives even if +-- the experiment throws halfway through. +local function runE4(catalog, photos, progress, lrVersion, presetFiles, sink) + local exp = newExperiment( + sink, + "E4", + "Plugin preset lifecycle", + "Where do plugin presets live and what do they hold, does a same-name add overwrite, are preset masks added to or replacing the photo's masks, is a re-apply idempotent, what does the amount do, and can a preset's file be deleted right after applying it?" + ) + exp.presetFiles = presetFiles + if not X.versionAtLeast(lrVersion, 15, 3) then + table.insert( + exp.verdicts, + "Lightroom is older than 15.3: applyDevelopPreset has no updateAI parameter there, so AI masks from presets are not expected to compute." + ) + end + + -- E4a/E4b: two presets under the same name, with different content. + progress:setCaption("E4: creating plugin presets") + local totalBefore, namedBefore = countPresetsNamed(PRESET_NAME) + local subject1 = + X.aiMaskCorrection({ name = "LrGenius E4 v1 Subject", mask = X.MASKS.subject, exposureStops = 0.5 }) + local p1, how1 = addPluginPreset(catalog, PRESET_NAME, { + Contrast2012 = 30, + EnableMaskGroupBasedCorrections = true, + MaskGroupBasedCorrections = { subject1 }, + }) + local p1Info = describePreset(p1) + recordPresetFile(exp, p1Info) + addStep(exp, "(global)", "E4a addDevelopPresetForPlugin v1", p1 ~= nil, p1 == nil and how1 or nil, { + how = how1, + preset = p1Info, + pluginPresetsBefore = totalBefore, + namedBefore = namedBefore, + }) + if p1 == nil then + table.insert(exp.verdicts, "E4a addDevelopPresetForPlugin failed: " .. tostring(how1)) + return exp + end + table.insert( + exp.verdicts, + string.format( + "E4a preset created %s; file %s (%s bytes); %d preset(s) of that name existed before", + how1, + tostring(p1Info.file), + tostring(p1Info.fileSize), + namedBefore + ) + ) + + local sky2 = X.aiMaskCorrection({ name = "LrGenius E4 v2 Sky", mask = X.MASKS.sky, exposureStops = -0.5 }) + local p2, how2 = addPluginPreset(catalog, PRESET_NAME, { + Contrast2012 = -30, + EnableMaskGroupBasedCorrections = true, + MaskGroupBasedCorrections = { sky2 }, + }) + local totalAfter, namedAfter = countPresetsNamed(PRESET_NAME) + local p2Info = describePreset(p2) + recordPresetFile(exp, p2Info) + local secondLabel = "E4b addDevelopPresetForPlugin v2 under the same name" + addStep(exp, "(global)", secondLabel, p2 ~= nil, p2 == nil and how2 or nil, { + how = how2, + preset = p2Info, + p1Now = describePreset(p1), + pluginPresetsAfter = totalAfter, + namedAfter = namedAfter, + }) + if p2 == nil then + table.insert(exp.verdicts, "E4b second addDevelopPresetForPlugin failed: " .. tostring(how2)) + return exp + end + table.insert( + exp.verdicts, + string.format( + "E4b same-name add: presets named %q %d -> %d; same uuid: %s; same file: %s", + PRESET_NAME, + namedBefore, + namedAfter, + tostring(p1Info.uuid == p2Info.uuid), + tostring(p1Info.file == p2Info.file) + ) + ) + + for index, photo in ipairs(photos) do + if progress:isCanceled() then + break + end + progress:setCaption("E4: " .. photoLabel(photo)) + runE4ApplyOnPhoto(exp, catalog, photo, p2, sky2, subject1, progress) + if index == 1 and not progress:isCanceled() then + runE4Amount(exp, catalog, photo, progress) + end + end + -- Last, so the other steps have recorded the preset files whose folder the + -- delete is checked against. + if photos[1] and not progress:isCanceled() then + runE4Delete(exp, catalog, photos[1], progress) + end + + table.insert( + exp.manualChecks, + "History panel of an 'LrG Exp E4' copy: how are the preset steps named, and is it one step per apply?" + ) + table.insert(exp.manualChecks, "Did an AI progress dialog appear during E4c/E4d, and did it close on its own?") + table.insert( + exp.manualChecks, + "On each 'LrG Exp E4 amount ...' copy in Develop: does the look match the amount (half at 50, double at 200)?" + ) + table.insert( + exp.manualChecks, + "Delete the plugin preset files listed in the report header by hand (E4f deletes its own; any it could not are listed too). They live in Lightroom's preset folder, outside the catalog, and are shared by every catalog; deleting the test catalog does not remove them." + ) + return exp +end + +--------------------------------------------------------------------------- +-- E11: Look transfer +--------------------------------------------------------------------------- + +--- Last-resort source for E11b: complete Looks (with `Parameters`) among the +-- installed develop presets. Lightroom's bundled presets carry Looks only as +-- stubs, so this normally finds nothing; the photos are the real source. +-- @return table `{ found, scanned, failedReads, firstError, stubKeys, +-- canceled, error }`: up to FULL_LOOKS_WANTED entries `{ look, preset, +-- folder }` with distinct names, how many presets were read and how many of +-- those reads failed (with the first error), the key list of the first +-- stubbed Look seen, whether the user canceled, and an error that stopped +-- the scan. +local function findFullLooks(progress) + local scan = { found = {}, scanned = 0, failedReads = 0, canceled = false } + local seen = {} + local ok, err = LrTasks.pcall(function() + for _, folder in ipairs(LrApplication.developPresetFolders() or {}) do + local folderName = presetCall(folder, "getName") + for _, preset in ipairs(folder:getDevelopPresets() or {}) do + if progress:isCanceled() then + scan.canceled = true + return + end + scan.scanned = scan.scanned + 1 + local okSetting, setting = LrTasks.pcall(function() + return preset:getSetting() + end) + if not okSetting or type(setting) ~= "table" then + scan.failedReads = scan.failedReads + 1 + scan.firstError = scan.firstError + or string.format("%s: %s", tostring(presetCall(preset, "getName")), tostring(setting)) + else + local look = setting.Look + if scan.stubKeys == nil and type(look) == "table" and look.Stubbed ~= nil then + scan.stubKeys = X.summarizeLook(look).keys + end + if X.isFullLook(look) and not look.isAdobeAdaptive and not seen[look.Name] then + seen[look.Name] = true + table.insert( + scan.found, + { look = look, preset = presetCall(preset, "getName"), folder = folderName } + ) + if #scan.found >= FULL_LOOKS_WANTED then + return + end + end + end + end + LrTasks.yield() + end + end) + if not ok then + scan.error = tostring(err) + end + return scan +end + +local function lookState(settings) + return { CameraProfile = settings.CameraProfile, look = X.summarizeLook(settings.Look) } +end + +local function cameraModel(photo) + local ok, model = LrTasks.pcall(function() + return photo:getFormattedMetadata("cameraModel") + end) + return ok and model or nil +end + +--- The E11 variants. E11a and E11c are stubs of the first Adobe Raw profile +-- whose name differs from the photo's Look - in the preset form and bare; +-- E11b is a complete Look, chosen at run time by `e11FullLook`. +local E11_VARIANTS = { + { id = "E11a", kind = "stubbed (preset form, Stubbed=true)" }, + { id = "E11b", kind = "full" }, + { id = "E11c", kind = "bare stub (no Stubbed flag)", bare = true }, +} + +--- Picks E11b's full Look, from the most to the least direct source: +-- 1. the Look of another selected photo (a complete Look as Lightroom +-- stores it on a photo); +-- 2. the Look the E11a copy read back, if Lightroom filled in the stub; +-- 3. the photo's own Look, written onto a copy that was first switched to a +-- stubbed different Look (see `prepareOwnLook`); +-- 4. a develop preset whose Look has `Parameters` (normally none). +-- @param ctx table `{ photo, currentName, masterLook, camera, donors, +-- e11aLook, getPresetScan }`. +-- @return table|nil, table source description, string|nil skip reason +local function e11FullLook(ctx) + local donor, rejected = X.pickDonorLook(ctx.donors, ctx.currentName, ctx.camera) + if donor then + return donor.look, { kind = "another selected photo", photo = donor.photo, passedOver = rejected }, nil + end + local fromE11a = ctx.e11aLook + if fromE11a and X.lookRejection(fromE11a, ctx.currentName, ctx.camera, ctx.camera) == nil then + return fromE11a, + { kind = "the E11a copy's readback (Lightroom filled in the stub)", passedOver = rejected }, + nil + end + local ownReason = X.lookRejection(ctx.masterLook, nil, ctx.camera, ctx.camera) + if ownReason == nil then + return ctx.masterLook, + { + kind = "the photo's own Look, after switching the copy away from it", + ownLook = true, + passedOver = rejected, + }, + nil + end + local scan = ctx.getPresetScan() + if scan.canceled then + return nil, nil, CANCELED_MESSAGE + end + if scan.error then + return nil, nil, "no photo offers a full Look, and the develop-preset scan failed: " .. scan.error + end + local entry = X.pickLook(scan.found, ctx.currentName, function(candidate) + return candidate.look.Name + end) + if entry then + return entry.look, { kind = "develop preset", preset = entry.preset, folder = entry.folder }, nil + end + local reasons = { "the photo's own Look: " .. tostring(ownReason) } + for _, item in ipairs(rejected) do + table.insert(reasons, tostring(item.photo) .. ": " .. tostring(item.reason)) + end + return nil, + nil, + "no source for a full Look (" .. table.concat(reasons, "; ") .. "), and " .. X.describePresetScan(scan) +end + +--- Switches a fresh copy away from the photo's own Look, in history steps of +-- their own, so writing that Look back afterwards has something to change. +-- Tries the preset-form stub first, then the bare one, since either may be +-- the form Lightroom ignores. +-- @return boolean, table true once the copy's Look differs from the +-- photo's, and the step data (every attempt). +local function prepareOwnLook(catalog, copy, currentName, cameraProfile) + local candidate = X.pickLook(X.STUB_LOOKS, currentName) + if not candidate then + return false, { error = "every stub candidate is already the photo's Look", attempts = {} } + end + local data = { attempts = {} } + for _, bare in ipairs({ false, true }) do + local stub = X.stubLook(candidate, bare) + local ok, err = withWrite(catalog, "LrGenius experiment E11b prepare", function() + copy:applyDevelopSettings({ CameraProfile = cameraProfile, Look = stub }, "LrGenius E11b prepare", false) + end) + local settings, readErr = readSettings(copy) + local state = lookState(settings) + table.insert(data.attempts, { applied = X.summarizeLook(stub), ok = ok, error = err or readErr, after = state }) + data.after, data.error = state, err or readErr + if ok and not readErr and state.look ~= nil and state.look.Name ~= currentName then + return true, data + end + end + return false, data +end + +local function runE11Variant(exp, catalog, photo, variant, ctx, progress) + local label = photoLabel(photo) + progress:setCaption("E11: " .. label .. " " .. variant.id) + local look, source, skip + if variant.id == "E11b" then + look, source, skip = e11FullLook(ctx) + else + local candidate = X.pickLook(X.STUB_LOOKS, ctx.currentName) + if candidate then + look, source = X.stubLook(candidate, variant.bare), { kind = "stub", profile = candidate.Name } + else + skip = "every candidate profile is already the photo's Look" + end + end + if skip == CANCELED_MESSAGE or progress:isCanceled() then + -- A scan cut short proves nothing; say nothing. + return nil + end + if skip then + table.insert(exp.verdicts, string.format("%s %s: skipped - %s", label, variant.id, skip)) + return nil + end + + local copy, copyErr = createVirtualCopy(catalog, photo, "LrG Exp " .. variant.id) + if not copy then + addStep(exp, label, variant.id .. " create virtual copy", false, copyErr, nil) + table.insert(exp.verdicts, string.format("%s %s: skipped - %s", label, variant.id, tostring(copyErr))) + return nil + end + local copyLabel = photoLabel(copy) + if source.ownLook then + local switched, prepared = prepareOwnLook(catalog, copy, ctx.currentName, ctx.cameraProfile) + source.prepare = prepared + if not switched then + addStep( + exp, + copyLabel, + variant.id .. " prepare: switch away from the photo's Look", + false, + prepared.error, + prepared + ) + table.insert( + exp.verdicts, + string.format( + "%s %s: inconclusive - the copy could not be switched away from %q first (%s), so writing that Look back cannot show an effect", + label, + variant.id, + tostring(ctx.currentName), + prepared.error and tostring(prepared.error) or X.describeLookState(prepared.after) + ) + ) + return nil + end + end + + local beforeSettings, beforeErr = readSettings(copy) + local ok, err = withWrite(catalog, "LrGenius experiment " .. variant.id, function() + copy:applyDevelopSettings({ CameraProfile = ctx.cameraProfile, Look = look }, "LrGenius " .. variant.id, false) + end) + local afterSettings, afterErr = readSettings(copy) + local readErr = beforeErr or afterErr + -- Summaries only: `Parameters` is Adobe's profile definition and stays out + -- of the report. + local applied = { CameraProfile = ctx.cameraProfile, look = X.summarizeLook(look) } + local before, after = lookState(beforeSettings), lookState(afterSettings) + addStep( + exp, + copyLabel, + string.format("%s apply %s Look %q", variant.id, variant.kind, tostring(look.Name)), + ok and not readErr, + err or readErr, + { applied = applied, source = source, before = before, after = after } + ) + table.insert( + exp.verdicts, + string.format( + "%s %s %s Look %q (source: %s) on CameraProfile=%s, from %s: %s", + label, + variant.id, + variant.kind, + tostring(look.Name), + tostring(source.kind == "stub" and ("stub of " .. tostring(source.profile)) or source.kind), + tostring(ctx.cameraProfile), + X.describeLookState(before), + X.describeLookOutcome(applied, before, after, ok, err, readErr) + ) + ) + return ok and not readErr and afterSettings.Look or nil +end + +local function runE11OnPhoto(exp, catalog, photo, masters, getPresetScan, progress) + local label = photoLabel(photo) + local isRaw, format, family = isRawFile(photo) + if not isRaw then + table.insert( + exp.verdicts, + string.format( + "%s (%s, white-balance family %s): E11 skipped - it tests Adobe Raw Looks only, which need a raw file; creative Looks on non-raw files are not tested", + label, + tostring(format), + tostring(family or "unknown") + ) + ) + return + end + -- The master decides which Look to write: every copy starts from it, and + -- a Look the photo already has would make "honoured" and "ignored" look + -- the same. + local masterSettings, masterErr = readSettings(photo) + if masterErr then + table.insert(exp.verdicts, label .. ": E11 skipped - " .. masterErr) + return + end + local ctx = { + currentName = type(masterSettings.Look) == "table" and masterSettings.Look.Name or nil, + masterLook = masterSettings.Look, + camera = cameraModel(photo), + cameraProfile = X.e11CameraProfile(masterSettings.CameraProfile), + donors = {}, + getPresetScan = getPresetScan, + } + for _, other in ipairs(masters) do + if other.photo ~= photo then + table.insert(ctx.donors, other.donor) + end + end + + for _, variant in ipairs(E11_VARIANTS) do + if progress:isCanceled() then + return + end + local readBack = runE11Variant(exp, catalog, photo, variant, ctx, progress) + if variant.id == "E11a" then + ctx.e11aLook = readBack + end + end +end + +local function runE11(catalog, photos, progress, sink) + local exp = newExperiment( + sink, + "E11", + "Look transfer via applyDevelopSettings", + "Does applyDevelopSettings set an Adobe Raw profile Look - as a stub in the form presets carry it (E11a, Stubbed=true), as a complete Look with Parameters taken from a photo (E11b), and as a bare stub without the Stubbed flag (E11c) - and does Lightroom fill in a stub's Parameters?" + ) + -- Every selected photo's Look, read once: the donors for E11b. + local masters = {} + for _, photo in ipairs(photos) do + local settings, err = readSettings(photo) + if not err then + table.insert(masters, { + photo = photo, + donor = { photo = photoLabel(photo), camera = cameraModel(photo), look = settings.Look }, + }) + end + end + -- The preset scan is a last resort: one scan for the whole run, and only + -- when no photo offers a full Look. + local scan + local function getPresetScan() + if scan == nil then + progress:setCaption("E11: reading develop presets for a full Look") + scan = findFullLooks(progress) + if not scan.canceled then + local found = {} + for _, entry in ipairs(scan.found) do + table.insert( + found, + { preset = entry.preset, folder = entry.folder, look = X.summarizeLook(entry.look) } + ) + end + -- Every read failing is a broken scan, not a finding. + local allFailed = scan.scanned > 0 and scan.failedReads == scan.scanned + addStep( + exp, + "(global)", + "E11b scan develop presets for a Look with Parameters", + scan.error == nil and not allFailed, + scan.error or (allFailed and ("every preset read failed: " .. tostring(scan.firstError))) or nil, + { + presetsRead = scan.scanned, + failedReads = scan.failedReads, + firstError = scan.firstError, + stubbedLookKeys = scan.stubKeys, + found = found, + } + ) + end + end + return scan + end + + for _, photo in ipairs(photos) do + if progress:isCanceled() then + break + end + runE11OnPhoto(exp, catalog, photo, masters, getPresetScan, progress) + end + table.insert( + exp.manualChecks, + "Profile browser of each 'LrG Exp E11a/E11b/E11c' copy: which profile is active, and does the photo look like it rather than plain Adobe Standard?" + ) + table.insert( + exp.manualChecks, + "History panel of an 'LrG Exp E11' copy: is it one step named 'LrGenius E11a' / 'E11b' / 'E11c'? (An E11b copy that used the photo's own Look has an 'LrGenius E11b prepare' step before it.)" + ) + return exp +end + +--------------------------------------------------------------------------- +-- Dialog and report +--------------------------------------------------------------------------- + +local function optionsDialog(ctx, catalogPath, photoCount) + local f = LrView.osFactory() + local bind = LrView.bind + local props = LrBinding.makePropertyTable(ctx) + props.runE13 = true + props.runE1 = true + props.runE11 = true + props.runE2 = true + props.runE4 = true + props.testCatalog = false + + local contents = f:column({ + bind_to_object = props, + spacing = f:control_spacing(), + f:static_text({ + title = "Runs the develop-settings experiments from the AI Edit XMP research\n" + .. "on the selected photos and writes a report you choose the location of.\n" + .. "Best set: a portrait-orientation raw with a straightened crop, a raw that\n" + .. "shows a person and sky, and a JPEG.", + }), + f:static_text({ title = "Catalog: " .. tostring(catalogPath) }), + f:static_text({ + title = string.format("Selected photos: %d (at most %d)", photoCount, MAX_PHOTOS), + }), + f:group_box({ + title = "Experiments", + fill_horizontal = 1, + f:checkbox({ + value = bind("runE13"), + title = "E13 Orientation, dimensions and crop at runtime (read-only)", + }), + f:checkbox({ + value = bind("runE1"), + title = "E1 White balance: Temp vs Temperature, mode without values", + }), + f:checkbox({ + value = bind("runE11"), + title = "E11 Look transfer: stubs and a full Look via applyDevelopSettings (raw only)", + }), + f:checkbox({ value = bind("runE2"), title = "E2 Add AI masks via applyDevelopSettings" }), + f:checkbox({ + value = bind("runE4"), + title = "E4 Plugin presets: overwrite, merge, re-apply, amount, delete after apply", + }), + }), + f:static_text({ + title = "E1, E11, E2 and E4 write only to new virtual copies named 'LrG Exp ...'.\n" + .. "They switch Lightroom to the Library module and change the selection.\n" + .. "E4 leaves hidden plugin presets in Lightroom's preset folder. They are\n" + .. "shared by all catalogs, are not removed with the test catalog, and the\n" + .. "SDK cannot delete them. E4f deletes the files of its own preset to test\n" + .. "exactly that; the report lists every preset file that is left.", + }), + f:checkbox({ + value = bind("testCatalog"), + title = "This is a test catalog - create the virtual copies, and I will delete the plugin presets", + }), + }) + + local result = LrDialogs.presentModalDialog({ + title = "Develop-Settings Experiments", + contents = contents, + actionVerb = "Run", + cancelVerb = "Cancel", + }) + if result ~= "ok" then + return nil + end + return { + E13 = props.runE13, + E1 = props.runE1, + E11 = props.runE11, + E2 = props.runE2, + E4 = props.runE4, + testCatalog = props.testCatalog, + } +end + +local function writeTextFile(path, text) + local file, openErr = io.open(path, "wb") + if not file then + return false, tostring(openErr) + end + local ok, writeErr = file:write(text) + -- Buffered data is flushed on close, so a full disk may only show here. + local closed, closeErr = file:close() + if not ok then + return false, tostring(writeErr) + end + if not closed then + return false, tostring(closeErr) + end + return true, nil +end + +local function summaryText(experiments, failedSteps) + local lines = {} + for _, experiment in ipairs(experiments) do + table.insert(lines, experiment.id .. " - " .. experiment.title) + -- The first few, plus a headline (the E4f answer) wherever it is. + local shown, hidden = X.summaryVerdicts(experiment.verdicts, 4) + for _, verdict in ipairs(shown) do + table.insert(lines, " " .. verdict) + end + if hidden > 0 then + table.insert(lines, string.format(" ... and %d more in the report", hidden)) + end + end + if failedSteps > 0 then + table.insert(lines, "") + table.insert( + lines, + string.format("%d step(s) raised an error. That is often the answer itself - see the report.", failedSteps) + ) + end + return table.concat(lines, "\n") +end + +--- Writes both report files and tells the user where they are - or, if a file +-- could not be written, what failed, together with the verdicts. +local function deliverReport(report, reportPath, jsonPath, runOk, runErr, presetFiles) + local okMd, errMd = writeTextFile(reportPath, X.renderMarkdown(report)) + local okJson, json = LrTasks.pcall(function() + return JSON:encode_pretty(report) + end) + local okJsonWrite, errJson = false, json + if okJson then + okJsonWrite, errJson = writeTextFile(jsonPath, json) + end + log:info( + "Develop experiments: report " + .. tostring(reportPath) + .. " (written: " + .. tostring(okMd) + .. "), JSON " + .. tostring(jsonPath) + .. " (written: " + .. tostring(okJsonWrite) + .. ")" + ) + + local summary = summaryText(report.experiments, countFailedSteps(report.experiments)) + if #presetFiles > 0 then + summary = summary + .. "\n\nPlugin preset files to delete by hand (outside the catalog):\n " + .. table.concat(presetFiles, "\n ") + end + + if not okMd or not okJsonWrite then + -- The verdicts are still worth having even when a file failed. + local markdownLine = okMd and ("Markdown report: " .. tostring(reportPath)) + or ("The Markdown report could not be written: " .. tostring(errMd)) + local jsonLine = okJsonWrite and ("JSON: " .. tostring(jsonPath)) + or ("The JSON copy could not be written: " .. tostring(errJson)) + local runLine = "" + if not runOk then + runLine = "\nThe experiments also stopped early: " .. tostring(runErr) + end + ErrorHandler.handleError( + "The experiment report was not saved completely", + markdownLine .. "\n" .. jsonLine .. runLine .. "\n\n" .. summary + ) + return + end + + LrTasks.pcall(function() + LrShell.revealInShell(reportPath) + end) + local locations = "Report: " .. tostring(reportPath) .. "\nJSON: " .. tostring(jsonPath) + if not runOk then + ErrorHandler.handleError( + "The experiments stopped early", + tostring(runErr) .. "\n\nEverything up to that point is saved.\n" .. locations .. "\n\n" .. summary + ) + return + end + LrDialogs.message( + report.meta.canceled and "Experiments canceled" or "Experiments finished", + summary .. "\n\n" .. locations, + "info" + ) +end + +LrTasks.startAsyncTask(function() + LrFunctionContext.callWithContext("TaskDevelopExperiments", function(ctx) + local catalog = LrApplication.activeCatalog() + local photos = catalog:getTargetPhotos() or {} + + local options = optionsDialog(ctx, catalog:getPath(), #photos) + if not options then + return + end + + if #photos == 0 or #photos > MAX_PHOTOS then + LrDialogs.message( + "Select 1 to " .. MAX_PHOTOS .. " photos", + "The experiments are meant for a handful of test photos: a portrait-orientation raw with a straightened crop, a raw that shows a person and sky, and a JPEG.", + "warning" + ) + return + end + + local stills = {} + for _, photo in ipairs(photos) do + if not photo:getRawMetadata("isVideo") then + table.insert(stills, photo) + end + end + if #stills == 0 then + LrDialogs.message("No photos selected", "Only videos are selected; the experiments need photos.", "warning") + return + end + + local writes = options.E1 or options.E11 or options.E2 or options.E4 + if not (writes or options.E13) then + return + end + if writes and not options.testCatalog then + LrDialogs.message( + "Confirm the test catalog", + "E1, E11, E2 and E4 create virtual copies, and E4 plugin presets. Run them in a test catalog and tick the confirmation box, or run E13 on its own.", + "warning" + ) + return + end + + local reportPath = LrDialogs.runSavePanel({ + title = "Save the experiment report", + prompt = "Save Report", + canCreateDirectories = true, + requiredFileType = "md", + }) + if not reportPath or reportPath == "" then + return + end + -- The save panel only asked about the .md file; never clobber a .json + -- the user did not choose. + local jsonPath = LrPathUtils.replaceExtension(reportPath, "json") + if LrFileUtils.exists(jsonPath) then + jsonPath = LrFileUtils.chooseUniqueFileName(jsonPath) + end + + local lrVersion = LrApplication.versionTable() + local moduleAtStart = currentModule() + local report = { + meta = { + lightroom = LrApplication.versionString(), + lightroomVersion = X.plainValue(lrVersion), + catalog = catalog:getPath(), + startedAt = LrDate.timeToW3CDate(LrDate.currentTime()), + moduleAtStart = moduleAtStart, + photos = {}, + }, + experiments = {}, + } + for _, photo in ipairs(stills) do + table.insert(report.meta.photos, photoLabel(photo)) + end + math.randomseed(math.floor(LrDate.currentTime() * 1000) % 2147483647) + + local progress = LrProgressScope({ + title = "Running develop-settings experiments", + functionContext = ctx, + }) + + local presetFiles = {} + local runOk, runErr = LrTasks.pcall(function() + if options.E13 then + progress:setCaption("E13: reading runtime formats") + runE13(catalog, stills, report.experiments) + end + if writes then + -- E2 and E4 ask whether masks can be added *outside* Develop, + -- so run from the Library module on purpose. + if moduleAtStart ~= "library" then + LrApplicationView.switchToModule("library") + LrTasks.sleep(0.5) + end + report.meta.moduleDuringRun = currentModule() + end + if options.E1 and not progress:isCanceled() then + runE1(catalog, stills, progress, report.experiments) + end + if options.E11 and not progress:isCanceled() then + runE11(catalog, stills, progress, report.experiments) + end + if options.E2 and not progress:isCanceled() then + runE2(catalog, stills, progress, lrVersion, report.experiments) + end + if options.E4 and not progress:isCanceled() then + runE4(catalog, stills, progress, lrVersion, presetFiles, report.experiments) + end + end) + report.meta.canceled = progress:isCanceled() + progress:done() + + report.meta.finishedAt = LrDate.timeToW3CDate(LrDate.currentTime()) + if not runOk then + report.meta.abortedWith = tostring(runErr) + end + if #presetFiles > 0 then + report.meta.pluginPresetFilesToDelete = presetFiles + end + + -- Put the selection back on the photos the user chose. + LrTasks.pcall(function() + catalog:setSelectedPhotos(stills[1], stills) + end) + + deliverReport(report, reportPath, jsonPath, runOk, runErr, presetFiles) + end) +end) diff --git a/plugin/LrGeniusAI.lrdevplugin/TaskTrainFromEdits.lua b/plugin/LrGeniusAI.lrdevplugin/TaskTrainFromEdits.lua index f92d7c69..7a4eab51 100644 --- a/plugin/LrGeniusAI.lrdevplugin/TaskTrainFromEdits.lua +++ b/plugin/LrGeniusAI.lrdevplugin/TaskTrainFromEdits.lua @@ -155,7 +155,10 @@ LrTasks.startAsyncTask(function() local successCount = 0 local errorCount = 0 local errorMessages = {} - local backendWarnings = {} + -- One line per distinct warning with its photo count, so a cause that + -- hits every example (an old process version) does not push the + -- others out of the summary. + local warningTally = Util.newWarningTally() local canceled = false @@ -201,10 +204,21 @@ LrTasks.startAsyncTask(function() local exifOptions = Util.getPhotoExif(photo) exifOptions.label = options.label exifOptions.summary = options.summary - -- Which Temp scale this example's develop settings are on. Left - -- absent when the format is unreadable; the style engine treats - -- that as compatible with anything. - exifOptions.is_raw = Util.isRawPhoto(photo) + -- Which white-balance family this example's develop settings + -- use: Kelvin `Temperature` (raw) or an `IncrementalTemperature` + -- offset (rendered, including a DNG converted from a JPEG). + -- Taken from the settings themselves, as the backend and + -- TaskAiEditPhotos do, with the file format as the fallback. + -- Left absent when neither knows; the backend then reads the + -- family from the settings' own white-balance keys, and an + -- example without them does not vote on white balance. + local wbFamily = DevelopEditManager.whiteBalanceFamily( + not developSettingsFailed and developSettings or nil, + Util.isRawPhoto(photo) + ) + if wbFamily ~= nil then + exifOptions.is_raw = wbFamily == "raw" + end -- Export a JPEG thumbnail for CLIP embedding + exposure analysis. local exportedPath = SearchIndexAPI.exportPhotoForIndexing(photo) @@ -228,14 +242,22 @@ LrTasks.startAsyncTask(function() if ok then successCount = successCount + 1 log:info("Saved training example for " .. fileName) + local exampleWarnings = {} if developSettingsFailed then table.insert( - backendWarnings, - fileName .. ": develop settings could not be read, so this example was saved without them." + exampleWarnings, + "Develop settings could not be read, so this example was saved without them." ) end - if resp and resp.warning then - table.insert(backendWarnings, fileName .. ": " .. tostring(resp.warning)) + -- `warnings`, or an older backend's joined `warning` string. + for _, warning in ipairs(Util.responseWarnings(resp)) do + table.insert(exampleWarnings, warning) + end + if #exampleWarnings > 0 then + log:warn( + "Training example warnings for " .. fileName .. ": " .. table.concat(exampleWarnings, " | ") + ) + Util.tallyWarnings(warningTally, exampleWarnings, fileName, index) end else errorCount = errorCount + 1 @@ -250,7 +272,8 @@ LrTasks.startAsyncTask(function() progressScope:done() -- Summary dialog. - if errorCount > 0 or #backendWarnings > 0 then + local warningCount = Util.warningTallySize(warningTally) + if errorCount > 0 or warningCount > 0 then local uniqueErrors = {} local errorList = {} for _, msg in ipairs(errorMessages) do @@ -284,21 +307,12 @@ LrTasks.startAsyncTask(function() end end - if #backendWarnings > 0 then + if warningCount > 0 then combinedReport = combinedReport .. "\n\n" - .. LOC("$$$/LrGeniusAI/common/BackendWarnings=Warnings:") + .. "Warnings:" .. "\n" - for i = 1, math.min(5, #backendWarnings) do - combinedReport = combinedReport .. "- " .. backendWarnings[i] .. "\n" - end - if #backendWarnings > 5 then - combinedReport = combinedReport - .. LOC( - "$$$/LrGeniusAI/common/MoreWarnings=... and ^1 more warnings", - tostring(#backendWarnings - 5) - ) - end + .. table.concat(Util.formatWarningTally(warningTally, 5), "\n") end if canceled then diff --git a/plugin/LrGeniusAI.lrdevplugin/Util.lua b/plugin/LrGeniusAI.lrdevplugin/Util.lua index 02df06fd..7194b1e6 100644 --- a/plugin/LrGeniusAI.lrdevplugin/Util.lua +++ b/plugin/LrGeniusAI.lrdevplugin/Util.lua @@ -273,12 +273,13 @@ end -- shows, so blown highlights are recoverable there and gone on a rendered file. -- The edit guardrails use it to decide how far the white point may be pushed. -- --- It also decides which scale Lightroom's `Temp` is on: Kelvin for raw, a --- relative -100..100 for JPEG/TIFF/PNG. Applying the wrong one ruins the photo, --- so the three answers are kept apart: a readable non-raw format is `false`, but --- a format we could not read at all is `nil` rather than `false`. Callers treat --- `nil` as "assume raw", which is what the plug-in did before this existed and --- what leaves an unreadable raw's white balance alone. +-- It is only a fallback for which white balance a photo uses (Kelvin in +-- `Temperature` for raw, an offset in `IncrementalTemperature` for rendered +-- files): a DNG converted from a JPEG counts as raw here but has the rendered +-- kind. DevelopEditManager.whiteBalanceFamily asks the develop settings first +-- and falls back to this. The three answers are kept apart: a readable non-raw +-- format is `false`, but a format we could not read at all is `nil` rather than +-- `false`, and a `nil` family means no white balance is written at all. -- -- DNG counts as raw — that is how Lightroom treats it, and a DNG carries the -- same headroom. @@ -2800,6 +2801,167 @@ function Util.formatDownloadSize(bytes) return string.format("%.0f MB", n / 1e6) end +--- +-- The warnings a backend response carries, as a list of strings. +-- +-- Current backends send a `warnings` array. Older ones sent a single +-- `warning` string with several reports joined by newlines; that is split +-- back apart so each report is counted on its own. Repeats are dropped. +-- +-- @param response table|nil A decoded backend response. +-- @return table Array of non-empty strings (possibly empty). +-- +function Util.responseWarnings(response) + local result, seen = {}, {} + if type(response) ~= "table" then + return result + end + local function add(text) + if text == nil then + return + end + text = tostring(text):gsub("^%s+", ""):gsub("%s+$", "") + if text ~= "" and not seen[text] then + seen[text] = true + table.insert(result, text) + end + end + if type(response.warnings) == "table" then + for _, text in ipairs(response.warnings) do + add(text) + end + return result + end + if type(response.warning) == "string" then + for line in response.warning:gmatch("[^\r\n]+") do + add(line) + end + end + return result +end + +--- +-- A per-run collection of warnings, deduplicated by text. +-- +-- One cause usually hits many photos in a batch ("white balance was not +-- transferred" for every JPEG); listing it once per photo buries every other +-- report. The tally keeps each text once, with how many photos it hit and the +-- first of them, so the end-of-run dialog can say "(12 photos)". +-- +-- @return table An empty tally for Util.tallyWarnings/Util.formatWarningTally. +-- +function Util.newWarningTally() + return { order = {}, entries = {} } +end + +--- +-- Records one photo's warnings in a tally. Each photo counts once per text, +-- however often the text is reported for it — within one list, or across +-- several calls for the same photo (the backend's warnings, then the ones +-- from applying the edit). +-- +-- @param tally table From Util.newWarningTally. +-- @param warnings table|nil Array of warning strings for this photo. +-- @param photoName string|nil The photo's file name, shown when only one +-- photo is affected. +-- @param photoKey any|nil What identifies the photo within the run; defaults +-- to `photoName`. Pass something unique when names can repeat (the +-- same file name in two folders). With neither, every call counts. +-- +function Util.tallyWarnings(tally, warnings, photoName, photoKey) + if type(tally) ~= "table" or type(warnings) ~= "table" then + return + end + if photoKey == nil then + photoKey = photoName + end + local seenInCall = {} + for _, warning in ipairs(warnings) do + local text = warning ~= nil and tostring(warning) or "" + if text ~= "" and not seenInCall[text] then + seenInCall[text] = true + local entry = tally.entries[text] + if not entry then + table.insert(tally.order, text) + entry = { text = text, count = 0, firstPhoto = photoName, photos = {}, seq = #tally.order } + tally.entries[text] = entry + end + if photoKey == nil then + entry.count = entry.count + 1 + elseif not entry.photos[photoKey] then + entry.photos[photoKey] = true + entry.count = entry.count + 1 + end + end + end +end + +--- +-- Number of distinct warnings in a tally. +-- +function Util.warningTallySize(tally) + if type(tally) ~= "table" or type(tally.order) ~= "table" then + return 0 + end + return #tally.order +end + +--- +-- The tally as dialog lines: the `maxShown` distinct warnings that hit the +-- most photos, most frequent first, then in the order they first appeared; +-- each with the photo count (or the one photo's name), followed by a line +-- counting the rest. A run-wide cause is never the one folded away, and +-- nothing is dropped silently. +-- +-- @param tally table From Util.newWarningTally. +-- @param maxShown number|nil How many distinct warnings to list (default 5). +-- @return table Array of lines, e.g. "- White balance ... (12 photos)". +-- +function Util.formatWarningTally(tally, maxShown) + local lines = {} + local total = Util.warningTallySize(tally) + if total == 0 then + return lines + end + maxShown = tonumber(maxShown) or 5 + if maxShown < 1 then + maxShown = 1 + end + local ranked = {} + for i, text in ipairs(tally.order) do + local entry = tally.entries[text] + entry.seq = entry.seq or i + ranked[i] = entry + end + -- table.sort is not stable, so the first-seen index breaks ties explicitly. + table.sort(ranked, function(a, b) + if a.count ~= b.count then + return a.count > b.count + end + return a.seq < b.seq + end) + for i = 1, math.min(maxShown, total) do + local entry = ranked[i] + local suffix + if entry.count > 1 then + suffix = " (" .. tostring(entry.count) .. " photos)" + elseif entry.firstPhoto and entry.firstPhoto ~= "" then + suffix = " (" .. tostring(entry.firstPhoto) .. ")" + else + suffix = "" + end + table.insert(lines, "- " .. entry.text .. suffix) + end + local hidden = total - maxShown + if hidden > 0 then + table.insert( + lines, + "... and " .. tostring(hidden) .. " more " .. (hidden == 1 and "warning" or "warnings") .. " (see the log)" + ) + end + return lines +end + --- -- Whether a culling preset judges the moment: emotion, expression, action. -- @@ -2989,33 +3151,6 @@ function Util.cullKeyPeopleLine(summary) ) end ---- --- A backend response's warnings as a list of distinct messages. --- --- Prefers the `warnings` list. Falls back to the single `warning` string an --- older backend sends, so a plugin newer than its backend still reports. --- --- @param response table|nil Decoded response. --- @return table List of strings, possibly empty. --- -function Util.responseWarnings(response) - if type(response) ~= "table" then - return {} - end - local source = response.warnings - if type(source) ~= "table" then - source = { response.warning } - end - local list, seen = {}, {} - for _, w in ipairs(source) do - if type(w) == "string" and w ~= "" and not seen[w] then - seen[w] = true - table.insert(list, w) - end - end - return list -end - --- -- Joins warnings for one dialog: the first `maxShown`, then a count of the -- rest. A wall of lines is as unreadable as none, and dropping the rest @@ -3718,4 +3853,37 @@ function Util.faceRecognizerSummary(state) return text end +--- +-- Whether a virtual copy Lightroom just created is a copy of `source`. +-- +-- `catalog:createVirtualCopies()` copies the selection, and the new copy always +-- belongs to a *master* photo: copying a master gives a copy whose +-- `masterPhoto` is that master, but copying a virtual copy gives a copy whose +-- `masterPhoto` is the master the source copy belongs to (it carries the source +-- copy's settings). Comparing the new copy's master with `source` alone +-- therefore rejects every virtual copy as "a different photo". +-- +-- Pure: takes the raw metadata values rather than LrPhoto objects, so it can be +-- tested headless. It never accepts a copy of a *different* master, but for a +-- virtual-copy source it accepts a copy made from any photo under the same +-- master (the master itself or a sibling copy): all of them share the image +-- file, only their settings differ. The caller therefore checks, inside the +-- write gate and right before copying, that `source` alone is selected. +-- +-- @param copyMaster any `masterPhoto` of the new copy. +-- @param source any The photo that was selected and copied. +-- @param sourceIsVirtualCopy boolean|nil `isVirtualCopy` of `source`. +-- @param sourceMaster any|nil `masterPhoto` of `source` (only read for a virtual copy). +-- @return boolean +-- +function Util.isVirtualCopyOf(copyMaster, source, sourceIsVirtualCopy, sourceMaster) + if copyMaster == nil or source == nil then + return false + end + if sourceIsVirtualCopy then + return sourceMaster ~= nil and copyMaster == sourceMaster + end + return copyMaster == source +end + return Util diff --git a/plugin/README.md b/plugin/README.md index 4925e9f5..ff5db0b3 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -69,10 +69,17 @@ The plugin is designed to work with local and cloud providers, while keeping Lig ### AI Develop Edits -- Generate a Develop recipe per photo and apply it non-destructively, either to - the photo itself or to a virtual copy -- No LLM involved: the backend matches the photo against the edits you saved - yourself and interpolates theirs +- Edit each photo and apply the result non-destructively, either to the photo + itself or to a virtual copy +- **AI retoucher**: a vision model edits, Lightroom renders the edit, the model + looks at the render with its measurements and corrects it, round by round + (`EditLoop.lua` with the backend's `/v1/edit/sessions`). Looks, your own + instructions, and Lightroom's AI masks (subject, sky, background, people + parts, landscape classes; Lightroom Classic 15.3+), applied through + `applyDevelopSettings` and `updateAISettings` +- **My style only**: no AI model; the backend matches the photo against the + edits you saved yourself and interpolates theirs. The retoucher can start + from this blend and only fix what does not suit the frame - Recipes are constrained to what the frame can take: the backend measures the image and caps contrast, clarity, shadow lift and whites accordingly, and reports why @@ -80,7 +87,8 @@ The plugin is designed to work with local and cloud providers, while keeping Lig ### Style Training - Save your own Develop settings as labeled training examples -- The sole input to AI Develop Edits, which needs at least five of them +- The sole input to *My style only*, which needs at least five of them, and the + AI retoucher's optional starting point ### Keyword Dedup & Declutter diff --git a/plugin/spec/develop_edit_manager_spec.lua b/plugin/spec/develop_edit_manager_spec.lua index a95bc971..e3eb9863 100644 --- a/plugin/spec/develop_edit_manager_spec.lua +++ b/plugin/spec/develop_edit_manager_spec.lua @@ -3,9 +3,9 @@ -- This is the seam where the edit path's value semantics live, and where it had -- shipped a whole class of silent corruption: global recipe values were merged -- *additively* onto the photo's current settings, even though every producer --- emits absolute Lightroom slider values. For `Temp` that turned an as-shot --- 5500 K plus a recommended 5600 K into 11100 K; for every control whose --- Lightroom default is non-zero it was quietly wrong in the same way. +-- emits absolute Lightroom slider values. For the white balance that turned an +-- as-shot 5500 K plus a recommended 5600 K into 11100 K; for every control +-- whose Lightroom default is non-zero it was quietly wrong in the same way. require("DevelopEditManager") @@ -17,10 +17,10 @@ local scaleDevelopSettings = internal.scaleDevelopSettings describe("DevelopEditManager global merge semantics", function() it("replaces white balance instead of adding to it", function() - local current = { Temp = 5500, Tint = 10 } - local merged = mergeGlobalDevelopSettings(current, { Temp = 5600, Tint = -4 }) + local current = { Temperature = 5500, Tint = 10 } + local merged = mergeGlobalDevelopSettings(current, { Temperature = 5600, Tint = -4 }) - assert.are.equal(5600, merged.Temp) + assert.are.equal(5600, merged.Temperature) assert.are.equal(-4, merged.Tint) end) @@ -65,9 +65,9 @@ describe("DevelopEditManager global merge semantics", function() end) it("keeps a nil current-settings table usable", function() - local merged = mergeGlobalDevelopSettings(nil, { Temp = 4800 }) + local merged = mergeGlobalDevelopSettings(nil, { Temperature = 4800 }) - assert.are.equal(4800, merged.Temp) + assert.are.equal(4800, merged.Temperature) end) end) @@ -89,7 +89,6 @@ describe("DevelopEditManager recipe mapping", function() global = { exposure = 0.4, contrast = 12, - temperature = 5200, clarity = 8, color_noise_reduction = 30, }, @@ -97,7 +96,6 @@ describe("DevelopEditManager recipe mapping", function() assert.are.equal(0.4, settings.Exposure2012) assert.are.equal(12, settings.Contrast2012) - assert.are.equal(5200, settings.Temp) assert.are.equal(8, settings.Clarity2012) assert.are.equal(30, settings.ColorNoiseReduction) end) @@ -157,74 +155,357 @@ describe("DevelopEditManager.formatRecipeDetails", function() assert.is_truthy(details:find("Highlights were already clipped", 1, true)) end) + it("lists the response's warnings next to the recipe's", function() + -- The style engine reports why white balance was left out in the + -- response's `warnings`; the recipe's own list is empty then. + local wb = "White balance was not transferred: your training examples are raw files and this photo is not." + local details = DevelopEditManager.formatRecipeDetails({ + engine = "style", + edit = { summary = "s", global = { exposure = 0.2 }, masks = {}, warnings = {} }, + warnings = { wb }, + }) + + local section = details:sub((details:find("Warnings", 1, true))) + assert.is_truthy(section:find(wb, 1, true)) + assert.is_falsy(section:find("- none", 1, true)) + end) + + it("lists each line of an old backend's joined warning once", function() + local details = DevelopEditManager.formatRecipeDetails({ + edit = { summary = "s", global = { exposure = 0.2 }, masks = {}, warnings = { "a" } }, + warning = "a\nb", + }) + + local section = details:sub((details:find("Warnings", 1, true))) + assert.are.equal("Warnings\n- a\n- b", section) + end) + it("still reports nothing available when there is no recipe", function() assert.is_truthy(DevelopEditManager.formatRecipeDetails(nil):find("No edit recipe available", 1, true)) assert.is_truthy(DevelopEditManager.formatRecipeDetails({}):find("No edit recipe available", 1, true)) end) end) --- Lightroom's Temp is Kelvin for raw and a relative -100..100 for rendered --- files. Every clamp below used the Kelvin bounds regardless of the file, which --- turned a sensible relative `+10` into 2000 — maximum cool — and a Kelvin --- value on a JPEG into +100, maximum warm. -describe("DevelopEditManager white balance scale", function() - it("clamps a raw photo's Temp on the Kelvin scale", function() - assert.are.equal(2000, normalizeDevelopValue("Temp", 1500, true)) - assert.are.equal(50000, normalizeDevelopValue("Temp", 90000, true)) - assert.are.equal(6200, normalizeDevelopValue("Temp", 6200, true)) +-- White balance lives in two families of develop keys: a raw file stores +-- Kelvin in `Temperature` plus an absolute `Tint` (-150..150); a rendered file +-- (and a DNG converted from one) stores offsets in `IncrementalTemperature` / +-- `IncrementalTint` (-100..100). The plug-in used to write `Temp` for both, +-- which is not a key Lightroom reports, and clamped it on whichever scale +-- `Util.isRawPhoto` guessed. +describe("DevelopEditManager.whiteBalanceFamily", function() + local family = DevelopEditManager.whiteBalanceFamily + + it("reads a raw file's family from its Temperature key", function() + assert.are.equal("raw", family({ Temperature = 5500, Tint = 10 }, true)) + end) + + it("reads a rendered file's family from its IncrementalTemperature key", function() + assert.are.equal("non_raw", family({ IncrementalTemperature = 0, IncrementalTint = 0 }, false)) end) - it("clamps a non-raw photo's Temp on the relative scale", function() - assert.are.equal(10, normalizeDevelopValue("Temp", 10, false)) - assert.are.equal(-100, normalizeDevelopValue("Temp", -180, false)) + it("trusts the settings over the file format for a DNG converted from a JPEG", function() + -- Util.isRawPhoto counts every DNG as raw; its white balance is not. + assert.are.equal("non_raw", family({ IncrementalTemperature = 0 }, true)) end) - it("treats an unknown raw status as raw, as it always did", function() - assert.are.equal(6200, normalizeDevelopValue("Temp", 6200, nil)) + it("falls back to the file format when neither key is present", function() + assert.are.equal("raw", family({}, true)) + assert.are.equal("non_raw", family({}, false)) + assert.are.equal("raw", family(nil, true)) end) - it("leaves every other key alone whatever the file is", function() - assert.are.equal(50, normalizeDevelopValue("Contrast2012", 50, false)) - assert.are.equal(100, normalizeDevelopValue("Contrast2012", 400, false)) + it("returns nil when neither source knows", function() + assert.is_nil(family({}, nil)) + assert.is_nil(family(nil, nil)) + end) +end) + +describe("DevelopEditManager white balance mapping", function() + local function wbRecipe(wb, global) + return { summary = "x", global = global or {}, masks = {}, white_balance = wb } + end + + it("writes a raw Custom white balance to Temperature/Tint", function() + local warnings = {} + local settings = buildDevelopSettings( + wbRecipe({ mode = "Custom", family = "raw", temperature = 5600, tint = 5 }), + warnings, + "raw" + ) + + assert.are.equal(5600, settings.Temperature) + assert.are.equal(5, settings.Tint) + assert.are.equal("Custom", settings.WhiteBalance) + assert.is_nil(settings.IncrementalTemperature) + assert.is_nil(settings.IncrementalTint) + assert.is_nil(settings.Temp) + assert.are.equal(0, #warnings) + end) + + it("writes a non-raw Custom white balance to the Incremental keys only", function() + local warnings = {} + local settings = buildDevelopSettings( + wbRecipe({ mode = "Custom", family = "non_raw", temperature = 12, tint = -3 }), + warnings, + "non_raw" + ) + + assert.are.equal(12, settings.IncrementalTemperature) + assert.are.equal(-3, settings.IncrementalTint) + assert.are.equal("Custom", settings.WhiteBalance) + assert.is_nil(settings.Temperature) + assert.is_nil(settings.Tint) + assert.is_nil(settings.Temp) + assert.are.equal(0, #warnings) end) - it("drops a Kelvin value for a non-raw photo instead of clamping it", function() - -- Clamping 6200 into -100..100 gives +100: a hard orange cast. Leaving - -- the photo's own white balance untouched is the lesser harm. + it("maps the old global.temperature/tint form on a raw photo", function() local warnings = {} - local settings = buildDevelopSettings({ global = { temperature = 6200, contrast = 10 } }, warnings, false) + local settings = buildDevelopSettings({ global = { temperature = 6200, tint = 4 } }, warnings, "raw") + assert.are.equal(6200, settings.Temperature) + assert.are.equal(4, settings.Tint) + assert.are.equal("Custom", settings.WhiteBalance) assert.is_nil(settings.Temp) + assert.are.equal(0, #warnings) + end) + + it("maps the old global.temperature/tint form on a non-raw photo", function() + local warnings = {} + local settings = buildDevelopSettings({ global = { temperature = 12, tint = -2 } }, warnings, "non_raw") + + assert.are.equal(12, settings.IncrementalTemperature) + assert.are.equal(-2, settings.IncrementalTint) + assert.are.equal("Custom", settings.WhiteBalance) + assert.is_nil(settings.Temperature) + assert.is_nil(settings.Tint) + assert.are.equal(0, #warnings) + end) + + it("maps the LLM's global.white_balance alias object like the old form", function() + local settings = buildDevelopSettings( + { global = { white_balance = { temperature = 6200, tint = 13 } } }, + {}, + "raw" + ) + + assert.are.equal(6200, settings.Temperature) + assert.are.equal(13, settings.Tint) + end) + + it("prefers the recipe-level white balance over the old global form", function() + local settings = buildDevelopSettings( + wbRecipe({ mode = "Custom", family = "raw", temperature = 5600, tint = 5 }, { temperature = 9000 }), + {}, + "raw" + ) + + assert.are.equal(5600, settings.Temperature) + end) + + it("writes the Incremental keys for a DNG converted from a JPEG", function() + -- The develop settings say non-raw although the file format says DNG. + local family = DevelopEditManager.whiteBalanceFamily({ IncrementalTemperature = 0 }, true) + local settings = buildDevelopSettings({ global = { temperature = 8, tint = 1 } }, {}, family) + + assert.are.equal(8, settings.IncrementalTemperature) + assert.are.equal(1, settings.IncrementalTint) + assert.is_nil(settings.Temperature) + end) + + it("clamps each family on its own scale", function() + local raw = mergeGlobalDevelopSettings( + {}, + buildDevelopSettings( + wbRecipe({ mode = "Custom", family = "raw", temperature = 90000, tint = 400 }), + {}, + "raw" + ) + ) + assert.are.equal(50000, raw.Temperature) + assert.are.equal(150, raw.Tint) + + local rendered = mergeGlobalDevelopSettings( + {}, + buildDevelopSettings( + wbRecipe({ mode = "Custom", family = "non_raw", temperature = -180, tint = 140 }), + {}, + "non_raw" + ) + ) + assert.are.equal(-100, rendered.IncrementalTemperature) + assert.are.equal(100, rendered.IncrementalTint) + + assert.are.equal(2000, normalizeDevelopValue("Temperature", 1500)) + assert.are.equal(-150, normalizeDevelopValue("Tint", -200)) + end) + + it("drops a Kelvin value aimed at a non-raw photo, with a warning", function() + -- Clamping 6200 into -100..100 gives +100: a hard orange cast. + local warnings = {} + local settings = + buildDevelopSettings({ global = { temperature = 6200, tint = 20, contrast = 10 } }, warnings, "non_raw") + + assert.is_nil(settings.IncrementalTemperature) + assert.is_nil(settings.IncrementalTint) + assert.is_nil(settings.WhiteBalance) assert.are.equal(10, settings.Contrast2012) assert.are.equal(1, #warnings) assert.is_truthy(warnings[1]:find("Kelvin", 1, true)) end) - it("keeps a relative value for a non-raw photo", function() + it("drops a relative value aimed at a raw photo, with a warning", function() + -- The Kelvin clamp would turn +10 into 2000 K, maximum cool. local warnings = {} - local settings = buildDevelopSettings({ global = { temperature = 12 } }, warnings, false) + local settings = buildDevelopSettings({ global = { temperature = 10 } }, warnings, "raw") - assert.are.equal(12, settings.Temp) - assert.are.equal(0, #warnings) + assert.is_nil(settings.Temperature) + assert.is_nil(settings.WhiteBalance) + assert.are.equal(1, #warnings) end) - it("keeps Kelvin for a raw photo", function() + it("drops a white balance learned from the other family, with a warning", function() local warnings = {} - local settings = buildDevelopSettings({ global = { temperature = 6200 } }, warnings, true) + local settings = buildDevelopSettings( + wbRecipe({ mode = "Custom", family = "raw", temperature = 5600, tint = 5 }), + warnings, + "non_raw" + ) + + assert.is_nil(settings.Temperature) + assert.is_nil(settings.IncrementalTemperature) + assert.is_nil(settings.WhiteBalance) + assert.are.equal(1, #warnings) + assert.is_truthy(warnings[1]:find("raw files", 1, true)) + end) - assert.are.equal(6200, settings.Temp) - assert.are.equal(0, #warnings) + it("writes no white balance and warns when the photo's family is unknown", function() + local warnings = {} + local settings = buildDevelopSettings( + wbRecipe({ mode = "Custom", family = "raw", temperature = 5600, tint = 5 }), + warnings, + nil + ) + assert.is_nil(settings.Temperature) + assert.is_nil(settings.Tint) + assert.is_nil(settings.WhiteBalance) + assert.are.equal(1, #warnings) + + local oldWarnings = {} + local oldForm = buildDevelopSettings({ global = { temperature = 5600 } }, oldWarnings, nil) + assert.is_nil(oldForm.Temperature) + assert.is_nil(oldForm.Temp) + assert.are.equal(1, #oldWarnings) + end) + + it("warns about an unrecognised family instead of guessing", function() + local warnings = {} + local settings = + buildDevelopSettings(wbRecipe({ mode = "Custom", family = "linear", temperature = 5600 }), warnings, "raw") + + assert.is_nil(settings.Temperature) + assert.are.equal(1, #warnings) + end) + + it("reports a named mode instead of applying it blind", function() + -- Not sent by the backend until experiment E1 shows how Lightroom + -- treats a mode without numbers. + local warnings = {} + local settings = buildDevelopSettings(wbRecipe({ mode = "Daylight", family = "raw" }), warnings, "raw") + + assert.is_nil(settings.WhiteBalance) + assert.are.equal(1, #warnings) + assert.is_truthy(warnings[1]:find("Daylight", 1, true)) end) - it("does not strip the photo's own Temp when it drops the recipe's", function() - -- The merge starts from the current settings, so a dropped key has to be - -- absent from the recipe rather than nil'd during the merge. + it("writes nothing and warns about nothing when the recipe has no white balance", function() local warnings = {} - local settings = buildDevelopSettings({ global = { temperature = 6200 } }, warnings, false) - local merged = mergeGlobalDevelopSettings({ Temp = 25, Tint = 3 }, settings, false) + local settings = buildDevelopSettings({ global = { exposure = 0.2 } }, warnings, nil) + + assert.is_nil(settings.WhiteBalance) + assert.is_nil(settings.Temperature) + assert.are.equal(0, #warnings) + end) + + it("keeps the photo's own white balance when the recipe has none", function() + local settings = buildDevelopSettings({ global = { contrast = 10 } }, {}, "raw") + local merged = mergeGlobalDevelopSettings({ Temperature = 4300, Tint = 7, WhiteBalance = "As Shot" }, settings) + + assert.are.equal(4300, merged.Temperature) + assert.are.equal(7, merged.Tint) + assert.are.equal("As Shot", merged.WhiteBalance) + assert.are.equal(10, merged.Contrast2012) + end) + + it("keeps the photo's own white balance when it drops the recipe's", function() + -- The merge starts from the current settings, so a dropped key has to + -- be absent from the recipe rather than nil'd during the merge. + local settings = buildDevelopSettings({ global = { temperature = 6200 } }, {}, "non_raw") + local merged = mergeGlobalDevelopSettings( + { IncrementalTemperature = 25, IncrementalTint = 3, WhiteBalance = "As Shot" }, + settings + ) + + assert.are.equal(25, merged.IncrementalTemperature) + assert.are.equal(3, merged.IncrementalTint) + assert.are.equal("As Shot", merged.WhiteBalance) + end) +end) + +describe("DevelopEditManager white balance display", function() + it("shows a raw white balance in Kelvin with a signed tint", function() + assert.are.equal( + "Custom 5600 K, tint +5", + DevelopEditManager.formatWhiteBalance({ mode = "Custom", family = "raw", temperature = 5600, tint = 5 }) + ) + end) + + it("shows a non-raw white balance as signed offsets", function() + assert.are.equal( + "Custom, temperature +12, tint -3", + DevelopEditManager.formatWhiteBalance({ mode = "Custom", family = "non_raw", temperature = 12, tint = -3 }) + ) + end) + + it("shows a mode without numbers by its name", function() + assert.are.equal("Auto", DevelopEditManager.formatWhiteBalance({ mode = "Auto", family = "raw" })) + end) + + it("never prints a table address for a style-engine response", function() + local details = DevelopEditManager.formatRecipeDetails({ + status = "success", + engine = "style", + warnings = { "White balance was not transferred" }, + edit = { + summary = "Warm", + global = { exposure = 0.2 }, + masks = {}, + warnings = {}, + white_balance = { mode = "Custom", family = "raw", temperature = 5600, tint = 5 }, + }, + }) + + assert.is_falsy(details:find("table: 0x", 1, true)) + assert.is_truthy(details:find("White balance: Custom 5600 K, tint +5", 1, true)) + assert.is_truthy(details:find("exposure", 1, true)) + end) + + it("never prints a table address for the LLM's white_balance alias or unknown groups", function() + local details = DevelopEditManager.formatRecipeDetails({ + edit = { + summary = "LLM", + global = { + white_balance = { temperature = 6200, tint = 13 }, + some_future_group = { a = 1, b = 2 }, + }, + masks = {}, + warnings = {}, + }, + }) - assert.are.equal(25, merged.Temp) - assert.are.equal(3, merged.Tint) + assert.is_falsy(details:find("table: 0x", 1, true)) + assert.is_truthy(details:find("white_balance: temperature 6200, tint +13", 1, true)) + assert.is_truthy(details:find("some_future_group: 2 setting(s)", 1, true)) end) end) diff --git a/plugin/spec/develop_experiments_spec.lua b/plugin/spec/develop_experiments_spec.lua new file mode 100644 index 00000000..4041d4e7 --- /dev/null +++ b/plugin/spec/develop_experiments_spec.lua @@ -0,0 +1,1201 @@ +-- Tests for the pure helpers behind TaskDevelopExperiments. +-- +-- The experiments exist to answer questions about Lightroom that nothing on +-- disk could settle, so the helpers that build what they apply and read back +-- what Lightroom did have to be right on their own: a wrong scaling or a +-- mutated input would turn a clean experiment into a misleading one. + +require("DevelopExperiments") + +local X = DevelopExperiments + +local function counter() + local n = 0 + return function() + n = n + 1 + return string.format("%032X", n) + end +end + +describe("DevelopExperiments.newSyncId", function() + it("produces 32 upper-case hex digits", function() + local id = X.newSyncId() + assert.are.equal(32, #id) + assert.is_truthy(id:match("^[0-9A-F]+$")) + end) + + it("uses the injected random source", function() + local id = X.newSyncId(function() + return 16 + end) + assert.are.equal(string.rep("F", 32), id) + end) +end) + +describe("DevelopExperiments.aiMaskCorrection", function() + it("stores exposure as EV/4 and mirrors Adobe's adaptive-preset shape", function() + local correction = X.aiMaskCorrection({ + name = "Test", + mask = X.MASKS.subject, + exposureStops = 1, + }, counter()) + + assert.are.equal("Correction", correction.What) + assert.are.equal(0.25, correction.LocalExposure2012) + assert.are.equal(0, correction.LocalClarity2012) + assert.are.equal(1, correction.CorrectionAmount) + assert.are.equal(1, #correction.CorrectionMasks) + + local tool = correction.CorrectionMasks[1] + assert.are.equal("Mask/Image", tool.What) + assert.are.equal(1, tool.MaskSubType) + assert.are.equal("0.500000 0.500000", tool.ReferencePoint) + assert.are.equal(0, tool.ErrorReason) + assert.is_nil(tool.MaskSubCategoryID) + end) + + it("never writes computed fields or runtime ids", function() + local correction = X.aiMaskCorrection({ name = "Test", mask = X.MASKS.hair }, counter()) + local tool = correction.CorrectionMasks[1] + + for _, key in ipairs({ "MaskDigest", "InputDigest", "FullMaskSize", "WholeImageArea", "Origin", "MaskID" }) do + assert.is_nil(tool[key], key) + end + assert.is_nil(correction.CorrectionID) + assert.are.equal(3, tool.MaskSubType) + assert.are.equal(5, tool.MaskSubCategoryID) + end) + + it("gives correction and tool distinct sync ids unless told otherwise", function() + local correction = X.aiMaskCorrection({ name = "Test", mask = X.MASKS.sky }, counter()) + assert.are_not.equal(correction.CorrectionSyncID, correction.CorrectionMasks[1].MaskSyncID) + + local fixed = X.aiMaskCorrection({ name = "Test", mask = X.MASKS.sky, syncId = "ABC" }, counter()) + assert.are.equal("ABC", fixed.CorrectionSyncID) + end) +end) + +describe("DevelopExperiments.appendCorrections", function() + it("keeps existing corrections and does not mutate the input", function() + local existing = { { CorrectionSyncID = "A" } } + local combined = X.appendCorrections(existing, { { CorrectionSyncID = "B" } }) + + assert.are.equal(2, #combined) + assert.are.equal(1, #existing) + assert.are.equal(existing[1], combined[1]) + end) + + it("treats a missing mask array as empty", function() + assert.are.equal(1, #X.appendCorrections(nil, { { CorrectionSyncID = "B" } })) + end) +end) + +describe("DevelopExperiments mask states", function() + it("tells computed, failed and pending apart", function() + assert.are.equal("computed", X.toolState({ What = "Mask/Image", MaskDigest = "F679" })) + assert.are.equal("failed", X.toolState({ What = "Mask/Image", ErrorReason = 1 })) + assert.are.equal("failed", X.toolState({ What = "Mask/Image", ErrorReason = "1" })) + assert.are.equal("pending", X.toolState({ What = "Mask/Image", ErrorReason = 0 })) + assert.are.equal("geometric", X.toolState({ What = "Mask/Gradient" })) + end) + + it("reports a correction as pending while any AI tool is pending", function() + local correction = { + CorrectionMasks = { + { What = "Mask/Image", MaskDigest = "X" }, + { What = "Mask/Image", ErrorReason = 0 }, + }, + } + assert.are.equal("pending", X.correctionState(correction)) + assert.are.equal("missing", X.correctionState(nil)) + assert.are.equal("no-ai-mask", X.correctionState({ CorrectionMasks = { { What = "Mask/Gradient" } } })) + end) + + it("finds corrections by sync id, including duplicates", function() + local groups = { { CorrectionSyncID = "A" }, { CorrectionSyncID = "B" }, { CorrectionSyncID = "A" } } + assert.are.equal(2, #X.findCorrections(groups, "A")) + assert.are.equal(0, #X.findCorrections(nil, "A")) + end) +end) + +describe("DevelopExperiments.diff", function() + it("distinguishes a dropped key from an unchanged one", function() + local changes = X.diff({ Temperature = 5500, Tint = 3 }, { Temperature = 7000, Tint = 3 }, { + "Temperature", + "Tint", + "Temp", + }) + assert.are.same({ Temperature = { before = 5500, after = 7000 } }, changes) + end) +end) + +describe("DevelopExperiments.parseDimensions", function() + it("reads both the raw table and the formatted string", function() + assert.are.same({ 6000, 4000 }, { X.parseDimensions({ width = 6000, height = 4000 }) }) + assert.are.same({ 3072, 2304 }, { X.parseDimensions("3072 x 2304") }) + assert.are.same({}, { X.parseDimensions("unknown") }) + end) +end) + +describe("DevelopExperiments.cropModelCheck", function() + it("reproduces an unrotated crop in the sensor frame", function() + -- A 6000x4000 sensor, crop keeps the middle half horizontally. + local results = X.cropModelCheck({ w = 6000, h = 4000 }, { w = 3000, h = 4000 }, { + left = 0.25, + top = 0, + right = 0.75, + bottom = 1, + angle = 0, + }) + assert.are.equal("as reported", results[1].dimensions) + assert.are.equal("as reported", results[1].cropped) + assert.is_true(results[1].errorPx < 1e-6) + end) + + it("follows the rotated-rectangle model for an angled crop", function() + -- Corners of a rectangle 3000x2000 px rotated by +5 degrees, starting at (500, 500). + local theta = math.rad(5) + local w, h = 3000, 2000 + local tlx, tly = 500, 500 + local brx = tlx + w * math.cos(theta) - h * math.sin(theta) + local bry = tly + w * math.sin(theta) + h * math.cos(theta) + local crop = { left = tlx / 6000, top = tly / 4000, right = brx / 6000, bottom = bry / 4000, angle = 5 } + + local results = X.cropModelCheck({ w = 6000, h = 4000 }, { w = w, h = h }, crop) + assert.is_true(results[1].errorPx < 1e-6) + assert.are.equal("as reported", results[1].dimensions) + end) + + it("detects dimensions reported in display orientation", function() + -- Sensor 6000x4000, portrait photo: the SDK hands back 4000x6000. + local results = X.cropModelCheck({ w = 4000, h = 6000 }, { w = 3000, h = 4000 }, { + left = 0.25, + top = 0, + right = 0.75, + bottom = 1, + angle = 0, + }) + assert.are.equal("swapped", results[1].dimensions) + assert.is_true(results[1].errorPx < 1e-6) + + local text = X.describeCropModel(results, "DA") + assert.is_truthy(text:find("dimensions are reported in display orientation", 1, true)) + end) + + it("does not claim a frame for an unrotated photo", function() + local results = X.cropModelCheck({ w = 6000, h = 4000 }, { w = 6000, h = 4000 }, {}) + assert.is_truthy(X.describeCropModel(results, "AB"):find("cannot tell", 1, true)) + end) + + it("says so when nothing matches", function() + local results = X.cropModelCheck({ w = 6000, h = 4000 }, { w = 1234, h = 777 }, {}) + assert.is_truthy(X.describeCropModel(results, "DA"):find("no hypothesis matches", 1, true)) + end) +end) + +describe("DevelopExperiments orientation helpers", function() + it("maps Lightroom's codes to EXIF numbers", function() + assert.are.equal(1, X.exifOrientation("AB")) + assert.are.equal(8, X.exifOrientation("DA")) + assert.are.equal(6, X.exifOrientation("BC")) + assert.is_nil(X.exifOrientation("??")) + assert.is_true(X.isQuarterTurn("DA")) + assert.is_false(X.isQuarterTurn("CD")) + end) +end) + +describe("DevelopExperiments.versionAtLeast", function() + it("compares major and minor", function() + assert.is_true(X.versionAtLeast({ major = 15, minor = 5 }, 15, 3)) + assert.is_false(X.versionAtLeast({ major = 15, minor = 2 }, 15, 3)) + assert.is_true(X.versionAtLeast({ major = 16, minor = 0 }, 15, 3)) + assert.is_false(X.versionAtLeast(nil, 15, 3)) + end) +end) + +describe("DevelopExperiments report rendering", function() + it("turns arbitrary values into plain data", function() + local plain = X.plainValue({ 1, 2, { a = true }, [false] = "x" }) + assert.are.equal("x", plain["false"]) + assert.are.equal(true, plain["3"].a) + + local list = X.plainValue({ "a", "b" }) + assert.are.same({ "a", "b" }, list) + assert.are.equal("string", type(X.plainValue(print))) + end) + + it("renders deterministically and truncates long values", function() + assert.are.equal('{a=1, b="x"}', X.inlineValue({ b = "x", a = 1 })) + local long = X.inlineValue(string.rep("y", 50), 10) + assert.is_truthy(long:find("truncated", 1, true)) + end) + + it("renders verdicts, manual checks and failed steps", function() + local markdown = X.renderMarkdown({ + meta = { lightroom = "15.5.1" }, + experiments = { + { + id = "E1", + title = "White balance", + question = "Temp?", + verdicts = { "Temp was ignored" }, + manualChecks = { "Look at History" }, + steps = { { photo = "a.cr3", label = "E1a", ok = false, error = "boom", data = { x = 1 } } }, + }, + }, + }) + assert.is_truthy(markdown:find("## E1 - White balance", 1, true)) + assert.is_truthy(markdown:find("- Temp was ignored", 1, true)) + assert.is_truthy(markdown:find("- [ ] Look at History", 1, true)) + assert.is_truthy(markdown:find("(FAILED)", 1, true)) + assert.is_truthy(markdown:find("Error: `boom`", 1, true)) + end) +end) + +describe("DevelopExperiments crop-model ambiguity", function() + it("calls an uncropped portrait photo ambiguous instead of guessing a frame", function() + local results = X.cropModelCheck({ w = 4000, h = 6000 }, { w = 4000, h = 6000 }, { + left = 0, + top = 0, + right = 1, + bottom = 1, + angle = 0, + }) + assert.is_truthy(X.describeCropModel(results, "DA"):find("ambiguous", 1, true)) + end) + + it("calls an aspect-preserving unrotated crop ambiguous", function() + local results = X.cropModelCheck({ w = 4000, h = 6000 }, { w = 3000, h = 4500 }, { + left = 0.1, + top = 0.1, + right = 0.85, + bottom = 0.85, + angle = 0, + }) + assert.is_truthy(X.describeCropModel(results, "DA"):find("ambiguous", 1, true)) + end) + + it("decides the frame from a straightened portrait crop", function() + -- Sensor 6000x4000 (landscape), crop rotated by +3 degrees; the SDK is + -- assumed to report the uncropped size in display orientation. + local theta = math.rad(3) + local w, h = 2000, 3000 + local tlx, tly = 1500, 400 + local brx = tlx + w * math.cos(theta) - h * math.sin(theta) + local bry = tly + w * math.sin(theta) + h * math.cos(theta) + local crop = { left = tlx / 6000, top = tly / 4000, right = brx / 6000, bottom = bry / 4000, angle = 3 } + + local results = X.cropModelCheck({ w = 4000, h = 6000 }, { w = h, h = w }, crop) + local text = X.describeCropModel(results, "DA") + assert.is_truthy(text:find("dimensions are reported in display orientation", 1, true), text) + assert.is_truthy(text:find("croppedDimensions in display orientation", 1, true), text) + end) + + it("orders ties deterministically", function() + local a = X.cropModelCheck({ w = 4000, h = 6000 }, { w = 4000, h = 6000 }, {}) + local b = X.cropModelCheck({ w = 4000, h = 6000 }, { w = 4000, h = 6000 }, {}) + assert.are.equal(a[1].dimensions .. a[1].cropped, b[1].dimensions .. b[1].cropped) + assert.are.equal("as reported", a[1].dimensions) + end) +end) + +describe("DevelopExperiments.gradientCorrection", function() + it("builds a linear gradient with its own sync ids", function() + local correction = X.gradientCorrection({ name = "Seed", exposureStops = -0.5 }, counter()) + local tool = correction.CorrectionMasks[1] + + assert.are.equal("Mask/Gradient", tool.What) + assert.are.equal(-0.125, correction.LocalExposure2012) + assert.is_number(tool.ZeroY) + assert.is_number(tool.FullY) + assert.are_not.equal(correction.CorrectionSyncID, tool.MaskSyncID) + assert.are.equal("no-ai-mask", X.correctionState(correction)) + end) +end) + +describe("DevelopExperiments.describePoll", function() + it("words 'nothing found' as computed, not as a rejection", function() + local text = X.describePoll({ + state = "failed", + seconds = 4, + timedOut = false, + summary = { { tools = { { errorReason = 1 } } } }, + }) + assert.are.equal("computed, nothing found (ErrorReason=1) after 4 s", text) + end) + + it("distinguishes timeouts, cancellations and success", function() + assert.are.equal( + "still pending after 60 s", + X.describePoll({ state = "pending", seconds = 60, timedOut = true }) + ) + assert.are.equal( + "not finished (run canceled after 3 s)", + X.describePoll({ state = "canceled", seconds = 3, timedOut = false }) + ) + assert.are.equal("computed after 7 s", X.describePoll({ state = "computed", seconds = 7, timedOut = false })) + assert.are.equal("not polled", X.describePoll(nil)) + end) +end) + +describe("DevelopExperiments report details", function() + it("lists paths in the header verbatim, without quoting or truncation", function() + local long = string.rep("a", 300) + local markdown = X.renderMarkdown({ + meta = { pluginPresetFilesToDelete = { "C:\\Presets\\one.lrtemplate", long } }, + experiments = {}, + }) + assert.is_truthy(markdown:find(" - C:\\Presets\\one.lrtemplate", 1, true)) + assert.is_truthy(markdown:find(" - " .. long, 1, true)) + assert.is_falsy(markdown:find("truncated", 1, true)) + end) + + it("words an unreadable poll as unknown, not as a Lightroom result", function() + local text = X.describePoll({ state = "unreadable", seconds = 0, timedOut = false }) + assert.is_truthy(text:find("could not be read back", 1, true)) + end) +end) + +describe("DevelopExperiments.settingsFamily", function() + it("reads the family from the white-balance keys, not the file format", function() + assert.are.equal("raw", X.settingsFamily({ Temperature = 5500, Tint = 0 })) + assert.are.equal("non-raw", X.settingsFamily({ IncrementalTemperature = 0, IncrementalTint = 0 })) + assert.is_nil(X.settingsFamily({ Exposure2012 = 0 })) + assert.is_nil(X.settingsFamily(nil)) + end) +end) + +describe("DevelopExperiments.describeWbModeOutcome", function() + it("reports a recomputed raw temperature and tint with before and after", function() + local text = X.describeWbModeOutcome( + "Daylight", + "raw", + { WhiteBalance = "As Shot", Temperature = 4300, Tint = 2 }, + { WhiteBalance = "Daylight", Temperature = 5500, Tint = 10 } + ) + assert.are.equal("WhiteBalance = Daylight; Lightroom recomputed Temperature 4300 -> 5500, Tint 2 -> 10", text) + end) + + it("says so when the values stay put", function() + local text = X.describeWbModeOutcome( + "Auto", + "non-raw", + { WhiteBalance = "As Shot", IncrementalTemperature = 0, IncrementalTint = 0 }, + { WhiteBalance = "Auto", IncrementalTemperature = 0, IncrementalTint = 0 } + ) + assert.are.equal( + "WhiteBalance = Auto; IncrementalTemperature/IncrementalTint not recomputed (still 0 / 0)", + text + ) + end) + + it("flags a mode that was not taken and a copy already on that mode", function() + local text = X.describeWbModeOutcome( + "Auto", + "raw", + { WhiteBalance = "Auto", Temperature = 5000, Tint = 0 }, + { WhiteBalance = "As Shot", Temperature = 5000, Tint = 0 } + ) + assert.is_truthy(text:find("WhiteBalance was not taken (reads As Shot)", 1, true), text) + assert.is_truthy(text:find("so this proves nothing", 1, true), text) + end) + + it("notices a value resolved only after the call returned", function() + local text = X.describeWbModeOutcome( + "Auto", + "raw", + { WhiteBalance = "As Shot", Temperature = 4300, Tint = 2 }, + { WhiteBalance = "Auto", Temperature = 4800, Tint = 5 }, + { + immediate = { WhiteBalance = "Auto", Temperature = 4300, Tint = 2 }, + settle = { seconds = 3.5, changed = true }, + } + ) + assert.is_truthy(text:find("Tint 2 -> 5 after 3.5 s", 1, true), text) + assert.is_truthy(text:find("still showed Temperature = 4300", 1, true), text) + assert.is_truthy(text:find("asynchronously", 1, true), text) + end) + + it("words a wait without a change as bounded, not final", function() + local text = X.describeWbModeOutcome( + "Auto", + "raw", + { WhiteBalance = "Custom", Temperature = 3000, Tint = 40 }, + { WhiteBalance = "Auto", Temperature = 3000, Tint = 40 }, + { settle = { seconds = 15, changed = false } } + ) + assert.are.equal("WhiteBalance = Auto; Temperature/Tint not recomputed within 15 s (still 3000 / 40)", text) + local canceled = X.describeWbModeOutcome( + "Auto", + "raw", + { WhiteBalance = "Custom", Temperature = 3000, Tint = 40 }, + { WhiteBalance = "Auto", Temperature = 3000, Tint = 40 }, + { settle = { seconds = 2, changed = false, canceled = true } } + ) + assert.is_truthy(canceled:find("within 2 s (run canceled)", 1, true), canceled) + end) + + it("says when the values were already in the first readback", function() + local text = X.describeWbModeOutcome( + "Daylight", + "raw", + { WhiteBalance = "Custom", Temperature = 3000, Tint = 40 }, + { WhiteBalance = "Daylight", Temperature = 5500, Tint = 10 }, + { settle = { seconds = 0, changed = true } } + ) + assert.is_truthy(text:find("(already in the readback right after the call)", 1, true), text) + end) + + it("calls a flattened Auto flattened, not rejected", function() + local before = { WhiteBalance = "Custom", Temperature = 3000, Tint = 40 } + local after = { WhiteBalance = "Custom", Temperature = 4850, Tint = 4 } + local text = X.describeWbModeOutcome("Auto", "raw", before, after, { flatten = true }) + assert.are.equal( + "Auto was flattened to WhiteBalance = Custom; with Temperature 3000 -> 4850, Tint 40 -> 4", + text + ) + -- Without the flag, or without a change, it is still "not taken". + assert.is_truthy(X.describeWbModeOutcome("Auto", "raw", before, after):find("was not taken", 1, true)) + assert.is_truthy( + X.describeWbModeOutcome("Auto", "raw", before, before, { flatten = true }):find("was not taken", 1, true) + ) + end) + + it("reports the other family's keys appearing", function() + local text = X.describeWbModeOutcome( + "Auto", + "non-raw", + { WhiteBalance = "As Shot", IncrementalTemperature = 0, IncrementalTint = 0 }, + { WhiteBalance = "Auto", IncrementalTemperature = 0, IncrementalTint = 0, Temperature = 5000 } + ) + assert.is_truthy(text:find("Temperature appeared (5000)", 1, true), text) + end) +end) + +describe("DevelopExperiments white-balance precondition", function() + it("is a distinctive Custom white balance in the photo's key family", function() + assert.are.same({ WhiteBalance = "Custom", Temperature = 3000, Tint = 40 }, X.wbPrecondition("raw")) + assert.are.same( + { WhiteBalance = "Custom", IncrementalTemperature = -40, IncrementalTint = 40 }, + X.wbPrecondition("non-raw") + ) + -- A fresh table each time, so a caller cannot corrupt the next one. + local first = X.wbPrecondition("raw") + first.Temperature = 1 + assert.are.equal(3000, X.wbPrecondition("raw").Temperature) + end) + + it("checks the readback against what was written", function() + local pre = X.wbPrecondition("raw") + assert.is_true(X.preconditionHeld(pre, { WhiteBalance = "Custom", Temperature = 3000, Tint = 40, Temp = 1 })) + assert.is_false(X.preconditionHeld(pre, { WhiteBalance = "As Shot", Temperature = 3000, Tint = 40 })) + assert.is_false(X.preconditionHeld(pre, nil)) + end) + + it("detects a change of the family's temperature or tint only", function() + local before = { WhiteBalance = "Custom", Temperature = 3000, Tint = 40, IncrementalTemperature = 0 } + assert.is_false(X.wbPairChanged("raw", before, { WhiteBalance = "Auto", Temperature = 3000, Tint = 40 })) + assert.is_true(X.wbPairChanged("raw", before, { Temperature = 3000, Tint = 41 })) + assert.is_false(X.wbPairChanged("non-raw", { IncrementalTemperature = -40, IncrementalTint = 40 }, { + IncrementalTemperature = -40, + IncrementalTint = 40, + Temperature = 5000, + })) + end) +end) + +describe("DevelopExperiments full readback", function() + local settings = { + Exposure2012 = 0, + CameraProfile = "Adobe Standard", + ConvertToGrayscale = false, + ToneCurvePV2012 = { 0, 0, 255, 255 }, + LensBlur = {}, + RetouchAreas = { { a = 1 }, { a = 2 } }, + Look = { + Name = "Adobe Color", + UUID = "B952C231111CD8E0ECCF14B86BAA7077", + Amount = 1, + Parameters = { Version = "1", ToneCurve = { 1, 2 } }, + }, + MaskGroupBasedCorrections = { + { CorrectionName = "Sky", CorrectionMasks = { { What = "Mask/Image", MaskDigest = "X" } } }, + }, + } + + it("keeps scalars verbatim and describes tables by shape", function() + local summary = X.summarizeSettings(settings) + assert.are.equal(0, summary.Exposure2012) + assert.are.equal("Adobe Standard", summary.CameraProfile) + assert.are.equal(false, summary.ConvertToGrayscale) + assert.are.same( + { type = "table", shape = "array", length = 4, keyCount = 4, values = { 0, 0, 255, 255 } }, + summary.ToneCurvePV2012 + ) + assert.are.same({ type = "table", shape = "empty", length = 0, keyCount = 0 }, summary.LensBlur) + assert.are.equal(2, summary.RetouchAreas.length) + assert.is_nil(summary.RetouchAreas.values) + end) + + it("summarises the Look without copying its Parameters", function() + local summary = X.summarizeSettings(settings) + local look = summary.Look + assert.are.equal("Look", look.type) + assert.are.equal("Adobe Color", look.Name) + assert.is_true(look.hasParameters) + assert.are.equal(2, look.parameterKeyCount) + assert.is_nil(look.Parameters) + assert.are.same({ "Amount", "Name", "Parameters", "UUID" }, look.keys) + end) + + it("summarises the masks like the other experiments do", function() + local masks = X.summarizeSettings(settings).MaskGroupBasedCorrections + assert.are.equal(1, masks.length) + assert.are.equal("computed", masks.corrections[1].state) + end) + + it("describes a missing Look as nil", function() + assert.is_nil(X.summarizeLook(nil)) + assert.are.same({}, X.summarizeSettings(nil)) + end) +end) + +describe("DevelopExperiments Look helpers", function() + it("picks the first stub whose name differs from the photo's Look", function() + assert.are.equal("Adobe Vivid", X.pickLook(X.STUB_LOOKS, "Adobe Color").Name) + assert.are.equal("Adobe Landscape", X.pickLook(X.STUB_LOOKS, "Adobe Vivid").Name) + assert.are.equal("Adobe Vivid", X.pickLook(X.STUB_LOOKS, nil).Name) + assert.is_nil(X.pickLook({ { Name = "A" } }, "A")) + end) + + it("reads names through an accessor for preset entries", function() + local entries = { { look = { Name = "A" } }, { look = { Name = "B" } } } + local picked = X.pickLook(entries, "A", function(entry) + return entry.look.Name + end) + assert.are.equal("B", picked.look.Name) + end) + + it("builds a stub in the preset form, or bare on request", function() + local stub = X.stubLook(X.STUB_LOOKS[1]) + assert.is_true(stub.Stubbed) + assert.are.same( + { Name = "Adobe Vivid", UUID = "EA1DE074F188405965EF399C72C221D9", Amount = 1, Stubbed = true }, + stub + ) + assert.are.same( + { Name = "Adobe Vivid", UUID = "EA1DE074F188405965EF399C72C221D9", Amount = 1 }, + X.stubLook(X.STUB_LOOKS[1], true) + ) + assert.is_false(X.isFullLook(stub)) + assert.is_true(X.isFullLook({ Name = "X", Parameters = { Version = 1 } })) + assert.is_false(X.isFullLook({ Name = "X", Parameters = {} })) + assert.is_false(X.isFullLook({ Name = "", Parameters = { Version = 1 } })) + end) +end) + +describe("DevelopExperiments E11 sources", function() + local full = { Name = "Adobe Landscape", UUID = "L", Amount = 1, Parameters = { Version = 1 } } + + it("keeps an Adobe Standard base profile and replaces anything else", function() + assert.are.equal("Adobe Standard v2", X.e11CameraProfile("Adobe Standard v2")) + assert.are.equal("Adobe Standard", X.e11CameraProfile("Adobe Standard")) + assert.are.equal("Adobe Standard", X.e11CameraProfile("Camera Standard v2")) + assert.are.equal("Adobe Standard", X.e11CameraProfile(nil)) + end) + + it("rejects Looks that cannot serve as the full Look", function() + assert.is_nil(X.lookRejection(full, "Adobe Color", "R6", "R6")) + assert.is_truthy(X.lookRejection({ Name = "Adobe Vivid", UUID = "V" }, nil):find("no full Look", 1, true)) + assert.is_truthy(X.lookRejection(full, "Adobe Landscape"):find("same Look", 1, true)) + local adaptive = { Name = "Adaptive Color", Parameters = { Version = 1 }, isAdobeAdaptive = true } + assert.is_truthy(X.lookRejection(adaptive, nil):find("Adaptive", 1, true)) + end) + + it("accepts a camera-restricted Look only between photos of the same camera", function() + local restricted = { + Name = "R6M2 Standard V4", + Parameters = { Version = 1 }, + CameraModelRestriction = "Canon EOS R6 Mark II", + } + -- The restriction uses Adobe's camera name, the photo its EXIF model. + assert.is_nil(X.lookRejection(restricted, "Adobe Color", "Canon EOS R6m2", "Canon EOS R6m2")) + assert.is_truthy( + X.lookRejection(restricted, "Adobe Color", "Canon EOS R6m2", "Canon EOS R5"):find("restricted to", 1, true) + ) + assert.is_truthy(X.lookRejection(restricted, "Adobe Color", nil, nil):find("restricted to", 1, true)) + end) + + it("picks the first usable donor and lists the ones passed over", function() + local donors = { + { photo = "a.jpg", camera = "X", look = { Name = "", Parameters = {} } }, + { photo = "b.cr3", camera = "X", look = { Name = "Adobe Color", Parameters = { Version = 1 } } }, + { photo = "c.cr3", camera = "X", look = full }, + } + local donor, rejected = X.pickDonorLook(donors, "Adobe Color", "X") + assert.are.equal("c.cr3", donor.photo) + assert.are.equal(2, #rejected) + assert.are.equal("a.jpg", rejected[1].photo) + assert.is_nil((X.pickDonorLook({}, "Adobe Color", "X"))) + end) + + it("summarises the Look flags that decide whether it can be reused", function() + local summary = X.summarizeLook({ + Name = "R6M2 Standard V4", + Stubbed = true, + isAdobeAdaptive = false, + CameraModelRestriction = "Canon EOS R6 Mark II", + }) + assert.is_true(summary.Stubbed) + assert.is_false(summary.isAdobeAdaptive) + assert.are.equal("Canon EOS R6 Mark II", summary.CameraModelRestriction) + end) + + it("reports unreadable presets instead of hiding them in the count", function() + assert.is_truthy(X.describePresetScan({ scanned = 446 }):find("none of the 446", 1, true)) + local text = X.describePresetScan({ scanned = 10, failedReads = 10, firstError = "Foo: boom" }) + assert.is_truthy(text:find("10 preset(s) could not be read, first error: Foo: boom", 1, true), text) + end) +end) + +describe("DevelopExperiments.describeLookOutcome", function() + local before = { + CameraProfile = "Adobe Standard", + look = { Name = "Adobe Color", UUID = "C", hasParameters = true }, + } + local stubApplied = { + CameraProfile = "Adobe Standard", + look = { Name = "Adobe Vivid", UUID = "V", hasParameters = false }, + } + + it("calls a stub honoured and says whether Lightroom filled in Parameters", function() + local after = + { CameraProfile = "Adobe Standard", look = { Name = "Adobe Vivid", UUID = "V", hasParameters = true } } + local text = X.describeLookOutcome(stubApplied, before, after, true) + assert.is_truthy(text:find("^honoured; Lightroom filled in the profile's Parameters"), text) + + local bare = + { CameraProfile = "Adobe Standard", look = { Name = "Adobe Vivid", UUID = "V", hasParameters = false } } + assert.is_truthy( + X.describeLookOutcome(stubApplied, before, bare, true):find("still has no Parameters", 1, true) + ) + end) + + it("calls an unchanged Look ignored", function() + local text = X.describeLookOutcome(stubApplied, before, before, true) + assert.is_truthy(text:find("^ignored"), text) + end) + + it("reports a different result, a wrong CameraProfile, errors and unreadable results", function() + local other = { CameraProfile = "Camera Standard", look = { Name = "Other", UUID = "O", hasParameters = true } } + local text = X.describeLookOutcome(stubApplied, before, other, true) + assert.is_truthy(text:find("^changed to something else"), text) + assert.is_truthy(text:find("CameraProfile reads Camera Standard", 1, true), text) + + assert.are.equal("error: boom", X.describeLookOutcome(stubApplied, before, nil, false, "boom")) + assert.is_truthy( + X.describeLookOutcome(stubApplied, before, nil, true, nil, "no read"):find("result is unknown", 1, true) + ) + end) + + it("checks that a full Look kept its Parameters", function() + local applied = + { CameraProfile = "Adobe Standard", look = { Name = "Vintage", UUID = "W", hasParameters = true } } + local dropped = + { CameraProfile = "Adobe Standard", look = { Name = "Vintage", UUID = "W", hasParameters = false } } + assert.is_truthy(X.describeLookOutcome(applied, before, dropped, true):find("Parameters dropped", 1, true)) + end) + + it("renders a Look state", function() + assert.are.equal( + "CameraProfile Adobe Standard, Look Adobe Color (C, Parameters: yes)", + X.describeLookState(before) + ) + assert.are.equal("CameraProfile nil, no Look", X.describeLookState({})) + end) +end) + +describe("DevelopExperiments E2 timing", function() + it("words an update that led to a ready mask", function() + local text = X.describeTiming({ + source = "update", + updateOk = true, + updateSeconds = 0.43, + updateCallSeconds = 0.41, + state = "computed", + readySeconds = 6.4, + }) + assert.are.equal("update call 0.4 s, mask ready after 6.4 s", text) + end) + + it("separates the write-gate wait from the call when they differ", function() + local text = X.describeTiming({ + source = "update", + updateOk = true, + updateSeconds = 3.2, + updateCallSeconds = 0.4, + gateWaitSeconds = 2.8, + state = "failed", + readySeconds = 9, + }) + assert.are.equal( + "update call 0.4 s (after 2.8 s waiting for write access), mask computed (nothing found) after 9.0 s", + text + ) + end) + + it("marks a time as a lower bound when Lightroom was already computing", function() + local base = + { source = "update", updateOk = true, updateCallSeconds = 0.4, state = "computed", readySeconds = 0.4 } + local waited = {} + for k, v in pairs(base) do + waited[k] = v + end + waited.waitedBeforeUpdate = 12 + assert.is_truthy( + X.describeTiming(waited):find( + "lower bound: Lightroom was already computing before the update (the photo was locked for 12.0 s", + 1, + true + ) + ) + local locked = {} + for k, v in pairs(base) do + locked[k] = v + end + locked.lockedDuringAutoWait = true + assert.is_truthy(X.describeTiming(locked):find("locked during the unasked wait", 1, true)) + assert.is_falsy(X.describeTiming(base):find("lower bound", 1, true)) + end) + + it("does not count a vanished AI tool as a ready mask", function() + local text = X.describeTiming({ + source = "update", + updateOk = true, + updateCallSeconds = 0.4, + state = "no-ai-mask", + readySeconds = 3.4, + }) + assert.are.equal("update call 0.4 s, the AI mask tool disappeared after 3.4 s", text) + assert.is_false(X.timingIsTerminal({ state = "no-ai-mask" })) + assert.is_truthy( + X.describeTiming({ source = "auto", state = "no-ai-mask", readySeconds = 2 }):find("disappeared", 1, true) + ) + assert.is_truthy(X.describePoll({ state = "no-ai-mask", seconds = 3 }):find("no longer an AI mask", 1, true)) + end) + + it("covers timeouts, cancellations, failures and the automatic case", function() + assert.are.equal( + "update call 0.2 s, mask still pending after 60.2 s", + X.describeTiming({ + source = "update", + updateOk = true, + updateSeconds = 0.2, + state = "pending", + readySeconds = 60.2, + }) + ) + assert.is_truthy(X.describeTiming({ + source = "update", + updateOk = true, + updateSeconds = 0.2, + state = "canceled", + readySeconds = 3, + }):find("run canceled", 1, true)) + assert.are.equal( + "update call 0.1 s, and it failed", + X.describeTiming({ source = "update", updateOk = false, updateSeconds = 0.1 }) + ) + assert.are.equal( + "without updateAISettings() the mask was ready after 12.0 s", + X.describeTiming({ source = "auto", state = "computed", readySeconds = 12 }) + ) + assert.are.equal("not measured (canceled)", X.describeTiming({ source = "none", reason = "canceled" })) + end) + + it("puts every photo on one run-level line", function() + local line = X.describeTimings({ + { + photo = "a.cr3", + source = "update", + updateOk = true, + updateSeconds = 0.4, + state = "computed", + readySeconds = 5, + }, + { photo = "b.jpg", source = "none", reason = "no virtual copy" }, + }) + assert.are.equal( + "E2 timing per photo - a.cr3: update call 0.4 s, mask ready after 5.0 s (includes loading the AI model if this was the first AI use this session); b.jpg: not measured (no virtual copy)", + line + ) + -- The tag goes to the first photo that was measured at all. + local later = X.describeTimings({ + { photo = "a", source = "none", reason = "x" }, + { photo = "b", source = "auto", state = "computed", readySeconds = 1 }, + { photo = "c", source = "auto", state = "computed", readySeconds = 1 }, + }) + local _, tags = later:gsub("includes loading the AI model", "") + assert.are.equal(1, tags) + assert.is_truthy(later:find("b: without updateAISettings() the mask was ready after 1.0 s (includes", 1, true)) + assert.is_truthy(X.describeTimings({}):find("no photo", 1, true)) + assert.is_true(X.timingIsTerminal({ state = "failed" })) + assert.is_false(X.timingIsTerminal({ state = "pending" })) + end) +end) + +describe("DevelopExperiments.presetFileDeletable", function() + local NAME = "LrGenius Experiment E4f" + local DIR = "/Users/me/Library/Application Support/Adobe/CameraRaw/Settings/Plugin Develop Presets" + local known = { "/Users/me/presets/LrGenius Experiment E4.xmp" } + + it("accepts the preset's own file next to the other experiment files", function() + assert.is_true((X.presetFileDeletable("/Users/me/presets/" .. NAME .. ".xmp", NAME, known))) + -- A numbered or uuid-suffixed name is still this preset. + assert.is_true((X.presetFileDeletable("/Users/me/presets/" .. NAME .. " 2.xmp", NAME, known))) + assert.is_true((X.presetFileDeletable("/Users/me/presets/" .. NAME .. "-ABC.lrtemplate", NAME, known))) + end) + + it("accepts a file inside the documented plugin-preset folder without known files", function() + assert.is_true((X.presetFileDeletable(DIR .. "/" .. NAME .. ".xmp", NAME, {}))) + assert.is_true((X.presetFileDeletable(DIR .. "/com.lrgeniusai/" .. NAME .. ".xmp", NAME, nil))) + end) + + it("compares Windows paths without regard to case or separator", function() + local winKnown = { "C:\\Users\\Me\\Presets\\LrGenius Experiment E4.xmp" } + assert.is_true((X.presetFileDeletable("c:/users/me/presets/" .. NAME .. ".xmp", NAME, winKnown))) + assert.is_true((X.presetFileDeletable("C:\\Users\\Me\\Presets\\" .. NAME .. ".xmp", NAME, winKnown))) + end) + + it("refuses anything that is not clearly this preset's file", function() + local cases = { + { nil, "no preset file path" }, + { "", "no preset file path" }, + { "error: attempt to call", "no preset file path" }, + { "presets/" .. NAME .. ".xmp", "not absolute" }, + { "/Users/me/presets/../presets/" .. NAME .. ".xmp", "relative component" }, + { "/Users/me/presets/./" .. NAME .. ".xmp", "relative component" }, + { "/Users/me/presets/" .. NAME, "not a preset file" }, + { "/Users/me/presets/" .. NAME .. ".lrcat", "not a preset file" }, + { "/Users/me/presets/" .. NAME .. "x.xmp", "does not match" }, + { "/Users/me/presets/LrGenius Experiment E4.xmp", "does not match" }, + { "/Users/me/presets/Other.xmp", "does not match" }, + { "/Users/me/elsewhere/" .. NAME .. ".xmp", "neither next to" }, + { "/" .. NAME .. ".xmp", "neither next to" }, + } + for _, case in ipairs(cases) do + local ok, why = X.presetFileDeletable(case[1], NAME, known) + assert.is_false(ok, tostring(case[1])) + assert.is_truthy(why:find(case[2], 1, true), tostring(case[1]) .. " -> " .. why) + end + end) + + it("refuses a path that belongs to another experiment step", function() + local other = "/Users/me/presets/" .. NAME .. ".xmp" + local ok, why = X.presetFileDeletable(other, NAME, { other }) + assert.is_false(ok) + assert.is_truthy(why:find("another experiment step", 1, true)) + end) + + it("refuses without a name to match", function() + assert.is_false((X.presetFileDeletable("/Users/me/presets/x.xmp", "", known))) + assert.is_false((X.presetFileDeletable("/Users/me/presets/x.xmp", nil, known))) + end) + + it("splits both separators", function() + assert.are.same({ "/a/b", "c.xmp" }, { X.splitPath("/a/b/c.xmp") }) + assert.are.same({ "C:\\a", "c.xmp" }, { X.splitPath("C:\\a\\c.xmp") }) + assert.are.same({ nil, "c.xmp" }, { X.splitPath("c.xmp") }) + end) +end) + +describe("DevelopExperiments.describeE4f", function() + local function base() + return { + name = "LrGenius Experiment E4f", + expectedContrast = 25, + before = {}, + create = { ok = true }, + apply = { + ok = true, + contrast = 25, + maskFound = true, + maskState = "computed after 3 s", + maskPollState = "computed", + }, + delete = { ok = true, existsAfter = false }, + after = { + namedCount = 1, + listedByUuid = true, + lookup = "found LrGenius Experiment E4f", + setting = { ok = true, contrast = 25 }, + first = { contrast = 25, maskFound = true, maskState = "computed" }, + }, + second = { ok = true, contrast = 25, maskFound = true, fileBack = false }, + readd = { + ok = true, + file = "/p/LrGenius Experiment E4f.xmp", + fileExists = true, + samePath = true, + sameUuid = false, + contrast = 15, + expectedContrast = 15, + fileContrast = 15, + fileHasNewContrast = true, + namedCount = 2, + cleanup = { ok = true, existsAfter = false }, + }, + } + end + + local function joined(r) + return table.concat(X.describeE4f(r), "\n") + end + + it("calls apply-then-delete viable within the session and says what Lightroom still shows", function() + local lines = X.describeE4f(base()) + assert.are.equal(4, #lines) + assert.is_truthy(lines[1]:find("no preset named", 1, true)) + assert.is_truthy(lines[2]:find("E4f apply then delete is VIABLE within this session", 1, true)) + assert.is_truthy(lines[2]:find("restart behaviour is a manual check", 1, true)) + assert.is_truthy(lines[2]:find("mask state before the delete: computed after 3 s, after: computed", 1, true)) + assert.is_falsy(lines[2]:find("still pending", 1, true)) + assert.is_truthy(lines[3]:find("getDevelopPresetsForPlugin still lists it: yes", 1, true)) + assert.is_truthy(lines[3]:find("preset:getSetting(): works (Contrast2012 25)", 1, true)) + assert.is_truthy(lines[3]:find("second copy: still works", 1, true)) + assert.is_truthy(lines[3]:find("'Plugin Develop Presets' folder", 1, true)) + assert.is_truthy(lines[4]:find("created a file again", 1, true)) + assert.is_truthy( + lines[4]:find("holds the new settings (Contrast2012 15, the deleted preset held 25): yes", 1, true) + ) + assert.is_truthy(lines[4]:find("(getSetting reads 15, the file holds 15)", 1, true)) + assert.is_truthy(lines[4]:find("presets of that name now listed: 2", 1, true)) + assert.is_truthy(lines[4]:find("the re-added file was deleted.", 1, true)) + end) + + it("calls it not viable when the delete changed the applied edit", function() + local r = base() + r.after.first = { contrast = 0, maskFound = false, maskState = "missing" } + local line = X.describeE4f(r)[2] + assert.is_truthy(line:find("NOT viable", 1, true)) + assert.is_truthy(line:find("Contrast2012 25 -> 0, subject mask present -> missing", 1, true)) + assert.is_truthy(line:find("after: missing", 1, true)) + end) + + it("calls it not viable when a computed mask lost its computation", function() + local r = base() + r.after.first.maskState = "pending" + assert.is_truthy(X.describeE4f(r)[2]:find("NOT viable", 1, true)) + end) + + it("qualifies the verdict when the mask was still pending at the delete", function() + local r = base() + r.apply.maskState = "still pending after 60 s" + r.apply.maskPollState = "pending" + r.after.first.maskState = "pending" + local line = X.describeE4f(r)[2] + assert.is_truthy(line:find("VIABLE within this session", 1, true)) + assert.is_truthy(line:find("still pending, not computed, when the file was deleted", 1, true)) + assert.is_truthy(line:find("covers the settings only", 1, true)) + -- A mask computed with nothing found is computed too. + r.apply.maskPollState, r.after.first.maskState = "failed", "failed" + assert.is_falsy(X.describeE4f(r)[2]:find("covers the settings only", 1, true)) + end) + + it("is inconclusive, not viable, when the preset had no visible effect", function() + local r = base() + r.apply = { ok = true, contrast = 0, maskFound = false, maskPollState = "missing" } + r.after.first = { contrast = 0, maskFound = false, maskState = "missing" } + local text = joined(r) + assert.is_truthy( + text:find( + "E4f inconclusive: the preset had no visible effect on the first copy (Contrast2012 0, subject mask missing), so there was no edit for the delete to preserve.", + 1, + true + ) + ) + assert.is_falsy(text:find("VIABLE", 1, true)) + assert.is_falsy(text:find("NOT viable", 1, true)) + assert.is_falsy(text:find("did not arrive", 1, true)) + + -- Without a delete to judge, the no-effect note still stands on its own. + r.delete = { ok = false, error = "permission denied", existsAfter = true } + r.after, r.second, r.readd = nil, nil, nil + text = joined(r) + assert.is_truthy(text:find("E4f the preset had no visible effect on the first copy", 1, true)) + assert.is_falsy(text:find("VIABLE", 1, true)) + end) + + it("judges on the mask alone when only the mask arrived", function() + local r = base() + r.apply.contrast = 0 + r.after.first.contrast = 0 + local lines = X.describeE4f(r) + assert.is_truthy(lines[2]:find("did not arrive on the first copy", 1, true)) + assert.is_truthy(lines[2]:find("rests on the mask alone", 1, true)) + assert.is_truthy(lines[3]:find("VIABLE", 1, true)) + r.after.first.maskFound, r.after.first.maskState = false, "missing" + assert.is_truthy(X.describeE4f(r)[3]:find("NOT viable", 1, true)) + end) + + it("reports a delete that failed or was refused as inconclusive and stops there", function() + local r = base() + r.delete = { ok = false, error = "permission denied", existsAfter = true } + r.after, r.second, r.readd = nil, nil, nil + local lines = X.describeE4f(r) + assert.are.equal(2, #lines) + assert.is_truthy( + lines[2]:find("inconclusive: the preset file could not be deleted - permission denied", 1, true) + ) + assert.is_truthy(lines[2]:find("stays in the list", 1, true)) + + r.delete = { skipped = "the path is not absolute: x" } + assert.is_truthy(X.describeE4f(r)[2]:find("was not deleted - the path is not absolute", 1, true)) + r.delete = { ok = true, existsAfter = true } + assert.is_truthy(X.describeE4f(r)[2]:find("still there, although LrFileUtils.delete", 1, true)) + end) + + it("describes the deleted preset failing, doing nothing or working partially on a second copy", function() + local r = base() + r.second = { ok = false, error = "preset not found" } + r.after.setting = { ok = false, error = "file missing" } + r.after.namedCount, r.after.listedByUuid = 0, false + local line = X.describeE4f(r)[3] + assert.is_truthy(line:find("still lists it: no (0 preset(s)", 1, true)) + assert.is_truthy(line:find("preset:getSetting(): fails - file missing", 1, true)) + assert.is_truthy(line:find("second copy: fails - preset not found", 1, true)) + + r.second = { ok = true, contrast = 0, maskFound = false, fileBack = true } + line = X.describeE4f(r)[3] + assert.is_truthy(line:find("has no effect (Contrast2012 0, the preset holds 25", 1, true)) + assert.is_truthy(line:find("wrote the preset file again", 1, true)) + + r.second = { ok = true, contrast = 25, maskFound = false } + line = X.describeE4f(r)[3] + assert.is_truthy( + line:find("second copy: works partially (Contrast2012 yes (25), subject mask missing)", 1, true) + ) + assert.is_falsy(line:find("still works", 1, true)) + end) + + it("sorts the presets listed before the run by whether their file exists", function() + local r = base() + r.before = { + { uuid = "a", file = "/p/LrGenius Experiment E4f.xmp", fileExists = false }, + { uuid = "b", file = "/p/LrGenius Experiment E4f.xmp", fileExists = false }, + } + local lines = X.describeE4f(r) + assert.is_truthy(lines[1]:find("2 preset(s) named", 1, true)) + assert.is_truthy( + lines[1]:find("although no file exists at their path (/p/LrGenius Experiment E4f.xmp)", 1, true) + ) + assert.is_truthy(lines[1]:find("somewhere other than the file", 1, true)) + assert.is_truthy(lines[2]:find("VIABLE", 1, true)) + + r.before = { { uuid = "c", file = "/p/LrGenius Experiment E4f.xmp", fileExists = true } } + local text = joined(r) + assert.is_truthy( + text:find( + '1 preset(s) named "LrGenius Experiment E4f" were listed before this run with their file present', + 1, + true + ) + ) + assert.is_truthy(text:find("written back by Lightroom at quit", 1, true)) + assert.is_falsy(text:find("somewhere other than the file", 1, true)) + + r.before = { { uuid = "d", file = "error: boom" } } + assert.is_truthy(X.describeE4f(r)[1]:find("without a readable file path", 1, true)) + + r.before = {} + assert.is_truthy(X.describeE4f(r)[1]:find("forgot the deleted preset at the restart", 1, true)) + r.before = nil + assert.is_truthy(X.describeE4f(r)[1]:find("VIABLE", 1, true)) + end) + + it("says nothing beyond the point where the run stopped", function() + local r = base() + r.before = nil + r.apply, r.delete, r.after, r.second, r.readd = nil, nil, nil, nil, nil + assert.are.same({}, X.describeE4f(r)) + r.create = { ok = false, error = "boom" } + assert.are.same({ "E4f not run: the preset could not be created - boom" }, X.describeE4f(r)) + assert.are.same({}, X.describeE4f(nil)) + end) + + it("notes a canceled run after whatever it got through", function() + local r = base() + r.before = nil + r.delete, r.after, r.second, r.readd = nil, nil, nil, nil + r.canceled = true + local lines = X.describeE4f(r) + assert.are.equal(1, #lines) + assert.is_truthy(lines[1]:find("E4f not finished: the run was canceled", 1, true)) + end) + + it("says whether the re-added preset holds the new or the stale settings", function() + local r = base() + r.readd.contrast, r.readd.fileContrast, r.readd.fileHasNewContrast = 25, 25, false + local line = X.describeE4f(r)[4] + assert.is_truthy(line:find("holds the new settings (Contrast2012 15, the deleted preset held 25): no", 1, true)) + assert.is_truthy(line:find("(getSetting reads 25, the file holds 25)", 1, true)) + + r.readd.contrast, r.readd.fileContrast, r.readd.fileHasNewContrast = 15, nil, nil + line = X.describeE4f(r)[4] + assert.is_truthy(line:find("): yes (getSetting reads 15, no Contrast2012 found in the file)", 1, true)) + end) + + it("keeps a re-added file that could not be deleted in the cleanup list", function() + local r = base() + r.readd.cleanup = { skipped = "no match" } + local line = X.describeE4f(r)[4] + assert.is_truthy(line:find("was not deleted - no match and stays in the list", 1, true)) + r.readd = { ok = true, file = "/p/x.xmp", fileExists = false, sameUuid = true } + assert.is_truthy(X.describeE4f(r)[4]:find("but no file exists at its path /p/x.xmp (same uuid: yes)", 1, true)) + r.readd = { ok = false, error = "nope" } + assert.are.equal("E4f re-adding the same name after the delete failed - nope.", X.describeE4f(r)[4]) + end) +end) + +describe("DevelopExperiments.presetFileSetting", function() + it("reads the XMP attribute, the XMP element and the lrtemplate field", function() + assert.are.equal(15, X.presetFileSetting('-8", "Contrast2012")) + assert.are.equal(25, X.presetFileSetting("s = {\n\tContrast2012 = 25,\n}", "Contrast2012")) + end) + + it("does not match a longer key or a missing one", function() + assert.is_nil(X.presetFileSetting('crs:LocalContrast2012="+40"', "Contrast2012")) + assert.are.equal(15, X.presetFileSetting('crs:LocalContrast2012="+40" crs:Contrast2012="+15"', "Contrast2012")) + assert.is_nil(X.presetFileSetting("", "Contrast2012")) + assert.is_nil(X.presetFileSetting(nil, "Contrast2012")) + end) +end) + +describe("DevelopExperiments.summaryVerdicts", function() + it("keeps the E4f answer past the cap and counts only the rest", function() + local verdicts = { + "a", + "b", + "c", + "d", + "e", + "E4f until Lightroom restarts ...", + "E4f apply then delete is VIABLE within this session", + "f", + } + local shown, hidden = X.summaryVerdicts(verdicts, 4) + assert.are.same({ "a", "b", "c", "d", "E4f apply then delete is VIABLE within this session" }, shown) + assert.are.equal(3, hidden) + end) + + it("recognises every E4f outcome and nothing else", function() + assert.is_true(X.isHeadlineVerdict("E4f apply then delete is NOT viable: x")) + assert.is_true(X.isHeadlineVerdict("E4f inconclusive: x")) + assert.is_true(X.isHeadlineVerdict("E4f not run: x")) + assert.is_true(X.isHeadlineVerdict("E4f not finished: the run was canceled.")) + assert.is_false(X.isHeadlineVerdict('E4f no preset named "x" was listed before this run.')) + assert.is_false(X.isHeadlineVerdict("E4c inconclusive")) + assert.is_false(X.isHeadlineVerdict(nil)) + end) +end) diff --git a/plugin/spec/develop_style_response_spec.lua b/plugin/spec/develop_style_response_spec.lua new file mode 100644 index 00000000..0b0a89cc --- /dev/null +++ b/plugin/spec/develop_style_response_spec.lua @@ -0,0 +1,221 @@ +-- A style-engine response with every `global` field the backend sends since +-- step 1f (detail, vignette and grain sliders, HSL, colour grading shadows/ +-- highlights/balance, the parametric curve splits), fed through the recipe +-- mapping. +-- +-- The point of 1f is that the *installed* plug-in already applies all of +-- these, so this spec is written against the module's long-standing seams +-- only (formatRecipeDetails, internal.buildDevelopSettings, +-- internal.mergeGlobalDevelopSettings, internal.normalizeDevelopValue). It +-- also runs unchanged against the release on `main`, which is how the +-- compatibility claim was checked. + +require("DevelopEditManager") + +local internal = DevelopEditManager.internal + +-- The shape of POST /v1/edit/style's answer (see the contract test +-- `style_edit_sends_every_global_field_the_installed_plugin_applies`). +local function styleResponse() + return { + status = "success", + engine = "style", + confidence = 0.82, + matched_examples = 3, + matched_filenames = { "a.cr3", "b.cr3", "c.cr3" }, + warnings = {}, + guardrail_reasons = {}, + guardrail_explanations = {}, + edit = { + summary = "Style: Moody / Matched 3 of 12 examples (confidence 82%)", + global = { + exposure = 0.35, + contrast = 12, + highlights = -40, + shadows = 25, + whites = 10, + blacks = -8, + texture = 5, + clarity = 8, + dehaze = 3, + vibrance = 10, + saturation = -5, + sharpening = 40, + sharpen_radius = 1.2, + sharpen_detail = 30, + sharpen_masking = 15, + noise_reduction = 20, + noise_reduction_detail = 55, + noise_reduction_contrast = 5, + color_noise_reduction = 25, + color_noise_reduction_detail = 50, + color_noise_reduction_smoothness = 60, + vignette = -12, + vignette_midpoint = 40, + vignette_roundness = -10, + vignette_feather = 70, + vignette_highlights = 20, + grain = 15, + grain_size = 30, + grain_roughness = 60, + hsl = { + red = { hue = 0, saturation = 0, luminance = 0 }, + orange = { hue = 4, saturation = -6, luminance = 12 }, + yellow = { hue = 0, saturation = -10, luminance = 0 }, + green = { hue = 20, saturation = -30, luminance = -10 }, + aqua = { hue = -8, saturation = -15, luminance = 0 }, + blue = { hue = -5, saturation = -20, luminance = -12 }, + purple = { hue = 0, saturation = 0, luminance = 0 }, + magenta = { hue = 0, saturation = -5, luminance = 0 }, + }, + color_grading = { + shadows = { hue = 220, saturation = 15 }, + highlights = { hue = 40, saturation = 20 }, + balance = -10, + }, + tone_curve = { + highlights = -10, + lights = 5, + darks = 8, + shadows = -6, + shadow_split = 20, + midtone_split = 45, + highlight_split = 70, + }, + }, + masks = {}, + warnings = {}, + white_balance = { mode = "Custom", family = "raw", temperature = 5450, tint = 10 }, + }, + } +end + +-- Recipe field -> Lightroom key and value, for everything 1f added. +local EXPECTED = { + SharpenRadius = 1.2, + SharpenDetail = 30, + SharpenEdgeMasking = 15, + LuminanceNoiseReductionDetail = 55, + LuminanceNoiseReductionContrast = 5, + ColorNoiseReductionDetail = 50, + ColorNoiseReductionSmoothness = 60, + PostCropVignetteMidpoint = 40, + PostCropVignetteRoundness = -10, + PostCropVignetteFeather = 70, + PostCropVignetteHighlightContrast = 20, + GrainSize = 30, + GrainFrequency = 60, + HueAdjustmentGreen = 20, + SaturationAdjustmentAqua = -15, + LuminanceAdjustmentBlue = -12, + HueAdjustmentPurple = 0, + SplitToningShadowHue = 220, + SplitToningShadowSaturation = 15, + SplitToningHighlightHue = 40, + SplitToningHighlightSaturation = 20, + SplitToningBalance = -10, + ParametricShadowSplit = 20, + ParametricMidtoneSplit = 45, + ParametricHighlightSplit = 70, +} + +-- The registry's UI range of each key 1f sends (lrg-develop registry, the +-- same ranges the backend's schema drift test pins). The plug-in must not +-- clamp anything inside them. +local REGISTRY_RANGES = { + SharpenRadius = { 0.5, 3.0 }, + SharpenDetail = { 0, 100 }, + SharpenEdgeMasking = { 0, 100 }, + LuminanceNoiseReductionDetail = { 0, 100 }, + LuminanceNoiseReductionContrast = { 0, 100 }, + ColorNoiseReductionDetail = { 0, 100 }, + ColorNoiseReductionSmoothness = { 0, 100 }, + PostCropVignetteMidpoint = { 0, 100 }, + PostCropVignetteRoundness = { -100, 100 }, + PostCropVignetteFeather = { 0, 100 }, + PostCropVignetteHighlightContrast = { 0, 100 }, + GrainSize = { 0, 100 }, + GrainFrequency = { 0, 100 }, + SplitToningShadowSaturation = { 0, 100 }, + SplitToningHighlightSaturation = { 0, 100 }, + SplitToningBalance = { -100, 100 }, + ParametricShadowSplit = { 0, 100 }, + ParametricMidtoneSplit = { 0, 100 }, + ParametricHighlightSplit = { 0, 100 }, +} +for _, colour in ipairs({ "Red", "Orange", "Yellow", "Green", "Aqua", "Blue", "Purple", "Magenta" }) do + for _, slider in ipairs({ "Hue", "Saturation", "Luminance" }) do + REGISTRY_RANGES[slider .. "Adjustment" .. colour] = { -100, 100 } + end +end + +describe("a 1f style response", function() + it("maps every new field onto its Lightroom key without a warning", function() + local warnings = {} + local recipe = styleResponse().edit + -- The third argument is the photo's white-balance family ("raw") on + -- this branch and `isRaw` on older releases; "raw" is truthy for both. + local settings = internal.buildDevelopSettings(recipe, warnings, "raw") + + assert.are.same({}, warnings) + for key, value in pairs(EXPECTED) do + assert.are.equal(value, settings[key], key) + end + assert.is_true(settings.EnableSplitToning) + -- 24 HSL keys, all of them. + local hslCount = 0 + for key in pairs(settings) do + if + key:match("^HueAdjustment") + or key:match("^SaturationAdjustment") + or key:match("^LuminanceAdjustment") + then + hslCount = hslCount + 1 + end + end + assert.are.equal(24, hslCount) + -- Nothing outside what 1f intends: no colour-grading keys the plug-in + -- cannot map, and no `Temp`. + for key in pairs(settings) do + assert.is_falsy(key:match("^ColorGrade"), key) + assert.are_not.equal("Temp", key) + end + end) + + it("merges onto the photo without clamping or wrapping any value", function() + local settings = internal.buildDevelopSettings(styleResponse().edit, {}, "raw") + local merged = internal.mergeGlobalDevelopSettings({ HueAdjustmentRed = 30, Exposure2012 = 1 }, settings) + + for key, value in pairs(EXPECTED) do + assert.are.equal(value, merged[key], key) + end + assert.are.equal(0, merged.HueAdjustmentRed, "the recipe's absolute 0 replaces the photo's 30") + end) + + it("clamps nothing inside the registry's range of each key", function() + for key, range in pairs(REGISTRY_RANGES) do + assert.are.equal(range[1], internal.normalizeDevelopValue(key, range[1]), key .. " min") + assert.are.equal(range[2], internal.normalizeDevelopValue(key, range[2]), key .. " max") + end + -- Hue angles: 0..359 pass as they are (360 is 0 on the circle). + for _, key in ipairs({ "SplitToningShadowHue", "SplitToningHighlightHue" }) do + assert.are.equal(0, internal.normalizeDevelopValue(key, 0)) + assert.are.equal(359, internal.normalizeDevelopValue(key, 359)) + assert.are.equal(0, internal.normalizeDevelopValue(key, 360)) + end + end) + + it("shows up in the review dialog without table addresses or new warnings", function() + local details = DevelopEditManager.formatRecipeDetails(styleResponse()) + + assert.is_falsy(details:find("table: 0x", 1, true), details) + assert.is_truthy(details:find("- hsl: 8 channel(s)", 1, true), details) + assert.is_truthy(details:find("- color_grading: enabled", 1, true), details) + assert.is_truthy(details:find("- tone_curve: enabled", 1, true), details) + assert.is_truthy(details:find("- sharpen_radius: 1.2", 1, true), details) + assert.is_truthy(details:find("- grain_roughness: 60", 1, true), details) + assert.is_truthy(details:find("- vignette_highlights: 20", 1, true), details) + local section = details:sub((details:find("\nWarnings", 1, true))) + assert.are.equal("\nWarnings\n- none", section) + end) +end) diff --git a/plugin/spec/edit_loop_spec.lua b/plugin/spec/edit_loop_spec.lua new file mode 100644 index 00000000..3b2b972f --- /dev/null +++ b/plugin/spec/edit_loop_spec.lua @@ -0,0 +1,145 @@ +-- Tests for AI Edit's retoucher loop, Lightroom side: how each round's table is +-- put together from the photo's starting point, how masks are merged by sync +-- id, and what Lightroom's mask states tell the model. Applying, rendering and +-- the backend need Lightroom; these decisions do not. + +require("EditLoop") + +local function sky(amount) + return { + CorrectionName = "LrGenius · Sky", + CorrectionSyncID = "SKY", + LocalExposure2012 = amount, + CorrectionMasks = { { What = "Mask/Image", MaskSubType = 2 } }, + } +end + +local function subject() + return { + CorrectionName = "LrGenius · Subject", + CorrectionSyncID = "SUBJECT", + CorrectionMasks = { { What = "Mask/Image", MaskSubType = 1 } }, + } +end + +local function own() + return { + CorrectionName = "Mask 1", + CorrectionSyncID = "MINE", + CorrectionMasks = { { What = "Mask/Image", MaskSubType = 1, MaskDigest = "ABC" } }, + } +end + +describe("EditLoop.composeApply", function() + it("applies a first round as it came", function() + local state = {} + local out = EditLoop.composeApply({ Exposure2012 = 0.2 }, { Exposure2012 = 0.6, Contrast2012 = 10 }, state) + + assert.are.equal(0.6, out.Exposure2012) + assert.are.equal(10, out.Contrast2012) + assert.is_nil(out.MaskGroupBasedCorrections) + assert.is_true(state.touched.Contrast2012) + end) + + it("puts a slider a later round leaves out back to its starting value", function() + local baseline = { Exposure2012 = 0.2, Contrast2012 = 5, Vibrance = 0 } + local state = {} + EditLoop.composeApply(baseline, { Exposure2012 = 0.6, Vibrance = 20 }, state) + local second = EditLoop.composeApply(baseline, { Exposure2012 = 0.5 }, state) + + assert.are.equal(0.5, second.Exposure2012) + assert.are.equal(0, second.Vibrance) + assert.is_nil(second.Contrast2012, "a slider no round touched is not written") + end) + + it("keeps the photo's own masks and replaces its own by sync id", function() + local baseline = { MaskGroupBasedCorrections = { own() } } + local state = {} + local first = EditLoop.composeApply(baseline, { MaskGroupBasedCorrections = { sky(-0.1), subject() } }, state) + assert.are.equal(3, #first.MaskGroupBasedCorrections) + assert.are.equal("MINE", first.MaskGroupBasedCorrections[1].CorrectionSyncID) + + local second = EditLoop.composeApply(baseline, { MaskGroupBasedCorrections = { sky(-0.2) } }, state) + local ids = {} + for _, c in ipairs(second.MaskGroupBasedCorrections) do + table.insert(ids, c.CorrectionSyncID) + end + assert.are.same({ "MINE", "SKY" }, ids, "round 2 dropped the subject, and the sky is round 2's") + assert.are.equal(-0.2, second.MaskGroupBasedCorrections[2].LocalExposure2012) + end) + + it("replaces a mask an earlier AI Edit run left on the photo", function() + local baseline = { MaskGroupBasedCorrections = { own(), sky(-0.9) } } + local out = EditLoop.composeApply(baseline, { MaskGroupBasedCorrections = { sky(-0.3) } }, {}) + + assert.are.equal(2, #out.MaskGroupBasedCorrections) + assert.are.equal(-0.3, out.MaskGroupBasedCorrections[2].LocalExposure2012) + end) + + it("leaves the masks alone when no round has any", function() + local baseline = { MaskGroupBasedCorrections = { own() } } + local out = EditLoop.composeApply(baseline, { Exposure2012 = 0.1 }, {}) + assert.is_nil(out.MaskGroupBasedCorrections) + end) +end) + +describe("EditLoop.composeRevert", function() + it("takes back every slider and mask the loop wrote", function() + local baseline = { Exposure2012 = 0.2, Vibrance = 0, MaskGroupBasedCorrections = { own() } } + local state = {} + EditLoop.composeApply( + baseline, + { Exposure2012 = 0.6, Vibrance = 20, MaskGroupBasedCorrections = { sky(-0.2) } }, + state + ) + local out = EditLoop.composeRevert(baseline, state) + + assert.are.equal(0.2, out.Exposure2012) + assert.are.equal(0, out.Vibrance) + assert.are.equal(1, #out.MaskGroupBasedCorrections) + assert.are.equal("MINE", out.MaskGroupBasedCorrections[1].CorrectionSyncID) + end) + + it("clears the masks when the photo had none of its own", function() + local state = {} + EditLoop.composeApply({}, { MaskGroupBasedCorrections = { sky(-0.2) } }, state) + local out = EditLoop.composeRevert({}, state) + assert.are.same({}, out.MaskGroupBasedCorrections) + end) +end) + +describe("EditLoop.maskState", function() + it("reads computed, nothing found and pending the way the experiments did", function() + assert.are.equal("computed", EditLoop.maskState(own())) + assert.are.equal("pending", EditLoop.maskState(sky(0))) + local empty = sky(0) + empty.CorrectionMasks[1].ErrorReason = 1 + assert.are.equal("nothing_found", EditLoop.maskState(empty)) + assert.are.equal("missing", EditLoop.maskState(nil)) + end) +end) + +describe("EditLoop.roundMaskStates and maskNotes", function() + it("tells the model about a mask that found nothing, and nothing about one that worked", function() + local onPhoto = sky(0) + onPhoto.CorrectionMasks[1].ErrorReason = 1 + local computedSubject = subject() + computedSubject.CorrectionMasks[1].MaskDigest = "X" + local settings = { MaskGroupBasedCorrections = { own(), onPhoto, computedSubject } } + local states = EditLoop.roundMaskStates(settings, { MaskGroupBasedCorrections = { sky(0), subject() } }) + + assert.are.equal(2, #states) + local notes = EditLoop.maskNotes(states) + assert.are.equal(1, #notes) + assert.is_truthy(notes[1]:find("Sky mask found nothing", 1, true)) + end) +end) + +describe("EditLoop.supportsMasks", function() + it("needs Lightroom 15.3 or later", function() + assert.is_false(EditLoop.supportsMasks({ major = 15, minor = 2 })) + assert.is_true(EditLoop.supportsMasks({ major = 15, minor = 3 })) + assert.is_true(EditLoop.supportsMasks({ major = 16, minor = 0 })) + assert.is_false(EditLoop.supportsMasks(nil)) + end) +end) diff --git a/plugin/spec/native_wire_format_spec.lua b/plugin/spec/native_wire_format_spec.lua new file mode 100644 index 00000000..5d02d9f5 --- /dev/null +++ b/plugin/spec/native_wire_format_spec.lua @@ -0,0 +1,635 @@ +-- Contract test: the develop settings tables the backend writes for +-- photo:applyDevelopSettings(), decoded the way the plugin decodes them. +-- +-- server-rs/testdata/develop/wire/*.json are written by the backend's Lua +-- writer (lrg_develop::lua::to_lua_value, blessed with +-- `LRG_BLESS=1 cargo test -p lrg-develop --test lua_wire_goldens`). The Rust +-- tests pin the bytes; this spec checks what reaches Lightroom: the Lua table +-- the plugin's own JSON.lua makes of each file, under Lua 5.1 in CI as in +-- Lightroom. +-- +-- The goldens are provisional until experiments E1, E2, E4 and E11 +-- (TaskDevelopExperiments.lua) have been run. The forms those experiments +-- bear on (mask enums as numbers or numeric strings, 0/1 flags as numbers or +-- booleans, ...; see LuaOptions::PROVISIONAL in +-- server-rs/crates/lrg-develop/src/lua/write.rs) are accepted either way here. +-- What is checked holds for every outcome (plan section 7.3, point 5): +-- * real sequences (keys 1..n, nothing missing), no empty, mixed or null +-- value: JSON.lua encodes {} as [] and turns a null in a list into a hole, +-- and an empty MaskGroupBasedCorrections would delete every mask; +-- * every value has the Lua type of its key's registry class +-- (wire/key_classes.txt, generated from lrg-develop's registry next to +-- the goldens): numbers are numbers, numeric-looking strings stay +-- strings, compound strings hold numbers (ReferencePoint 2, LumRange and +-- FocalRange 4), booleans are booleans; +-- * global curves are flat number lists of even length >= 4, local curves +-- lists of "x,y" strings; +-- * every correction has a non-empty CorrectionMasks; +-- * no key Lightroom must not be handed (the registry's never-written +-- keys, digests, brush tables), no key the registry does not know, no +-- Temp at any depth, no ProcessVersion at the top; +-- * white balance as one family. +-- +-- Values are compared decoded, never as encoded text: Lua 5.1 (Lightroom, +-- CI) re-encodes numbers with %.14g, a local Lua 5.5 with the shortest exact +-- form. +-- +-- Run from the repo root with: busted + +local Json = require("JSON") +local lfs = require("lfs") -- a dependency of busted itself + +local WIRE_DIR = "server-rs/testdata/develop/wire" +local KEY_CLASSES_FILE = WIRE_DIR .. "/key_classes.txt" + +-- Decoding with a placeholder for null makes a null visible instead of a +-- missing key or a hole. +local NULL = setmetatable({}, { + __tostring = function() + return "JSON null" + end, +}) + +local function decode(text) + return Json:decode(text, nil, { null = NULL }) +end + +local function read(path) + local fh = assert(io.open(path, "r"), "cannot open " .. path) + local text = fh:read("*a") + fh:close() + return text +end + +local function goldenFiles() + local names = {} + for name in lfs.dir(WIRE_DIR) do + if name:match("%.json$") then + names[#names + 1] = name + end + end + table.sort(names) + return names +end + +local function set(list) + local out = {} + for _, k in ipairs(list) do + out[k] = true + end + return out +end + +-- `{ [key name] = { [class] = true } }` from wire/key_classes.txt: the +-- registry's classes of each key (a name at several levels has several), +-- or `never` for a key the writer never writes. +local function loadKeyClasses(path) + local classes, count = {}, 0 + for line in read(path):gmatch("[^\r\n]+") do + if line:sub(1, 1) ~= "#" then + local name, rest = line:match("^(%S+)%s+(.+)$") + assert(name, "malformed line in " .. path .. ": " .. line) + local cs = {} + for c in rest:gmatch("%S+") do + cs[c] = true + end + classes[name] = cs + count = count + 1 + end + end + return classes, count +end + +local KEY_CLASSES, KEY_CLASS_COUNT = loadKeyClasses(KEY_CLASSES_FILE) + +-- Numbers inside one string, per key that has a fixed count. +local COMPOUND_ARITY = { ReferencePoint = 2, LumRange = 4, FocalRange = 4 } +local SYNC_ID_KEYS = set({ "CorrectionSyncID", "MaskSyncID" }) + +local function isInteger(v) + return type(v) == "number" and v == math.floor(v) +end + +-- How many numbers `s` holds (separated by spaces or commas), or nil when +-- it holds none or anything else. +local function numbersIn(s) + local count = 0 + for token in s:gmatch("[^%s,]+") do + if tonumber(token) == nil then + return nil + end + count = count + 1 + end + return count > 0 and count or nil +end + +-- Whether `v` is a valid compound number string for key `k`. +local function compoundOk(k, v) + if type(v) ~= "string" then + return false + end + local n = numbersIn(v) + return n ~= nil and (COMPOUND_ARITY[k] == nil or n == COMPOUND_ARITY[k]) +end + +local function isList(v, item) + if type(v) ~= "table" or #v == 0 then + return false + end + for i = 1, #v do + if not item(v[i]) then + return false + end + end + return true +end + +-- One check per class in key_classes.txt. +local CLASS_CHECKS = { + integer = isInteger, + number = function(v) + return type(v) == "number" + end, + -- 0/1 flags: numbers or booleans (LuaOptions.int_flag_as; no experiment + -- writes a flag key, so the read-back form is the provisional one). + flag = function(v) + return type(v) == "boolean" or v == 0 or v == 1 + end, + -- Mask enums (lrg_develop's KeySpec::is_mask_enum): integers, as numbers + -- or numeric strings (LuaOptions.mask_enum_as; E2/E4 apply numbers). + maskenum = function(v) + return isInteger(v) or (type(v) == "string" and v:match("^%-?%d+$") ~= nil) + end, + boolean = function(v) + return type(v) == "boolean" + end, + string = function(v) + return type(v) == "string" + end, + compound = function(v, k) + return compoundOk(k, v) + end, + strings = function(v) + return isList(v, function(x) + return type(x) == "string" + end) + end, + compounds = function(v, k) + return isList(v, function(x) + return compoundOk(k, x) + end) + end, + globalcurve = function(v) + return type(v) == "table" + end, + localcurve = function(v) + return type(v) == "table" + end, + table = function(v) + return type(v) == "table" + end, + list = function(v) + return type(v) == "table" + end, + any = function() + return true + end, +} + +local function denied(key) + local classes = KEY_CLASSES[key] + return (classes ~= nil and classes.never) + or key == "Temp" + or key:find("Digest", 1, true) ~= nil + or key:sub(1, 14) == "MaskBrushTable" +end + +-- "object", "sequence", or what is wrong with the table. +local function classify(t) + local n, strings, numbers = 0, 0, 0 + for k in pairs(t) do + n = n + 1 + if type(k) == "string" then + strings = strings + 1 + elseif type(k) == "number" then + numbers = numbers + 1 + end + end + if n == 0 then + return "empty" + elseif strings == n then + return "object" + elseif numbers == n then + for i = 1, n do + if t[i] == nil then + return "sparse" + end + end + return "sequence" + end + return "mixed" +end + +-- Calls visit(key, value, path, parentKey) for every keyed value below `t` +-- (list items are visited with their index as key; `parentKey` is the +-- nearest string key above). +local function walk(t, path, visit, parentKey) + for k, v in pairs(t) do + local p = path == "" and tostring(k) + or (type(k) == "number" and (path .. "[" .. k .. "]") or (path .. "." .. k)) + visit(k, v, p, parentKey) + if type(v) == "table" and v ~= NULL then + walk(v, p, visit, type(k) == "string" and k or parentKey) + end + end +end + +-- The violations of the structural rules, as a list of strings. +local function structureViolations(top) + local bad = {} + walk(top, "", function(_, v, path) + if v == NULL then + bad[#bad + 1] = path .. ": null" + elseif type(v) == "table" then + local kind = classify(v) + if kind ~= "object" and kind ~= "sequence" then + bad[#bad + 1] = path .. ": " .. kind .. " table" + elseif kind == "sequence" then + local first = type(v[1]) + for i = 2, #v do + if type(v[i]) ~= first then + bad[#bad + 1] = path .. ": a list mixing " .. first .. " and " .. type(v[i]) + break + end + end + end + end + end) + return bad +end + +-- Every value against its key's registry classes, plus the keys that need +-- more than a type: sync ids, boolean-looking strings. +local function classViolations(top) + local bad = {} + walk(top, "", function(k, v, path, parentKey) + if type(k) ~= "string" then + return + end + if v == "true" or v == "false" then + bad[#bad + 1] = path .. ": a boolean as a string" + end + if SYNC_ID_KEYS[k] and not (type(v) == "string" and v:match("^%x+$") and #v == 32) then + bad[#bad + 1] = path .. ": not 32 hex digits" + end + local classes = KEY_CLASSES[k] + if classes == nil then + -- The one key that is no registry key: a text alternative's + -- language (`Look.Group = { ["x-default"] = ... }`). + if not (k == "x-default" and type(v) == "string") then + bad[#bad + 1] = path .. ": not a key the registry knows" + end + return + end + if classes.never then + return -- reported by the denylist check + end + -- `Type` is the range mask's kind, nothing else's. + if k == "Type" and parentKey ~= "CorrectionRangeMask" then + bad[#bad + 1] = path .. ": Type outside a CorrectionRangeMask" + return + end + for class in pairs(classes) do + local check = CLASS_CHECKS[class] + assert(check, "key_classes.txt names an unknown class: " .. class) + if check(v, k) then + return + end + end + local names = {} + for class in pairs(classes) do + names[#names + 1] = class + end + table.sort(names) + bad[#bad + 1] = path .. ": " .. type(v) .. " " .. tostring(v) .. " is not " .. table.concat(names, " or ") + end) + return bad +end + +-- The curve shapes (rule 5). +local function curveViolations(top) + local bad = {} + walk(top, "", function(k, v, path) + local classes = type(k) == "string" and KEY_CLASSES[k] + if not classes then + return + end + if classes.globalcurve then + if type(v) ~= "table" or #v < 4 or #v % 2 ~= 0 then + bad[#bad + 1] = path .. ": not an even list of at least 4" + else + for i = 1, #v do + if type(v[i]) ~= "number" then + bad[#bad + 1] = path .. "[" .. i .. "]: " .. type(v[i]) + end + end + end + elseif classes.localcurve then + if type(v) ~= "table" or #v < 2 then + bad[#bad + 1] = path .. ": not a list of at least 2 points" + else + for i = 1, #v do + if not (type(v[i]) == "string" and numbersIn(v[i]) == 2 and v[i]:find(",", 1, true)) then + bad[#bad + 1] = path .. "[" .. i .. "]: not an x,y string" + end + end + end + end + end) + return bad +end + +-- What a golden holds, counted, so a re-blessed set that lost a shape +-- cannot make the checks above pass vacuously. +local function countShapes(top, seen) + if type(top) ~= "table" then + return + end + if type(top.MaskGroupBasedCorrections) == "table" then + seen.corrections = seen.corrections + #top.MaskGroupBasedCorrections + end + walk(top, "", function(k, v) + local classes = type(k) == "string" and KEY_CLASSES[k] + if not classes then + return + end + if classes.globalcurve then + seen.globalCurves = seen.globalCurves + 1 + end + if classes.localcurve then + seen.localCurves = seen.localCurves + 1 + end + if classes.compound and type(v) == "string" then + seen.compoundStrings = seen.compoundStrings + 1 + end + if classes.compounds and type(v) == "table" then + seen.compoundLists = seen.compoundLists + 1 + end + if classes.boolean and type(v) == "boolean" then + seen.booleans = seen.booleans + 1 + end + if classes.flag then + seen.flags = seen.flags + 1 + end + if classes.maskenum then + seen.maskEnums = seen.maskEnums + 1 + end + if classes.number and type(v) == "number" then + seen.numbers = seen.numbers + 1 + end + end) +end + +local function close(a, b) + return a == b or math.abs(a - b) <= 1e-9 * math.max(1, math.abs(a), math.abs(b)) +end + +-- Deep equality of decoded values, numbers within the precision %.14g keeps. +local function same(a, b) + if type(a) == "number" and type(b) == "number" then + return close(a, b) + end + if type(a) ~= "table" or type(b) ~= "table" then + return a == b + end + for k, v in pairs(a) do + if not same(v, b[k]) then + return false + end + end + for k in pairs(b) do + if a[k] == nil then + return false + end + end + return true +end + +describe("JSON.lua, as the wire format relies on it", function() + it("encodes an empty table as an empty JSON array", function() + -- So an empty object would arrive as an absent key, and an empty + -- MaskGroupBasedCorrections as []: the writer never sends either. + assert.are.equal("[]", Json:encode({})) + end) + + it("decodes a null inside an array to a hole", function() + local t = Json:decode("[1,null,3]") + assert.are.equal(1, t[1]) + assert.is_nil(t[2]) + assert.are.equal(3, t[3]) + assert.are.equal("sparse", classify(t)) + end) + + it("keeps numeric-looking strings as strings", function() + local t = Json:decode('{"ProcessVersion":"15.4","ReferencePoint":"0.5 0.5","n":15.4}') + assert.are.equal("string", type(t.ProcessVersion)) + assert.are.equal("15.4", t.ProcessVersion) + assert.are.equal("string", type(t.ReferencePoint)) + assert.are.equal("number", type(t.n)) + end) +end) + +describe("the wire checks themselves", function() + -- Each broken table must be caught, so a check that silently passes + -- everything fails here instead. + local function violations(top) + local bad = {} + for _, list in ipairs({ structureViolations(top), classViolations(top), curveViolations(top) }) do + for _, b in ipairs(list) do + bad[#bad + 1] = b + end + end + return bad + end + + it("know the registry's key classes", function() + assert.is_true(KEY_CLASS_COUNT > 300, "only " .. KEY_CLASS_COUNT .. " keys in " .. KEY_CLASSES_FILE) + assert.is_true(KEY_CLASSES.Exposure2012.number) + assert.is_true(KEY_CLASSES.MaskSubType.maskenum) + assert.is_true(KEY_CLASSES.ErrorReason.maskenum) + assert.is_true(KEY_CLASSES.Type.maskenum) + assert.is_true(KEY_CLASSES.CorrectionID.never) + end) + + it("count no number in an empty or blank string", function() + assert.is_nil(numbersIn("")) + assert.is_nil(numbersIn(" ")) + assert.are.equal(2, numbersIn("0.5 0.5")) + assert.is_nil(numbersIn("0.5 x")) + end) + + for what, broken in pairs({ + ["a number as a string"] = { Exposure2012 = "0.5" }, + ["an empty string on a number key"] = { Exposure2012 = "" }, + ["text on a number key"] = { Contrast2012 = "x" }, + ["an empty compound string"] = { + MaskGroupBasedCorrections = { + { + What = "Correction", + CorrectionMasks = { { What = "Mask/Image", ReferencePoint = "" } }, + }, + }, + }, + ["a compound string with the wrong count"] = { + MaskGroupBasedCorrections = { + { + What = "Correction", + CorrectionMasks = { { What = "Mask/Image", ReferencePoint = "0.5" } }, + }, + }, + }, + ["a mask enum that is no integer"] = { + MaskGroupBasedCorrections = { + { + What = "Correction", + CorrectionMasks = { { What = "Mask/Image", ErrorReason = "zero" } }, + }, + }, + }, + ["a range kind that is no integer"] = { + MaskGroupBasedCorrections = { + { + What = "Correction", + CorrectionMasks = { { What = "Mask/RangeMask", CorrectionRangeMask = { Type = "x" } } }, + }, + }, + }, + ["a boolean as a number"] = { ConvertToGrayscale = 1 }, + ["a key the registry does not know"] = { NotADevelopKey = 1 }, + ["an odd global curve"] = { ToneCurvePV2012 = { 0, 0, 255 } }, + }) do + it("catch " .. what, function() + assert.is_true(#violations(broken) > 0, what) + end) + end + + it("accept both forms of each provisional encoding", function() + local masks = { + { + What = "Correction", + CorrectionMasks = { + { What = "Mask/Image", MaskSubType = "1", MaskBlendMode = 0, ErrorReason = "0" }, + { What = "Mask/RangeMask", CorrectionRangeMask = { Type = "2", SampleType = 0 } }, + }, + }, + } + assert.are.same( + {}, + violations({ LensProfileEnable = true, AutoLateralCA = 1, MaskGroupBasedCorrections = masks }) + ) + end) +end) + +describe("native wire format goldens", function() + local names = goldenFiles() + local goldens = {} + -- Counted here, while collecting, so the coverage check below depends on + -- no other test having run (busted --shuffle, --filter). + local seen = { + corrections = 0, + globalCurves = 0, + localCurves = 0, + compoundStrings = 0, + compoundLists = 0, + booleans = 0, + flags = 0, + maskEnums = 0, + numbers = 0, + } + for _, name in ipairs(names) do + local top = decode(read(WIRE_DIR .. "/" .. name)) + goldens[#goldens + 1] = { name = name, top = top } + countShapes(top, seen) + end + + it("are present", function() + -- An empty or moved folder would make every check below vacuous. + assert.is_true(#names >= 18, "only " .. #names .. " goldens in " .. WIRE_DIR) + end) + + it("cover every shape the checks are about", function() + for what, count in pairs(seen) do + assert.is_true(count > 0, "no golden has " .. what) + end + end) + + for _, golden in ipairs(goldens) do + local name, top = golden.name, golden.top + + describe(name, function() + it("is a non-empty table with string keys at the top", function() + assert.are.equal("table", type(top)) + assert.are.equal("object", classify(top)) + end) + + it("holds real sequences and no empty, mixed, sparse or null value", function() + assert.are.same({}, structureViolations(top)) + end) + + it("gives every value the type of its key's registry class", function() + assert.are.same({}, classViolations(top)) + end) + + it("writes global curves as flat number lists and local curves as x,y strings", function() + assert.are.same({}, curveViolations(top)) + end) + + it("gives every correction a non-empty CorrectionMasks", function() + local corrections = top.MaskGroupBasedCorrections + if corrections == nil then + return + end + assert.are.equal("sequence", classify(corrections)) + for i, c in ipairs(corrections) do + local where = "MaskGroupBasedCorrections[" .. i .. "]" + assert.are.equal("Correction", c.What, where) + assert.are.equal("table", type(c.CorrectionMasks), where) + assert.are.equal("sequence", classify(c.CorrectionMasks), where) + for j, m in ipairs(c.CorrectionMasks) do + assert.is_truthy( + type(m.What) == "string" and m.What:match("^Mask/"), + where .. ".CorrectionMasks[" .. j .. "]" + ) + end + end + end) + + it("carries no key Lightroom must not be handed, no Temp and no ProcessVersion", function() + local bad = {} + walk(top, "", function(k, _, path) + if type(k) == "string" and denied(k) then + bad[#bad + 1] = path + end + end) + assert.are.same({}, bad) + -- The example's process version describes the example, not the photo. + assert.is_nil(top.ProcessVersion) + end) + + it("writes white balance as one family", function() + local kelvin = top.Temperature ~= nil or top.Tint ~= nil + local incremental = top.IncrementalTemperature ~= nil or top.IncrementalTint ~= nil + assert.is_false(kelvin and incremental, "both white-balance families") + if kelvin or incremental then + -- Numbers only next to Custom, or with no mode at all + -- (LuaOptions.wb_custom_with_numbers). + assert.is_truthy(top.WhiteBalance == nil or top.WhiteBalance == "Custom") + end + end) + + it("decodes to the same values after the plugin's own encode", function() + assert.is_true(same(top, decode(Json:encode(top)))) + end) + end) + end +end) diff --git a/plugin/spec/util_virtual_copy_spec.lua b/plugin/spec/util_virtual_copy_spec.lua new file mode 100644 index 00000000..376a06ca --- /dev/null +++ b/plugin/spec/util_virtual_copy_spec.lua @@ -0,0 +1,45 @@ +-- Unit tests for Util.isVirtualCopyOf, the check AI Edit runs after +-- `catalog:createVirtualCopies()` to make sure the copy it is about to edit +-- came from the photo it meant to copy. +-- +-- Plain tables stand in for LrPhoto objects: the helper only compares identity. +-- +-- Run from the repo root with: busted + +local Util = require("Util") + +describe("Util.isVirtualCopyOf", function() + local master = { name = "master" } + local siblingCopy = { name = "virtual copy of master" } + local otherPhoto = { name = "unrelated photo" } + + it("accepts a copy of a master photo", function() + assert.is_true(Util.isVirtualCopyOf(master, master, false, nil)) + -- isVirtualCopy may come back nil rather than false for a master. + assert.is_true(Util.isVirtualCopyOf(master, master, nil, nil)) + end) + + it("accepts a copy of a virtual copy, which belongs to the same master", function() + assert.is_true(Util.isVirtualCopyOf(master, siblingCopy, true, master)) + end) + + it("rejects a copy of a virtual copy whose master is a different photo", function() + assert.is_false(Util.isVirtualCopyOf(otherPhoto, siblingCopy, true, master)) + end) + + it("rejects a copy that belongs to a genuinely different photo", function() + assert.is_false(Util.isVirtualCopyOf(otherPhoto, master, false, nil)) + end) + + it("ignores sourceMaster for a master source", function() + -- Only a virtual-copy source may be matched through its master; a stray + -- sourceMaster must not let a copy of another photo through. + assert.is_false(Util.isVirtualCopyOf(otherPhoto, master, false, otherPhoto)) + assert.is_false(Util.isVirtualCopyOf(otherPhoto, master, nil, otherPhoto)) + end) + + it("rejects when the master cannot be read", function() + assert.is_false(Util.isVirtualCopyOf(nil, master, false, nil)) + assert.is_false(Util.isVirtualCopyOf(master, siblingCopy, true, nil)) + end) +end) diff --git a/plugin/spec/util_warning_tally_spec.lua b/plugin/spec/util_warning_tally_spec.lua new file mode 100644 index 00000000..9b75ad53 --- /dev/null +++ b/plugin/spec/util_warning_tally_spec.lua @@ -0,0 +1,145 @@ +-- Tests for the per-run warning tally used by TaskAiEditPhotos. +-- +-- A run-wide cause ("white balance was not transferred" on every JPEG) used to +-- be listed once per photo, which pushed every other report past the five the +-- dialog shows. The tally keeps each text once with a photo count, and still +-- counts what it does not show. + +local Util = require("Util") + +describe("Util.responseWarnings", function() + it("reads the warnings array", function() + assert.are.same({ "a", "b" }, Util.responseWarnings({ warnings = { "a", "b" }, warning = "a\nb" })) + end) + + it("splits the legacy joined warning string", function() + assert.are.same({ "first", "second" }, Util.responseWarnings({ warning = "first\n\n second \r\n" })) + end) + + it("returns an empty list for anything else", function() + assert.are.same({}, Util.responseWarnings(nil)) + assert.are.same({}, Util.responseWarnings({})) + assert.are.same({}, Util.responseWarnings("error")) + end) +end) + +describe("Util warning tally", function() + it("reports one cause for many photos once, with the photo count", function() + local tally = Util.newWarningTally() + for i = 1, 12 do + Util.tallyWarnings(tally, { "White balance was not transferred" }, "IMG_" .. i .. ".jpg", i) + end + + assert.are.equal(1, Util.warningTallySize(tally)) + assert.are.same({ "- White balance was not transferred (12 photos)" }, Util.formatWarningTally(tally, 5)) + end) + + it("names the photo when only one is affected", function() + local tally = Util.newWarningTally() + Util.tallyWarnings(tally, { "Mask skipped" }, "IMG_7.CR3", 7) + + assert.are.same({ "- Mask skipped (IMG_7.CR3)" }, Util.formatWarningTally(tally)) + end) + + it("counts a photo once per text across repeats and calls", function() + local tally = Util.newWarningTally() + Util.tallyWarnings(tally, { "same", "same" }, "a.jpg", 1) + Util.tallyWarnings(tally, { "same" }, "a.jpg", 1) + Util.tallyWarnings(tally, { "same" }, "b.jpg", 2) + + assert.are.same({ "- same (2 photos)" }, Util.formatWarningTally(tally)) + end) + + it("keeps photos with the same file name apart by key", function() + local tally = Util.newWarningTally() + Util.tallyWarnings(tally, { "w" }, "IMG_0001.CR3", 1) + Util.tallyWarnings(tally, { "w" }, "IMG_0001.CR3", 2) + + assert.are.same({ "- w (2 photos)" }, Util.formatWarningTally(tally)) + end) + + it("shows a handful in first-seen order and counts the rest", function() + local tally = Util.newWarningTally() + for i = 1, 8 do + Util.tallyWarnings(tally, { "warning " .. i }, "p" .. i, i) + end + + local lines = Util.formatWarningTally(tally, 5) + assert.are.equal(6, #lines) + assert.are.equal("- warning 1 (p1)", lines[1]) + assert.are.equal("- warning 5 (p5)", lines[5]) + assert.is_truthy(lines[6]:find("3 more warnings", 1, true)) + end) + + it("lists a run-wide cause first in a mixed run", function() + -- 6 raw photos, each with its own one-off note, then 12 JPEGs sharing + -- the white-balance note: first-seen order would hide the latter. + local tally = Util.newWarningTally() + for i = 1, 6 do + Util.tallyWarnings(tally, { "one-off " .. i }, "RAW_" .. i .. ".CR3", i) + end + for i = 7, 18 do + Util.tallyWarnings(tally, { "White balance was not transferred" }, "IMG_" .. i .. ".jpg", i) + end + + local lines = Util.formatWarningTally(tally, 5) + assert.are.equal(6, #lines) + assert.are.equal("- White balance was not transferred (12 photos)", lines[1]) + assert.are.equal("- one-off 1 (RAW_1.CR3)", lines[2]) + assert.are.equal("- one-off 4 (RAW_4.CR3)", lines[5]) + assert.is_truthy(lines[6]:find("2 more warnings", 1, true)) + end) + + it("keeps first-seen order among equal counts", function() + local tally = Util.newWarningTally() + for i = 1, 5 do + Util.tallyWarnings(tally, { "single " .. i }, "p" .. i, i) + end + for i = 6, 9 do + Util.tallyWarnings(tally, { "shared later", "also shared" }, "p" .. i, i) + end + + local lines = Util.formatWarningTally(tally, 5) + assert.are.equal("- shared later (4 photos)", lines[1]) + assert.are.equal("- also shared (4 photos)", lines[2]) + assert.are.equal("- single 1 (p1)", lines[3]) + assert.are.equal("- single 3 (p3)", lines[5]) + assert.is_truthy(lines[6]:find("2 more warnings", 1, true)) + end) + + it("keeps a later one-off visible next to a shared confidence note", function() + -- 8 photos share the (number-free) confidence note and the JPEG + -- white-balance note; a 9th photo's distinct warning must still show. + local tally = Util.newWarningTally() + for i = 1, 8 do + Util.tallyWarnings(tally, { + "Moderate style match confidence. Review the result before applying.", + "White balance was not transferred", + }, "IMG_" .. i .. ".jpg", i) + end + Util.tallyWarnings(tally, { "Something else" }, "IMG_9.jpg", 9) + + local lines = Util.formatWarningTally(tally, 5) + assert.are.equal(3, #lines) + assert.are.equal("- Something else (IMG_9.jpg)", lines[3]) + end) + + it("says 'warning' for a single hidden one", function() + local tally = Util.newWarningTally() + Util.tallyWarnings(tally, { "one", "two" }, nil) + + local lines = Util.formatWarningTally(tally, 1) + assert.are.equal("- one", lines[1]) + assert.is_truthy(lines[2]:find("1 more warning ", 1, true)) + end) + + it("ignores empty input and empty texts", function() + local tally = Util.newWarningTally() + Util.tallyWarnings(tally, nil, "a") + Util.tallyWarnings(tally, { "" }, "a") + Util.tallyWarnings(nil, { "x" }, "a") + + assert.are.equal(0, Util.warningTallySize(tally)) + assert.are.same({}, Util.formatWarningTally(tally)) + end) +end) diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index 9296a7ee..8cf54917 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -4364,6 +4364,8 @@ name = "lrg-analysis" version = "0.1.0" dependencies = [ "base64 0.23.1", + "log", + "lrg-develop", "lrg-imaging", "rayon", "serde", @@ -4384,6 +4386,7 @@ dependencies = [ "log", "lrg-analysis", "lrg-common", + "lrg-develop", "lrg-imaging", "lrg-llama", "lrg-ml", @@ -4450,6 +4453,19 @@ dependencies = [ "uuid", ] +[[package]] +name = "lrg-develop" +version = "0.1.0" +dependencies = [ + "lrg-develop", + "regex", + "roxmltree", + "serde", + "serde_json", + "sha2 0.11.1", + "thiserror 2.0.21", +] + [[package]] name = "lrg-imaging" version = "0.1.0" @@ -4545,6 +4561,7 @@ dependencies = [ "futures", "image", "log", + "lrg-develop", "lrg-imaging", "regex", "reqwest", @@ -6131,6 +6148,15 @@ dependencies = [ "byteorder", ] +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + [[package]] name = "rsqlite-vfs" version = "0.1.1" diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml index d7dc980c..946e96fa 100644 --- a/server-rs/Cargo.toml +++ b/server-rs/Cargo.toml @@ -14,6 +14,7 @@ members = [ "crates/lrg-mcp", "crates/lrg-chat", "crates/lrg-server", + "crates/lrg-develop", ] [workspace.package] @@ -32,6 +33,7 @@ lrg-providers = { path = "crates/lrg-providers" } lrg-llama = { path = "crates/lrg-llama" } lrg-mlx = { path = "crates/lrg-mlx" } lrg-api = { path = "crates/lrg-api" } +lrg-develop = { path = "crates/lrg-develop" } lrg-mcp = { path = "crates/lrg-mcp" } lrg-chat = { path = "crates/lrg-chat" } diff --git a/server-rs/README.md b/server-rs/README.md index 0476acf5..278d2bf7 100644 --- a/server-rs/README.md +++ b/server-rs/README.md @@ -8,9 +8,14 @@ endpoint reference. Workspace layout: `crates/lrg-common`, `lrg-store` (LanceDB), `lrg-imaging`, `lrg-ml` (ONNX Runtime via `ort`), `lrg-analysis`, `lrg-providers` (LLM -clients), `lrg-llama` (in-process llama.cpp, behind the `llamacpp` feature), +clients, edit-recipe schema), `lrg-develop` (the native Lightroom develop +model: key registry, typed settings, Lua/JSON and XMP readers, the +develop-preset writer, the Lua writer for `applyDevelopSettings` (provisional +until experiments E1/E2/E4/E11) and correction builders; a leaf crate), +`lrg-llama` (in-process llama.cpp, behind the `llamacpp` feature), `lrg-mlx` (supervises the Apple silicon MLX sidecar), `lrg-api` (axum -routers, including the agent bridge to the plugin), `lrg-mcp` (the MCP endpoint +routers, including the agent bridge to the plugin and AI Edit's retoucher +sessions at `/v1/edit/sessions`), `lrg-mcp` (the MCP endpoint AI agents connect to at `/mcp`, and the `geniusai-server mcp` stdio proxy — see [`docs/wiki/Dev-Agent-Bridge.md`](../docs/wiki/Dev-Agent-Bridge.md)), `lrg-chat` (the in-app agent chat at `/v1/chat/*` and its page, a local or @@ -23,7 +28,9 @@ providers. They are independent — a build can have either, both, or neither and both are served by the same `LocalEngine` trait in `lrg-providers/src/local.rs`. Besides indexing photos in batches (`generate`), the trait answers a whole conversation with one turn, -streaming its text as it is decoded (`chat`) — the agent chat is built on that. +streaming its text as it is decoded (`chat`) — the agent chat is built on that, +and so is AI Edit's retoucher, which edits through two tools (`apply_edit`, +`finish`) and sees each of Lightroom's renders as a tool result. The chat itself talks to a model-neutral `ChatModel` (`lrg-providers/src/chat.rs`); `local_chat.rs` puts a local engine behind it, calling tools by decoding against a JSON Schema with one branch per tool, since llama.cpp cannot render @@ -135,13 +142,23 @@ so a job that provisions assets can prove it used them: ```bash LRG_REQUIRE_GOLDENS=face cargo test --workspace # one family LRG_REQUIRE_GOLDENS=face,siglip cargo test --workspace # a subset -LRG_REQUIRE_GOLDENS=all cargo test --workspace # every family +LRG_REQUIRE_GOLDENS=all cargo test --workspace # every family except the local-only ones ``` Family names are `face`, `auraface`, `facestate`, `subject`, `siglip`, `aesthetic` and `bioclip`. Per-family rather than a single switch because they differ in cost by more than an order of magnitude (89 MB, 261 MB, 7 MB, 123 MB, 2.2 GB, 868 MB and 834 MB respectively). +Local-only families can never be provisioned in CI, because their data is +private or not redistributable: `all` leaves them out, and only naming one +makes its absence a failure. They are lrg-develop's local sweeps, each reading +a path from an environment variable: `training-dump` +(`LRG_DEVELOP_TRAINING_DUMP`, the maintainer's training dump), `lrc-presets` +(`LRG_LRC_PRESETS_DIR`, Lightroom Classic's bundled presets and profiles), +`acr-presets` (`LRG_ACR_PRESETS_DIR`, Camera Raw's user folder: its presets and +profiles) and `xmp-corpus` (`LRG_XMP_CORPUS_DIR`, a private sidecar corpus). See +[Dev-Develop-Model.md](../docs/wiki/Dev-Develop-Model.md). + CI uses both halves: `server-rs-tests.yml` downloads the face pair on every PR and sets `LRG_REQUIRE_GOLDENS=face`, while the nightly `golden-tests-full.yml` fetches every family (AuraFace from its pinned Hugging Face commit, the @@ -404,6 +421,11 @@ It runs with `--no-project` on purpose — `facenet-pytorch` pins `torch<2.3` and `numpy<2.0`, which `scripts/pyproject.toml` cannot satisfy, so the script carries its own PEP 723 dependency header. +`scripts/develop_registry/` is a separate, stdlib-only `uv` project for the +native Lightroom develop model: the `crs:` key inventory and registry +generator, and the extractor that produces the scrubbed fixtures in +`testdata/develop/`. See its README. + Then point the server at the files: ```bash diff --git a/server-rs/crates/lrg-analysis/Cargo.toml b/server-rs/crates/lrg-analysis/Cargo.toml index 94881e5c..ee11544e 100644 --- a/server-rs/crates/lrg-analysis/Cargo.toml +++ b/server-rs/crates/lrg-analysis/Cargo.toml @@ -6,6 +6,8 @@ license.workspace = true [dependencies] base64 = "0.23" +log.workspace = true +lrg-develop.workspace = true lrg-imaging.workspace = true rayon = "1.12.0" serde = { workspace = true, features = ["derive"] } diff --git a/server-rs/crates/lrg-analysis/src/style_engine.rs b/server-rs/crates/lrg-analysis/src/style_engine.rs index 467891a9..d6d4a2d6 100644 --- a/server-rs/crates/lrg-analysis/src/style_engine.rs +++ b/server-rs/crates/lrg-analysis/src/style_engine.rs @@ -9,8 +9,16 @@ //! layer, which builds a `TrainingCandidate` list and calls //! [`generate_style_edit`] here. +use lrg_develop::model::{WbFamily, WbMode, WbSetting}; +use lrg_develop::registry::{self, Gate, Level}; +use lrg_develop::{FileKind, Finite}; use serde_json::{json, Map, Value}; +use crate::training::{ + canonical_key, canonical_keys, number_json, white_balance_from_json, white_balance_json, + CanonicalKey, +}; + pub const WEIGHT_CLIP: f64 = 0.50; pub const WEIGHT_EXPOSURE: f64 = 0.25; pub const WEIGHT_SCENE: f64 = 0.15; @@ -123,11 +131,11 @@ pub struct TrainingCandidate { pub scene_tags: Vec, pub exposure: ExposureFeatures, pub time_of_day_bucket: String, - /// Whether the photo this example was saved from is a raw file. - /// - /// `None` for examples saved before the flag was recorded. See - /// [`restrict_temperature_to_matching_raw_status`] for why it matters. - pub is_raw: Option, + /// The example's white balance (mode, key family, and the numbers when + /// the mode is Custom); `None` when its settings carry no + /// `WhiteBalance`. The family is the example's file kind: see + /// [`blend_white_balance`] for why it matters. + pub white_balance: Option, } /// The new photo's own query-side features. @@ -140,6 +148,14 @@ pub struct StyleQuery { pub is_raw: Option, } +impl StyleQuery { + /// The target's file kind, which decides the white-balance family. + pub fn file_kind(&self) -> Option { + self.is_raw + .map(|raw| if raw { FileKind::Raw } else { FileKind::NonRaw }) + } +} + /// Port of `calculate_composite_score`. pub fn calculate_composite_score( clip_sim: f64, @@ -155,97 +171,493 @@ pub fn calculate_composite_score( + WEIGHT_TIME_OF_DAY * tod_score } -/// Port of `interpolate_recipes`: weighted blend of numeric canonical -/// settings across the winners, weights normalized by composite score. -pub fn interpolate_recipes(winners: &[(TrainingCandidate, f64)]) -> Map { - let total_weight: f64 = winners.iter().map(|(_, s)| s).sum(); - if total_weight <= 0.0 { - return Map::new(); - } - let mut blended: std::collections::HashMap = std::collections::HashMap::new(); - for (example, score) in winners { - let weight = score / total_weight; - for (key, value) in &example.canonical_settings { - if let Some(v) = value.as_f64() { - *blended.entry(key.clone()).or_insert(0.0) += weight * v; +/// The blended canonical settings of the winners, and what the blend had +/// to leave out. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Blend { + /// Canonical name → blended value (see [`interpolate_recipes`]). + pub settings: Map, + /// One note per setting the winners disagree on too much to average, + /// constant per cause so the plugin can merge them across photos. + pub warnings: Vec, +} + +/// Registry keys blended as one group because Lightroom keeps them ordered +/// (`ParametricShadowSplit < ParametricMidtoneSplit < +/// ParametricHighlightSplit`). A weighted mean of ordered triples is ordered, +/// so the group is blended only over winners that carry all of its keys. +const ORDERED_GROUPS: &[&[&str]] = &[&[ + "ParametricShadowSplit", + "ParametricMidtoneSplit", + "ParametricHighlightSplit", +]]; + +/// Below this agreement of the toned winners' hues (their mean resultant +/// length: 0 = the tints cancel out, 1 = all the same hue) the blended tint +/// kept less than this share of the toned examples' strength, and the blend +/// says so. The saturation itself is the length of the mean colour vector, +/// so disagreeing tints fade towards neutral continuously; this only decides +/// when that fading is worth a note. +pub const MIN_HUE_AGREEMENT: f64 = 0.5; + +/// A zone whose linear mean saturation is below this is too faint for its +/// fading to be worth a note. +const MIN_NOTICEABLE_TONING: f64 = 5.0; + +/// The note for a colour-grading zone whose tints faded in the blend. +fn toning_warning(saturation_key: &str) -> String { + let zone = if saturation_key.contains("Shadow") { + "shadows" + } else if saturation_key.contains("Highlight") { + "highlights" + } else { + "photo" + }; + format!( + "Color grading of the {zone} was toned down: your matching examples tint the {zone} in clearly different colors." + ) +} + +/// The note for curve splits that could not be averaged into a valid order. +const SPLITS_WARNING: &str = "The tone curve's region splits were not transferred: your matching examples' splits could not be averaged into a valid order."; + +/// Port of `interpolate_recipes`: weighted blend of the canonical settings +/// across the winners, weights normalized by composite score **per key**, +/// over the winners that have the key (an example that never touched a +/// slider must not pull it towards 0), rounded to the key's registry +/// precision (integers for integer sliders, `Exposure2012` to 2 decimals). +/// +/// Two kinds of key are not plain means: +/// - **Hue angles** ([`Gate::CircularHue`], the colour-grading hues) and +/// their saturation: each winner is a colour vector (hue angle, saturation +/// length), and the zone gets the score-weighted mean vector: its angle is +/// the hue, its length the saturation. A hue at saturation 0 does not +/// pull; 350° and 10° average to 0°, not 180°; and tints that disagree +/// fade towards neutral instead of landing at full strength on a hue no +/// example used. When the saturations sum to about 0, or the mean vector +/// rounds to saturation 0, the hue is left out and saturation 0 is sent +/// (the photo keeps its own hue under no toning). When the toned winners +/// agree less than [`MIN_HUE_AGREEMENT`] the blend says so. +/// - **Ordered groups** ([`ORDERED_GROUPS`], the parametric curve splits): +/// blended together over the winners that carry the whole group; left out, +/// with a note, if rounding breaks their order. +/// +/// Only keys of [`canonical_keys`] are blended; white balance is not a +/// canonical number (see [`blend_white_balance`]). +/// +/// [`Gate::CircularHue`]: lrg_develop::registry::Gate::CircularHue +pub fn interpolate_recipes(winners: &[(TrainingCandidate, f64)]) -> Blend { + let value_of = |example: &TrainingCandidate, name: &str| { + example.canonical_settings.get(name).and_then(Value::as_f64) + }; + let mut blend = Blend::default(); + let mut hues: Vec<(&CanonicalKey, &CanonicalKey)> = Vec::new(); + for key in canonical_keys() { + if ORDERED_GROUPS.iter().any(|g| g.contains(&key.spec.name)) { + continue; + } + if let Some(Gate::CircularHue(weight)) = key.spec.policy.gate() { + // A hue whose saturation is not canonical has no weight: skip it. + if let Some(sat) = canonical_keys().iter().find(|k| k.spec.name == weight) { + hues.push((key, sat)); } + continue; + } + let mut weight = 0.0; + let mut sum = 0.0; + for (example, score) in winners { + if let Some(v) = value_of(example, key.name) { + weight += score; + sum += score * v; + } + } + if weight > 0.0 { + blend + .settings + .insert(key.name.to_string(), number_json(key.spec, sum / weight)); } } - blended - .into_iter() - .map(|(k, v)| (k, json!(round_to(v, 1)))) - .collect() + + for group in ORDERED_GROUPS { + let keys: Vec<&CanonicalKey> = group + .iter() + .filter_map(|name| canonical_keys().iter().find(|k| k.spec.name == *name)) + .collect(); + if keys.len() != group.len() { + continue; + } + let mut weight = 0.0; + let mut sums = vec![0.0; keys.len()]; + for (example, score) in winners { + let values: Option> = keys.iter().map(|k| value_of(example, k.name)).collect(); + if let Some(values) = values { + weight += score; + for (sum, v) in sums.iter_mut().zip(values) { + *sum += score * v; + } + } + } + if weight <= 0.0 { + continue; + } + let blended: Vec = keys + .iter() + .zip(&sums) + .map(|(k, sum)| number_json(k.spec, sum / weight)) + .collect(); + // Integer rounding can make two neighbours equal (1.5 and 2.5 both + // round to 2); Lightroom's splits must stay strictly ordered. + let ordered = blended + .windows(2) + .all(|w| match (w[0].as_f64(), w[1].as_f64()) { + (Some(a), Some(b)) => a < b, + _ => false, + }); + if ordered { + for (k, v) in keys.iter().zip(blended) { + blend.settings.insert(k.name.to_string(), v); + } + } else { + // Only reachable when the winners' splits are 1 apart and the + // means land on .5 ties (or a blob is unordered); Lightroom-written + // triples are far apart. The photo keeps its own splits. + log::warn!( + "Style blend: {group:?} lost their order after rounding ({blended:?}); left out" + ); + if !blend.warnings.iter().any(|w| w == SPLITS_WARNING) { + blend.warnings.push(SPLITS_WARNING.to_string()); + } + } + } + + for (hue, sat) in hues { + // x, y: the score-weighted sum of the colour vectors; weight: the + // sum of score x saturation (the length the vectors would have if + // they all agreed); score_sum: the scores of the winners carrying + // the pair, which the mean vector is divided by. + let (mut x, mut y, mut weight, mut score_sum) = (0.0, 0.0, 0.0, 0.0); + for (example, score) in winners { + let (Some(h), Some(s)) = (value_of(example, hue.name), value_of(example, sat.name)) + else { + continue; + }; + let w = score.max(0.0) * s.max(0.0); + let rad = h.to_radians(); + x += w * rad.cos(); + y += w * rad.sin(); + weight += w; + score_sum += score.max(0.0); + } + if weight <= 1e-9 { + // Every winner leaves this zone untoned; the blended saturation + // (0) says so, and a hue would be noise. A saturation without + // any hue next to it (a blob missing one of the pair) would tint + // the photo in whatever hue it has, so it goes too. Silent: + // Lightroom always writes hue and saturation together, so only a + // malformed blob gets here and the user has nothing to act on. + if blend + .settings + .get(sat.name) + .and_then(Value::as_f64) + .is_some_and(|v| v > 0.0) + { + blend.settings.remove(sat.name); + } + continue; + } + let agreement = x.hypot(y) / weight; + // The linear mean from the loop above, before the vector replaces it. + let linear = blend + .settings + .get(sat.name) + .and_then(Value::as_f64) + .unwrap_or(0.0); + let saturation = number_json(sat.spec, x.hypot(y) / score_sum); + let faded = saturation.as_f64().is_none_or(|v| v <= 0.0); + blend.settings.insert(sat.name.to_string(), saturation); + if agreement < MIN_HUE_AGREEMENT && linear >= MIN_NOTICEABLE_TONING { + log::info!( + "Style blend: {} hues disagree (agreement {agreement:.3}); saturation {linear:.1} faded", + hue.spec.name, + ); + let note = toning_warning(sat.spec.name); + if !blend.warnings.contains(¬e) { + blend.warnings.push(note); + } + } + if faded { + // No toning left: the photo keeps its own hue under saturation 0. + continue; + } + let degrees = y.atan2(x).to_degrees().rem_euclid(360.0); + let mut value = number_json(hue.spec, degrees); + // 359.6° rounds to 360, which is 0° (the plugin wraps it too). + if value.as_f64().is_some_and(|v| v >= 360.0) { + value = number_json(hue.spec, 0.0); + } + blend.settings.insert(hue.name.to_string(), value); + } + blend } -/// Recompute `temperature` from only the winners whose raw status matches the -/// photo being edited, or drop it. +/// Whether the style engine may send a white-balance *mode* without numbers +/// (`Auto`, `Daylight`, ...), for Lightroom to resolve per photo. /// -/// Every other canonical field means the same thing on a raw file and on a -/// JPEG. `temperature` does not: Lightroom exposes it as Kelvin for raw and as -/// a relative -100..100 for everything else. Averaging 5600 and +12 produces a -/// number that is meaningless on either scale, and the result is applied to a -/// real photo — so the blend is restricted rather than corrected afterwards. +/// Off until experiment E1 shows that `applyDevelopSettings{WhiteBalance = +/// "Daylight"}` makes Lightroom recompute the temperature for the target +/// photo. If it does not, a mode alone would leave the photo's old Kelvin in +/// place under a new label. Until then an Auto/preset majority transfers +/// nothing and says so ([`WbOutcome::NotTransferred`]). /// -/// `tint` is deliberately left alone. Its *range* differs (-150..150 raw, -/// -100..100 otherwise) but its meaning does not, so a mixed blend is a scale -/// distortion the clamp already handles, not a category error. +/// First run (Lightroom 15.6, one non-raw TIFF, run from the Library +/// module): `WhiteBalance = "Auto"` alone changed the mode, and the readback +/// showed the previous incremental numbers unchanged within 15 s, with and +/// without `optFlattenAutoNow` (E1f/E1g; whether the Basic panel shows them +/// is an open manual check). The raw side (E1e, `Daylight`) has not run, so +/// this stays off. A positive raw answer turns it on for raw targets only: +/// [`blend_white_balance`] refuses a mode alone for a non-raw target before +/// it looks at this switch. +/// +/// The same E1 answer is the Lua writer's provisional +/// `lrg_develop::lua::LuaOptions::PROVISIONAL.wb_mode_only` (whether a mode +/// alone is written at all): flip both together, with the const assert in +/// `a_named_mode_majority_is_not_sent_while_the_switch_is_off`. One without +/// the other either sends a mode the writer drops and reports, or enables a +/// form nothing produces. +pub const EMIT_NAMED_WB_MODES: bool = false; + +/// At most this many Custom examples (the best-scored ones of the pool) give +/// the transferred temperature and tint. +pub const WB_CUSTOM_EXAMPLES: usize = 3; + +/// At least this many of them must exist: one example's Kelvin belongs to +/// the light of its own scene. +pub const WB_MIN_CUSTOM_EXAMPLES: usize = 2; + +/// Their raw temperatures may be at most this far apart (Kelvin); further +/// apart, they do not describe one habit. +pub const WB_MAX_KELVIN_SPREAD: f64 = 500.0; + +/// The same agreement limit for non-raw examples, whose +/// `IncrementalTemperature` is an offset on -100..100. An assumption like the +/// Kelvin one (every example so far is raw), to revisit with data. +pub const WB_MAX_INCREMENTAL_SPREAD: f64 = 20.0; + +/// What [`blend_white_balance`] decided. +#[derive(Debug, Clone, PartialEq)] +pub enum WbOutcome { + /// Leave the photo's own white balance alone; nothing to report (the + /// examples keep theirs as shot, or carry none). + Unchanged, + /// Send this white balance. + Transfer(WbSetting), + /// Nothing is sent, and the user should know why. + NotTransferred(String), +} + +fn family_label(family: WbFamily) -> &'static str { + match family { + WbFamily::Raw => "raw files", + WbFamily::NonRaw => "JPEG/TIFF (non-raw) files", + } +} + +/// Learns the white balance to send from the scored candidate pool (all of +/// it, best first — not only the [`TOP_K_BLEND`] winners: a 2-of-3 vote says +/// nothing). /// -/// Examples with an unknown raw status (saved before the flag was recorded) -/// count as matching, since excluding them would quietly shrink every -/// long-standing user's pool to nothing. +/// 1. The pool votes for a mode, weighted by composite score. Examples +/// without a `WhiteBalance` do not vote. Ties go to the more conservative +/// mode (As Shot, then Auto, then a preset, then Custom). +/// 2. As Shot wins: nothing is sent; the photo keeps its own, which is what +/// the examples show. +/// 3. Auto or a preset wins: only the mode would be sent, without numbers — +/// and only once [`EMIT_NAMED_WB_MODES`] is on. +/// 4. Custom wins: temperature and tint from up to [`WB_CUSTOM_EXAMPLES`] of +/// the best-scored Custom examples **of the target's family** (raw +/// examples' Kelvin only for raw targets, non-raw offsets only for non-raw +/// targets), only when at least [`WB_MIN_CUSTOM_EXAMPLES`] exist and their +/// temperatures agree within the spread limit, weighted over exactly +/// those examples (renormalised per number: a missing tint does not pull +/// towards 0). /// -/// Returns a warning when the field had to be dropped entirely. -pub fn restrict_temperature_to_matching_raw_status( - blended: &mut Map, - winners: &[(TrainingCandidate, f64)], - query_is_raw: Option, -) -> Option { - if !blended.contains_key("temperature") { - return None; - } - let compatible = |candidate: &TrainingCandidate| match (query_is_raw, candidate.is_raw) { - (Some(q), Some(c)) => q == c, - _ => true, +/// An unknown target file kind sends nothing: Kelvin on a JPEG, or an offset +/// on a raw file, would be a scale error. +pub fn blend_white_balance( + pool: &[(TrainingCandidate, f64)], + target: Option, +) -> WbOutcome { + fn rank(mode: WbMode) -> u8 { + match mode { + WbMode::AsShot => 0, + WbMode::Auto => 1, + WbMode::Named(_) => 2, + WbMode::Custom => 3, + } + } + let mut votes: Vec<(WbMode, f64)> = Vec::new(); + for (c, score) in pool { + let Some(wb) = c.white_balance else { continue }; + match votes.iter_mut().find(|(m, _)| *m == wb.mode) { + Some((_, w)) => *w += score.max(0.0), + None => votes.push((wb.mode, score.max(0.0))), + } + } + let Some(&(winner, _)) = votes.iter().min_by(|a, b| { + b.1.partial_cmp(&a.1) + .unwrap_or(std::cmp::Ordering::Equal) + .then(rank(a.0).cmp(&rank(b.0))) + }) else { + return WbOutcome::Unchanged; }; - if winners.iter().all(|(c, _)| compatible(c)) { - return None; + + match winner { + WbMode::AsShot => WbOutcome::Unchanged, + WbMode::Auto | WbMode::Named(_) => { + // Non-raw targets never get a mode alone, whatever the switch + // says: Lightroom offers only As Shot, Auto and Custom for them, + // and `Auto` alone left the old incremental numbers in place + // (E1f/E1g, Lightroom 15.6). Checked first, so a raw answer that + // turns EMIT_NAMED_WB_MODES on cannot reach a non-raw photo. + if target == Some(FileKind::NonRaw) { + return WbOutcome::NotTransferred(match winner { + WbMode::Named(_) => format!( + "White balance was not transferred: the \"{winner}\" preset exists only for raw files, and this photo is not raw." + ), + _ => format!( + "White balance was not transferred: most of your matching examples use the \"{winner}\" white balance, which AI Edit does not apply to JPEG/TIFF (non-raw) files." + ), + }); + } + // Decision (plan step 1, 6.1): a mode without numbers is only + // sent after E1; see EMIT_NAMED_WB_MODES. Reported rather than + // silent, because the examples do show a white-balance habit + // that this photo will not get. + if !EMIT_NAMED_WB_MODES { + return WbOutcome::NotTransferred(format!( + "White balance was not transferred: most of your matching examples use the \"{winner}\" white balance, which AI Edit does not apply yet." + )); + } + let Some(target) = target else { + return WbOutcome::NotTransferred( + "White balance was not transferred: it is not known whether this photo is a raw file.".to_string(), + ); + }; + WbOutcome::Transfer(WbSetting { + mode: winner, + family: target.into(), + temperature: None, + tint: None, + }) + } + WbMode::Custom => blend_custom_white_balance(pool, target), } +} - let matching: Vec<&(TrainingCandidate, f64)> = - winners.iter().filter(|(c, _)| compatible(c)).collect(); - let total_weight: f64 = matching.iter().map(|(_, s)| s).sum(); - let recomputed: Option = if total_weight > 0.0 { - let sum: f64 = matching - .iter() - .filter_map(|(c, s)| { - c.canonical_settings - .get("temperature") - .and_then(Value::as_f64) - .map(|v| v * (s / total_weight)) +fn blend_custom_white_balance( + pool: &[(TrainingCandidate, f64)], + target: Option, +) -> WbOutcome { + let Some(target) = target else { + return WbOutcome::NotTransferred( + "White balance was not transferred: it is not known whether this photo is a raw file, so the temperature scale is unknown.".to_string(), + ); + }; + let family = WbFamily::from(target); + let mut customs: Vec<(WbSetting, f64)> = pool + .iter() + .filter_map(|(c, s)| { + let wb = c.white_balance?; + (wb.mode == WbMode::Custom && wb.temperature.is_some()).then_some((wb, *s)) + }) + .collect(); + // Stable: equal scores keep pool order. + customs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + let other_family = customs.iter().any(|(wb, _)| wb.family != family); + customs.retain(|(wb, _)| wb.family == family); + customs.truncate(WB_CUSTOM_EXAMPLES); + + if customs.is_empty() { + return if other_family { + WbOutcome::NotTransferred(match family { + // No "save JPEG examples" advice: training accepts raw and DNG + // files only, so the user could not follow it. + WbFamily::NonRaw => "White balance was not transferred: your training examples are raw files and this photo is not, so their Kelvin temperature does not apply. The photo keeps its own white balance; set it by hand if needed.".to_string(), + WbFamily::Raw => "White balance was not transferred: your matching examples are JPEG/TIFF (non-raw) files and this photo is a raw file, so their temperature offset does not apply. Save a few edited raw photos as training examples to learn their white balance.".to_string(), }) - .sum(); - Some(round_to(sum, 1)) - } else { - None + } else { + WbOutcome::Unchanged + }; + } + if customs.len() < WB_MIN_CUSTOM_EXAMPLES { + return WbOutcome::NotTransferred(format!( + "White balance was not transferred: only {} of your matching examples sets a custom white balance on {}; at least {WB_MIN_CUSTOM_EXAMPLES} are needed.", + customs.len(), + family_label(family), + )); + } + + let temps: Vec = customs + .iter() + .filter_map(|(wb, _)| wb.temperature.map(Finite::get)) + .collect(); + let spread = temps.iter().cloned().fold(f64::MIN, f64::max) + - temps.iter().cloned().fold(f64::MAX, f64::min); + let (limit, unit) = match family { + WbFamily::Raw => (WB_MAX_KELVIN_SPREAD, " K"), + WbFamily::NonRaw => (WB_MAX_INCREMENTAL_SPREAD, ""), }; + if spread > limit { + // The measured spread differs per photo, so it goes to the log and + // the text stays constant per family: the plugin merges warnings by + // exact text, and a per-photo number would give every photo its own + // line and push run-wide causes out of the end-of-run dialog. + log::info!( + "White balance not transferred: custom temperatures spread {spread:.0}{unit} > {limit:.0}{unit}" + ); + return WbOutcome::NotTransferred(format!( + "White balance was not transferred: your matching examples disagree (their custom temperatures are more than {limit:.0}{unit} apart). Set it by hand for this photo." + )); + } - match recomputed { - Some(v) => { - blended.insert("temperature".into(), json!(v)); - Some(format!( - "White balance was blended from {} of {} matched examples — the others were saved from {} files, where Lightroom's temperature is on a different scale.", - matching.len(), - winners.len(), - if query_is_raw == Some(true) { "non-raw" } else { "raw" } - )) - } - None => { - blended.remove("temperature"); - Some( - "White balance was left unchanged: every matched training example was saved from a file whose temperature is on a different scale than this photo's." - .to_string(), - ) + let blend = |pick: fn(&WbSetting) -> Option, key: &str| -> Option { + let spec = registry::lookup(Level::Global, key)?.spec(); + let (mut sum, mut weight) = (0.0, 0.0); + for (wb, s) in &customs { + if let Some(v) = pick(wb) { + sum += s.max(0.0) * v.get(); + weight += s.max(0.0); + } + } + if weight <= 0.0 { + return None; } + let mut v = sum / weight; + if let Some((lo, hi)) = spec.range { + v = v.clamp(lo.get(), hi.get()); + } + number_json(spec, v) + .as_f64() + .and_then(|x| Finite::new(x).ok()) + }; + let temperature = blend(|wb| wb.temperature, family.temperature_key()); + let Some(temperature) = temperature else { + // Every chosen example scored 0: nothing to weight by. + return WbOutcome::Unchanged; + }; + WbOutcome::Transfer(WbSetting { + mode: WbMode::Custom, + family, + temperature: Some(temperature), + tint: blend(|wb| wb.tint, family.tint_key()), + }) +} + +/// `x` in the precision of the canonical key `name`. +fn rounded_like(name: &str, x: f64) -> Value { + match canonical_key(name) { + Some(key) => number_json(key.spec, x), + None => json!(round_to(x, 2)), } } @@ -288,7 +700,7 @@ pub fn adaptive_compensation( .unwrap_or(0.0); recipe.insert( "exposure".into(), - json!(round_to(current + exposure_correction, 2)), + rounded_like("exposure", current + exposure_correction), ); } if contrast_correction.abs() > 1.0 { @@ -298,54 +710,52 @@ pub fn adaptive_compensation( .unwrap_or(0.0); recipe.insert( "contrast".into(), - json!(round_to(current + contrast_correction, 1)), + rounded_like("contrast", current + contrast_correction), ); } } -const CANONICAL_TO_RECIPE_FIELDS: &[&str] = &[ - "exposure", - "contrast", - "highlights", - "shadows", - "whites", - "blacks", - "temperature", - "tint", - "texture", - "clarity", - "dehaze", - "vibrance", - "saturation", - "sharpening", - "noise_reduction", - "color_noise_reduction", - "vignette", - "grain", -]; -const TONE_CURVE_KEYS: &[(&str, &str)] = &[ - ("tone_curve_highlights", "highlights"), - ("tone_curve_lights", "lights"), - ("tone_curve_darks", "darks"), - ("tone_curve_shadows", "shadows"), -]; - -/// Port of `_canonical_to_edit_recipe`. -pub fn canonical_to_edit_recipe(canonical: &Map, summary: &str) -> Value { +/// Port of `_canonical_to_edit_recipe`: each canonical value goes to its +/// registry recipe alias below `global` (`tone_curve_shadows` → +/// `global.tone_curve.shadows`, `hsl_red_hue` → `global.hsl.red.hue`, +/// `color_grading_shadows_hue` → `global.color_grading.shadows.hue`). Those +/// nested objects are exactly what the installed plugin's +/// `DevelopEditManager` reads (`buildHslDevelopSettings`, +/// `buildColorGradingDevelopSettings`, `buildToneCurveSettings`), and they +/// are the only objects in `global`: its review dialog prints any other +/// table as an address. +/// +/// White balance goes to the **recipe level**, `white_balance: {mode, +/// family, temperature?, tint?}` next to `global`/`masks`, never into +/// `global`: `global.white_balance` is already the LLM schema's +/// `{temperature, tint}` alias, and installed plugins print every unknown +/// `global` key (a table shows as `table: 0x...`). Those plugins read only +/// `summary`/`global`/`masks`/`warnings` and ignore this field. +pub fn canonical_to_edit_recipe( + canonical: &Map, + summary: &str, + white_balance: Option<&WbSetting>, +) -> Value { let mut global_settings = Map::new(); - for &key in CANONICAL_TO_RECIPE_FIELDS { - if let Some(v) = canonical.get(key) { - global_settings.insert(key.to_string(), v.clone()); - } - } - let mut tone_curve = Map::new(); - for &(canon_key, tc_key) in TONE_CURVE_KEYS { - if let Some(v) = canonical.get(canon_key) { - tone_curve.insert(tc_key.to_string(), v.clone()); + for key in canonical_keys() { + let Some(v) = canonical.get(key.name) else { + continue; + }; + let (leaf, parents) = key + .recipe_path + .split_last() + .expect("a recipe alias has at least one segment"); + let mut node = &mut global_settings; + for parent in parents { + node = match node + .entry(parent.to_string()) + .or_insert_with(|| Value::Object(Map::new())) + { + Value::Object(m) => m, + _ => unreachable!("recipe alias paths never end where another continues"), + }; } - } - if !tone_curve.is_empty() { - global_settings.insert("tone_curve".into(), Value::Object(tone_curve)); + node.insert(leaf.to_string(), v.clone()); } let summary = if summary.is_empty() { @@ -353,12 +763,16 @@ pub fn canonical_to_edit_recipe(canonical: &Map, summary: &str) - } else { summary }; - json!({ + let mut recipe = json!({ "summary": summary, "global": global_settings, "masks": [], "warnings": [], - }) + }); + if let Some(wb) = white_balance { + recipe["white_balance"] = white_balance_json(wb); + } + recipe } #[derive(Debug, Clone)] @@ -367,7 +781,9 @@ pub struct StyleEngineResult { pub confidence: f64, pub matched_count: usize, pub engine: &'static str, - pub warning: Option, + /// Everything the user should know about this result, in order (a + /// list: the confidence note and a white-balance note can both apply). + pub warnings: Vec, pub matched_filenames: Vec, } @@ -387,9 +803,9 @@ pub fn generate_style_edit( confidence: 0.0, matched_count: 0, engine: "none", - warning: Some(format!( + warnings: vec![format!( "Style engine inactive: only {training_count} training example(s) available (minimum {MIN_TRAINING_EXAMPLES} required). Please save more AI training examples." - )), + )], matched_filenames: Vec::new(), }; } @@ -399,7 +815,7 @@ pub fn generate_style_edit( confidence: 0.0, matched_count: 0, engine: "none", - warning: Some("No training examples could be retrieved from the database.".to_string()), + warnings: vec!["No training examples could be retrieved from the database.".to_string()], matched_filenames: Vec::new(), }; } @@ -417,6 +833,8 @@ pub fn generate_style_edit( let best_score = scored.first().map(|(_, s)| *s).unwrap_or(0.0); let confidence = round_to(best_score, 3); + // Before the pool is cut to the winners: the mode vote needs all of it. + let white_balance = blend_white_balance(&scored, query.file_kind()); let winners: Vec<(TrainingCandidate, f64)> = scored.into_iter().take(TOP_K_BLEND).collect(); let matched_filenames: Vec = winners .iter() @@ -428,9 +846,10 @@ pub fn generate_style_edit( }) .collect(); - let mut blended = interpolate_recipes(&winners); - let temperature_warning = - restrict_temperature_to_matching_raw_status(&mut blended, &winners, query.is_raw); + let Blend { + settings: mut blended, + warnings: blend_warnings, + } = interpolate_recipes(&winners); adaptive_compensation(&mut blended, &query.exposure, &winners); // Python builds this from a `set(...)`, whose iteration order is not @@ -466,38 +885,91 @@ pub fn generate_style_edit( )); let summary = summary_parts.join(" \u{2014} "); - let recipe = canonical_to_edit_recipe(&blended, &summary); - - let warning = if confidence < CONFIDENCE_LOW { - Some(format!( - "Low style match confidence ({:.0}%). Results may not match your editing style precisely. Consider adding more training examples for this type of photo.", - confidence * 100.0 - )) - } else if confidence < CONFIDENCE_GOOD { - Some(format!( - "Moderate style match confidence ({:.0}%). Review the result before applying.", - confidence * 100.0 - )) - } else { - None - }; - // Both can be true at once, and the user needs both: one is about how well - // the style matched, the other about a field that was left out of it. - let warning = match (warning, temperature_warning) { - (Some(a), Some(b)) => Some(format!("{a}\n{b}")), - (a, b) => a.or(b), + let (wb_setting, wb_warning) = match white_balance { + WbOutcome::Transfer(wb) => (Some(wb), None), + WbOutcome::Unchanged => (None, None), + WbOutcome::NotTransferred(w) => (None, Some(w)), }; + let recipe = canonical_to_edit_recipe(&blended, &summary, wb_setting.as_ref()); + + // Both can apply at once, and the user needs both: one is about how well + // the style matched, the other about a setting that was left out of it. + let warnings: Vec = confidence_warning(confidence) + .into_iter() + .chain(wb_warning) + .chain(blend_warnings) + .collect(); StyleEngineResult { recipe, confidence, matched_count: winners.len(), engine: "style", - warning, + warnings, matched_filenames, } } +/// The note for a match below [`CONFIDENCE_GOOD`], or `None`. +/// +/// The text carries no percentage on purpose: the number is already in the +/// response's `confidence` and in the recipe summary, and the plugin merges +/// warnings by exact text into one "(N photos)" line per cause. A per-photo +/// number would make every photo its own line and crowd run-wide causes (a +/// white balance left out on every JPEG) out of the end-of-run dialog. +pub fn confidence_warning(confidence: f64) -> Option { + if confidence < CONFIDENCE_LOW { + Some( + "Low style match confidence. Results may not match your editing style precisely. Consider adding more training examples for this type of photo." + .to_string(), + ) + } else if confidence < CONFIDENCE_GOOD { + Some("Moderate style match confidence. Review the result before applying.".to_string()) + } else { + None + } +} + +/// A style recipe in the shape `lrg_develop::build::recipe` reads: its +/// `white_balance` (mode, family, numbers) moved into `global.temperature` +/// and `global.tint`, or dropped with a note when it cannot go onto a photo +/// of `file_kind` — learned on the other family's scale, or a named mode +/// without numbers. +/// +/// AI Edit's retoucher applies the style as its round 0 through the recipe +/// builder, which knows only the global numbers. +pub fn white_balance_into_global( + mut recipe: Value, + file_kind: Option, +) -> (Value, Vec) { + let mut notes = Vec::new(); + let Some(raw) = recipe + .as_object_mut() + .and_then(|r| r.remove("white_balance")) + else { + return (recipe, notes); + }; + let usable = white_balance_from_json(&raw) + .filter(|wb| wb.mode == WbMode::Custom && Some(FileKind::from(wb.family)) == file_kind); + let Some(wb) = usable else { + notes.push( + "Your style's white balance was not carried over: it was learned on another kind of \ + file than this one." + .to_string(), + ); + return (recipe, notes); + }; + if let Some(global) = recipe.get_mut("global").and_then(Value::as_object_mut) { + if let Some(t) = wb.temperature { + global.insert("temperature".to_string(), json!(t.get())); + } + if let Some(t) = wb.tint { + global.insert("tint".to_string(), json!(t.get())); + } + } + (recipe, notes) +} + #[cfg(test)] mod tests { use super::*; @@ -547,26 +1019,102 @@ mod tests { assert!((tod_proximity("dawn", "night") - 0.65).abs() < 1e-9); } - fn candidate(canonical: &[(&str, f64)], distance: f64) -> TrainingCandidate { - let mut c = TrainingCandidate { + /// A candidate from a real `develop_settings` blob (the plugin's + /// JSON.lua form), canonicalised the way the route does it. + fn from_blob(blob: Value, distance: f64) -> TrainingCandidate { + let c = crate::training::canonicalize_develop_settings( + &blob, + lrg_develop::FileKindHint::Unknown, + ) + .unwrap(); + TrainingCandidate { distance, + canonical_settings: c.settings, + white_balance: c.white_balance, ..Default::default() - }; - for &(k, v) in canonical { - c.canonical_settings.insert(k.to_string(), json!(v)); } - c + } + + /// A raw example with the given white balance (`None` Kelvin for the + /// non-Custom modes, which carry the camera's reading). + fn raw_wb(mode: &str, kelvin: f64, tint: f64) -> TrainingCandidate { + from_blob( + json!({ + "ProcessVersion": "15.4", + "WhiteBalance": mode, + "Temperature": kelvin, + "Tint": tint, + "Contrast2012": 10, + }), + 0.0, + ) + } + + fn non_raw_wb(mode: &str, offset: f64, tint: f64) -> TrainingCandidate { + from_blob( + json!({ + "ProcessVersion": "15.4", + "WhiteBalance": mode, + "IncrementalTemperature": offset, + "IncrementalTint": tint, + }), + 0.0, + ) + } + + fn transferred(outcome: WbOutcome) -> WbSetting { + match outcome { + WbOutcome::Transfer(wb) => wb, + other => panic!("expected a transfer, got {other:?}"), + } + } + + fn not_transferred(outcome: WbOutcome) -> String { + match outcome { + WbOutcome::NotTransferred(w) => w, + other => panic!("expected a reported non-transfer, got {other:?}"), + } } #[test] fn interpolate_recipes_weighted_blend() { let winners = vec![ - (candidate(&[("exposure", 1.0)], 0.0), 2.0), - (candidate(&[("exposure", 0.0)], 0.0), 1.0), + (from_blob(json!({"Exposure2012": 1.0}), 0.0), 2.0), + (from_blob(json!({"Exposure2012": 0.0}), 0.0), 1.0), + ]; + let blended = interpolate_recipes(&winners).settings; + // 2/3 * 1.0 + 1/3 * 0.0, to Exposure2012's 2 decimals + assert_eq!(blended["exposure"], json!(0.67)); + } + + #[test] + fn interpolate_renormalises_per_key_over_the_winners_that_have_it() { + // The second example never touched Clarity; it must not pull the + // first one's +30 halfway to 0. + let winners = vec![ + ( + from_blob(json!({"Clarity2012": 30, "Contrast2012": 10}), 0.0), + 1.0, + ), + (from_blob(json!({"Contrast2012": 21}), 0.0), 1.0), ]; - let blended = interpolate_recipes(&winners); - // weight 2/3 * 1.0 + 1/3 * 0.0 = 0.667 -> rounds to 0.7 - assert_eq!(blended["exposure"], json!(0.7)); + let blended = interpolate_recipes(&winners).settings; + assert_eq!(blended["clarity"], json!(30)); + assert_eq!( + blended["contrast"], + json!(16), + "15.5 to even, as an integer" + ); + } + + #[test] + fn interpolate_ignores_keys_that_are_not_canonical() { + let mut c = from_blob(json!({"Contrast2012": 10}), 0.0); + // A version-1 row still carrying the old tint number. + c.canonical_settings.insert("tint".into(), json!(40.0)); + let blended = interpolate_recipes(&[(c, 1.0)]).settings; + assert!(blended.get("tint").is_none()); + assert_eq!(blended["contrast"], json!(10)); } #[test] @@ -575,7 +1123,8 @@ mod tests { let result = generate_style_edit(2, &[], &query); assert_eq!(result.engine, "none"); assert_eq!(result.confidence, 0.0); - assert!(result.warning.unwrap().contains("only 2 training example")); + assert_eq!(result.warnings.len(), 1); + assert!(result.warnings[0].contains("only 2 training example")); } #[test] @@ -590,7 +1139,10 @@ mod tests { time_of_day_bucket: "afternoon".to_string(), is_raw: None, }; - let mut c = candidate(&[("exposure", 1.0), ("contrast", 10.0)], 0.1); + let mut c = from_blob( + json!({"Exposure2012": 1.0, "Contrast2012": 10, "ParametricDarks": -5}), + 0.1, + ); c.exposure = ExposureFeatures { luminance_mean: Some(0.5), contrast: Some(0.4), @@ -605,92 +1157,555 @@ mod tests { assert_eq!(result.matched_count, 1); assert!(result.confidence > 0.9); // near-identical query/candidate assert_eq!(result.recipe["global"]["exposure"], json!(1.0)); + assert_eq!(result.recipe["global"]["contrast"], json!(10)); + assert_eq!(result.recipe["global"]["tone_curve"]["darks"], json!(-5)); + assert!(result.recipe.get("white_balance").is_none()); + assert!(result.warnings.is_empty(), "{:?}", result.warnings); assert_eq!(result.matched_filenames, vec!["photo1.jpg".to_string()]); } - fn raw_candidate(temperature: f64, is_raw: Option) -> TrainingCandidate { - let mut c = candidate(&[("temperature", temperature), ("contrast", 10.0)], 0.0); - c.is_raw = is_raw; - c + #[test] + fn a_custom_majority_with_agreeing_examples_transfers_its_white_balance() { + let p = vec![ + (raw_wb("Custom", 5600.0, 10.0), 0.9), + (raw_wb("Custom", 5400.0, 4.0), 0.8), + (raw_wb("As Shot", 5000.0, 0.0), 0.7), + (raw_wb("Custom", 5500.0, 6.0), 0.6), + (raw_wb("Custom", 9000.0, 50.0), 0.1), + ]; + let wb = transferred(blend_white_balance(&p, Some(FileKind::Raw))); + assert_eq!((wb.mode, wb.family), (WbMode::Custom, WbFamily::Raw)); + // The best three Custom examples only; 9000 K is the fourth. + // (0.9*5600 + 0.8*5400 + 0.6*5500) / 2.3 = 5504.3 + assert_eq!(wb.temperature.map(Finite::get), Some(5504.0)); + // (0.9*10 + 0.8*4 + 0.6*6) / 2.3 = 6.87 + assert_eq!(wb.tint.map(Finite::get), Some(7.0)); } #[test] - fn a_uniform_pool_blends_temperature_as_before() { - let mut blended = interpolate_recipes(&[ - (raw_candidate(5600.0, Some(true)), 1.0), - (raw_candidate(5000.0, Some(true)), 1.0), - ]); - let winners = vec![ - (raw_candidate(5600.0, Some(true)), 1.0), - (raw_candidate(5000.0, Some(true)), 1.0), + fn the_custom_numbers_are_renormalised_over_exactly_the_chosen_examples() { + // A missing value must not count as 0: one 5600 K example among three + // equally weighted ones would otherwise come out near 1867 K. + let mut no_tint = raw_wb("Custom", 5600.0, 0.0); + no_tint.white_balance.as_mut().unwrap().tint = None; + let p = vec![ + (no_tint, 1.0), + (raw_wb("Custom", 5600.0, 12.0), 1.0), + (raw_wb("Custom", 5600.0, 12.0), 1.0), ]; - let warning = - restrict_temperature_to_matching_raw_status(&mut blended, &winners, Some(true)); + let wb = transferred(blend_white_balance(&p, Some(FileKind::Raw))); + assert_eq!(wb.temperature.map(Finite::get), Some(5600.0)); + assert_eq!(wb.tint.map(Finite::get), Some(12.0)); + } - assert!(warning.is_none(), "nothing to report when nothing changed"); - assert_eq!(blended["temperature"], json!(5300.0)); + #[test] + fn a_single_custom_example_is_not_enough() { + let p = vec![ + (raw_wb("Custom", 5600.0, 10.0), 0.9), + (raw_wb("As Shot", 5000.0, 0.0), 0.2), + ]; + let w = not_transferred(blend_white_balance(&p, Some(FileKind::Raw))); + assert!(w.contains("only 1"), "{w}"); } #[test] - fn a_mixed_pool_blends_only_the_matching_examples() { - // Averaging 5600 K with a relative +10 produces a number that is - // meaningless on either scale, and it lands on a real photo. - let winners = vec![ - (raw_candidate(5600.0, Some(true)), 1.0), - (raw_candidate(10.0, Some(false)), 1.0), + fn disagreeing_custom_examples_transfer_nothing_and_say_so() { + let p = vec![ + (raw_wb("Custom", 5000.0, 0.0), 0.9), + (raw_wb("Custom", 5600.0, 0.0), 0.8), ]; - let mut blended = interpolate_recipes(&winners); - assert_eq!(blended["temperature"], json!(2805.0), "the bad average"); + let w = not_transferred(blend_white_balance(&p, Some(FileKind::Raw))); + assert!(w.contains("disagree"), "{w}"); + assert!(w.starts_with("White balance was not transferred:"), "{w}"); + assert!(w.contains("more than 500 K apart"), "{w}"); - let warning = - restrict_temperature_to_matching_raw_status(&mut blended, &winners, Some(true)); - assert_eq!(blended["temperature"], json!(5600.0)); - let warning = warning.expect("the user has to be told the pool was narrowed"); - assert!(warning.contains("1 of 2"), "got {warning:?}"); + // The text names the limit, not the measured spread, so two photos + // whose pools disagree by different amounts merge into one line. + let wider = vec![ + (raw_wb("Custom", 4000.0, 0.0), 0.9), + (raw_wb("Custom", 6500.0, 0.0), 0.8), + ]; + let w2 = not_transferred(blend_white_balance(&wider, Some(FileKind::Raw))); + assert_eq!(w, w2); + + // 500 K apart is still one habit. + let p = vec![ + (raw_wb("Custom", 5000.0, 0.0), 0.9), + (raw_wb("Custom", 5500.0, 0.0), 0.8), + ]; + assert!(matches!( + blend_white_balance(&p, Some(FileKind::Raw)), + WbOutcome::Transfer(_) + )); } #[test] - fn temperature_is_dropped_when_no_example_matches() { - let winners = vec![(raw_candidate(5600.0, Some(true)), 1.0)]; - let mut blended = interpolate_recipes(&winners); - let warning = - restrict_temperature_to_matching_raw_status(&mut blended, &winners, Some(false)); + fn an_as_shot_majority_keeps_the_photos_own_white_balance() { + let p = vec![ + (raw_wb("Custom", 5600.0, 10.0), 0.9), + (raw_wb("Custom", 5500.0, 10.0), 0.8), + (raw_wb("As Shot", 5000.0, 0.0), 0.7), + (raw_wb("As Shot", 5100.0, 0.0), 0.6), + (raw_wb("As Shot", 5200.0, 0.0), 0.5), + ]; + // 1.7 Custom vs 1.8 As Shot + assert_eq!( + blend_white_balance(&p, Some(FileKind::Raw)), + WbOutcome::Unchanged + ); + } + #[test] + fn the_vote_counts_the_whole_pool_not_only_the_blend_winners() { + // The three best are Custom, but the pool as a whole shoots As Shot. + let mut items = vec![ + (raw_wb("Custom", 5600.0, 10.0), 0.9), + (raw_wb("Custom", 5500.0, 10.0), 0.9), + (raw_wb("Custom", 5550.0, 10.0), 0.9), + ]; + for _ in 0..6 { + items.push((raw_wb("As Shot", 5000.0, 0.0), 0.5)); + } + assert_eq!( + blend_white_balance(&items, Some(FileKind::Raw)), + WbOutcome::Unchanged + ); + } + + #[test] + fn a_named_mode_majority_is_not_sent_while_the_switch_is_off() { + const { assert!(!EMIT_NAMED_WB_MODES, "turn on only after experiment E1") }; + let p = vec![ + (raw_wb("Daylight", 5500.0, 10.0), 0.9), + (raw_wb("Daylight", 5500.0, 10.0), 0.8), + (raw_wb("Custom", 5600.0, 10.0), 0.2), + ]; + let w = not_transferred(blend_white_balance(&p, Some(FileKind::Raw))); + assert!(w.contains("\"Daylight\""), "{w}"); + let p = vec![(raw_wb("Auto", 5500.0, 10.0), 0.9)]; + assert!(not_transferred(blend_white_balance(&p, Some(FileKind::Raw))).contains("Auto")); + } + + /// A non-raw target never gets a mode alone, and the refusal comes before + /// the switch, so turning it on after a raw E1e cannot change this + /// (E1f/E1g: `Auto` alone kept a non-raw photo's old numbers). + #[test] + fn a_non_raw_photo_gets_no_mode_alone_whatever_the_switch() { + for mode in ["Auto", "Daylight"] { + let p = vec![ + (raw_wb(mode, 5500.0, 10.0), 0.9), + (non_raw_wb(mode, 0.0, 0.0), 0.8), + ]; + let w = not_transferred(blend_white_balance(&p, Some(FileKind::NonRaw))); + assert!(w.contains(&format!("\"{mode}\"")), "{w}"); + assert!(!w.contains("does not apply yet"), "{mode}: {w}"); + } + } + + #[test] + fn raw_examples_give_no_white_balance_to_a_jpeg() { + let p = vec![ + (raw_wb("Custom", 5600.0, 10.0), 0.9), + (raw_wb("Custom", 5500.0, 10.0), 0.8), + ]; + let w = not_transferred(blend_white_balance(&p, Some(FileKind::NonRaw))); assert!( - blended.get("temperature").is_none(), - "no white balance beats a wrong-scale one" + w.contains("your training examples are raw files and this photo is not"), + "{w}" + ); + } + + #[test] + fn non_raw_examples_transfer_offsets_to_non_raw_targets_only() { + let p = vec![ + (non_raw_wb("Custom", 12.0, -4.0), 0.9), + (non_raw_wb("Custom", 8.0, -2.0), 0.9), + ]; + let wb = transferred(blend_white_balance(&p, Some(FileKind::NonRaw))); + assert_eq!(wb.family, WbFamily::NonRaw); + assert_eq!(wb.temperature.map(Finite::get), Some(10.0)); + assert_eq!(wb.tint.map(Finite::get), Some(-3.0)); + let w = not_transferred(blend_white_balance(&p, Some(FileKind::Raw))); + assert!(w.contains("non-raw"), "{w}"); + } + + #[test] + fn an_unknown_target_gets_no_white_balance() { + let p = vec![ + (raw_wb("Custom", 5600.0, 10.0), 0.9), + (raw_wb("Custom", 5500.0, 10.0), 0.8), + ]; + let w = not_transferred(blend_white_balance(&p, None)); + assert!(w.contains("not known"), "{w}"); + } + + #[test] + fn examples_without_a_white_balance_do_not_vote() { + let p = vec![ + (from_blob(json!({"Exposure2012": 0.5}), 0.0), 5.0), + (from_blob(json!([]), 0.0), 5.0), + ]; + assert_eq!( + blend_white_balance(&p, Some(FileKind::Raw)), + WbOutcome::Unchanged + ); + } + + #[test] + fn the_style_recipe_carries_white_balance_at_recipe_level() { + let query = StyleQuery { + is_raw: Some(true), + ..Default::default() + }; + let cands = vec![ + raw_wb("Custom", 5600.0, 10.0), + raw_wb("Custom", 5400.0, 4.0), + raw_wb("Custom", 5500.0, 6.0), + ]; + let result = generate_style_edit(5, &cands, &query); + assert_eq!( + result.recipe["white_balance"], + json!({"mode": "Custom", "family": "raw", "temperature": 5500, "tint": 7}) + ); + let global = result.recipe["global"].as_object().unwrap(); + for key in ["temperature", "tint", "white_balance"] { + assert!(!global.contains_key(key), "{key} must not be in global"); + } + } + + #[test] + fn a_jpeg_target_gets_the_white_balance_warning_next_to_the_confidence_one() { + let query = StyleQuery { + is_raw: Some(false), + ..Default::default() + }; + let cands: Vec = [5600.0, 5500.0, 5550.0] + .iter() + .map(|&k| { + let mut c = raw_wb("Custom", k, 5.0); + c.distance = 1.0; + c + }) + .collect(); + let result = generate_style_edit(5, &cands, &query); + assert!(result.recipe.get("white_balance").is_none()); + assert_eq!(result.warnings.len(), 2, "{:?}", result.warnings); + assert!(result.warnings[0].contains("confidence")); + assert!(!result.warnings[0].contains('%'), "{}", result.warnings[0]); + assert!(result.warnings[1].contains("raw files and this photo is not")); + } + + /// Shadow toning of one example (hue in degrees, saturation 0..100). + fn toned(hue: f64, sat: f64) -> TrainingCandidate { + from_blob( + json!({ + "ProcessVersion": "15.4", + "SplitToningShadowHue": hue, + "SplitToningShadowSaturation": sat, + }), + 0.0, + ) + } + + #[test] + fn every_field_the_installed_plugin_applies_is_blended_and_nested() { + let blob = json!({ + "ProcessVersion": "15.4", + "SharpenRadius": 1.26, + "SharpenDetail": 30, + "SharpenEdgeMasking": 12, + "LuminanceNoiseReductionDetail": 55, + "LuminanceNoiseReductionContrast": 5, + "ColorNoiseReductionDetail": 50, + "ColorNoiseReductionSmoothness": 60, + "PostCropVignetteMidpoint": 40, + "PostCropVignetteRoundness": -10, + "PostCropVignetteFeather": 70, + "PostCropVignetteHighlightContrast": 20, + "GrainSize": 25, + "GrainFrequency": 50, + "HueAdjustmentAqua": -8, + "SaturationAdjustmentBlue": -20, + "LuminanceAdjustmentOrange": 12, + "SplitToningShadowHue": 220, + "SplitToningShadowSaturation": 15, + "SplitToningHighlightHue": 45, + "SplitToningHighlightSaturation": 20, + "SplitToningBalance": -10, + "ParametricShadowSplit": 20, + "ParametricMidtoneSplit": 45, + "ParametricHighlightSplit": 70, + // Not sent: the installed plugin warns about and drops them. + "ColorGradeMidtoneHue": 100, + "ColorGradeMidtoneSat": 10, + "ColorGradeBlending": 60, + "ColorGradeShadowLum": 5, + }); + let blend = interpolate_recipes(&[(from_blob(blob, 0.0), 1.0)]); + assert!(blend.warnings.is_empty(), "{:?}", blend.warnings); + let recipe = canonical_to_edit_recipe(&blend.settings, "", None); + let global = &recipe["global"]; + assert_eq!(global["sharpen_radius"], json!(1.3), "one decimal"); + for (field, v) in [ + ("sharpen_detail", 30), + ("sharpen_masking", 12), + ("noise_reduction_detail", 55), + ("noise_reduction_contrast", 5), + ("color_noise_reduction_detail", 50), + ("color_noise_reduction_smoothness", 60), + ("vignette_midpoint", 40), + ("vignette_roundness", -10), + ("vignette_feather", 70), + ("vignette_highlights", 20), + ("grain_size", 25), + ("grain_roughness", 50), + ] { + assert_eq!(global[field], json!(v), "{field}"); + } + assert_eq!(global["hsl"]["aqua"]["hue"], json!(-8)); + assert_eq!(global["hsl"]["blue"]["saturation"], json!(-20)); + assert_eq!(global["hsl"]["orange"]["luminance"], json!(12)); + assert_eq!( + global["color_grading"], + json!({ + "shadows": {"hue": 220, "saturation": 15}, + "highlights": {"hue": 45, "saturation": 20}, + "balance": -10, + }) + ); + assert_eq!( + global["tone_curve"], + json!({"shadow_split": 20, "midtone_split": 45, "highlight_split": 70}) ); - assert_eq!(blended["contrast"], json!(10.0), "other fields survive"); - assert!(warning.unwrap().contains("left unchanged")); } #[test] - fn examples_saved_before_the_flag_existed_still_count() { - // Excluding them would shrink every long-standing user's pool to - // nothing on the first edit after upgrading. + fn hsl_values_are_renormalised_per_key() { + // The second example's settings carry no colour mixer at all (older + // Lightroom tables can omit it); it must not pull red towards 0. + let winners = vec![ + ( + from_blob(json!({"HueAdjustmentRed": 20, "Contrast2012": 0}), 0.0), + 1.0, + ), + (from_blob(json!({"Contrast2012": 10}), 0.0), 3.0), + ]; + let blended = interpolate_recipes(&winners).settings; + assert_eq!(blended["hsl_red_hue"], json!(20)); + // HueAdjustment* is an offset on -100..100: a plain (linear) mean. let winners = vec![ - (raw_candidate(5600.0, None), 1.0), - (raw_candidate(5000.0, Some(true)), 1.0), + (from_blob(json!({"HueAdjustmentRed": -90}), 0.0), 1.0), + (from_blob(json!({"HueAdjustmentRed": 90}), 0.0), 1.0), ]; - let mut blended = interpolate_recipes(&winners); - let warning = - restrict_temperature_to_matching_raw_status(&mut blended, &winners, Some(true)); + assert_eq!( + interpolate_recipes(&winners).settings["hsl_red_hue"], + json!(0) + ); + } + + #[test] + fn split_toning_hues_average_on_the_circle() { + // 350 and 10 are 20 degrees apart around red, not 180 apart. + let blend = interpolate_recipes(&[(toned(350.0, 20.0), 1.0), (toned(10.0, 20.0), 1.0)]); + assert_eq!(blend.settings["color_grading_shadows_hue"], json!(0)); + assert_eq!( + blend.settings["color_grading_shadows_saturation"], + json!(20) + ); + assert!(blend.warnings.is_empty()); - assert!(warning.is_none()); - assert_eq!(blended["temperature"], json!(5300.0)); + // A mean just below 360 rounds to 360, which is written as 0. + let blend = interpolate_recipes(&[(toned(359.0, 40.0), 1.0), (toned(0.0, 60.0), 1.0)]); + assert_eq!(blend.settings["color_grading_shadows_hue"], json!(0)); } #[test] - fn an_unknown_target_accepts_every_example() { + fn split_toning_hues_are_weighted_by_score_times_saturation() { + // Equal scores: 30 x (1, 0) + 10 x (0, 1) -> atan(1/3) = 18.4 degrees. + let blend = interpolate_recipes(&[(toned(0.0, 30.0), 1.0), (toned(90.0, 10.0), 1.0)]); + assert_eq!(blend.settings["color_grading_shadows_hue"], json!(18)); + // Equal saturations: the score decides. 3 x (1, 0) + 1 x (0, 1). + let blend = interpolate_recipes(&[(toned(0.0, 20.0), 3.0), (toned(90.0, 20.0), 1.0)]); + assert_eq!(blend.settings["color_grading_shadows_hue"], json!(18)); + // A hue at saturation 0 is invisible: it does not pull at all. + let blend = interpolate_recipes(&[(toned(200.0, 25.0), 1.0), (toned(40.0, 0.0), 5.0)]); + assert_eq!(blend.settings["color_grading_shadows_hue"], json!(200)); + } + + #[test] + fn an_untoned_zone_sends_no_hue() { + // Lightroom keeps a hue around after the saturation went back to 0. + let blend = interpolate_recipes(&[(toned(218.0, 0.0), 1.0), (toned(33.0, 0.0), 1.0)]); + assert!(blend.settings.get("color_grading_shadows_hue").is_none()); + assert_eq!(blend.settings["color_grading_shadows_saturation"], json!(0)); + assert!(blend.warnings.is_empty()); + let recipe = canonical_to_edit_recipe(&blend.settings, "", None); + assert_eq!( + recipe["global"]["color_grading"]["shadows"], + json!({"saturation": 0}) + ); + } + + #[test] + fn opposite_toning_hues_cancel_to_neutral_and_say_so() { + let blend = interpolate_recipes(&[(toned(30.0, 20.0), 1.0), (toned(210.0, 20.0), 1.0)]); + assert!(blend.settings.get("color_grading_shadows_hue").is_none()); + assert_eq!(blend.settings["color_grading_shadows_saturation"], json!(0)); + assert_eq!(blend.warnings.len(), 1); + assert!( + blend.warnings[0].starts_with("Color grading of the shadows was toned down"), + "{:?}", + blend.warnings + ); + } + + /// Highlight toning of one example. + fn toned_highlights(hue: f64, sat: f64) -> TrainingCandidate { + from_blob( + json!({ + "ProcessVersion": "15.4", + "SplitToningHighlightHue": hue, + "SplitToningHighlightSaturation": sat, + }), + 0.0, + ) + } + + #[test] + fn disagreeing_tints_fade_towards_neutral_instead_of_a_third_colour() { + let sat_hue = |blend: &Blend, zone: &str| { + ( + blend.settings[&format!("color_grading_{zone}_saturation")].clone(), + blend + .settings + .get(&format!("color_grading_{zone}_hue")) + .cloned(), + ) + }; + // Orange and blue shadows: the midpoint (green) only at the length + // of the mean colour vector, and a note. + let blend = interpolate_recipes(&[(toned(30.0, 40.0), 1.0), (toned(190.0, 40.0), 1.0)]); + assert_eq!(sat_hue(&blend, "shadows"), (json!(7), Some(json!(110)))); + assert_eq!(blend.warnings.len(), 1, "{:?}", blend.warnings); + let blend = interpolate_recipes(&[(toned(47.0, 37.0), 1.0), (toned(216.0, 34.0), 1.0)]); + assert_eq!(sat_hue(&blend, "shadows"), (json!(4), Some(json!(108)))); + assert_eq!(blend.warnings.len(), 1); + let blend = interpolate_recipes(&[(toned(30.0, 20.0), 1.0), (toned(190.0, 20.0), 1.0)]); + assert_eq!(sat_hue(&blend, "shadows"), (json!(3), Some(json!(110)))); + assert_eq!(blend.warnings.len(), 1); + + let blend = interpolate_recipes(&[ + (toned_highlights(37.0, 20.0), 1.0), + (toned_highlights(213.0, 68.0), 1.0), + (toned_highlights(16.0, 55.0), 1.0), + ]); + assert_eq!( + blend.settings["color_grading_highlights_saturation"], + json!(5) + ); + assert!( + blend.warnings[0].starts_with("Color grading of the highlights was toned down"), + "{:?}", + blend.warnings + ); + + // Hues that agree keep the linear mean: untoned winners dilute, they + // do not disagree, so there is no note. + let blend = interpolate_recipes(&[ + (toned(40.0, 35.0), 1.0), + (toned(200.0, 0.0), 1.0), + (toned(90.0, 0.0), 1.0), + ]); + assert_eq!(sat_hue(&blend, "shadows"), (json!(12), Some(json!(40)))); + assert!(blend.warnings.is_empty(), "{:?}", blend.warnings); + } + + #[test] + fn the_curve_splits_are_blended_as_one_ordered_group() { + let splits = |s: i64, m: i64, h: i64| { + json!({ + "ParametricShadowSplit": s, + "ParametricMidtoneSplit": m, + "ParametricHighlightSplit": h, + }) + }; + let winners = vec![ + (from_blob(splits(10, 30, 50), 0.0), 1.0), + (from_blob(splits(40, 60, 90), 0.0), 1.0), + // Carries only one split: not part of the group's blend. + (from_blob(json!({"ParametricShadowSplit": 80}), 0.0), 5.0), + ]; + let blended = interpolate_recipes(&winners).settings; + assert_eq!(blended["tone_curve_shadow_split"], json!(25)); + assert_eq!(blended["tone_curve_midtone_split"], json!(45)); + assert_eq!(blended["tone_curve_highlight_split"], json!(70)); + + // Rounding half to even can collapse neighbours (25.5 and 26.5 both + // become 26): the group is then left out rather than sent unordered, + // and the blend says so. let winners = vec![ - (raw_candidate(5600.0, Some(true)), 1.0), - (raw_candidate(5000.0, Some(false)), 1.0), + (from_blob(splits(25, 26, 70), 0.0), 1.0), + (from_blob(splits(26, 27, 71), 0.0), 1.0), ]; - let mut blended = interpolate_recipes(&winners); - let warning = restrict_temperature_to_matching_raw_status(&mut blended, &winners, None); + let blend = interpolate_recipes(&winners); + for key in [ + "tone_curve_shadow_split", + "tone_curve_midtone_split", + "tone_curve_highlight_split", + ] { + assert!(blend.settings.get(key).is_none(), "{key}: {blend:?}"); + } + assert_eq!(blend.warnings, [SPLITS_WARNING]); + } + + #[test] + fn a_toning_note_reaches_the_style_result() { + let result = generate_style_edit( + 5, + &[toned(30.0, 20.0), toned(210.0, 20.0)], + &StyleQuery::default(), + ); assert!( - warning.is_none(), - "nothing is known, so nothing is excluded" + result + .warnings + .iter() + .any(|w| w.starts_with("Color grading of the shadows")), + "{:?}", + result.warnings ); } + + #[test] + fn confidence_notes_are_constant_per_band() { + // Same band, different scores: identical text, so the plugin's + // per-text tally merges them into one "(N photos)" line. + assert_eq!(confidence_warning(0.62), confidence_warning(0.50)); + assert_eq!(confidence_warning(0.30), confidence_warning(0.10)); + assert_ne!(confidence_warning(0.62), confidence_warning(0.30)); + for c in [0.10, 0.30, 0.50, 0.62] { + let w = confidence_warning(c).unwrap(); + assert!(!w.contains('%'), "{w}"); + assert!(!w.chars().any(|ch| ch.is_ascii_digit()), "{w}"); + } + assert_eq!(confidence_warning(CONFIDENCE_GOOD), None); + } + + #[test] + fn the_style_white_balance_moves_into_global_or_goes() { + let style = json!({"global": {"exposure": 0.2}, + "white_balance": {"mode": "Custom", "family": "raw", + "temperature": 5400, "tint": 8}}); + let (recipe, notes) = white_balance_into_global(style.clone(), Some(FileKind::Raw)); + assert!(notes.is_empty()); + assert_eq!(recipe["global"]["temperature"], json!(5400.0)); + assert_eq!(recipe["global"]["tint"], json!(8.0)); + assert!(recipe.get("white_balance").is_none()); + + let (recipe, notes) = white_balance_into_global(style, Some(FileKind::NonRaw)); + assert_eq!(notes.len(), 1); + assert!(recipe["global"].get("temperature").is_none()); + + let (_, notes) = white_balance_into_global(json!({"global": {}}), None); + assert!(notes.is_empty(), "no white balance, nothing to say"); + } } diff --git a/server-rs/crates/lrg-analysis/src/training.rs b/server-rs/crates/lrg-analysis/src/training.rs index b3045c4f..93b90caf 100644 --- a/server-rs/crates/lrg-analysis/src/training.rs +++ b/server-rs/crates/lrg-analysis/src/training.rs @@ -4,6 +4,11 @@ //! style-profile stats aggregation. I/O (the `edit_training` LanceDB //! table, CLIP embedding) lives in `lrg-api::routes::training`. +use std::sync::LazyLock; + +use lrg_develop::model::{WbFamily, WbMode, WbSetting}; +use lrg_develop::registry::{self, round_half_even, KeySpec, Level, NumFmt}; +use lrg_develop::{FileKind, FileKindHint, Finite, ParseError, ParseWarning, WarningKind}; use lrg_imaging::pil_resample::{resize_plane, Filter}; use serde_json::{json, Map, Value}; @@ -69,45 +74,237 @@ pub fn time_of_day_bucket_for_hour(hour: Option) -> &'static str { } } -const LR_TO_CANONICAL: &[(&str, &str)] = &[ - ("Exposure2012", "exposure"), - ("Contrast2012", "contrast"), - ("Highlights2012", "highlights"), - ("Shadows2012", "shadows"), - ("Whites2012", "whites"), - ("Blacks2012", "blacks"), - ("Temp", "temperature"), - ("Tint", "tint"), - ("Texture", "texture"), - ("Clarity2012", "clarity"), - ("Dehaze", "dehaze"), - ("Vibrance", "vibrance"), - ("Saturation", "saturation"), - ("Sharpness", "sharpening"), - ("LuminanceSmoothing", "noise_reduction"), - ("ColorNoiseReduction", "color_noise_reduction"), - ("PostCropVignetteAmount", "vignette"), - ("GrainAmount", "grain"), - ("ParametricHighlights", "tone_curve_highlights"), - ("ParametricLights", "tone_curve_lights"), - ("ParametricDarks", "tone_curve_darks"), - ("ParametricShadows", "tone_curve_shadows"), -]; +/// Version of the canonical form stored with each training example +/// (`canonical_settings`, `white_balance`), written as `canonical_version`. +/// +/// Rows without the field are version 1: the old alias table, which read a +/// `Temp` key Lightroom never writes (so no example ever carried a white +/// balance) and blended `Tint` without its file kind. Version 2 had the +/// typed white balance but only the 20 basic aliases; version 3 adds every +/// other field the installed plugin applies (detail, vignette and grain +/// sliders, HSL, colour grading shadows/highlights/balance, the parametric +/// curve splits). Readers re-derive anything older than this from the stored +/// `develop_settings` blob instead of trusting the frozen fields; bump it +/// whenever [`canonical_keys`] or the white-balance extraction changes. +pub const CANONICAL_VERSION: u64 = 3; + +/// One learnable global key: the canonical name the style engine blends +/// under, where it goes in the edit recipe, and its registry row. +#[derive(Debug, Clone, Copy)] +pub struct CanonicalKey { + /// `"exposure"`, `"tone_curve_shadows"`: the recipe alias without the + /// `global.` prefix, dots as underscores (the names version 1 used). + pub name: &'static str, + /// The recipe path below `global` (`["tone_curve", "shadows"]`). + pub recipe_path: &'static [&'static str], + /// The registry row (range, value kind and number format decide the + /// blend's rounding). + pub spec: &'static KeySpec, +} + +/// Every registry key with a `global.*` recipe alias, in registry order. +/// +/// The registry is the only source: white balance has no alias on purpose +/// (it is a mode plus a key family, not two numbers, see +/// [`canonicalize_develop_settings`]). +pub fn canonical_keys() -> &'static [CanonicalKey] { + static KEYS: LazyLock> = LazyLock::new(|| { + registry::iter() + .filter(|(_, spec)| spec.level == Level::Global) + .filter_map(|(_, spec)| { + let path = spec.recipe_alias?.strip_prefix("global.")?; + let recipe_path: Vec<&'static str> = path.split('.').collect(); + Some(CanonicalKey { + name: Box::leak(path.replace('.', "_").into_boxed_str()), + recipe_path: Box::leak(recipe_path.into_boxed_slice()), + spec, + }) + }) + .collect() + }); + &KEYS +} + +/// Looks up a canonical key by its name. +pub fn canonical_key(name: &str) -> Option<&'static CanonicalKey> { + canonical_keys().iter().find(|k| k.name == name) +} + +/// A training example's develop settings in the form the style engine +/// learns from. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct CanonicalExample { + /// Canonical name → value in the key's stored unit (see + /// [`canonical_keys`]). + pub settings: Map, + /// The example's white balance, typed: mode, key family, numbers. + pub white_balance: Option, + /// Raw or not, from the white-balance key family (a caller's `is_raw` + /// hint only where the settings carry no white-balance numbers). + pub file_kind: Option, + /// Everything the reader noticed, in reading order. + pub warnings: Vec, +} -/// Port of `normalize_develop_settings_for_style`. -pub fn normalize_develop_settings_for_style( - develop_settings: &Map, -) -> Map { +/// Reads a `develop_settings` blob (the plugin's JSON.lua encoding of +/// `photo:getDevelopSettings()`, `[]` for none) into the canonical form. +/// +/// Settings older than PV2012 (`"6.7"`) are read but not learned: they come +/// back empty, with the reader's `UnsupportedProcessVersion` warning. +pub fn canonicalize_develop_settings( + develop_settings: &Value, + hint: FileKindHint, +) -> Result { + let (settings, warnings) = lrg_develop::lua::from_lua_value(develop_settings, hint)?; + let file_kind = settings.file_kind; + let learnable = !warnings + .iter() + .any(|w| matches!(w.kind, WarningKind::UnsupportedProcessVersion { .. })); + if !learnable { + return Ok(CanonicalExample { + file_kind, + warnings, + ..CanonicalExample::default() + }); + } + // The recipe never carries ConvertToGrayscale, so a black-and-white + // example's colour keys would land on a colour photo as a cast (a sepia + // split toning, most visibly). They are not learned from such an example + // until the style engine can keep B&W and colour apart (Gate::Categorical). + // Silent by design: nothing is wrong with the example, and the user has + // nothing to act on; Help-AI-Edit says toning is not taken from B&W. + let monochrome = registry::lookup(Level::Global, "ConvertToGrayscale") + .and_then(|id| settings.get(id)) + .and_then(|v| v.as_bool()) + .unwrap_or(false); let mut canonical = Map::new(); - for &(lr_key, canon_key) in LR_TO_CANONICAL { - if let Some(raw) = develop_settings.get(lr_key).and_then(Value::as_f64) { - canonical.insert( - canon_key.to_string(), - json!((raw * 10000.0).round() / 10000.0), - ); + for key in canonical_keys() { + if monochrome && (key.name.starts_with("color_grading_") || key.name.starts_with("hsl_")) { + continue; } + let Some(id) = registry::lookup(Level::Global, key.spec.name) else { + continue; + }; + let Some(value) = settings.get(id).and_then(|v| v.as_finite()) else { + continue; + }; + canonical.insert(key.name.to_string(), number_json(key.spec, value.get())); + } + // Only Custom is a choice of numbers. As Shot, Auto and the named presets + // carry the Kelvin Lightroom resolved for *that* frame; keeping it would + // invite averaging one scene's light into another. + // + // With both key families present the family is ambiguous: the reader + // falls back to the caller's hint, but that hint (the plugin's own + // guess from the same keys) is no independent evidence, so a Kelvin + // could land on the offset scale or the other way round. Nothing is + // learned then, which is also what the training route tells the user. + let ambiguous_family = warnings + .iter() + .any(|w| matches!(w.kind, WarningKind::ConflictingFileKind)); + let white_balance = settings + .wb() + .filter(|_| !ambiguous_family) + .map(|wb| match wb.mode { + WbMode::Custom => wb, + _ => WbSetting { + temperature: None, + tint: None, + ..wb + }, + }); + Ok(CanonicalExample { + settings: canonical, + white_balance, + file_kind, + warnings, + }) +} + +/// [`canonicalize_develop_settings`] on the stored text form. +pub fn canonicalize_develop_settings_str( + develop_settings: &str, + hint: FileKindHint, +) -> Result { + let max = lrg_develop::lua::MAX_LUA_JSON_BYTES; + if develop_settings.len() > max { + return Err(ParseError::TooLarge { + size: develop_settings.len(), + max, + }); } - canonical + let value: Value = serde_json::from_str(develop_settings)?; + canonicalize_develop_settings(&value, hint) +} + +/// Port of `normalize_develop_settings_for_style`: the canonical settings +/// of a blob, or none when it cannot be read. +pub fn normalize_develop_settings_for_style(develop_settings: &Value) -> Map { + canonicalize_develop_settings(develop_settings, FileKindHint::Unknown) + .map(|c| c.settings) + .unwrap_or_default() +} + +/// A number as JSON in the precision of `spec`: an integer for integer keys, +/// otherwise rounded to the key's XMP decimals (4 when it has none). +pub fn number_json(spec: &KeySpec, x: f64) -> Value { + if spec.kind.lua_form().is_integer() { + if let Some(i) = Finite::new(x).ok().and_then(round_half_even) { + return json!(i); + } + } + let decimals = match spec.fmt { + NumFmt::Fixed(d) => i32::from(d), + _ => 4, + }; + let factor = 10f64.powi(decimals); + json!((x * factor).round() / factor) +} + +/// A white balance as JSON: `{mode, family, temperature?, tint?}` with +/// `family` `"raw"` or `"non_raw"` and the numbers in the family's own keys' +/// precision. The same shape is stored with each training example and sent +/// as the style recipe's `white_balance`. +pub fn white_balance_json(wb: &WbSetting) -> Value { + let mut out = Map::new(); + out.insert("mode".into(), json!(wb.mode.as_str())); + out.insert( + "family".into(), + json!(match wb.family { + WbFamily::Raw => "raw", + WbFamily::NonRaw => "non_raw", + }), + ); + let spec = |name| registry::lookup(Level::Global, name).map(|id| id.spec()); + if let (Some(t), Some(spec)) = (wb.temperature, spec(wb.family.temperature_key())) { + out.insert("temperature".into(), number_json(spec, t.get())); + } + if let (Some(t), Some(spec)) = (wb.tint, spec(wb.family.tint_key())) { + out.insert("tint".into(), number_json(spec, t.get())); + } + Value::Object(out) +} + +/// Reads [`white_balance_json`] back; `None` for anything else. +pub fn white_balance_from_json(value: &Value) -> Option { + let mode = WbMode::parse(value.get("mode")?.as_str()?)?; + let family = match value.get("family")?.as_str()? { + "raw" => WbFamily::Raw, + "non_raw" => WbFamily::NonRaw, + _ => return None, + }; + let number = |k: &str| { + value + .get(k) + .and_then(Value::as_f64) + .and_then(|x| Finite::new(x).ok()) + }; + Some(WbSetting { + mode, + family, + temperature: number("temperature"), + tint: number("tint"), + }) } fn round4(x: f64) -> f64 { @@ -457,16 +654,230 @@ mod tests { assert_eq!(time_of_day_bucket_for_hour(Some(23)), "night"); } + fn fixture(name: &str) -> Value { + let path = format!( + "{}/../../testdata/develop/lua/{name}", + env!("CARGO_MANIFEST_DIR") + ); + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap() + } + #[test] - fn normalize_develop_settings_maps_known_keys_only() { - let mut dev = Map::new(); - dev.insert("Exposure2012".into(), json!(0.333333)); - dev.insert("UnknownKey".into(), json!(42)); - dev.insert("Temp".into(), json!(5600)); + fn normalize_develop_settings_maps_registry_aliases_only() { + let dev = json!({ + "Exposure2012": 0.333333, + "Contrast2012": 12, + "ParametricShadows": -8, + "UnknownKey": 42, + "WhiteBalance": "Custom", + "Temperature": 5600, + "Tint": 5, + }); let canonical = normalize_develop_settings_for_style(&dev); - assert_eq!(canonical["exposure"], json!(0.3333)); - assert_eq!(canonical["temperature"], json!(5600.0)); + assert_eq!( + canonical["exposure"], + json!(0.33), + "Exposure2012 has 2 decimals" + ); + assert_eq!(canonical["contrast"], json!(12)); + assert_eq!(canonical["tone_curve_shadows"], json!(-8)); assert!(canonical.get("UnknownKey").is_none()); + assert!( + canonical.get("temperature").is_none() && canonical.get("tint").is_none(), + "white balance is typed, never a canonical number: {canonical:?}" + ); + } + + #[test] + fn canonical_keys_come_from_the_registry_aliases() { + let names: Vec<&str> = canonical_keys().iter().map(|k| k.name).collect(); + assert!(names.contains(&"exposure")); + assert!(names.contains(&"tone_curve_highlights")); + assert!(!names.iter().any(|n| n.contains("temp") || *n == "tint")); + let tc = canonical_key("tone_curve_lights").unwrap(); + assert_eq!(tc.recipe_path, ["tone_curve", "lights"]); + assert_eq!(tc.spec.name, "ParametricLights"); + let hsl = canonical_key("hsl_magenta_luminance").unwrap(); + assert_eq!(hsl.recipe_path, ["hsl", "magenta", "luminance"]); + assert_eq!(hsl.spec.name, "LuminanceAdjustmentMagenta"); + let cg = canonical_key("color_grading_balance").unwrap(); + assert_eq!(cg.recipe_path, ["color_grading", "balance"]); + assert_eq!( + canonical_key("grain_roughness").unwrap().spec.name, + "GrainFrequency" + ); + } + + #[test] + fn a_custom_raw_example_carries_its_white_balance() { + let c = + canonicalize_develop_settings(&fixture("lensblur_object.json"), FileKindHint::Unknown) + .unwrap(); + let wb = c.white_balance.expect("Custom with Temperature/Tint"); + assert_eq!(wb.mode, WbMode::Custom); + assert_eq!(wb.family, WbFamily::Raw); + assert_eq!(wb.temperature.map(Finite::get), Some(5432.0)); + assert_eq!(wb.tint.map(Finite::get), Some(23.0)); + assert_eq!(c.file_kind, Some(FileKind::Raw)); + assert!(c.warnings.is_empty(), "{:?}", c.warnings); + assert_eq!(c.settings["exposure"], json!(0.15)); + } + + #[test] + fn an_as_shot_example_keeps_the_mode_without_numbers() { + // As Shot's Kelvin is the camera's reading for that frame, not a + // choice the photographer made. + let c = + canonicalize_develop_settings(&fixture("filterlist_filters.json"), FileKindHint::Raw) + .unwrap(); + let wb = c.white_balance.unwrap(); + assert_eq!((wb.mode, wb.family), (WbMode::AsShot, WbFamily::Raw)); + assert_eq!((wb.temperature, wb.tint), (None, None)); + } + + #[test] + fn incremental_keys_make_a_non_raw_example() { + let c = canonicalize_develop_settings( + &fixture("hand_non_raw_white_balance.json"), + FileKindHint::Raw, + ) + .unwrap(); + let wb = c.white_balance.unwrap(); + assert_eq!(wb.family, WbFamily::NonRaw); + assert_eq!(wb.temperature.map(Finite::get), Some(12.0)); + assert_eq!(wb.tint.map(Finite::get), Some(-4.0)); + assert_eq!( + c.file_kind, + Some(FileKind::NonRaw), + "the keys beat the hint" + ); + let kinds: Vec<&str> = c.warnings.iter().map(|w| w.kind.name()).collect(); + assert_eq!(kinds, ["FileKindMismatch"]); + } + + #[test] + fn both_key_families_teach_no_white_balance_whatever_the_hint() { + for hint in [ + FileKindHint::Raw, + FileKindHint::NonRaw, + FileKindHint::Unknown, + ] { + let c = canonicalize_develop_settings(&fixture("hand_wb_both_families.json"), hint) + .unwrap(); + assert_eq!(c.white_balance, None, "hint {hint:?}"); + assert!( + c.warnings + .iter() + .any(|w| matches!(w.kind, WarningKind::ConflictingFileKind)), + "hint {hint:?}: {:?}", + c.warnings + ); + } + // The file kind still follows the hint; only white balance is dropped. + let c = canonicalize_develop_settings( + &fixture("hand_wb_both_families.json"), + FileKindHint::Raw, + ) + .unwrap(); + assert_eq!(c.file_kind, Some(FileKind::Raw)); + } + + #[test] + fn a_black_and_white_example_teaches_no_toning_or_colour_mixer() { + let blob = |grayscale: bool| { + json!({ + "ProcessVersion": "15.4", + "ConvertToGrayscale": grayscale, + "Contrast2012": 20, + "SplitToningShadowHue": 233, + "SplitToningShadowSaturation": 43, + "SplitToningHighlightHue": 60, + "SplitToningHighlightSaturation": 27, + "SplitToningBalance": 10, + "HueAdjustmentRed": 5, + }) + }; + let c = canonicalize_develop_settings(&blob(true), FileKindHint::Raw).unwrap(); + assert_eq!(c.settings.get("contrast"), Some(&json!(20))); + let leaked: Vec<&String> = c + .settings + .keys() + .filter(|k| k.starts_with("color_grading_") || k.starts_with("hsl_")) + .collect(); + assert!(leaked.is_empty(), "{leaked:?}"); + // The same settings in colour are learned in full. + let c = canonicalize_develop_settings(&blob(false), FileKindHint::Raw).unwrap(); + assert_eq!( + c.settings.get("color_grading_shadows_hue"), + Some(&json!(233)) + ); + assert_eq!(c.settings.get("color_grading_balance"), Some(&json!(10))); + assert_eq!(c.settings.get("hsl_red_hue"), Some(&json!(5))); + } + + #[test] + fn an_empty_blob_is_empty_without_an_error() { + let c = canonicalize_develop_settings(&json!([]), FileKindHint::Raw).unwrap(); + assert!(c.settings.is_empty()); + assert_eq!(c.white_balance, None); + assert_eq!(c.file_kind, Some(FileKind::Raw), "only the hint is left"); + assert!(c.warnings.is_empty()); + let c = canonicalize_develop_settings_str("[]", FileKindHint::Unknown).unwrap(); + assert!(c.settings.is_empty()); + } + + #[test] + fn a_non_table_blob_is_an_error() { + assert!(canonicalize_develop_settings(&json!([1, 2]), FileKindHint::Unknown).is_err()); + assert!(canonicalize_develop_settings_str("not json", FileKindHint::Unknown).is_err()); + } + + #[test] + fn settings_older_than_pv2012_are_not_learned() { + let c = canonicalize_develop_settings(&fixture("hand_pv_2010.json"), FileKindHint::Unknown) + .unwrap(); + assert!(c.settings.is_empty()); + assert_eq!(c.white_balance, None); + assert!(c + .warnings + .iter() + .any(|w| matches!(w.kind, WarningKind::UnsupportedProcessVersion { .. }))); + } + + #[test] + fn numbers_take_their_keys_precision() { + let spec = |n| registry::lookup(Level::Global, n).unwrap().spec(); + assert_eq!( + number_json(spec("Contrast2012"), 12.5), + json!(12), + "half to even" + ); + assert_eq!(number_json(spec("Contrast2012"), 13.5), json!(14)); + assert_eq!(number_json(spec("Exposure2012"), 0.666666), json!(0.67)); + assert_eq!(number_json(spec("Temperature"), 5350.67), json!(5351)); + } + + #[test] + fn white_balance_json_round_trips() { + let wb = WbSetting { + mode: WbMode::Custom, + family: WbFamily::Raw, + temperature: Some(Finite::new_const(5600.0)), + tint: Some(Finite::new_const(-5.0)), + }; + let j = white_balance_json(&wb); + assert_eq!( + j, + json!({"mode": "Custom", "family": "raw", "temperature": 5600, "tint": -5}) + ); + assert_eq!(white_balance_from_json(&j), Some(wb)); + let auto = json!({"mode": "Auto", "family": "non_raw"}); + let wb = white_balance_from_json(&auto).unwrap(); + assert_eq!( + (wb.mode, wb.family, wb.temperature), + (WbMode::Auto, WbFamily::NonRaw, None) + ); + assert_eq!(white_balance_from_json(&json!({"mode": "Custom"})), None); } #[test] diff --git a/server-rs/crates/lrg-analysis/tests/style_fixtures.rs b/server-rs/crates/lrg-analysis/tests/style_fixtures.rs new file mode 100644 index 00000000..49672233 --- /dev/null +++ b/server-rs/crates/lrg-analysis/tests/style_fixtures.rs @@ -0,0 +1,229 @@ +//! The style blend over the real `getDevelopSettings()` fixtures in +//! `testdata/develop/lua/`: every generated fixture is canonicalised the way +//! the training route stores it, blended with equal weight, and turned into +//! the recipe the installed plugin reads. +//! +//! Checks that the fields added in step 1f (detail, vignette and grain +//! sliders, HSL, colour grading shadows/highlights/balance, the parametric +//! curve splits) come out of real data, not only out of hand-built maps, and +//! that keys some fixtures lack are renormalised over the fixtures that have +//! them. + +use std::path::PathBuf; + +use lrg_analysis::style_engine::{ + canonical_to_edit_recipe, interpolate_recipes, TrainingCandidate, +}; +use lrg_analysis::training::{canonical_key, canonicalize_develop_settings}; +use lrg_develop::FileKindHint; +use serde_json::Value; + +fn lua_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../testdata/develop/lua") +} + +/// The generated fixtures (real rows, scrubbed): `(name, blob)`, sorted. +fn generated_fixtures() -> Vec<(String, Value)> { + let manifest: Value = serde_json::from_str( + &std::fs::read_to_string(lua_dir().join("manifest.json")).expect("manifest"), + ) + .expect("manifest is JSON"); + let mut out: Vec<(String, Value)> = manifest["files"] + .as_array() + .expect("manifest files") + .iter() + .map(|f| { + let name = f["file"].as_str().unwrap().to_string(); + let text = std::fs::read_to_string(lua_dir().join(&name)).unwrap(); + (name, serde_json::from_str(&text).unwrap()) + }) + .collect(); + out.sort_by(|a, b| a.0.cmp(&b.0)); + out +} + +fn candidates() -> Vec<(TrainingCandidate, f64)> { + generated_fixtures() + .into_iter() + .map(|(name, blob)| { + let c = canonicalize_develop_settings(&blob, FileKindHint::Raw) + .unwrap_or_else(|e| panic!("{name}: {e}")); + let candidate = TrainingCandidate { + photo_id: name, + canonical_settings: c.settings, + white_balance: c.white_balance, + ..Default::default() + }; + (candidate, 1.0) + }) + .collect() +} + +#[test] +fn real_blobs_carry_the_new_canonical_keys() { + let pool = candidates(); + assert!(pool.len() >= 10, "only {} generated fixtures", pool.len()); + for name in [ + "sharpen_radius", + "sharpen_detail", + "noise_reduction_detail", + "color_noise_reduction_smoothness", + "vignette_midpoint", + "vignette_feather", + "grain_size", + "grain_roughness", + "hsl_blue_saturation", + "color_grading_shadows_saturation", + "color_grading_balance", + "tone_curve_shadow_split", + "tone_curve_highlight_split", + ] { + let with = pool + .iter() + .filter(|(c, _)| c.canonical_settings.contains_key(name)) + .count(); + assert!(with > 0, "no fixture yields {name}"); + } + // Colour grading midtones/global/blending never become canonical: the + // installed plugin would warn about them. + for (c, _) in &pool { + assert!( + !c.canonical_settings + .keys() + .any(|k| k.contains("midtone_hue") || k.contains("blending")), + "{}: {:?}", + c.photo_id, + c.canonical_settings.keys().collect::>() + ); + } +} + +#[test] +fn blending_real_blobs_gives_the_recipe_the_installed_plugin_reads() { + let pool = candidates(); + let blend = interpolate_recipes(&pool); + let recipe = canonical_to_edit_recipe(&blend.settings, "", None); + let global = &recipe["global"]; + + for field in [ + "sharpen_radius", + "sharpen_detail", + "sharpen_masking", + "noise_reduction_detail", + "noise_reduction_contrast", + "color_noise_reduction_detail", + "color_noise_reduction_smoothness", + "vignette_midpoint", + "vignette_roundness", + "vignette_feather", + "vignette_highlights", + "grain_size", + "grain_roughness", + ] { + assert!(global[field].is_number(), "global.{field} in {global}"); + } + for colour in [ + "red", "orange", "yellow", "green", "aqua", "blue", "purple", "magenta", + ] { + for field in ["hue", "saturation", "luminance"] { + assert!( + global["hsl"][colour][field].is_i64(), + "hsl.{colour}.{field} in {global}" + ); + } + } + let grading = global["color_grading"].as_object().unwrap(); + let mut keys: Vec<&str> = grading.keys().map(String::as_str).collect(); + keys.sort_unstable(); + assert_eq!(keys, ["balance", "highlights", "shadows"], "{grading:?}"); + for zone in ["shadows", "highlights"] { + let z = grading[zone].as_object().unwrap(); + assert!(z.keys().all(|k| k == "hue" || k == "saturation"), "{z:?}"); + let hue = z["hue"].as_i64().expect("toned fixtures give a hue"); + assert!((0..360).contains(&hue), "{zone} hue {hue}"); + } + let curve = global["tone_curve"].as_object().unwrap(); + let (s, m, h) = ( + curve["shadow_split"].as_i64().unwrap(), + curve["midtone_split"].as_i64().unwrap(), + curve["highlight_split"].as_i64().unwrap(), + ); + assert!(s < m && m < h, "{s} {m} {h}"); + assert!(blend.warnings.is_empty(), "{:?}", blend.warnings); +} + +#[test] +fn keys_missing_from_some_fixtures_are_renormalised_over_the_others() { + let pool = candidates(); + let blended = interpolate_recipes(&pool).settings; + // Every plain (linear) key that some fixture lacks: its blend is the mean + // over the fixtures that carry it, not over all of them. + let mut checked = 0; + let mut distinguishing = 0; + for key in lrg_analysis::training::canonical_keys() { + if key.recipe_path[0] == "color_grading" + && matches!(key.recipe_path.last(), Some(&"hue") | Some(&"saturation")) + { + continue; // a zone's mean colour vector, see below + } + let values: Vec = pool + .iter() + .filter_map(|(c, _)| c.canonical_settings.get(key.name).and_then(Value::as_f64)) + .collect(); + if values.is_empty() || values.len() == pool.len() { + continue; + } + let mean = values.iter().sum::() / values.len() as f64; + let over_all = values.iter().sum::() / pool.len() as f64; + let got = blended[key.name].as_f64().unwrap(); + assert!( + (got - mean).abs() <= 0.5, + "{}: {got} is not the mean {mean} over the fixtures that have it", + key.name + ); + checked += 1; + if (mean - over_all).abs() > 0.5 { + distinguishing += 1; + } + } + assert!(checked > 0, "no fixture lacks a canonical key"); + assert!( + distinguishing > 0, + "the fixtures no longer tell renormalised and naive means apart" + ); +} + +#[test] +fn toning_hues_from_real_blobs_ignore_untoned_examples() { + // Only the fixtures with a shadow saturation above 0 weigh in; the ones + // that keep a stale hue at saturation 0 must not move the mean. + let pool = candidates(); + let toned: Vec<(TrainingCandidate, f64)> = pool + .iter() + .filter(|(c, _)| { + c.canonical_settings + .get("color_grading_shadows_saturation") + .and_then(Value::as_f64) + .is_some_and(|s| s > 0.0) + }) + .cloned() + .collect(); + assert!(!toned.is_empty() && toned.len() < pool.len()); + assert!( + pool.iter().any(|(c, _)| { + let get = |k: &str| c.canonical_settings.get(k).and_then(Value::as_f64); + get("color_grading_shadows_saturation") == Some(0.0) + && get("color_grading_shadows_hue").is_some_and(|h| h != 0.0) + }), + "this test needs a fixture with a stale hue at saturation 0" + ); + let all = interpolate_recipes(&pool).settings; + let only_toned = interpolate_recipes(&toned).settings; + assert_eq!( + all["color_grading_shadows_hue"], + only_toned["color_grading_shadows_hue"] + ); + let key = canonical_key("color_grading_shadows_hue").unwrap(); + assert_eq!(key.spec.name, "SplitToningShadowHue"); + assert_eq!(key.recipe_path, ["color_grading", "shadows", "hue"]); +} diff --git a/server-rs/crates/lrg-analysis/tests/training_dump_canonical_local.rs b/server-rs/crates/lrg-analysis/tests/training_dump_canonical_local.rs new file mode 100644 index 00000000..57d9bd17 --- /dev/null +++ b/server-rs/crates/lrg-analysis/tests/training_dump_canonical_local.rs @@ -0,0 +1,144 @@ +//! Local-only: the style engine's canonical form over every row of the +//! maintainer's private training dump. +//! +//! Same dump and gate as `lrg-develop/tests/training_dump_local.rs` (see +//! there for the format and `server-rs/scripts/develop_registry/ +//! dump_training.py`): +//! +//! ```text +//! LRG_DEVELOP_TRAINING_DUMP=/path/to/training_rows.json \ +//! cargo test -p lrg-analysis --test training_dump_canonical_local -- --nocapture +//! ``` +//! +//! Without the variable the test skips through the shared golden gate +//! (family `training-dump`, local-only). With it, every row must +//! canonicalise without an error, every canonical key must come out of some +//! row, and blending all rows must give the recipe groups the installed +//! plugin applies. Output is counts per key only, never values or photo ids. + +#[path = "../../lrg-ml/tests/common/mod.rs"] +mod common; + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use lrg_analysis::style_engine::{ + canonical_to_edit_recipe, interpolate_recipes, TrainingCandidate, +}; +use lrg_analysis::training::{canonical_keys, canonicalize_develop_settings_str}; +use lrg_develop::FileKindHint; +use serde_json::Value as J; + +const VAR: &str = "LRG_DEVELOP_TRAINING_DUMP"; + +#[test] +fn every_training_row_yields_the_style_keys() { + let path = match std::env::var_os(VAR) { + None => { + if !common::assets_ready("training-dump", false, &format!("{VAR} is not set")) { + return; + } + unreachable!("assets_ready panics when the family is required") + } + Some(p) => { + let p = PathBuf::from(p); + assert!(p.is_file(), "{VAR}={} is not a file", p.display()); + p + } + }; + let text = std::fs::read_to_string(&path).expect("read the dump"); + let rows: Vec = serde_json::from_str(&text).expect("the dump is a JSON array"); + assert!(!rows.is_empty(), "empty dump"); + + let mut errors = 0usize; + let mut empty = 0usize; + let mut per_key: BTreeMap<&'static str, usize> = BTreeMap::new(); + let mut stale_hues = 0usize; + let mut pool: Vec<(TrainingCandidate, f64)> = Vec::new(); + for row in &rows { + let Some(meta) = row["metadata"] + .as_str() + .and_then(|m| serde_json::from_str::(m).ok()) + else { + errors += 1; + continue; + }; + let Some(blob) = meta["develop_settings"].as_str() else { + errors += 1; + continue; + }; + let hint = FileKindHint::from(meta["is_raw"].as_bool()); + let Ok(c) = canonicalize_develop_settings_str(blob, hint) else { + errors += 1; + continue; + }; + if c.settings.is_empty() { + empty += 1; + } + for key in canonical_keys() { + if c.settings.contains_key(key.name) { + *per_key.entry(key.name).or_default() += 1; + } + } + for zone in ["shadows", "highlights"] { + let get = |f: &str| { + c.settings + .get(&format!("color_grading_{zone}_{f}")) + .and_then(J::as_f64) + }; + if get("saturation") == Some(0.0) && get("hue").is_some_and(|h| h != 0.0) { + stale_hues += 1; + } + } + pool.push(( + TrainingCandidate { + canonical_settings: c.settings, + ..Default::default() + }, + 1.0, + )); + } + + eprintln!( + "training dump: {} rows, {errors} unreadable, {empty} with no canonical key", + rows.len() + ); + eprintln!("rows per canonical key ({} keys):", canonical_keys().len()); + for key in canonical_keys() { + eprintln!(" {:<40} {}", key.name, per_key.get(key.name).unwrap_or(&0)); + } + eprintln!("zones with a stale hue at saturation 0: {stale_hues}"); + + assert_eq!(errors, 0, "rows that could not be canonicalised"); + let missing: Vec<&str> = canonical_keys() + .iter() + .map(|k| k.name) + .filter(|n| !per_key.contains_key(n)) + .collect(); + assert!(missing.is_empty(), "keys no row yields: {missing:?}"); + + let blend = interpolate_recipes(&pool); + let recipe = canonical_to_edit_recipe(&blend.settings, "", None); + let global = recipe["global"].as_object().unwrap(); + let groups: Vec = global + .iter() + .map(|(k, v)| match v.as_object() { + Some(m) => format!("{k}({})", m.len()), + None => k.clone(), + }) + .collect(); + eprintln!( + "blend of all rows: {} global fields: {groups:?}", + global.len() + ); + eprintln!("blend notes: {}", blend.warnings.len()); + for group in ["hsl", "color_grading", "tone_curve"] { + assert!(global.get(group).is_some_and(J::is_object), "no {group}"); + } + let hsl = global["hsl"].as_object().unwrap(); + assert_eq!(hsl.len(), 8, "HSL channels"); + let curve = global["tone_curve"].as_object().unwrap(); + for split in ["shadow_split", "midtone_split", "highlight_split"] { + assert!(curve.contains_key(split), "tone_curve.{split}"); + } +} diff --git a/server-rs/crates/lrg-api/Cargo.toml b/server-rs/crates/lrg-api/Cargo.toml index 96c878a1..59424e29 100644 --- a/server-rs/crates/lrg-api/Cargo.toml +++ b/server-rs/crates/lrg-api/Cargo.toml @@ -49,6 +49,7 @@ futures-util = { version = "0.3.33", default-features = false, features = ["std" zip = { version = "9.0.1", default-features = false, features = ["deflate"] } async-trait = "0.1.91" rayon = "1.12.0" +lrg-develop.workspace = true uuid.workspace = true [dev-dependencies] diff --git a/server-rs/crates/lrg-api/src/edit_loop.rs b/server-rs/crates/lrg-api/src/edit_loop.rs new file mode 100644 index 00000000..6870a064 --- /dev/null +++ b/server-rs/crates/lrg-api/src/edit_loop.rs @@ -0,0 +1,867 @@ +//! AI Edit's retoucher loop, backend side: one session per photo. +//! +//! The loop needs Lightroom between its rounds — only Lightroom can apply a +//! develop table and render the result — so the backend holds the +//! conversation and the plugin drives it: +//! +//! 1. `start`: the plugin uploads the photo as it is and its develop +//! settings. The backend measures it, opens the conversation and answers +//! with the first edit as a table for `applyDevelopSettings` (or, starting +//! from the photographer's style, with that style as round 0). +//! 2. `render`: the plugin applied it, rendered the photo and uploads the +//! render. The model sees it with its measurements and either edits again +//! or finishes. +//! 3. The plugin ends the session when it is done with it; an abandoned one +//! expires. +//! +//! The model's tools and prompt are [`lrg_providers::edit_loop`]; the recipe +//! becomes Lightroom keys through [`lrg_develop::build::recipe`] and the Lua +//! writer, after the same guardrails every AI Edit recipe passes. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex as StdMutex}; +use std::time::{Duration, Instant}; + +use serde_json::{json, Value}; +use tokio::sync::Mutex; + +use lrg_analysis::edit_guardrails::CaptureConditions; +use lrg_analysis::style_engine::white_balance_into_global; +use lrg_develop::build::recipe::settings_from_recipe; +use lrg_develop::lua::{to_lua_value, LuaMode, LuaOptions, PhotoContext}; +use lrg_develop::{DevelopSettings, FileKind, FileKindHint}; +use lrg_imaging::tone::{measure, ToneStats}; +use lrg_providers::chat::{ + ChatImage, ChatMessage, ChatModel, ChatPart, ChatRequest, ChatRole, ToolCall, ToolResult, + ToolSpec, +}; +use lrg_providers::edit_loop::{ + against_original, apply_edit_tool, finish_tool, merge_onto, opening_text, + recipe_from_arguments, recipe_text, render_text, starting_text, system_prompt, EditArguments, + LoopSetup, APPLY_EDIT, FINISH, NUDGE, +}; + +use crate::edit_budget::{measure_and_apply, FaceReaders}; +use crate::state::AppState; + +/// A session nobody has touched for this long is dropped: the plugin that +/// drove it is gone. +const IDLE_LIMIT: Duration = Duration::from_secs(30 * 60); + +/// Long edge of every image the model sees. Larger costs tokens on every +/// turn and shows the model nothing an edit decision needs. +const MODEL_IMAGE_EDGE: u32 = 1536; + +/// Tokens one answer may use. An edit with masks and colour grading is a +/// few hundred; the rest is room for a model that reasons first. +const MAX_ANSWER_TOKENS: u32 = 4096; + +/// Low, for steady values from one round to the next; not zero, which makes +/// some models repeat their previous call verbatim. +const TEMPERATURE: f32 = 0.3; + +/// Answers the loop asks for before it gives up on a model that will not +/// call a tool: the first, one nudge, one more. +const MAX_TURNS_PER_STEP: usize = 3; + +/// The edits a session allows at most, whatever the plugin asks. +pub const MAX_EDITS_CAP: u32 = 5; + +/// What the user is told when the backend itself failed on an edit; the +/// cause is in the log. +const INTERNAL_ERROR: &str = "An edit could not be prepared for Lightroom because of an error in \ + the LrGeniusAI backend. Please report it on GitHub with the backend log."; + +/// The sessions in flight, by id. +#[derive(Default)] +pub struct EditSessions { + sessions: StdMutex>>>, +} + +impl EditSessions { + fn purge(map: &mut HashMap>>) { + map.retain(|_, s| match s.try_lock() { + Ok(s) => s.last_used.elapsed() < IDLE_LIMIT, + // In use right now, so not idle. + Err(_) => true, + }); + } + + fn insert(&self, session: EditSession) -> String { + let id = uuid::Uuid::new_v4().simple().to_string(); + let mut map = self.sessions.lock().expect("edit sessions lock"); + Self::purge(&mut map); + map.insert(id.clone(), Arc::new(Mutex::new(session))); + id + } + + pub fn get(&self, id: &str) -> Option>> { + let mut map = self.sessions.lock().expect("edit sessions lock"); + Self::purge(&mut map); + map.get(id).cloned() + } + + pub fn remove(&self, id: &str) -> bool { + self.sessions + .lock() + .expect("edit sessions lock") + .remove(id) + .is_some() + } +} + +/// One edit the session handed out, kept so a later `best_round` can point +/// back at it. +#[derive(Clone)] +struct Round { + /// 0 for the photographer's style, then 1, 2, ... for the model's edits, + /// as the model is told them. + number: u32, + recipe: Value, + assessment: Option, + /// What did not reach the photo as given, told to the model with the + /// render. + notes: Vec, +} + +/// What the session waits for: the render of the edit it handed out last. +enum Pending { + /// Round 0, the photographer's style: no model turn yet. The opening + /// message goes out with this render. + Style { opening: String }, + /// An `apply_edit` call to answer with the render. `others` are any + /// further calls in the same turn, answered as not taken. + Edit { + call: ToolCall, + others: Vec, + }, + /// Nothing: the session has finished. + Nothing, +} + +pub struct EditSession { + model: Arc, + system: String, + tools: Vec, + messages: Vec, + setup: LoopSetup, + photo: PhotoContext, + /// The photo as uploaded at the start, for the guardrails. + original_bytes: Vec, + /// The same, at the size the model sees. + original_image: ChatImage, + original_tones: ToneStats, + capture: CaptureConditions, + pending: Pending, + /// The photographer's style, when the session started from it: every + /// edit the model sends is merged onto it, so what the model leaves out + /// keeps the style's value instead of falling back to the photo's own. + base: Option, + /// Round 0 (the style) when there is one, then every edit in order. + rounds: Vec, + /// Edits the model applied (round 0 not counted). + edits: u32, + last_used: Instant, + pub photo_id: String, + pub filename: Option, + /// The start request's fields, for persisting the finished recipe. + fields: HashMap, + input_tokens: u64, + output_tokens: u64, +} + +/// One answer to the plugin. +pub struct Step { + pub round: u32, + /// `"style"` or `"model"`. + pub source: &'static str, + pub done: bool, + pub recipe: Value, + pub develop_settings: Value, + pub update_ai_settings: bool, + pub assessment: Option, + pub verdict: Option, + /// The round to keep when the session is done: the latest unless the + /// model named an earlier one. + pub keep_round: Option, + /// What did not reach the photo as given, and other notes for the user. + pub warnings: Vec, + pub guardrail_reasons: Vec, +} + +impl Step { + pub fn to_json(&self, session_id: &str, session: &EditSession) -> Value { + json!({ + "status": "success", + "session_id": session_id, + "photo_id": session.photo_id, + "round": self.round, + "source": self.source, + "done": self.done, + "edit": self.recipe, + "develop_settings": self.develop_settings, + "update_ai_settings": self.update_ai_settings, + "assessment": self.assessment, + "verdict": self.verdict, + "keep_round": self.keep_round, + "edits_left": session.setup.max_edits.saturating_sub(session.edits), + "warnings": self.warnings, + "guardrail_reasons": self.guardrail_reasons, + "guardrail_explanations": crate::routes::edit::guardrail_explanations(&self.guardrail_reasons), + "input_tokens": session.input_tokens, + "output_tokens": session.output_tokens, + }) + } +} + +/// What a session is started with. +pub struct StartRequest { + pub photo_id: String, + pub filename: Option, + pub image: Vec, + /// `photo:getDevelopSettings()` as JSON.lua encoded it, or `None`. + pub develop_settings: Option, + /// The plugin's guess, used only when the settings carry no + /// white-balance keys. + pub is_raw: Option, + pub setup: LoopSetup, + /// The photographer's style for round 0: the `edit` of a + /// `/v1/edit/style` answer. + pub style_recipe: Option, + pub model: Arc, + pub fields: HashMap, +} + +/// Why a step failed, for the HTTP answer. +pub struct StepError { + pub status: axum::http::StatusCode, + pub message: String, +} + +impl StepError { + fn bad_request(message: impl Into) -> StepError { + StepError { + status: axum::http::StatusCode::BAD_REQUEST, + message: message.into(), + } + } + + fn model(message: impl Into) -> StepError { + StepError { + status: axum::http::StatusCode::BAD_GATEWAY, + message: message.into(), + } + } +} + +/// An uploaded JPEG at the model's size, and its tones. +fn prepare_image(bytes: &[u8]) -> Result<(ChatImage, ToneStats), String> { + let decoded = + image::load_from_memory(bytes).map_err(|e| format!("could not decode the image: {e}"))?; + let rgb = decoded.to_rgb8(); + let tones = measure(rgb.as_raw(), rgb.width() as usize, rgb.height() as usize) + .ok_or_else(|| "the image is empty".to_string())?; + let long_edge = rgb.width().max(rgb.height()); + let data = if long_edge <= MODEL_IMAGE_EDGE { + bytes.to_vec() + } else { + let resized = image::DynamicImage::ImageRgb8(rgb).resize( + MODEL_IMAGE_EDGE, + MODEL_IMAGE_EDGE, + image::imageops::FilterType::Triangle, + ); + let mut out = std::io::Cursor::new(Vec::new()); + resized + .write_to(&mut out, image::ImageFormat::Jpeg) + .map_err(|e| format!("could not encode the image: {e}"))?; + out.into_inner() + }; + Ok(( + ChatImage { + data, + mime_type: "image/jpeg".to_string(), + }, + tones, + )) +} + +/// Turns a recipe into the table Lightroom takes, after the guardrails. +struct Built { + recipe: Value, + table: Value, + update_ai_settings: bool, + /// What did not reach the photo as given, for the user and the model. + warnings: Vec, + /// The guardrails' explanations: for the model with the render, and for + /// the user only through `guardrail_explanations`, where the review shows + /// them under "What the frame allowed" — as warnings they would be + /// counted for every photo of a run and push real problems out of the + /// summary. + frame_notes: Vec, + guardrail_reasons: Vec, +} + +impl Built { + /// Everything the model should hear about this edit. + fn model_notes(&self) -> Vec { + self.frame_notes + .iter() + .chain(&self.warnings) + .cloned() + .collect() + } +} + +async fn build(state: &AppState, session: &EditSession, mut recipe: Value) -> Built { + let bytes = session.original_bytes.clone(); + let capture = session.capture; + let faces = state.face.clone(); + let face_state = state.face_state.clone(); + let guarded = tokio::task::spawn_blocking(move || { + let applied = measure_and_apply( + &mut recipe, + &bytes, + &capture, + FaceReaders::when_downloaded(&faces, &face_state), + ); + (recipe, applied) + }) + .await; + let (recipe, applied) = match guarded { + Ok(result) => result, + Err(e) => { + log::error!("Edit loop guardrail task failed: {e}"); + return Built { + recipe: Value::Null, + table: json!({}), + update_ai_settings: false, + warnings: vec![INTERNAL_ERROR.to_string()], + frame_notes: Vec::new(), + guardrail_reasons: Vec::new(), + }; + } + }; + let mut warnings: Vec = applied.warning.into_iter().collect(); + let built = settings_from_recipe(&recipe, session.photo.file_kind); + warnings.extend(built.notes); + let (table, update_ai_settings) = match to_lua_value( + &built.settings, + &LuaMode::Apply(session.photo.clone()), + &LuaOptions::default(), + ) { + Ok(written) => { + warnings.extend(written.skipped.iter().map(|s| s.sentence())); + (written.table, written.call.update_ai_settings) + } + Err(e) => { + log::error!("Edit loop: the develop table could not be written: {e}"); + warnings.push(INTERNAL_ERROR.to_string()); + (json!({}), false) + } + }; + Built { + recipe, + table, + update_ai_settings, + warnings, + frame_notes: crate::routes::edit::guardrail_explanations(&applied.reasons), + guardrail_reasons: applied.reasons, + } +} + +fn is_empty_table(table: &Value) -> bool { + table.as_object().is_none_or(|m| m.is_empty()) +} + +/// Opens a session and returns its id with the first step. +pub async fn start(state: &AppState, request: StartRequest) -> Result<(String, Step), StepError> { + if !request.model.supports_images() { + return Err(StepError::bad_request(format!( + "{} cannot see images, and AI Edit has to look at the photo. Choose a vision model.", + request.model.name() + ))); + } + let (original_image, original_tones) = + prepare_image(&request.image).map_err(StepError::bad_request)?; + + let hint = match request.is_raw { + Some(true) => FileKindHint::Raw, + Some(false) => FileKindHint::NonRaw, + None => FileKindHint::Unknown, + }; + let current = match &request.develop_settings { + Some(blob) => match lrg_develop::lua::from_lua_value(blob, hint) { + Ok((settings, _)) => settings, + Err(e) => { + return Err(StepError::bad_request(format!( + "the photo's develop settings could not be read: {e}" + ))) + } + }, + None => DevelopSettings::default(), + }; + let file_kind = current.file_kind.or(match request.is_raw { + Some(true) => Some(FileKind::Raw), + Some(false) => Some(FileKind::NonRaw), + None => None, + }); + let photo = PhotoContext { + file_kind, + process_version: current.process_version(), + camera: None, + }; + let mut setup = request.setup; + setup.is_raw = file_kind.map(|k| k == FileKind::Raw); + setup.max_edits = setup.max_edits.clamp(1, MAX_EDITS_CAP); + setup.from_style = request.style_recipe.is_some(); + + let capture = CaptureConditions { + iso: lrg_imaging::capture::read_iso(&request.image), + is_raw: file_kind == Some(FileKind::Raw), + }; + let frame_notes = frame_notes(state, &request.image, &capture).await; + let opening = opening_text( + &starting_text(¤t), + &original_tones.describe(), + &frame_notes, + &setup, + ); + + let mut session = EditSession { + model: request.model, + system: system_prompt(&setup), + tools: vec![apply_edit_tool(&setup), finish_tool()], + messages: Vec::new(), + setup, + photo, + original_bytes: request.image, + original_image, + original_tones, + capture, + pending: Pending::Nothing, + base: None, + rounds: Vec::new(), + edits: 0, + last_used: Instant::now(), + photo_id: request.photo_id, + filename: request.filename, + fields: request.fields, + input_tokens: 0, + output_tokens: 0, + }; + + let step = if let Some(style) = request.style_recipe { + let (style, mut notes) = white_balance_into_global(style, file_kind); + let built = build(state, &session, style.clone()).await; + notes.extend(built.warnings.iter().cloned()); + if is_empty_table(&built.table) { + // Nothing to show the model as round 0: go straight to the model. + session.setup.from_style = false; + session.system = system_prompt(&session.setup); + let opening = opening_text( + &starting_text(¤t), + &session.original_tones.describe(), + &frame_notes, + &session.setup, + ); + session + .messages + .push(opening_message(&session, &opening, None)); + let mut step = advance(state, &mut session).await?; + step.warnings.splice(0..0, notes); + step + } else { + session.base = Some(style); + session.rounds.push(Round { + number: 0, + recipe: built.recipe.clone(), + assessment: None, + notes: Vec::new(), + }); + session.pending = Pending::Style { opening }; + Step { + round: 0, + source: "style", + done: false, + recipe: built.recipe, + develop_settings: built.table, + update_ai_settings: built.update_ai_settings, + assessment: None, + verdict: None, + keep_round: None, + warnings: notes, + guardrail_reasons: built.guardrail_reasons, + } + } + } else { + session + .messages + .push(opening_message(&session, &opening, None)); + advance(state, &mut session).await? + }; + + let id = state.edit_sessions.insert(session); + Ok((id, step)) +} + +/// The first user message: the original, what the model works from, and, +/// from a style, the style's render. +fn opening_message( + session: &EditSession, + opening: &str, + style: Option<(ChatImage, String)>, +) -> ChatMessage { + let mut parts = vec![ + ChatPart::Image(session.original_image.clone()), + ChatPart::Text(opening.to_string()), + ]; + if let Some((image, text)) = style { + parts.push(ChatPart::Image(image)); + parts.push(ChatPart::Text(text)); + } + ChatMessage { + role: ChatRole::User, + parts, + } +} + +/// What the frame allows, found by asking the guardrails about an edit that +/// pushes every capped slider to the end of its range. +async fn frame_notes(state: &AppState, image: &[u8], capture: &CaptureConditions) -> Vec { + let bytes = image.to_vec(); + let capture = *capture; + let faces = state.face.clone(); + let face_state = state.face_state.clone(); + let probed = tokio::task::spawn_blocking(move || { + let mut probe = json!({"global": { + "contrast": 100, "clarity": 100, "dehaze": 100, "shadows": 100, "whites": 100 + }}); + let applied = measure_and_apply( + &mut probe, + &bytes, + &capture, + FaceReaders::when_downloaded(&faces, &face_state), + ); + (probe, applied.reasons) + }) + .await; + let Ok((probe, reasons)) = probed else { + return Vec::new(); + }; + let mut notes = crate::routes::edit::guardrail_explanations(&reasons); + let caps: Vec = ["contrast", "clarity", "dehaze", "shadows", "whites"] + .iter() + .filter_map(|k| { + let v = probe["global"][k].as_f64()?; + (v < 100.0).then(|| format!("{k} at most {v:.0}")) + }) + .collect(); + if !caps.is_empty() { + notes.push(format!("Upper limits for this frame: {}.", caps.join(", "))); + } + notes +} + +/// Takes the render of the pending edit and asks the model what comes next. +/// +/// `lightroom_notes` are what the plugin saw while applying the edit — an AI +/// mask that found nothing, say — and reach the model with the render. +pub async fn render( + state: &AppState, + session: &mut EditSession, + image: &[u8], + lightroom_notes: Vec, +) -> Result { + session.last_used = Instant::now(); + let (render_image, tones) = prepare_image(image).map_err(StepError::bad_request)?; + let comparison = against_original(&session.original_tones, &tones); + let left = session.setup.max_edits.saturating_sub(session.edits); + + match std::mem::replace(&mut session.pending, Pending::Nothing) { + Pending::Nothing => { + return Err(StepError::bad_request( + "this edit session is not waiting for a render", + )) + } + Pending::Style { opening } => { + let applied = session + .rounds + .first() + .map(|r| recipe_text(&r.recipe)) + .unwrap_or_default(); + let text = render_text( + 0, + &applied, + &tones.describe(), + &comparison, + &lightroom_notes, + left, + ); + let message = opening_message(session, &opening, Some((render_image, text))); + session.messages.push(message); + } + Pending::Edit { call, others } => { + let mut notes = session + .rounds + .last() + .map(|r| r.notes.clone()) + .unwrap_or_default(); + notes.extend(lightroom_notes); + let applied = session + .rounds + .last() + .map(|r| recipe_text(&r.recipe)) + .unwrap_or_default(); + let text = render_text( + session.edits, + &applied, + &tones.describe(), + &comparison, + ¬es, + left, + ); + let mut parts = vec![ChatPart::ToolResult(ToolResult { + call_id: call.id, + name: call.name, + text, + images: vec![render_image], + is_error: false, + })]; + parts.extend(others.into_iter().map(not_taken)); + session.messages.push(ChatMessage { + role: ChatRole::User, + parts, + }); + } + } + advance(state, session).await +} + +fn not_taken(call: ToolCall) -> ChatPart { + ChatPart::ToolResult(ToolResult { + call_id: call.id, + name: call.name, + text: "Not done: one tool call per turn. The first call of the turn was taken.".to_string(), + images: Vec::new(), + is_error: true, + }) +} + +fn tool_error(call: &ToolCall, text: &str) -> ChatPart { + ChatPart::ToolResult(ToolResult { + call_id: call.id.clone(), + name: call.name.clone(), + text: text.to_string(), + images: Vec::new(), + is_error: true, + }) +} + +/// The step that ends the session, keeping `keep_round` when it names a +/// round the session had and the latest round otherwise. +fn finished( + session: &mut EditSession, + verdict: Option, + keep_round: Option, + warnings: Vec, +) -> Step { + session.pending = Pending::Nothing; + let kept = keep_round + .and_then(|n| session.rounds.iter().find(|r| r.number == n)) + .or_else(|| session.rounds.last()); + Step { + round: kept.map_or(0, |r| r.number), + source: "model", + done: true, + recipe: kept.map_or(Value::Null, |r| r.recipe.clone()), + develop_settings: json!({}), + update_ai_settings: false, + assessment: kept.and_then(|r| r.assessment.clone()), + verdict, + keep_round: kept.map(|r| r.number), + warnings, + guardrail_reasons: Vec::new(), + } +} + +/// Asks the model for its next move and turns the answer into a step. +async fn advance(state: &AppState, session: &mut EditSession) -> Result { + let mut warnings = Vec::new(); + // Why edits the model sent this step did not reach Lightroom at all. + let mut rejected: Vec = Vec::new(); + for _ in 0..MAX_TURNS_PER_STEP { + let request = ChatRequest { + system: session.system.clone(), + messages: session.messages.clone(), + tools: session.tools.clone(), + max_tokens: MAX_ANSWER_TOKENS, + temperature: TEMPERATURE, + }; + let turn = session + .model + .complete(&request, None) + .await + .map_err(|e| StepError::model(format!("{}: {e}", session.model.name())))?; + session.input_tokens += u64::from(turn.input_tokens); + session.output_tokens += u64::from(turn.output_tokens); + session.messages.push(turn.to_message()); + + let mut calls = turn.tool_calls.into_iter(); + let Some(call) = calls.next() else { + if session.edits == 0 { + session.messages.push(ChatMessage::user_text(NUDGE)); + continue; + } + warnings.push( + "The AI model stopped without saying it was done; the latest edit was kept." + .to_string(), + ); + return Ok(finished(session, None, None, warnings)); + }; + let others: Vec = calls.collect(); + + match call.name.as_str() { + FINISH if session.edits > 0 => { + let verdict = call + .arguments + .get("verdict") + .and_then(Value::as_str) + .map(str::to_string); + let keep = call + .arguments + .get("best_round") + .and_then(Value::as_u64) + .map(|r| r as u32); + return Ok(finished(session, verdict, keep, warnings)); + } + FINISH => { + let mut parts = vec![tool_error( + &call, + "Nothing has been applied yet. Call apply_edit with your edit first.", + )]; + parts.extend(others.into_iter().map(not_taken)); + session.messages.push(ChatMessage { + role: ChatRole::User, + parts, + }); + } + APPLY_EDIT if session.edits >= session.setup.max_edits => { + let mut parts = vec![tool_error(&call, "No edits are left. Call finish.")]; + parts.extend(others.into_iter().map(not_taken)); + session.messages.push(ChatMessage { + role: ChatRole::User, + parts, + }); + } + APPLY_EDIT => { + let EditArguments { + recipe, + assessment, + notes: argument_notes, + } = recipe_from_arguments(&call.arguments); + let recipe = match &session.base { + Some(base) => merge_onto(base, &recipe), + None => recipe, + }; + let mut built = build(state, session, recipe).await; + built.warnings.splice(0..0, argument_notes); + if is_empty_table(&built.table) { + let mut text = "Nothing in this edit reaches Lightroom.".to_string(); + if !built.warnings.is_empty() { + text.push_str(&format!(" {}", built.warnings.join(" "))); + } + // Kept for the user: if the model never gets an edit + // through, these are why, not its tool calling. + rejected.extend(built.warnings.iter().cloned()); + let mut parts = vec![tool_error(&call, &text)]; + parts.extend(others.into_iter().map(not_taken)); + session.messages.push(ChatMessage { + role: ChatRole::User, + parts, + }); + continue; + } + session.edits += 1; + session.rounds.push(Round { + number: session.edits, + recipe: built.recipe.clone(), + assessment: assessment.clone(), + notes: built.model_notes(), + }); + session.pending = Pending::Edit { call, others }; + warnings.extend(built.warnings); + return Ok(Step { + round: session.edits, + source: "model", + done: false, + recipe: built.recipe, + develop_settings: built.table, + update_ai_settings: built.update_ai_settings, + assessment, + verdict: None, + keep_round: None, + warnings, + guardrail_reasons: built.guardrail_reasons, + }); + } + other => { + let mut parts = vec![tool_error( + &call, + &format!("There is no tool named {other}. Use apply_edit or finish."), + )]; + parts.extend(others.into_iter().map(not_taken)); + session.messages.push(ChatMessage { + role: ChatRole::User, + parts, + }); + } + } + } + rejected.dedup(); + if session.edits > 0 { + warnings.push(if rejected.is_empty() { + "The AI model did not answer with an edit or a finish; the latest edit was kept." + .to_string() + } else { + format!( + "The AI model's last edits could not be applied ({}); the latest edit that could \ + was kept.", + rejected.join(" ") + ) + }); + return Ok(finished(session, None, None, warnings)); + } + Err(StepError::model(if rejected.is_empty() { + format!( + "{} did not call apply_edit after {MAX_TURNS_PER_STEP} answers. Models without tool \ + calling cannot run AI Edit; choose another model.", + session.model.name() + ) + } else { + format!( + "{} sent edits Lightroom could not use: {} Try again, or choose a larger model.", + session.model.name(), + rejected.join(" ") + ) + })) +} + +impl EditSession { + /// Round `number`'s recipe with the model's words on it as `summary`, + /// and the fields the session was started with: what persisting the + /// finished edit needs. + pub fn result( + &self, + number: u32, + verdict: Option<&str>, + ) -> Option<(Value, HashMap)> { + let round = self.rounds.iter().find(|r| r.number == number)?; + let mut recipe = round.recipe.clone(); + if let Some(object) = recipe.as_object_mut() { + if let Some(summary) = verdict.or(round.assessment.as_deref()) { + object.insert("summary".to_string(), json!(summary)); + } + } + Some((recipe, self.fields.clone())) + } +} diff --git a/server-rs/crates/lrg-api/src/lib.rs b/server-rs/crates/lrg-api/src/lib.rs index b2d854b3..cf50013c 100644 --- a/server-rs/crates/lrg-api/src/lib.rs +++ b/server-rs/crates/lrg-api/src/lib.rs @@ -4,6 +4,7 @@ pub mod agent_bridge; pub mod api_auth; pub mod edit_budget; +pub mod edit_loop; #[cfg(test)] mod fake_release; pub mod hf_repo; @@ -57,6 +58,7 @@ pub fn build_router(state: Arc) -> Router { .merge(routes::find_similar::router()) .merge(routes::group_similar::router()) .merge(routes::edit::router()) + .merge(routes::edit_session::router()) .merge(routes::training::router()) .merge(routes::keywords::router()) .merge(routes::llm::router()) diff --git a/server-rs/crates/lrg-api/src/routes/edit.rs b/server-rs/crates/lrg-api/src/routes/edit.rs index 88d2d714..1c1be4d8 100644 --- a/server-rs/crates/lrg-api/src/routes/edit.rs +++ b/server-rs/crates/lrg-api/src/routes/edit.rs @@ -411,23 +411,35 @@ pub(crate) fn training_example_is_current(metadata: &Map) -> bool == Some(lrg_ml::siglip::MODEL_ID) } -/// Lightroom's own key for white balance in a saved develop-settings blob, and -/// the recipe schema's spelling of the same thing, since examples can come from -/// either shape. -const TEMPERATURE_KEYS: [&str; 2] = ["Temp", "temperature"]; - -/// Strip the white balance from an example saved from a file whose temperature -/// scale differs from the photo being edited. +/// The white-balance keys of a saved develop-settings blob (Lightroom's raw +/// family `Temperature`/`Tint`, the non-raw family +/// `IncrementalTemperature`/`IncrementalTint`, and the `WhiteBalance` mode), +/// plus the recipe schema's spelling, since examples can come from either +/// shape. +const WHITE_BALANCE_KEYS: [&str; 7] = [ + "Temperature", + "Tint", + "IncrementalTemperature", + "IncrementalTint", + "WhiteBalance", + "temperature", + "tint", +]; + +/// Strip the white balance from an example saved from a file whose +/// white-balance scale differs from the photo being edited. /// /// The few-shot block exists to give the model the user's own numbers to anchor -/// on. A Kelvin `Temp` offered as a reference for a JPEG — or the reverse — -/// anchors it on a number that is meaningless for the target, while the schema -/// it must answer in declares the other range entirely. Every other develop -/// setting means the same thing on both kinds of file, so only this one goes. +/// on. A Kelvin `Temperature` offered as a reference for a JPEG — or an +/// `IncrementalTemperature` offset for a raw file — anchors it on a number that +/// is meaningless for the target, while the schema it must answer in declares +/// the other range entirely. Tint changes meaning with it (absolute on raw, an +/// offset otherwise), so the whole family goes; every other develop setting +/// means the same thing on both kinds of file. /// /// An unknown raw status on either side means no conflict can be established, /// so nothing is removed. -fn drop_incompatible_temperature( +fn drop_incompatible_white_balance( settings: &mut Value, example_is_raw: Option, target_is_raw: Option, @@ -442,7 +454,7 @@ fn drop_incompatible_temperature( return false; }; let mut removed = false; - for key in TEMPERATURE_KEYS { + for key in WHITE_BALANCE_KEYS { removed |= map.remove(key).is_some(); } removed @@ -494,7 +506,8 @@ pub(crate) async fn fetch_training_examples( .and_then(|s| serde_json::from_str::(s).ok()) .unwrap_or_else(|| json!({})); let example_is_raw = r.metadata.get("is_raw").and_then(Value::as_bool); - if drop_incompatible_temperature(&mut develop_settings, example_is_raw, target_is_raw) { + if drop_incompatible_white_balance(&mut develop_settings, example_is_raw, target_is_raw) + { dropped_temperature += 1; } json!({ @@ -783,11 +796,28 @@ pub(crate) async fn persist_edit_recipe( .map_err(|e| e.to_string()) } +/// The provider's warning slot as one list entry per note. +/// +/// `generate_edit_recipe_for_photo` appends the style-training note to +/// whatever the provider already put in `warning`, one note per line. Collected +/// as a single entry, a provider note and a training note would reach the plugin +/// as one combined text, which it counts as its own cause instead of grouping it +/// with the same note on other photos. +pub(crate) fn warning_entries(warning: Option) -> Vec { + warning + .iter() + .flat_map(|w| w.lines()) + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(str::to_string) + .collect() +} + pub(crate) fn success_payload( photo_id: &str, recipe: &Value, options: &EditOptions, - warning: Option<&str>, + warnings: &[String], guardrail_reasons: &[String], ) -> Value { let now = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); @@ -805,9 +835,15 @@ pub(crate) fn success_payload( // photograph would not take. "guardrail_reasons": guardrail_reasons, "guardrail_explanations": guardrail_explanations(guardrail_reasons), + // The backend's notes about this run (style-match confidence, white + // balance not transferred, ...), one entry each: a list, because a + // single slot loses the second report. + "warnings": warnings, }); - if let Some(w) = warning { - payload["warning"] = json!(w); + // Legacy, for plugins that read only the single `warning` string: the same + // notes joined. Remove once every supported plugin reads `warnings`. + if !warnings.is_empty() { + payload["warning"] = json!(warnings.join("\n")); } payload } @@ -819,7 +855,7 @@ pub(crate) fn success_payload( /// photographer, and a second copy in Lua would drift from the thresholds it /// describes. Unknown codes are dropped rather than echoed: a plugin talking to /// a newer backend should show nothing rather than a bare identifier. -fn guardrail_explanations(codes: &[String]) -> Vec { +pub(crate) fn guardrail_explanations(codes: &[String]) -> Vec { use lrg_analysis::edit_guardrails::GuardrailReason::*; const ALL: [lrg_analysis::edit_guardrails::GuardrailReason; 9] = [ HardLightNoAddedContrast, @@ -959,11 +995,12 @@ async fn finish_edit( } } + let warnings = warning_entries(response.warning); let mut payload = success_payload( photo_id, &recipe, options, - response.warning.as_deref(), + &warnings, &response.guardrail_reasons, ); payload["input_tokens"] = json!(response.input_tokens); @@ -1141,29 +1178,45 @@ mod tests { fn a_wrong_scale_white_balance_is_kept_out_of_the_few_shot_block() { // A Kelvin number offered to the model as a reference for a JPEG // anchors it on a value the target's schema cannot even express. - let mut settings = json!({"Temp": 5600, "Exposure2012": 0.3}); - assert!(drop_incompatible_temperature( + let mut settings = json!({ + "WhiteBalance": "Custom", + "Temperature": 5600, + "Tint": 12, + "Exposure2012": 0.3, + }); + assert!(drop_incompatible_white_balance( &mut settings, Some(true), Some(false) )); - assert!(settings.get("Temp").is_none()); + for key in ["WhiteBalance", "Temperature", "Tint"] { + assert!(settings.get(key).is_none(), "{key} survived"); + } assert_eq!( settings["Exposure2012"], json!(0.3), "everything else means the same on both kinds of file" ); + + let mut non_raw = json!({"IncrementalTemperature": 12, "IncrementalTint": -4}); + assert!(drop_incompatible_white_balance( + &mut non_raw, + Some(false), + Some(true) + )); + assert_eq!(non_raw, json!({})); } #[test] fn a_matching_example_keeps_its_white_balance() { - let mut settings = json!({"Temp": 5600}); - assert!(!drop_incompatible_temperature( + let mut settings = json!({"WhiteBalance": "Custom", "Temperature": 5600, "Tint": 3}); + assert!(!drop_incompatible_white_balance( &mut settings, Some(true), Some(true) )); - assert_eq!(settings["Temp"], json!(5600)); + assert_eq!(settings["Temperature"], json!(5600)); + assert_eq!(settings["Tint"], json!(3)); } #[test] @@ -1171,13 +1224,44 @@ mod tests { // Examples saved before the flag existed would otherwise lose their // white balance on every edit after upgrading. for (example, target) in [(None, Some(true)), (Some(true), None), (None, None)] { - let mut settings = json!({"Temp": 5600}); - assert!(!drop_incompatible_temperature( + let mut settings = json!({"Temperature": 5600}); + assert!(!drop_incompatible_white_balance( &mut settings, example, target )); - assert_eq!(settings["Temp"], json!(5600), "{example:?} vs {target:?}"); + assert_eq!( + settings["Temperature"], + json!(5600), + "{example:?} vs {target:?}" + ); } } + + #[test] + fn warning_entries_keeps_a_provider_note_and_a_training_note_apart() { + assert_eq!( + warning_entries(Some( + "Gemini fell back to high.\nStyle training skipped.".into() + )), + vec!["Gemini fell back to high.", "Style training skipped."] + ); + assert_eq!(warning_entries(Some("only one".into())), vec!["only one"]); + assert!(warning_entries(Some(" \n".into())).is_empty()); + assert!(warning_entries(None).is_empty()); + } + + #[test] + fn success_payload_lists_warnings_and_keeps_the_legacy_string() { + let options = parse_edit_options_form(&HashMap::new()); + let recipe = json!({"summary": "s", "global": {}, "masks": [], "warnings": []}); + let warnings = vec!["first".to_string(), "second".to_string()]; + let payload = success_payload("p1", &recipe, &options, &warnings, &[]); + assert_eq!(payload["warnings"], json!(["first", "second"])); + assert_eq!(payload["warning"], json!("first\nsecond")); + + let payload = success_payload("p1", &recipe, &options, &[], &[]); + assert_eq!(payload["warnings"], json!([])); + assert!(payload.get("warning").is_none()); + } } diff --git a/server-rs/crates/lrg-api/src/routes/edit_session.rs b/server-rs/crates/lrg-api/src/routes/edit_session.rs new file mode 100644 index 00000000..2fce2f01 --- /dev/null +++ b/server-rs/crates/lrg-api/src/routes/edit_session.rs @@ -0,0 +1,298 @@ +//! `/v1/edit/sessions` — AI Edit's retoucher loop: open a session for one +//! photo, hand back each render, end it. The loop itself is +//! [`crate::edit_loop`]. + +use std::collections::HashMap; +use std::sync::Arc; + +use axum::extract::{Multipart, Path, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Json, Response}; +use serde_json::{json, Value}; + +use lrg_providers::chat::ChatModel; +use lrg_providers::cloud_chat::build_chat_model; +use lrg_providers::edit_loop::LoopSetup; +use lrg_providers::local_chat::LocalChatModel; +use lrg_providers::provider::ProviderSelection; + +use crate::edit_loop::{self, StartRequest, Step}; +use crate::routes::edit::{parse_edit_options_form, persist_edit_recipe}; +use crate::routes::llm::{engine_for_request, engine_overrides_from_fields}; +use crate::routes::route_util::{parse_multipart, SinglePhotoForm}; +use crate::state::AppState; + +/// Edits a session allows when the plugin does not say. +const DEFAULT_MAX_EDITS: u32 = 3; + +pub fn router() -> axum::Router> { + axum::Router::new() + .route("/edit/sessions", axum::routing::post(start_session)) + .route( + "/edit/sessions/{session_id}/render", + axum::routing::post(render_session), + ) + .route( + "/edit/sessions/{session_id}", + axum::routing::delete(end_session), + ) +} + +fn error(status: StatusCode, message: impl Into) -> Response { + ( + status, + Json(json!({"status": "error", "error": message.into()})), + ) + .into_response() +} + +fn text(fields: &HashMap, key: &str) -> Option { + fields + .get(key) + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + +/// The model the session runs on, from the same fields the other edit and +/// indexing requests send. +async fn chat_model_for( + state: &AppState, + fields: &HashMap, +) -> Result, String> { + let missing = "No AI model is chosen for AI Edit. Pick one in the AI Edit dialog."; + let provider = match text(fields, "provider") + .ok_or(missing)? + .to_lowercase() + .as_str() + { + "openai" => "chatgpt".to_string(), + other => other.to_string(), + }; + let model = text(fields, "model").ok_or(missing)?; + if matches!(provider.as_str(), "llamacpp" | "mlx") { + let engine = engine_for_request( + state, + &provider, + &model, + engine_overrides_from_fields(fields), + ) + .await? + .ok_or_else(|| format!("\"{provider}\" is not a local AI model provider."))?; + return Ok(Arc::new(LocalChatModel::new(engine))); + } + let selection = ProviderSelection { + name: provider, + api_key: text(fields, "api_key"), + ollama_base_url: text(fields, "ollama_base_url"), + lmstudio_base_url: text(fields, "lmstudio_base_url"), + server_url: text(fields, "server_url"), + local_engine: None, + }; + build_chat_model(&selection, &model) +} + +fn json_field(fields: &HashMap, key: &str) -> Result, String> { + match fields.get(key).map(|s| s.trim()).filter(|s| !s.is_empty()) { + None => Ok(None), + Some(raw) => serde_json::from_str(raw) + .map(Some) + .map_err(|e| format!("{key} is not valid JSON: {e}")), + } +} + +fn setup_from(fields: &HashMap) -> LoopSetup { + LoopSetup { + // Decided by `edit_loop::start` from the photo's own settings. + is_raw: None, + masks: fields + .get("masks") + .is_some_and(|v| v.trim().eq_ignore_ascii_case("true")), + language: text(fields, "language").unwrap_or_else(|| "English".to_string()), + look: text(fields, "look").unwrap_or_else(|| "natural".to_string()), + instructions: text(fields, "instructions"), + max_edits: fields + .get("max_edits") + .and_then(|v| v.trim().parse().ok()) + .unwrap_or(DEFAULT_MAX_EDITS), + from_style: false, + } +} + +/// `POST /v1/edit/sessions` — opens a session and answers with its first +/// step. +async fn start_session(State(state): State>, mut multipart: Multipart) -> Response { + let form = match parse_multipart(&mut multipart).await { + Ok(form) => form, + Err(e) => return error(StatusCode::BAD_REQUEST, e), + }; + let SinglePhotoForm { + photo_ids, + image_bytes, + filename, + fields, + } = form.into_single_photo(); + if let Some(db_path) = fields.get("db_path") { + if let Err(e) = state.ensure_db_path(db_path).await { + log::error!("Auto-bind to db_path {db_path} failed: {e}"); + } + } + let (Some(image), [photo_id]) = (image_bytes, photo_ids.as_slice()) else { + return error( + StatusCode::BAD_REQUEST, + "An edit session needs one photo_id and one image.", + ); + }; + let develop_settings = match json_field(&fields, "develop_settings") { + Ok(v) => v, + Err(e) => return error(StatusCode::BAD_REQUEST, e), + }; + let style_recipe = match json_field(&fields, "style_recipe") { + Ok(v) => v, + Err(e) => return error(StatusCode::BAD_REQUEST, e), + }; + let model = match chat_model_for(&state, &fields).await { + Ok(model) => model, + Err(e) => return error(StatusCode::BAD_REQUEST, e), + }; + log::info!( + "Edit session for {photo_id} on {} (look {:?}, from style: {})", + model.name(), + fields.get("look"), + style_recipe.is_some() + ); + + let request = StartRequest { + photo_id: photo_id.clone(), + filename, + image, + develop_settings, + is_raw: fields.get("is_raw").map(|v| v.eq_ignore_ascii_case("true")), + setup: setup_from(&fields), + style_recipe, + model, + fields, + }; + match edit_loop::start(&state, request).await { + Ok((id, step)) => { + let Some(session) = state.edit_sessions.get(&id) else { + return error( + StatusCode::INTERNAL_SERVER_ERROR, + "the edit session vanished as it was opened", + ); + }; + let session = session.lock().await; + Json(step.to_json(&id, &session)).into_response() + } + Err(e) => { + log::warn!("Edit session for {photo_id} could not start: {}", e.message); + error(e.status, e.message) + } + } +} + +/// `POST /v1/edit/sessions/{session_id}/render` — the render of the last +/// step's edit; answers with the next step. +async fn render_session( + State(state): State>, + Path(session_id): Path, + mut multipart: Multipart, +) -> Response { + let form = match parse_multipart(&mut multipart).await { + Ok(form) => form, + Err(e) => return error(StatusCode::BAD_REQUEST, e), + }; + let lightroom_notes: Vec = match json_field(&form.fields, "lightroom_notes") { + Ok(Some(Value::Array(items))) => items + .into_iter() + .filter_map(|n| n.as_str().map(str::to_string)) + .collect(), + Ok(_) => Vec::new(), + Err(e) => return error(StatusCode::BAD_REQUEST, e), + }; + let Some(image) = form.images.into_iter().next().map(|i| i.bytes) else { + return error(StatusCode::BAD_REQUEST, "No render was uploaded."); + }; + let Some(session) = state.edit_sessions.get(&session_id) else { + return error( + StatusCode::NOT_FOUND, + "This edit session has ended or expired. Run AI Edit on the photo again.", + ); + }; + let mut session = session.lock().await; + let step = match edit_loop::render(&state, &mut session, &image, lightroom_notes).await { + Ok(step) => step, + Err(e) => { + log::warn!( + "Edit session {session_id} ({}): {}", + session.photo_id, + e.message + ); + return error(e.status, e.message); + } + }; + let mut step = step; + if step.done { + if let Some(warning) = persist(&state, &session, &step).await { + step.warnings.push(warning); + } + } + let body = step.to_json(&session_id, &session); + if step.done { + drop(session); + state.edit_sessions.remove(&session_id); + } + Json(body).into_response() +} + +/// Stores the finished recipe on the photo's index record, as +/// `/v1/edit/style` stores the recipe it generates: what the AI made of the +/// photo, whether or not the user then keeps it in Lightroom (the plugin's +/// review comes after this answer). Returns what to tell the user when it +/// could not be stored; the edit itself is in Lightroom already. +async fn persist( + state: &AppState, + session: &edit_loop::EditSession, + step: &Step, +) -> Option { + // No catalog bound means nothing of this photo is indexed either, so + // there is no record to keep the recipe on; nothing the user lost. + let store = state.store()?; + let (recipe, fields) = step + .keep_round + .and_then(|round| session.result(round, step.verdict.as_deref()))?; + let options = parse_edit_options_form(&fields); + match persist_edit_recipe( + &store, + &session.photo_id, + session.filename.as_deref(), + &recipe, + &options, + ) + .await + { + Ok(()) => None, + Err(e) => { + log::error!( + "Failed to persist the AI Edit recipe for {}: {e}", + session.photo_id + ); + Some( + "The edit is on the photo, but the backend could not store a copy of it with the \ + photo's record in the index. Nothing on the photo is affected; the backend log \ + has the cause." + .to_string(), + ) + } + } +} + +/// `DELETE /v1/edit/sessions/{session_id}` — ends a session early. Ending one +/// that has already ended is not an error. +async fn end_session( + State(state): State>, + Path(session_id): Path, +) -> Response { + let ended = state.edit_sessions.remove(&session_id); + Json(json!({"status": "success", "ended": ended})).into_response() +} diff --git a/server-rs/crates/lrg-api/src/routes/index_upload.rs b/server-rs/crates/lrg-api/src/routes/index_upload.rs index e2e33e06..038f088f 100644 --- a/server-rs/crates/lrg-api/src/routes/index_upload.rs +++ b/server-rs/crates/lrg-api/src/routes/index_upload.rs @@ -76,8 +76,9 @@ pub(crate) struct ParsedOptions { exposure_bias: Option, /// Whether the original is a raw file, from the multipart path. Stored /// with the photo rather than used during indexing: style training has to - /// keep raw and rendered originals apart, because Lightroom's `Temp` is - /// Kelvin for one and a relative -100..100 for the other. Absent when the + /// keep raw and rendered originals apart, because Lightroom's white balance + /// is Kelvin (`Temperature`) for one and a relative -100..100 offset + /// (`IncrementalTemperature`) for the other. Absent when the /// plugin could not read the format. is_raw: Option, /// How many photos to hand the provider per LLM call. `None` means "ask diff --git a/server-rs/crates/lrg-api/src/routes/mod.rs b/server-rs/crates/lrg-api/src/routes/mod.rs index 731804d3..4db7380e 100644 --- a/server-rs/crates/lrg-api/src/routes/mod.rs +++ b/server-rs/crates/lrg-api/src/routes/mod.rs @@ -6,6 +6,7 @@ pub mod clip; pub mod cull_taste; pub mod db; pub mod edit; +pub mod edit_session; pub mod face_recognizer; pub mod faces; pub mod find_similar; diff --git a/server-rs/crates/lrg-api/src/routes/style_edit.rs b/server-rs/crates/lrg-api/src/routes/style_edit.rs index f34effc1..ce3ba6e3 100644 --- a/server-rs/crates/lrg-api/src/routes/style_edit.rs +++ b/server-rs/crates/lrg-api/src/routes/style_edit.rs @@ -17,12 +17,17 @@ use lrg_analysis::style_engine::{ generate_style_edit, ExposureFeatures, StyleEngineResult, StyleQuery, TrainingCandidate, CANDIDATE_POOL, CONFIDENCE_LOW, }; -use lrg_analysis::training::{compute_exposure_metrics, time_of_day_bucket_for_hour}; +use lrg_analysis::training::{ + canonicalize_develop_settings_str, compute_exposure_metrics, time_of_day_bucket_for_hour, + white_balance_from_json, CANONICAL_VERSION, +}; +use lrg_develop::model::WbSetting; +use lrg_develop::{FileKindHint, WarningKind}; use lrg_store::{StoreRecord, IMAGE_TABLE, TRAINING_TABLE}; use super::edit::{ cosine_distance, generate_edit_recipe_for_photo, parse_edit_options_form, persist_edit_recipe, - success_payload, training_example_is_current, OUTDATED_TRAINING_EXAMPLES, + success_payload, training_example_is_current, warning_entries, OUTDATED_TRAINING_EXAMPLES, }; use crate::routes::route_util::{ local_hour, parse_multipart, reasoning_effort_field, SinglePhotoForm, @@ -106,23 +111,73 @@ async fn score_edit(State(state): State>, mut multipart: Multipart } } -fn record_to_candidate(id: &str, meta: &Map, distance: f64) -> TrainingCandidate { - let canonical_settings = meta - .get("canonical_settings") - .and_then(Value::as_str) - .and_then(|s| serde_json::from_str::(s).ok()) - .and_then(|v| match v { - Value::Object(m) => Some(m), - _ => None, - }) - .unwrap_or_default(); +/// The learnable form of a stored training example: its frozen +/// `canonical_settings`/`white_balance` when they were written by the current +/// [`CANONICAL_VERSION`], otherwise derived again from its `develop_settings` +/// blob (rows saved before white balance was read from the right keys carry +/// none, and a stale tint). Only the retrieved candidates (at most +/// `CANDIDATE_POOL`) are re-derived, and nothing is written back. +/// +/// The flag is true when a re-derived row teaches nothing: its blob is +/// missing or unreadable, or older than PV2012. Training told the user +/// about those only if the row was saved by a build that already knew; an +/// older row reaches the user through the style route's warning instead. +fn canonical_of( + id: &str, + meta: &Map, +) -> (Map, Option, bool) { + let stored_json = |key: &str| { + meta.get(key) + .and_then(Value::as_str) + .and_then(|s| serde_json::from_str::(s).ok()) + }; + if meta.get("canonical_version").and_then(Value::as_u64) == Some(CANONICAL_VERSION) { + let settings = match stored_json("canonical_settings") { + Some(Value::Object(m)) => m, + _ => Map::new(), + }; + let wb = stored_json("white_balance").and_then(|v| white_balance_from_json(&v)); + return (settings, wb, false); + } + let Some(blob) = meta.get("develop_settings").and_then(Value::as_str) else { + log::warn!("Training example {id}: no stored develop settings; it contributes nothing to the style blend"); + return (Map::new(), None, true); + }; + log::debug!("Training example {id}: stored canonical form is outdated, re-deriving it"); + let hint = FileKindHint::from(meta.get("is_raw").and_then(Value::as_bool)); + match canonicalize_develop_settings_str(blob, hint) { + Ok(c) => { + let too_old = c + .warnings + .iter() + .any(|w| matches!(w.kind, WarningKind::UnsupportedProcessVersion { .. })); + if too_old { + log::warn!("Training example {id}: process version older than PV2012; it contributes nothing to the style blend"); + } + (c.settings, c.white_balance, too_old) + } + Err(e) => { + log::warn!("Training example {id}: stored develop settings are unreadable ({e}); it contributes nothing to the style blend"); + (Map::new(), None, true) + } + } +} + +/// A stored training row as a style-engine candidate, and whether it had +/// to be re-derived into nothing (see [`canonical_of`]). +fn record_to_candidate( + id: &str, + meta: &Map, + distance: f64, +) -> (TrainingCandidate, bool) { + let (canonical_settings, white_balance, unlearned) = canonical_of(id, meta); let scene_tags: Vec = meta .get("scene_tags") .and_then(Value::as_str) .and_then(|s| serde_json::from_str(s).ok()) .unwrap_or_default(); - TrainingCandidate { + let candidate = TrainingCandidate { photo_id: id.to_string(), filename: meta .get("filename") @@ -149,10 +204,9 @@ fn record_to_candidate(id: &str, meta: &Map, distance: f64) -> Tr .and_then(Value::as_str) .unwrap_or("unknown") .to_string(), - // Absent on examples saved before the flag was recorded, which the - // style engine treats as compatible with anything. - is_raw: meta.get("is_raw").and_then(Value::as_bool), - } + white_balance, + }; + (candidate, unlearned) } /// Port of the candidate-retrieval half of `generate_style_edit`: CLIP @@ -161,51 +215,64 @@ fn record_to_candidate(id: &str, meta: &Map, distance: f64) -> Tr /// `clip_distance_to_similarity` expects), otherwise the most-recent- /// examples fallback with Python's neutral 0.5 distance. /// -/// The similarity search only considers examples embedded with the current -/// search model. The second value says how many it had to leave out for that, -/// so the caller can explain an empty result. +/// Also returns how many of the candidates teach nothing (see +/// [`canonical_of`]) and, as the third value, how many examples the +/// similarity search had to leave out because they were embedded with an +/// earlier search model, so the caller can tell the user either. async fn fetch_style_candidates( store: &lrg_store::Store, query_embedding: Option<&[f32]>, n_results: usize, -) -> (Vec, usize) { - if let Some(q) = query_embedding { - let records = store.scan_all(TRAINING_TABLE).await.unwrap_or_default(); - let outdated = records - .iter() - .filter(|r| r.vector.is_some() && !training_example_is_current(&r.metadata)) - .count(); - let mut scored: Vec<(f64, StoreRecord)> = records - .into_iter() - .filter(|r| training_example_is_current(&r.metadata)) - .filter_map(|r| { - let v = r.vector.clone()?; - Some((cosine_distance(q, &v), r)) - }) - .collect(); - scored.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap()); - scored.truncate(n_results); - let candidates = scored - .into_iter() - .map(|(cos_dist, r)| record_to_candidate(&r.id, &r.metadata, 2.0 * cos_dist)) - .collect(); - (candidates, outdated) - } else { - let mut rows = store.scan_meta(TRAINING_TABLE).await.unwrap_or_default(); - rows.sort_by(|a, b| { - let ca = a.1.get("captured_at").and_then(Value::as_str).unwrap_or(""); - let cb = b.1.get("captured_at").and_then(Value::as_str).unwrap_or(""); - cb.cmp(ca) - }); - rows.truncate(n_results); - let candidates = rows - .into_iter() - .map(|(id, meta)| record_to_candidate(&id, &meta, 0.5)) - .collect(); - (candidates, 0) - } +) -> (Vec, usize, usize) { + let (pairs, outdated): (Vec<(TrainingCandidate, bool)>, usize) = + if let Some(q) = query_embedding { + let records = store.scan_all(TRAINING_TABLE).await.unwrap_or_default(); + let outdated = records + .iter() + .filter(|r| r.vector.is_some() && !training_example_is_current(&r.metadata)) + .count(); + let mut scored: Vec<(f64, StoreRecord)> = records + .into_iter() + .filter(|r| training_example_is_current(&r.metadata)) + .filter_map(|r| { + let v = r.vector.clone()?; + Some((cosine_distance(q, &v), r)) + }) + .collect(); + scored.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap()); + scored.truncate(n_results); + let pairs = scored + .into_iter() + .map(|(cos_dist, r)| record_to_candidate(&r.id, &r.metadata, 2.0 * cos_dist)) + .collect(); + (pairs, outdated) + } else { + let mut rows = store.scan_meta(TRAINING_TABLE).await.unwrap_or_default(); + rows.sort_by(|a, b| { + let ca = a.1.get("captured_at").and_then(Value::as_str).unwrap_or(""); + let cb = b.1.get("captured_at").and_then(Value::as_str).unwrap_or(""); + cb.cmp(ca) + }); + rows.truncate(n_results); + let pairs = rows + .into_iter() + .map(|(id, meta)| record_to_candidate(&id, &meta, 0.5)) + .collect(); + (pairs, 0) + }; + let unlearned = pairs.iter().filter(|(_, u)| *u).count(); + ( + pairs.into_iter().map(|(c, _)| c).collect(), + unlearned, + outdated, + ) } +/// The note for candidates that were retrieved but teach nothing. The count +/// goes to the log, not into the text: the plugin merges warnings by exact +/// text into one "(N photos)" line, and a per-photo number would defeat it. +const UNLEARNED_EXAMPLES_WARNING: &str = "Some of your matching training examples use a Lightroom process version older than PV2012, or their develop settings could not be read, so they were not used. Update them to the current process version in the Develop module and save them as training examples again."; + async fn style_edit(State(state): State>, mut multipart: Multipart) -> Response { log::info!("Style edit request received"); @@ -295,7 +362,7 @@ async fn style_edit(State(state): State>, mut multipart: Multipart ); let training_count = store.count(TRAINING_TABLE).await.unwrap_or(0); - let (candidates, outdated_examples) = fetch_style_candidates( + let (candidates, unlearned, outdated_examples) = fetch_style_candidates( &store, clip_embedding.as_deref(), CANDIDATE_POOL.min(training_count.max(1)), @@ -308,8 +375,8 @@ async fn style_edit(State(state): State>, mut multipart: Multipart .then_some(OUTDATED_TRAINING_EXAMPLES.to_string()); // Parsed before the style engine runs, not after: the engine needs to know - // whether this photo is raw in order to decide which examples may - // contribute a temperature. + // whether this photo is raw to decide which examples' white balance + // applies (Kelvin for raw, an offset for everything else). let options = parse_edit_options_form(&fields); let query = StyleQuery { @@ -320,12 +387,22 @@ async fn style_edit(State(state): State>, mut multipart: Multipart }, scene_tags: query_scene_tags, time_of_day_bucket: query_tod, - // Decides which examples may contribute a temperature: Lightroom's is - // Kelvin for raw and relative for everything else, and the two cannot - // be averaged together. + // Decides the white-balance family: Kelvin (`Temperature`) for raw, + // an offset (`IncrementalTemperature`) for everything else. Unknown + // means no white balance is transferred. is_raw: options.is_raw, }; - let result: StyleEngineResult = generate_style_edit(training_count, &candidates, &query); + let mut result: StyleEngineResult = generate_style_edit(training_count, &candidates, &query); + if unlearned > 0 && result.engine != "none" { + log::warn!( + "Photo {photo_id}: {unlearned} of {} matching training examples taught nothing (older than PV2012 or unreadable)", + candidates.len() + ); + result.warnings.push(UNLEARNED_EXAMPLES_WARNING.to_string()); + } + if result.warnings.is_empty() { + result.warnings.extend(outdated_note); + } if (result.engine == "none" || result.confidence < CONFIDENCE_LOW) && use_llm_fallback { log::info!( @@ -353,21 +430,22 @@ async fn style_edit(State(state): State>, mut multipart: Multipart { log::error!("Failed to persist style_edit LLM-fallback recipe for {photo_id}: {e}"); } + let llm_warnings = warning_entries(llm_response.warning); let mut payload = success_payload( &photo_id, &recipe, &options, - llm_response.warning.as_deref(), + &llm_warnings, &llm_response.guardrail_reasons, ); payload["engine"] = json!("llm"); payload["confidence"] = json!(round3(result.confidence)); payload["matched_examples"] = json!(result.matched_count); - payload["style_engine_note"] = result - .warning - .or(outdated_note) - .map(|w| json!(w)) - .unwrap_or(Value::Null); + payload["style_engine_note"] = if result.warnings.is_empty() { + Value::Null + } else { + json!(result.warnings.join("\n")) + }; payload["input_tokens"] = json!(llm_response.input_tokens); payload["output_tokens"] = json!(llm_response.output_tokens); return Json(payload).into_response(); @@ -381,7 +459,11 @@ async fn style_edit(State(state): State>, mut multipart: Multipart "engine": "none", "confidence": 0.0, "matched_examples": 0, - "error": result.warning.or(outdated_note).unwrap_or_else(|| "Style engine could not produce a result.".to_string()), + "error": if result.warnings.is_empty() { + "Style engine could not produce a result.".to_string() + } else { + result.warnings.join("\n") + }, })), ) .into_response(); @@ -411,18 +493,10 @@ async fn style_edit(State(state): State>, mut multipart: Multipart // average was formed across frames this one may have nothing in common // with. A habitual +25 contrast learnt on softly lit material is exactly // what `derive_budget` exists to keep off a harshly lit frame. + // White balance needs no sanitising here: `blend_white_balance` only + // sends numbers of the target's own key family, as `white_balance` at + // recipe level, never as `global.temperature`. let mut styled_recipe = result.recipe; - // The interpolated settings come straight from the user's own edits, so - // `temperature` carries whatever scale *those* photos used. Applying a - // Kelvin average to a JPEG is the maximum-warmth accident this guards - // against; the LLM paths get the same treatment inside - // `normalize_edit_recipe`, which the style engine never goes through. - if lrg_providers::edit_recipe::sanitize_recipe_temperature(&mut styled_recipe, options.is_raw) { - log::info!( - "Photo {photo_id}: dropped the style engine's temperature — \ - it is on the raw Kelvin scale and this photo is not raw." - ); - } let applied = crate::edit_budget::measure_and_apply( &mut styled_recipe, &image_bytes, @@ -448,16 +522,15 @@ async fn style_edit(State(state): State>, mut multipart: Multipart { log::error!("Failed to persist style_edit recipe for {photo_id}: {e}"); } - let warning = match (result.warning.as_deref(), applied.warning) { - (Some(a), Some(b)) => Some(format!("{a}\n{b}")), - (Some(a), None) => Some(a.to_string()), - (None, b) => b, - }; + // The style engine's notes first, then what the frame would not take: a + // list, so the guardrails' note never erases the engine's. + let mut warnings = result.warnings; + warnings.extend(applied.warning); let mut payload = success_payload( &photo_id, &styled_recipe, &options, - warning.as_deref(), + &warnings, &guardrail_reasons, ); payload["engine"] = json!("style"); @@ -470,3 +543,120 @@ async fn style_edit(State(state): State>, mut multipart: Multipart fn round3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 } + +#[cfg(test)] +mod tests { + use super::*; + use lrg_develop::model::{WbFamily, WbMode}; + + fn meta(pairs: Value) -> Map { + match pairs { + Value::Object(m) => m, + _ => unreachable!(), + } + } + + #[test] + fn an_outdated_row_is_re_derived_from_its_blob() { + // Version 1 (no `canonical_version`): the frozen form has the old + // tint number and no white balance, the blob has the truth. + let blob = json!({ + "ProcessVersion": "15.4", + "WhiteBalance": "Custom", + "Temperature": 5600, + "Tint": 5, + "Contrast2012": 20, + }); + let row = meta(json!({ + "canonical_settings": json!({"contrast": 99.0, "tint": 5.0}).to_string(), + "develop_settings": blob.to_string(), + "is_raw": true, + })); + let (c, unlearned) = record_to_candidate("old", &row, 0.5); + assert!(!unlearned); + assert_eq!(c.canonical_settings["contrast"], json!(20)); + assert!(c.canonical_settings.get("tint").is_none()); + let wb = c.white_balance.unwrap(); + assert_eq!((wb.mode, wb.family), (WbMode::Custom, WbFamily::Raw)); + assert_eq!(wb.temperature.map(|t| t.get()), Some(5600.0)); + } + + #[test] + fn a_current_row_uses_its_stored_form() { + let row = meta(json!({ + "canonical_version": CANONICAL_VERSION, + "canonical_settings": json!({"contrast": 7}).to_string(), + "white_balance": json!({"mode": "As Shot", "family": "raw"}).to_string(), + // Would say otherwise; not read for a current row. + "develop_settings": json!({"Contrast2012": 50}).to_string(), + })); + let (c, unlearned) = record_to_candidate("new", &row, 0.5); + assert!(!unlearned); + assert_eq!(c.canonical_settings["contrast"], json!(7)); + assert_eq!(c.white_balance.unwrap().mode, WbMode::AsShot); + } + + #[test] + fn a_version_2_row_is_re_derived_to_pick_up_the_1f_keys() { + // Saved by the 1d build: white balance typed, but only the 20 basic + // aliases frozen. The blob has the colour mixer and the toning. + let blob = json!({ + "ProcessVersion": "15.4", + "Contrast2012": 20, + "HueAdjustmentRed": -6, + "SplitToningShadowHue": 220, + "SplitToningShadowSaturation": 15, + "ParametricShadowSplit": 30, + "ParametricMidtoneSplit": 50, + "ParametricHighlightSplit": 70, + }); + let row = meta(json!({ + "canonical_version": 2, + "canonical_settings": json!({"contrast": 20}).to_string(), + "develop_settings": blob.to_string(), + "is_raw": true, + })); + assert_ne!(CANONICAL_VERSION, 2); + let (c, unlearned) = record_to_candidate("v2", &row, 0.5); + assert!(!unlearned); + assert_eq!(c.canonical_settings["contrast"], json!(20)); + assert_eq!(c.canonical_settings["hsl_red_hue"], json!(-6)); + assert_eq!( + c.canonical_settings["color_grading_shadows_hue"], + json!(220) + ); + assert_eq!(c.canonical_settings["tone_curve_shadow_split"], json!(30)); + } + + #[test] + fn an_unreadable_or_missing_blob_contributes_nothing_and_says_so() { + let (c, unlearned) = + record_to_candidate("x", &meta(json!({"develop_settings": "not json"})), 0.5); + assert!(c.canonical_settings.is_empty() && c.white_balance.is_none()); + assert!(unlearned); + let (c, unlearned) = record_to_candidate("y", &meta(json!({})), 0.5); + assert!(c.canonical_settings.is_empty() && c.white_balance.is_none()); + assert!(unlearned); + } + + #[test] + fn an_outdated_row_older_than_pv2012_is_flagged_as_unlearned() { + // No `canonical_version`, so the blob is read again, and PV 5.7 + // (PV2010) teaches nothing. + let blob = json!({ + "ProcessVersion": "5.7", + "WhiteBalance": "Custom", + "Temperature": 5600, + "Tint": 5, + "Contrast": 20, + }); + let row = meta(json!({ + "canonical_settings": json!({"contrast": 20.0}).to_string(), + "develop_settings": blob.to_string(), + "is_raw": true, + })); + let (c, unlearned) = record_to_candidate("pv2010", &row, 0.5); + assert!(unlearned); + assert!(c.canonical_settings.is_empty() && c.white_balance.is_none()); + } +} diff --git a/server-rs/crates/lrg-api/src/routes/training.rs b/server-rs/crates/lrg-api/src/routes/training.rs index 69f4b35f..3b8b4081 100644 --- a/server-rs/crates/lrg-api/src/routes/training.rs +++ b/server-rs/crates/lrg-api/src/routes/training.rs @@ -13,9 +13,10 @@ use axum::response::{IntoResponse, Json, Response}; use serde_json::{json, Map, Value}; use lrg_analysis::training::{ - aggregate_training_stats, compute_exposure_metrics, focal_length_bucket, - normalize_develop_settings_for_style, time_of_day_bucket_for_hour, + aggregate_training_stats, canonicalize_develop_settings, compute_exposure_metrics, + focal_length_bucket, time_of_day_bucket_for_hour, white_balance_json, CANONICAL_VERSION, }; +use lrg_develop::{FileKind, WarningKind}; use lrg_store::TRAINING_TABLE; use crate::routes::route_util::{local_hour, parse_multipart, SinglePhotoForm}; @@ -98,17 +99,27 @@ async fn add_training_example( return err(StatusCode::BAD_REQUEST, "photo_id is required"); } - let develop_settings: Map = match fields.get("develop_settings") { - Some(raw) if !raw.is_empty() => match serde_json::from_str::(raw) { - Ok(Value::Object(m)) => m, - Ok(_) | Err(_) => { + // `[]` is how the plugin's JSON.lua encodes an empty Lua table, so a + // photo without develop settings arrives as `"[]"`; it is the empty table, + // not an error. + let develop_settings: Value = match fields.get("develop_settings") { + Some(raw) if !raw.trim().is_empty() => match serde_json::from_str::(raw) { + Ok(Value::Array(a)) if a.is_empty() => Value::Object(Map::new()), + Ok(v @ Value::Object(_)) => v, + Ok(_) => { + return err( + StatusCode::BAD_REQUEST, + "develop_settings must be a JSON object (or [] for none)", + ) + } + Err(_) => { return err( StatusCode::BAD_REQUEST, "develop_settings must be valid JSON", ) } }, - _ => Map::new(), + _ => Value::Object(Map::new()), }; let label = opt_str(&fields, "label"); @@ -120,15 +131,42 @@ async fn add_training_example( let shutter_speed = opt_str(&fields, "shutter_speed"); let iso = opt_f64(&fields, "iso"); let aperture = opt_f64(&fields, "aperture"); - // Recorded so the style engine can keep raw and rendered examples apart - // when it blends a temperature. Absent means unknown, which it treats as - // compatible with anything — the only workable answer for examples saved - // before this field existed. - let is_raw = fields + // What the plugin believes. The develop settings' own white-balance keys + // win over it below: `Temperature` exists only on raw files and + // `IncrementalTemperature` only on the others, while the plugin's flag + // counts every DNG as raw, including DNGs converted from JPEGs. + let is_raw_hint = fields .get("is_raw") .map(|s| s.trim().eq_ignore_ascii_case("true")); - let mut warning: Option<&'static str> = None; + let canonical = match canonicalize_develop_settings(&develop_settings, is_raw_hint.into()) { + Ok(c) => c, + Err(e) => return err(StatusCode::BAD_REQUEST, e.to_string()), + }; + let mut warnings: Vec = Vec::new(); + for w in &canonical.warnings { + match &w.kind { + // The keys are the better source and the stored flag follows them. + // Nothing for the user to do about it, so the log only. + WarningKind::FileKindMismatch { .. } => log::info!( + "Training example {photo_id}: {w}; storing is_raw from the white-balance keys" + ), + WarningKind::UnsupportedProcessVersion { found } => warnings.push(format!( + "This example uses Lightroom process version {found}, older than PV2012 (6.7), so its develop settings are not learned. Update it to the current process version in the Develop module and save it again." + )), + WarningKind::ConflictingFileKind => warnings.push( + "The white balance of this example was not learned: its develop settings carry both raw (Temperature/Tint) and non-raw (IncrementalTemperature/IncrementalTint) keys.".to_string(), + ), + // Unknown keys, odd types and the like are kept verbatim and do + // not affect what is learned today; the user cannot act on them. + _ => log::debug!("Training example {photo_id}: {w}"), + } + } + let is_raw = canonical + .file_kind + .map(|k| k == FileKind::Raw) + .or(is_raw_hint); + let mut embedding: Option> = None; let mut decoded_rgb: Option<(Vec, usize, usize)> = None; if let Some(bytes) = &image_bytes { @@ -148,8 +186,9 @@ async fn add_training_example( Err(e) => log::warn!("Failed to decode training image: {e}"), } if embedding.is_none() { - warning = Some( - "Could not compute CLIP embedding for training example. AI style prediction may be less accurate.", + warnings.push( + "Could not compute CLIP embedding for training example. AI style prediction may be less accurate." + .to_string(), ); } } @@ -158,12 +197,19 @@ async fn add_training_example( metadata.insert("photo_id".into(), json!(photo_id)); metadata.insert( "develop_settings".into(), - json!(Value::Object(develop_settings.clone()).to_string()), + json!(develop_settings.to_string()), ); metadata.insert( "canonical_settings".into(), - json!(Value::Object(normalize_develop_settings_for_style(&develop_settings)).to_string()), + json!(Value::Object(canonical.settings).to_string()), ); + if let Some(wb) = &canonical.white_balance { + metadata.insert( + "white_balance".into(), + json!(white_balance_json(wb).to_string()), + ); + } + metadata.insert("canonical_version".into(), json!(CANONICAL_VERSION)); metadata.insert( "captured_at".into(), json!(chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string()), @@ -257,9 +303,16 @@ async fn add_training_example( } let total_count = store.count(TRAINING_TABLE).await.unwrap_or(0); - let mut response = json!({"status": "ok", "photo_id": photo_id, "total_count": total_count}); - if let Some(w) = warning { - response["warning"] = json!(w); + let mut response = json!({ + "status": "ok", + "photo_id": photo_id, + "total_count": total_count, + "warnings": warnings, + }); + // Legacy, for plugins that read only the single `warning` string; remove + // once every supported plugin reads `warnings`. + if !warnings.is_empty() { + response["warning"] = json!(warnings.join("\n")); } Json(response).into_response() } diff --git a/server-rs/crates/lrg-api/src/state.rs b/server-rs/crates/lrg-api/src/state.rs index 091670d7..aefd166b 100644 --- a/server-rs/crates/lrg-api/src/state.rs +++ b/server-rs/crates/lrg-api/src/state.rs @@ -98,6 +98,9 @@ pub struct AppState { /// Keeps the computer awake while index batches run; released by /// `run_maintenance` once indexing has stopped. See [`crate::keep_awake`]. pub keep_awake: Arc, + /// AI Edit's retoucher sessions in flight, one per photo being edited. + /// See [`crate::edit_loop`]. + pub edit_sessions: crate::edit_loop::EditSessions, } /// Where the three ONNX model families live on disk. @@ -173,6 +176,7 @@ impl AppState { relaunch_after_shutdown: StdMutex::new(None), species_links: Arc::new(crate::species_links::LinkResolver::new()), keep_awake: Arc::default(), + edit_sessions: crate::edit_loop::EditSessions::default(), } } diff --git a/server-rs/crates/lrg-api/tests/contract.rs b/server-rs/crates/lrg-api/tests/contract.rs index c7c2b067..4ff627c9 100644 --- a/server-rs/crates/lrg-api/tests/contract.rs +++ b/server-rs/crates/lrg-api/tests/contract.rs @@ -2844,6 +2844,401 @@ async fn style_edit_without_training_data_is_an_error_the_plugin_must_handle() { assert!(json["error"].is_string()); } +// --------------------------------------------------------------------------- +// Training examples and the style engine's white balance (PR 1d) +// --------------------------------------------------------------------------- + +/// A scrubbed real `develop_settings` blob, as the plugin's JSON.lua sends it. +fn develop_fixture(name: &str) -> serde_json::Value { + let path = format!( + "{}/../../testdata/develop/lua/{name}", + env!("CARGO_MANIFEST_DIR") + ); + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap() +} + +/// `name` with its white balance replaced (a raw blob: `Temperature`/`Tint`). +fn with_white_balance(name: &str, mode: &str, kelvin: i64, tint: i64) -> String { + let mut blob = develop_fixture(name); + blob["WhiteBalance"] = serde_json::json!(mode); + blob["Temperature"] = serde_json::json!(kelvin); + blob["Tint"] = serde_json::json!(tint); + blob.to_string() +} + +/// A small decodable image, so the style route measures a real frame. +fn tiny_png() -> Vec { + let img = image::RgbImage::from_pixel(48, 32, image::Rgb([120, 110, 100])); + let mut out = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgb8(img) + .write_to(&mut out, image::ImageFormat::Png) + .unwrap(); + out.into_inner() +} + +async fn add_training_example( + app: axum::Router, + db_path: &str, + photo_id: &str, + develop_settings: &str, + is_raw: Option<&str>, +) -> (StatusCode, serde_json::Value) { + let mut fields = vec![ + ("photo_id", photo_id), + ("db_path", db_path), + ("develop_settings", develop_settings), + ]; + if let Some(r) = is_raw { + fields.push(("is_raw", r)); + } + let (content_type, body) = multipart_body(&fields, None); + let response = app + .oneshot( + Request::post("/v1/edit/training") + .header("content-type", content_type) + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + (status, body_json(response).await) +} + +async fn bound_app() -> (axum::Router, Arc, tempfile::TempDir, String) { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("lrgenius.db").to_str().unwrap().to_string(); + let (app, state) = fresh_app(); + state.ensure_db_path(&db_path).await.unwrap(); + (app, state, dir, db_path) +} + +#[tokio::test] +async fn training_accepts_real_blobs_and_the_empty_table() { + let (app, state, _dir, db_path) = bound_app().await; + + // JSON.lua encodes an empty Lua table as `[]`. + let (status, json) = add_training_example(app.clone(), &db_path, "empty", "[]", None).await; + assert_eq!(status, StatusCode::OK, "{json}"); + assert_eq!(json["status"], "ok"); + assert_eq!(json["warnings"], serde_json::json!([])); + assert!(json.get("warning").is_none()); + + // The plugin says "not raw", the blob's `Temperature` says raw: the keys win. + let blob = develop_fixture("lensblur_object.json").to_string(); + let (status, json) = + add_training_example(app.clone(), &db_path, "raw1", &blob, Some("false")).await; + assert_eq!(status, StatusCode::OK, "{json}"); + assert_eq!(json["total_count"], 2); + + let store = state.store().unwrap(); + let rows = store + .get(lrg_store::TRAINING_TABLE, &["raw1".to_string()]) + .await + .unwrap(); + let meta = &rows[0].metadata; + assert_eq!( + meta["canonical_version"], + lrg_analysis::training::CANONICAL_VERSION + ); + assert_eq!(meta["is_raw"], true); + let wb: serde_json::Value = + serde_json::from_str(meta["white_balance"].as_str().unwrap()).unwrap(); + assert_eq!( + wb, + serde_json::json!({"mode": "Custom", "family": "raw", "temperature": 5432, "tint": 23}) + ); + let canonical: serde_json::Value = + serde_json::from_str(meta["canonical_settings"].as_str().unwrap()).unwrap(); + assert_eq!(canonical["exposure"], 0.15); + assert!(canonical.get("temperature").is_none()); + // Version 3 (1f): the colour mixer, toning and curve splits too. + assert!(canonical["hsl_red_hue"].is_i64(), "{canonical}"); + assert!(canonical["color_grading_shadows_saturation"].is_i64()); + assert!(canonical["tone_curve_midtone_split"].is_i64()); + + // Not a table at all is still the caller's mistake. + let (status, json) = add_training_example(app.clone(), &db_path, "bad", "[1,2]", None).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{json}"); + assert!(json["error"].is_string()); +} + +#[tokio::test] +async fn training_learns_no_white_balance_from_both_key_families() { + let (app, state, _dir, db_path) = bound_app().await; + // Raw `Temperature` next to non-raw `IncrementalTint`, with the plugin's + // "raw" hint: the hint is no independent evidence, so nothing is learned + // and the response says exactly that. + let blob = develop_fixture("hand_wb_both_families.json").to_string(); + let (status, json) = + add_training_example(app.clone(), &db_path, "both", &blob, Some("true")).await; + assert_eq!(status, StatusCode::OK, "{json}"); + let warnings = json["warnings"].as_array().unwrap(); + assert!( + warnings.iter().any(|w| w + .as_str() + .unwrap() + .contains("white balance of this example was not learned")), + "{json}" + ); + + let store = state.store().unwrap(); + let rows = store + .get(lrg_store::TRAINING_TABLE, &["both".to_string()]) + .await + .unwrap(); + let meta = &rows[0].metadata; + assert!( + meta.get("white_balance") + .is_none_or(|v| v.is_null() || v.as_str() == Some("null")), + "{meta:?}" + ); +} + +/// Five examples in real blob form: `custom` of them Custom white balance +/// (agreeing within 100 K), the rest As Shot. Saved without images, so the +/// style request (for a photo with no indexed image row either) takes the +/// recent-examples fallback, where every example scores the same. +async fn seed_white_balance_examples(app: &axum::Router, db_path: &str, custom: usize) { + let customs = [ + ("lensblur_object.json", 5400, 10), + ("mask_linear_gradient.json", 5500, 14), + ("mask_ai_subject.json", 5450, 6), + ]; + let as_shot = ["mask_ai_sky.json", "no_look.json", "mask_range_color.json"]; + let mut blobs: Vec = customs[..custom] + .iter() + .map(|&(f, k, t)| with_white_balance(f, "Custom", k, t)) + .collect(); + blobs.extend( + as_shot + .iter() + .take(5 - custom) + .map(|f| with_white_balance(f, "As Shot", 5000, 0)), + ); + for (i, blob) in blobs.iter().enumerate() { + let (status, json) = + add_training_example(app.clone(), db_path, &format!("ex{i}"), blob, Some("true")).await; + assert_eq!(status, StatusCode::OK, "{json}"); + } +} + +#[tokio::test] +async fn style_edit_transfers_a_custom_majority_white_balance_to_a_raw_photo() { + let (app, _state, _dir, db_path) = bound_app().await; + seed_white_balance_examples(&app, &db_path, 3).await; + + let png = tiny_png(); + let (status, json) = style_edit_request( + app.clone(), + &[ + ("photo_id", "target"), + ("db_path", db_path.as_str()), + ("is_raw", "true"), + ], + Some(("target.png", png.as_slice())), + ) + .await; + assert_eq!(status, StatusCode::OK, "{json}"); + assert_eq!(json["engine"], "style"); + // At recipe level, next to `global`: never `global.temperature`. + assert_eq!( + json["edit"]["white_balance"], + serde_json::json!({"mode": "Custom", "family": "raw", "temperature": 5450, "tint": 10}) + ); + let global = json["edit"]["global"].as_object().unwrap(); + for key in ["temperature", "tint", "white_balance"] { + assert!(!global.contains_key(key), "global.{key} in {json}"); + } + assert!(json["warnings"].is_array()); +} + +#[tokio::test] +async fn style_edit_leaves_white_balance_alone_for_an_as_shot_majority() { + let (app, _state, _dir, db_path) = bound_app().await; + seed_white_balance_examples(&app, &db_path, 2).await; + + let png = tiny_png(); + let (status, json) = style_edit_request( + app.clone(), + &[ + ("photo_id", "target"), + ("db_path", db_path.as_str()), + ("is_raw", "true"), + ], + Some(("target.png", png.as_slice())), + ) + .await; + assert_eq!(status, StatusCode::OK, "{json}"); + assert!(json["edit"].get("white_balance").is_none(), "{json}"); + assert!(json["edit"]["global"].get("temperature").is_none()); + // As Shot is the examples' own habit: nothing to report about it. + let warnings = json["warnings"].as_array().unwrap(); + assert!( + !warnings + .iter() + .any(|w| w.as_str().unwrap().contains("White balance")), + "{json}" + ); +} + +#[tokio::test] +async fn style_edit_reports_why_a_jpeg_gets_no_white_balance() { + let (app, _state, _dir, db_path) = bound_app().await; + seed_white_balance_examples(&app, &db_path, 3).await; + + let png = tiny_png(); + let (status, json) = style_edit_request( + app.clone(), + &[ + ("photo_id", "target"), + ("db_path", db_path.as_str()), + ("is_raw", "false"), + ], + Some(("target.png", png.as_slice())), + ) + .await; + assert_eq!(status, StatusCode::OK, "{json}"); + assert!(json["edit"].get("white_balance").is_none(), "{json}"); + assert!(json["edit"]["global"].get("temperature").is_none()); + + let expected = "your training examples are raw files and this photo is not"; + let warnings: Vec<&str> = json["warnings"] + .as_array() + .unwrap() + .iter() + .map(|w| w.as_str().unwrap()) + .collect(); + assert_eq!( + warnings.iter().filter(|w| w.contains(expected)).count(), + 1, + "{json}" + ); + // Installed plugins read only the legacy joined string. + let legacy = json["warning"].as_str().unwrap(); + assert!(legacy.contains(expected), "{legacy}"); + assert_eq!(legacy, warnings.join("\n")); +} + +/// Step 1f: the style recipe carries every `global` field the *installed* +/// plugin applies, in the nested shape its `DevelopEditManager` reads +/// (`hsl..{hue,saturation,luminance}`, `color_grading.{shadows, +/// highlights}.{hue,saturation}` + `balance`, `tone_curve.*_split`), and none +/// of the colour-grading fields that plugin warns about. +#[tokio::test] +async fn style_edit_sends_every_global_field_the_installed_plugin_applies() { + let (app, _state, _dir, db_path) = bound_app().await; + let style = serde_json::json!({ + "SharpenRadius": 1.2, + "SharpenDetail": 30, + "SharpenEdgeMasking": 15, + "LuminanceNoiseReductionDetail": 55, + "ColorNoiseReductionSmoothness": 60, + "PostCropVignetteAmount": -12, + "PostCropVignetteMidpoint": 40, + "PostCropVignetteFeather": 70, + "PostCropVignetteRoundness": -10, + "PostCropVignetteHighlightContrast": 20, + "GrainAmount": 15, + "GrainSize": 30, + "GrainFrequency": 60, + "HueAdjustmentAqua": -8, + "SaturationAdjustmentBlue": -20, + "LuminanceAdjustmentOrange": 12, + "SplitToningShadowHue": 220, + "SplitToningShadowSaturation": 15, + "SplitToningHighlightHue": 40, + "SplitToningHighlightSaturation": 20, + "SplitToningBalance": -10, + "ParametricShadowSplit": 20, + "ParametricMidtoneSplit": 45, + "ParametricHighlightSplit": 70, + "ColorGradeMidtoneHue": 100, + "ColorGradeMidtoneSat": 10, + "ColorGradeBlending": 60, + "ColorGradeShadowLum": 5, + }); + let fixtures = [ + "lensblur_object.json", + "mask_linear_gradient.json", + "mask_ai_subject.json", + "filterlist_filters.json", + "mask_radial_gradient.json", + ]; + for (i, name) in fixtures.iter().enumerate() { + let mut blob = develop_fixture(name); + for (k, v) in style.as_object().unwrap() { + blob[k] = v.clone(); + } + let (status, json) = add_training_example( + app.clone(), + &db_path, + &format!("style{i}"), + &blob.to_string(), + Some("true"), + ) + .await; + assert_eq!(status, StatusCode::OK, "{json}"); + } + + let png = tiny_png(); + let (status, json) = style_edit_request( + app.clone(), + &[ + ("photo_id", "target"), + ("db_path", db_path.as_str()), + ("is_raw", "true"), + ], + Some(("target.png", png.as_slice())), + ) + .await; + assert_eq!(status, StatusCode::OK, "{json}"); + assert_eq!(json["engine"], "style"); + let global = &json["edit"]["global"]; + for (field, value) in [ + ("sharpen_radius", serde_json::json!(1.2)), + ("sharpen_detail", serde_json::json!(30)), + ("sharpen_masking", serde_json::json!(15)), + ("noise_reduction_detail", serde_json::json!(55)), + ("color_noise_reduction_smoothness", serde_json::json!(60)), + ("vignette", serde_json::json!(-12)), + ("vignette_midpoint", serde_json::json!(40)), + ("vignette_feather", serde_json::json!(70)), + ("vignette_roundness", serde_json::json!(-10)), + ("vignette_highlights", serde_json::json!(20)), + ("grain", serde_json::json!(15)), + ("grain_size", serde_json::json!(30)), + ("grain_roughness", serde_json::json!(60)), + ] { + assert_eq!(global[field], value, "global.{field} in {global}"); + } + let hsl = global["hsl"].as_object().unwrap(); + assert_eq!(hsl.len(), 8, "{global}"); + assert_eq!(global["hsl"]["aqua"]["hue"], -8); + assert_eq!(global["hsl"]["blue"]["saturation"], -20); + assert_eq!(global["hsl"]["orange"]["luminance"], 12); + assert_eq!( + global["color_grading"], + serde_json::json!({ + "shadows": {"hue": 220, "saturation": 15}, + "highlights": {"hue": 40, "saturation": 20}, + "balance": -10, + }) + ); + assert_eq!(global["tone_curve"]["shadow_split"], 20); + assert_eq!(global["tone_curve"]["midtone_split"], 45); + assert_eq!(global["tone_curve"]["highlight_split"], 70); + // Nothing the installed plugin would print as a table address. + for (k, v) in global.as_object().unwrap() { + if v.is_object() { + assert!( + ["hsl", "color_grading", "tone_curve"].contains(&k.as_str()), + "global.{k} is an object the installed plugin has no line for" + ); + } + } +} + // --------------------------------------------------------------------------- // /v1/edit/score // diff --git a/server-rs/crates/lrg-api/tests/edit_loop.rs b/server-rs/crates/lrg-api/tests/edit_loop.rs new file mode 100644 index 00000000..706c7c11 --- /dev/null +++ b/server-rs/crates/lrg-api/tests/edit_loop.rs @@ -0,0 +1,443 @@ +//! AI Edit's retoucher loop end to end, on a scripted model: what the plugin +//! gets back at each step, and what the model is shown. + +use std::collections::{HashMap, VecDeque}; +use std::sync::{Arc, LazyLock, Mutex}; + +use async_trait::async_trait; +use serde_json::{json, Value}; + +use lrg_api::edit_loop::{render, start, StartRequest}; +use lrg_api::state::{AppState, ModelAssetPaths}; +use lrg_providers::chat::{ + ChatDeltas, ChatModel, ChatPart, ChatRequest, ChatRole, ChatTurn, ToolCall, +}; +use lrg_providers::edit_loop::LoopSetup; + +static NO_MODELS: LazyLock = + LazyLock::new(|| tempfile::tempdir().expect("temp dir for the empty model directory")); + +/// Every model family pointed at an empty directory, as in `contract.rs`: the +/// guardrails run without the face rules, and nothing loads. +fn models_absent() -> ModelAssetPaths { + let dir = NO_MODELS.path(); + ModelAssetPaths { + siglip: lrg_ml::siglip::ModelPaths { + image_onnx: dir.join("siglip2_naflex_image.onnx"), + text_onnx: dir.join("siglip2_naflex_text.onnx"), + tokenizer_json: dir.join("siglip2_naflex_tokenizer.json"), + }, + aesthetic: lrg_ml::aesthetic::AestheticPaths { + onnx: dir.join("aesthetic_siglip.onnx"), + }, + face: lrg_ml::faces::FaceModelPaths { + det_onnx: dir.join("yunet_face_detection.onnx"), + rec_onnx: dir.join("facenet_vggface2.onnx"), + auraface_onnx: dir.join("auraface_glintr100.onnx"), + }, + face_state: lrg_ml::face_state::FaceStatePaths { + mesh_onnx: dir.join("face_mesh_v2.onnx"), + blendshapes_onnx: dir.join("face_blendshapes_v2.onnx"), + }, + subject: lrg_ml::subject::SubjectPaths { + onnx: dir.join("subject_rfdetr_seg_nano.onnx"), + }, + bioclip: lrg_ml::bioclip::BioclipModelPaths { + image_onnx: dir.join("bioclip2_image.onnx"), + taxa_bin: dir.join("bioclip2_taxa.bin"), + taxa_json: dir.join("bioclip2_taxa.json"), + }, + } +} + +/// Answers with the turns it was given, in order, and keeps every request. +struct Scripted { + turns: Mutex>, + requests: Mutex>, +} + +impl Scripted { + fn new(turns: Vec) -> Arc { + Arc::new(Scripted { + turns: Mutex::new(turns.into()), + requests: Mutex::new(Vec::new()), + }) + } + + fn requests(&self) -> Vec { + self.requests.lock().unwrap().clone() + } +} + +#[async_trait] +impl ChatModel for Scripted { + fn name(&self) -> String { + "scripted".into() + } + fn context_window(&self) -> Option { + None + } + fn supports_images(&self) -> bool { + true + } + async fn complete( + &self, + request: &ChatRequest, + _deltas: Option, + ) -> Result { + self.requests.lock().unwrap().push(request.clone()); + self.turns + .lock() + .unwrap() + .pop_front() + .ok_or_else(|| "the script ran out".to_string()) + } +} + +fn call(id: &str, name: &str, arguments: Value) -> ChatTurn { + ChatTurn { + tool_calls: vec![ToolCall { + id: id.into(), + name: name.into(), + arguments, + }], + input_tokens: 100, + output_tokens: 20, + ..ChatTurn::default() + } +} + +fn jpeg(level: u8) -> Vec { + let img = image::RgbImage::from_fn(64, 48, |x, _| { + image::Rgb([level.saturating_add((x % 16) as u8); 3]) + }); + let mut out = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgb8(img) + .write_to(&mut out, image::ImageFormat::Jpeg) + .unwrap(); + out.into_inner() +} + +fn request(model: Arc, style: Option, masks: bool) -> StartRequest { + StartRequest { + photo_id: "photo-1".into(), + filename: Some("IMG_1.CR3".into()), + image: jpeg(60), + develop_settings: Some(json!({ + "ProcessVersion": "15.4", + "WhiteBalance": "As Shot", + "Temperature": 5100, + "Tint": 4, + "Exposure2012": 0.2, + })), + is_raw: Some(true), + setup: LoopSetup { + is_raw: None, + masks, + language: "English".into(), + look: "moody".into(), + instructions: None, + max_edits: 3, + from_style: false, + }, + style_recipe: style, + model, + fields: HashMap::new(), + } +} + +fn state() -> Arc { + Arc::new(AppState::with_model_paths(None, false, models_absent())) +} + +#[tokio::test] +async fn edit_render_refine_finish() { + let state = state(); + let model = Scripted::new(vec![ + call( + "c1", + "apply_edit", + json!({ + "assessment": "Dark and flat; lift and give the sky depth.", + "global": {"exposure": 0.6, "highlights": -40, "temperature": 5600}, + "masks": [{"target": "sky", "adjustments": {"exposure": -0.3}}] + }), + ), + call( + "c2", + "apply_edit", + json!({ + "assessment": "A touch too bright now.", + "global": {"exposure": 0.45, "highlights": -40, "temperature": 5600} + }), + ), + call( + "c3", + "finish", + json!({"verdict": "Round 1 had the better sky.", "best_round": 1}), + ), + ]); + + let (id, first) = start(&state, request(model.clone(), None, true)) + .await + .unwrap_or_else(|e| panic!("{}", e.message)); + assert_eq!(first.round, 1); + assert!(!first.done); + assert_eq!(first.source, "model"); + assert_eq!(first.develop_settings["Exposure2012"], json!(0.6)); + assert_eq!(first.develop_settings["Temperature"], json!(5600)); + assert_eq!(first.develop_settings["WhiteBalance"], json!("Custom")); + assert_eq!( + first.develop_settings["MaskGroupBasedCorrections"][0]["CorrectionName"], + json!("LrGenius · Sky") + ); + assert!(first.update_ai_settings); + + // The model saw the photo, its starting values and its measurements. + let opening = &model.requests()[0]; + assert!(opening.system.contains("Moody")); + assert!(opening.system.contains("Masks:")); + let parts = &opening.messages[0].parts; + assert!(matches!(parts[0], ChatPart::Image(_))); + let ChatPart::Text(text) = &parts[1] else { + panic!("the opening has text after the image") + }; + assert!(text.contains("It is a raw file."), "{text}"); + assert!(text.contains("exposure +0.20"), "{text}"); + assert!(text.contains("temperature 5100"), "{text}"); + assert!(text.contains("Luminance (0-100)"), "{text}"); + + let session = state.edit_sessions.get(&id).unwrap(); + let mut session = session.lock().await; + let second = render( + &state, + &mut session, + &jpeg(120), + vec!["The sky mask found nothing in this photo.".into()], + ) + .await + .unwrap_or_else(|e| panic!("{}", e.message)); + assert_eq!(second.round, 2); + assert_eq!(second.develop_settings["Exposure2012"], json!(0.45)); + assert!(second + .develop_settings + .get("MaskGroupBasedCorrections") + .is_none()); + + // The render went back as the result of the first call, with an image + // and the comparison against the original. + let after_render = &model.requests()[1]; + let last = after_render.messages.last().unwrap(); + assert_eq!(last.role, ChatRole::User); + let ChatPart::ToolResult(result) = &last.parts[0] else { + panic!("the render answers the tool call") + }; + assert_eq!(result.call_id, "c1"); + assert_eq!(result.images.len(), 1); + assert!( + result.text.contains("Against the original: median"), + "{}", + result.text + ); + assert!( + result.text.contains("- The sky mask found nothing"), + "{}", + result.text + ); + assert!( + result.text.contains("2 more edits allowed"), + "{}", + result.text + ); + + let done = render(&state, &mut session, &jpeg(140), Vec::new()) + .await + .unwrap_or_else(|e| panic!("{}", e.message)); + assert!(done.done); + assert_eq!(done.keep_round, Some(1)); + assert_eq!(done.verdict.as_deref(), Some("Round 1 had the better sky.")); + assert_eq!(done.recipe["global"]["exposure"], json!(0.6)); + let (persisted, _) = session.result(1, done.verdict.as_deref()).unwrap(); + assert_eq!(persisted["summary"], json!("Round 1 had the better sky.")); +} + +#[tokio::test] +async fn the_style_is_round_zero_and_the_model_sees_both_images() { + let state = state(); + let model = Scripted::new(vec![ + call( + "c1", + "apply_edit", + json!({"assessment": "Keep the style, open the face.", + "global": {"exposure": 0.3, "contrast": 12}}), + ), + call("c2", "finish", json!({"verdict": "Done."})), + ]); + let style = json!({"global": {"contrast": 12, "vibrance": 10}, + "white_balance": {"mode": "Custom", "family": "raw", + "temperature": 5300, "tint": 6}, + "masks": []}); + + let (id, zero) = start(&state, request(model.clone(), Some(style), false)) + .await + .unwrap_or_else(|e| panic!("{}", e.message)); + assert_eq!(zero.round, 0); + assert_eq!(zero.source, "style"); + assert_eq!(zero.develop_settings["Vibrance"], json!(10)); + assert_eq!(zero.develop_settings["Temperature"], json!(5300)); + assert!( + model.requests().is_empty(), + "round 0 needs no model, only Lightroom" + ); + + let session = state.edit_sessions.get(&id).unwrap(); + let mut session = session.lock().await; + let first = render(&state, &mut session, &jpeg(90), Vec::new()) + .await + .unwrap_or_else(|e| panic!("{}", e.message)); + assert_eq!(first.round, 1); + // The model sent exposure and contrast only; the style's vibrance stays. + assert_eq!(first.develop_settings["Exposure2012"], json!(0.3)); + assert_eq!(first.develop_settings["Vibrance"], json!(10)); + assert_eq!(first.develop_settings["Temperature"], json!(5300)); + let opening = &model.requests()[0]; + assert!(opening.system.contains("photographer's own style")); + let ChatPart::Text(style_text) = &opening.messages[0].parts[3] else { + panic!("the style's render is followed by its text") + }; + assert!( + style_text.contains("The edit this render shows:\n") && style_text.contains("vibrance 10"), + "{style_text}" + ); + let images = opening.messages[0] + .parts + .iter() + .filter(|p| matches!(p, ChatPart::Image(_))) + .count(); + assert_eq!(images, 2, "the original and the style's render"); + + let done = render(&state, &mut session, &jpeg(100), Vec::new()) + .await + .unwrap_or_else(|e| panic!("{}", e.message)); + assert!(done.done); + assert_eq!(done.keep_round, Some(1)); +} + +#[tokio::test] +async fn a_model_that_will_not_call_a_tool_is_nudged_then_refused() { + let state = state(); + let chatter = || ChatTurn { + text: "This photo looks lovely.".into(), + ..ChatTurn::default() + }; + let model = Scripted::new(vec![chatter(), chatter(), chatter()]); + let Err(e) = start(&state, request(model.clone(), None, false)).await else { + panic!("a model without tool calls cannot edit") + }; + assert!( + e.message.contains("did not call apply_edit"), + "{}", + e.message + ); + let requests = model.requests(); + assert_eq!(requests.len(), 3); + let ChatPart::Text(nudge) = &requests[1].messages.last().unwrap().parts[0] else { + panic!("the nudge is text") + }; + assert!(nudge.contains("apply_edit")); +} + +#[tokio::test] +async fn an_edit_past_the_budget_is_refused_and_finish_is_asked_for() { + let state = state(); + let mut turns = vec![call( + "c1", + "apply_edit", + json!({"assessment": "a", "global": {"exposure": 0.1}}), + )]; + turns.push(call( + "c2", + "apply_edit", + json!({"assessment": "b", "global": {"exposure": 0.2}}), + )); + turns.push(call("c3", "finish", json!({"verdict": "ok"}))); + let model = Scripted::new(turns); + let mut req = request(model.clone(), None, false); + req.setup.max_edits = 1; + + let (id, first) = start(&state, req) + .await + .unwrap_or_else(|e| panic!("{}", e.message)); + assert_eq!(first.round, 1); + let session = state.edit_sessions.get(&id).unwrap(); + let mut session = session.lock().await; + let done = render(&state, &mut session, &jpeg(90), Vec::new()) + .await + .unwrap_or_else(|e| panic!("{}", e.message)); + assert!(done.done); + assert_eq!(done.keep_round, Some(1)); + let refused = &model.requests()[2]; + let ChatPart::ToolResult(result) = &refused.messages.last().unwrap().parts[0] else { + panic!("the refusal answers the call") + }; + assert!(result.is_error); + assert!(result.text.contains("No edits are left")); +} + +#[tokio::test] +async fn a_jpeg_gets_an_offset_white_balance() { + let state = state(); + let model = Scripted::new(vec![call( + "c1", + "apply_edit", + json!({"assessment": "Cool it down.", "global": {"temperature": -12, "tint": 3}}), + )]); + let mut req = request(model.clone(), None, false); + req.develop_settings = Some(json!({ + "ProcessVersion": "15.4", + "WhiteBalance": "As Shot", + "IncrementalTemperature": 0, + "IncrementalTint": 0, + })); + req.is_raw = Some(false); + let (_, first) = start(&state, req) + .await + .unwrap_or_else(|e| panic!("{}", e.message)); + assert_eq!(first.develop_settings["IncrementalTemperature"], json!(-12)); + assert!(first.develop_settings.get("Temperature").is_none()); + let schema = &model.requests()[0].tools[0].input_schema; + assert_eq!( + schema["properties"]["global"]["properties"]["temperature"]["maximum"], + json!(100.0) + ); +} + +#[tokio::test] +async fn edits_lightroom_cannot_use_are_named_as_the_cause() { + let state = state(); + let unusable = || { + call( + "c", + "apply_edit", + json!({"assessment": "Brighten the moon.", "global": {}, + "masks": [{"target": "moon", "adjustments": {"exposure": 1.0}}]}), + ) + }; + let model = Scripted::new(vec![unusable(), unusable(), unusable()]); + let Err(e) = start(&state, request(model.clone(), None, true)).await else { + panic!("no usable edit, no session") + }; + assert!( + e.message.contains("Lightroom has no AI mask by that name"), + "{}", + e.message + ); + assert!(!e.message.contains("tool calling"), "{}", e.message); + // The model heard why each time. + let requests = model.requests(); + let ChatPart::ToolResult(result) = &requests[1].messages.last().unwrap().parts[0] else { + panic!("the rejection answers the call") + }; + assert!(result.is_error); + assert!(result.text.contains("moon"), "{}", result.text); +} diff --git a/server-rs/crates/lrg-develop/Cargo.toml b/server-rs/crates/lrg-develop/Cargo.toml new file mode 100644 index 00000000..48c7a318 --- /dev/null +++ b/server-rs/crates/lrg-develop/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "lrg-develop" +version.workspace = true +edition.workspace = true +license.workspace = true + +[features] +# Enables `xmp::WriteMode::TestRoundTrip` and `lua::LuaMode::TestRoundTrip`, +# which write opaque, COMPUTED, NEVER and UNKNOWN content verbatim. Tests +# only: it is switched on solely by the self dev-dependency below, so no +# production build (`cargo build -p lrg-server`) has it. Never enable it from +# another crate, and never name `WriteMode::TestRoundTrip` or +# `LuaMode::TestRoundTrip` outside lrg-develop: any build that compiles +# lrg-develop's tests (`cargo test --workspace`, `cargo clippy --workspace +# --all-targets`) unifies the feature into the lrg-develop every crate links, +# so CI cannot catch such a use; only the release build would fail. +test-roundtrip = [] + +[dependencies] +roxmltree = "0.21.1" +serde.workspace = true +serde_json.workspace = true +sha2 = "0.11" +thiserror = "2" + +[dev-dependencies] +lrg-develop = { path = ".", features = ["test-roundtrip"] } +regex.workspace = true diff --git a/server-rs/crates/lrg-develop/src/build/gradient.rs b/server-rs/crates/lrg-develop/src/build/gradient.rs new file mode 100644 index 00000000..93f47b85 --- /dev/null +++ b/server-rs/crates/lrg-develop/src/build/gradient.rs @@ -0,0 +1,97 @@ +//! Linear and radial gradients. +//! +//! Coordinates are normalised per axis in the **uncropped image in sensor +//! orientation** (0 = left/top edge, 1 = right/bottom edge; values may leave +//! 0..1), the frame Lightroom stores gradients in. Builders take only +//! [`SensorPoint`]s and normalised numbers, never raw pixel tuples; +//! converting export or display pixels (`FrameGeometry`) comes with its first +//! consumer in step 2. Gradients are PHOTO: they belong to one frame and +//! never reach a shared preset. + +use super::{BuildError, RADIAL_MIDPOINT, RADIAL_ROUNDNESS}; +use crate::model::correction::{LinearGradient, RadialGradient, SensorPoint}; +use crate::model::value::Finite; + +fn finite(x: f64, what: &'static str) -> Result { + Finite::new(x).map_err(|_| BuildError::NotFinite { what }) +} + +impl SensorPoint { + /// A point from normalised sensor coordinates. + pub fn new(x: f64, y: f64) -> Result { + Ok(SensorPoint { + x: finite(x, "point x")?, + y: finite(y, "point y")?, + }) + } +} + +impl LinearGradient { + /// A linear gradient from `zero` to `full`; the two points must differ. + /// By the key names (`ZeroX`/`FullX`) the effect is none at `zero` and + /// full at `full`; that direction is unverified until experiment E6. + pub fn new(zero: SensorPoint, full: SensorPoint) -> Result { + let g = LinearGradient { zero, full }; + check_linear(&g)?; + Ok(g) + } +} + +impl RadialGradient { + /// An unrotated ellipse in its bounding box (`top < bottom`, + /// `left < right`) with `feather` 0..=100. `Angle` is 0, `Midpoint` 50 + /// and `Roundness` 0, as Lightroom writes them; rotation stays out until + /// experiment E6 settles its convention. + /// + /// `flipped` starts true (effect inside); [`CorrectionBuilder::build`](super::CorrectionBuilder::build) + /// sets it to `!MaskInverted` of the component's combination, the + /// relation every radial gradient Lightroom writes holds. + pub fn new( + top: f64, + left: f64, + bottom: f64, + right: f64, + feather: i64, + ) -> Result { + let g = RadialGradient { + top: finite(top, "top")?, + left: finite(left, "left")?, + bottom: finite(bottom, "bottom")?, + right: finite(right, "right")?, + feather, + midpoint: RADIAL_MIDPOINT, + roundness: RADIAL_ROUNDNESS, + flipped: true, + }; + check_radial(&g)?; + Ok(g) + } +} + +pub(super) fn check_linear(g: &LinearGradient) -> Result<(), BuildError> { + if g.zero == g.full { + return Err(BuildError::Geometry( + "a linear gradient needs two different points", + )); + } + Ok(()) +} + +pub(super) fn check_radial(g: &RadialGradient) -> Result<(), BuildError> { + if g.top >= g.bottom || g.left >= g.right { + return Err(BuildError::Geometry( + "a radial gradient needs top < bottom and left < right", + )); + } + if !(0..=100).contains(&g.feather) { + return Err(BuildError::Geometry( + "a radial gradient's feather is 0..=100", + )); + } + if g.midpoint != RADIAL_MIDPOINT || g.roundness != RADIAL_ROUNDNESS { + return Err(BuildError::NotBuildable( + "a radial gradient with a midpoint other than 50 or a roundness other than 0", + )); + } + Ok(()) +} diff --git a/server-rs/crates/lrg-develop/src/build/mod.rs b/server-rs/crates/lrg-develop/src/build/mod.rs new file mode 100644 index 00000000..6e3c553a --- /dev/null +++ b/server-rs/crates/lrg-develop/src/build/mod.rs @@ -0,0 +1,517 @@ +//! Builders for local corrections: one [`Correction`] of the Masking panel +//! from adjustments in UI units and a mask made of typed components. +//! +//! ``` +//! use lrg_develop::build::{CorrectionBuilder, LuminanceRange, MaskTool, SyncNamespace}; +//! use lrg_develop::model::Semantic; +//! +//! # fn main() -> Result<(), lrg_develop::build::BuildError> { +//! let subject = CorrectionBuilder::new("Subject", &SyncNamespace::lrgenius()) +//! .amount_ui(100.0)? // CorrectionAmount 1 +//! .local_ui("LocalExposure2012", 0.25)? // stored 0.0625 (EV / 4) +//! .local_ui("LocalShadows2012", 12.0)? // stored 0.12 +//! .add(Semantic::Subject) +//! .intersect(LuminanceRange::lows(0.45, 0.1)?) +//! .build()?; +//! assert_eq!(subject.name.as_deref(), Some("LrGenius · Subject")); +//! // Intersecting sets MaskInverted, and the range's Invert with it. +//! assert!(matches!(&subject.masks[1].tool, MaskTool::LuminanceRange(r) if r.invert)); +//! # Ok(()) +//! # } +//! ``` +//! +//! What a builder produces, and nothing else: +//! +//! - the correction's `CorrectionName` (`"LrGenius · "`), +//! `CorrectionSyncID`, `CorrectionAmount` (1 unless set), `CorrectionActive` +//! and its adjustments in the **stored** unit, converted by +//! [`registry::from_ui_value`] (the registry's `ui` module is the only +//! place UI and stored units meet); +//! - per component its tool, its combination ([`Combine`]), a neutral English +//! `MaskName` close to Adobe's (`"Subject"`, `"Iris and Pupil"`, +//! `"Vegetation"`, ...; Adobe's own Subject and Sky presets say +//! `"Subject 1"`/`"Sky 1"`), a `MaskSyncID` and `MaskActive`. +//! +//! The fixed per-tool fields of Adobe's preset form (`What`, an AI mask's +//! `MaskVersion`/`ReferencePoint`/`ErrorReason`, a range mask's `Version` and +//! `SampleType`) are completed by the XMP writer, not here. A radial gradient +//! gets its `Version` 2 here, because the reader keeps that field in +//! [`MaskComponent::extra`]. +//! +//! Not built, by design: brush strokes, Select Object gestures, colour and +//! depth ranges, rotated radial gradients, and every COMPUTED field (digests, +//! runtime ids, mask bitmap sizes). Those are photo-specific or Lightroom's +//! own and have no place in a generated correction. +//! +//! # Sync ids +//! +//! `CorrectionSyncID` and each `MaskSyncID` are derived, not random: the +//! first 16 bytes of SHA-256 over `(namespace, role, slot)` as 32 upper-case +//! hex digits ([`SyncNamespace::id`]). The same role in the same namespace +//! always gets the same ids, so a later step can replace an earlier +//! correction instead of stacking a second one (whether Lightroom replaces or +//! duplicates on apply is what experiments E4/E5 settle). Two corrections of +//! one preset therefore need different roles: assemble a preset's +//! corrections with [`corrections`], which refuses a repeated role +//! ([`BuildError::DuplicateRole`]). The writer does not check this, because +//! Lightroom's own sidecars repeat `MaskSyncID`s across corrections. +//! +//! The derivation is pinned by known-answer tests: changing the domain tag, +//! the length prefixes or the slot texts changes every id, and a later +//! release could no longer replace the corrections an earlier one wrote. +//! +//! # Policy of what is built +//! +//! Semantic and luminance-range components are LEARN and reach a shared +//! preset. Linear and radial gradients and [`Semantic::PersonPartAt`] are +//! PHOTO (tied to one frame's geometry or to one person), so the preset +//! writer drops a correction containing one and reports it in +//! `Written::skipped`; they are for applying to one photo. `PersonPartAt` is +//! unverified until experiments E2/E8 show that Lightroom resolves a written +//! `ReferencePoint` to the person at that point. + +mod gradient; +mod range; +pub mod recipe; +mod semantic; + +use std::collections::BTreeMap; + +use sha2::{Digest, Sha256}; + +pub use semantic::{LandscapeClass, PeoplePart}; + +use crate::model::correction::is_local_adjustment; +pub use crate::model::correction::{ + Combine, Correction, LinearGradient, LuminanceRange, MaskComponent, MaskTool, RadialGradient, + Semantic, SensorPoint, +}; +use crate::model::value::{Fields, Finite, Hex32, Value}; +use crate::registry::{ + self, from_ui, from_ui_value, CurveKind, KeyId, Level, UiError, UiScale, ValueKind, +}; + +/// Prefix of every correction name a builder writes (`"LrGenius · Sky"`). +pub const NAME_PREFIX: &str = "LrGenius · "; + +/// `Version` of a radial gradient component, as Lightroom writes it. +pub const RADIAL_VERSION: i64 = 2; +/// `Midpoint` of a built radial gradient (the only value Lightroom writes). +pub const RADIAL_MIDPOINT: i64 = 50; +/// `Roundness` of a built radial gradient (the only value Lightroom writes). +pub const RADIAL_ROUNDNESS: i64 = 0; + +/// Why a correction could not be built. +#[derive(Clone, Debug, PartialEq, thiserror::Error)] +pub enum BuildError { + /// The correction has no mask component. + #[error("the correction has no mask component")] + Empty, + /// The correction has no adjustment, so its mask would change nothing. + #[error("the correction has no adjustment")] + NoAdjustments, + /// The role (the name after [`NAME_PREFIX`]) is empty. + #[error("the correction needs a role")] + EmptyRole, + /// The first component must add: there is nothing yet to subtract from + /// or intersect with. + #[error("the first mask component must be added, not {0:?}")] + FirstNotAdd(Combine), + /// The key cannot be set by a builder. + #[error("{key} cannot be set in a built correction: {reason}")] + NotWritable { + /// Key name as given. + key: String, + /// Why (unknown key, not an adjustment, not learnable, unverified + /// UI scale, wrong kind). + reason: &'static str, + }, + /// A number was NaN or infinite. + #[error("{what}: not a finite number")] + NotFinite { + /// Which input. + what: &'static str, + }, + /// A UI value outside the key's range, or another conversion error. + #[error(transparent)] + Ui(#[from] UiError), + /// A people-part or landscape category the model does not know. + #[error("{variant}: category {id} is not one of the known ids")] + InvalidCategory { + /// The [`Semantic`] variant. + variant: &'static str, + /// The id given. + id: i64, + }, + /// A luminance range whose points are not ordered `0 ≤ low feather ≤ + /// low ≤ high ≤ high feather ≤ 1`. + #[error( + "luminance range {0:?} is not ordered 0 <= low feather <= low <= high <= high feather <= 1" + )] + LumRangeOrder([f64; 4]), + /// A gradient or ellipse without extent, or a feather outside 0..=100. + #[error("{0}")] + Geometry(&'static str), + /// A local point curve that is not 2+ points with strictly increasing x. + #[error("{key}: a curve needs at least two points with strictly increasing x")] + Curve { + /// Key name. + key: String, + }, + /// A tool a builder does not create ([`MaskTool::Opaque`], or a typed + /// tool carrying fields a builder never sets). + #[error("{0} cannot be built")] + NotBuildable(&'static str), + /// Two corrections of one preset with the same role, which would share + /// their `CorrectionSyncID` and `MaskSyncID`s ([`corrections`]). + #[error("two corrections have the role {0:?}; each needs its own")] + DuplicateRole(String), +} + +/// The namespace sync ids are derived in. [`SyncNamespace::lrgenius`] is +/// the one production uses; another namespace gives unrelated ids for the +/// same roles (tests, or a caller that must not collide with it). +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct SyncNamespace(String); + +/// Which id of a correction [`SyncNamespace::id`] derives. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum IdSlot { + /// `CorrectionSyncID`. + Correction, + /// `MaskSyncID` of the component at this index. + Component(usize), +} + +impl SyncNamespace { + /// LrGeniusAI's namespace. + pub fn lrgenius() -> SyncNamespace { + SyncNamespace("LrGeniusAI".into()) + } + + /// A namespace of its own. + pub fn new(name: impl Into) -> SyncNamespace { + SyncNamespace(name.into()) + } + + /// The namespace's name. + pub fn as_str(&self) -> &str { + &self.0 + } + + /// The id of `slot` in the correction `role`: the first 16 bytes of + /// SHA-256 over a domain tag and the length-prefixed namespace, role and + /// slot, as 32 upper-case hex digits. The length prefixes keep + /// `("a", "bc")` and `("ab", "c")` apart. + pub fn id(&self, role: &str, slot: IdSlot) -> Hex32 { + let slot = match slot { + IdSlot::Correction => "correction".to_owned(), + IdSlot::Component(i) => format!("component {i}"), + }; + let mut h = Sha256::new(); + h.update(b"lrg-develop sync id v1"); + for part in [self.0.as_str(), role, slot.as_str()] { + h.update((part.len() as u64).to_le_bytes()); + h.update(part.as_bytes()); + } + let digest = h.finalize(); + let hex: String = digest[..16].iter().map(|b| format!("{b:02X}")).collect(); + Hex32::parse(&hex).expect("32 hex digits") + } +} + +/// Builds one [`Correction`]; see the module docs. +/// +/// Adjustments are set with [`local_ui`](Self::local_ui) (UI units) and +/// [`local_curve`](Self::local_curve); setting a key twice keeps the last +/// value. Components are added in evaluation order with +/// [`add`](Self::add), [`add_inverted`](Self::add_inverted), +/// [`subtract`](Self::subtract) and [`intersect`](Self::intersect), and +/// checked in [`build`](Self::build). +#[derive(Clone, Debug)] +pub struct CorrectionBuilder { + role: String, + namespace: SyncNamespace, + amount: Finite, + local: BTreeMap, + components: Vec<(Combine, MaskTool)>, +} + +const C: Level = Level::Correction; + +impl CorrectionBuilder { + /// A correction for `role` (`"Subject"`, `"Sky"`, ...), named + /// `"LrGenius · "`, with its sync ids derived in `namespace`. + pub fn new(role: impl Into, namespace: &SyncNamespace) -> CorrectionBuilder { + CorrectionBuilder { + role: role.into(), + namespace: namespace.clone(), + amount: Finite::new_const(1.0), + local: BTreeMap::new(), + components: Vec::new(), + } + } + + /// The correction's amount in percent (100 = as set; 0..=200). Out of + /// range is an error, never clamped. + pub fn amount_ui(mut self, percent: f64) -> Result { + let spec = registry::lookup(C, "CorrectionAmount") + .expect("registry row") + .spec(); + let ui = Finite::new(percent).map_err(|_| BuildError::NotFinite { what: "amount" })?; + self.amount = from_ui(spec, ui)?; + Ok(self) + } + + /// Sets the adjustment `key` (`"LocalExposure2012"`) from its UI value + /// (`0.25` for +0.25 EV, `12.0` for +12 shadows), converted to the stored + /// unit. Out of range is an error, never clamped. + /// + /// Only learnable numeric adjustments with a verified UI scale can be + /// set: `LocalHue` and `LocalGrain` (unverified scales), + /// `LocalPointColors`, the legacy `LocalExposure` family and every + /// bookkeeping key are [`BuildError::NotWritable`]. + pub fn local_ui(mut self, key: &str, ui: f64) -> Result { + let id = writable_local(key)?; + let spec = id.spec(); + if !matches!(spec.kind, ValueKind::Real | ValueKind::Int) { + return Err(BuildError::NotWritable { + key: key.to_owned(), + reason: "not a numeric adjustment", + }); + } + let ui = Finite::new(ui).map_err(|_| BuildError::NotFinite { what: "adjustment" })?; + match from_ui_value(spec, ui)? { + (value, None) => { + self.local.insert(id, value); + Ok(self) + } + (_, Some(err)) => Err(err.into()), + } + } + + /// Sets a local point curve (`"MainCurve"`, `"RedCurve"`, `"GreenCurve"`, + /// `"BlueCurve"`): at least two points, x strictly increasing. + pub fn local_curve( + mut self, + key: &str, + points: &[(u8, u8)], + ) -> Result { + let id = writable_local(key)?; + if id.spec().kind != ValueKind::Curve(CurveKind::Local) { + return Err(BuildError::NotWritable { + key: key.to_owned(), + reason: "not a local point curve", + }); + } + let increasing = points.windows(2).all(|w| w[0].0 < w[1].0); + if points.len() < 2 || !increasing { + return Err(BuildError::Curve { + key: key.to_owned(), + }); + } + let curve = points + .iter() + .map(|&(x, y)| (Finite::from_i64(x.into()), Finite::from_i64(y.into()))) + .collect(); + self.local.insert(id, Value::Curve(curve)); + Ok(self) + } + + fn push(mut self, combine: Combine, tool: impl Into) -> CorrectionBuilder { + self.components.push((combine, tool.into())); + self + } + + /// Adds a component (`MaskBlendMode` 0, `MaskValue` 1). + // Lightroom's "Add" mask operation, a sibling of `subtract` and + // `intersect`; not arithmetic, so no `std::ops::Add`. + #[allow(clippy::should_implement_trait)] + pub fn add(self, tool: impl Into) -> CorrectionBuilder { + self.push(Combine::Add { inverted: false }, tool) + } + + /// Adds the inverse of a component (`MaskInverted` true). + pub fn add_inverted(self, tool: impl Into) -> CorrectionBuilder { + self.push(Combine::Add { inverted: true }, tool) + } + + /// Subtracts a component from the mask so far. + pub fn subtract(self, tool: impl Into) -> CorrectionBuilder { + self.push(Combine::Subtract, tool) + } + + /// Intersects the mask so far with a component. + pub fn intersect(self, tool: impl Into) -> CorrectionBuilder { + self.push(Combine::Intersect, tool) + } + + /// Checks everything and builds the correction. + /// + /// Invariants of the result: at least one component, the first one + /// added; `MaskValue` 0 exactly when `MaskBlendMode` 1 (by + /// [`Combine::encode`]); at least one adjustment; every id derived from + /// the namespace and role; a radial gradient's `Flipped` is + /// `!MaskInverted` and a luminance range's `CorrectionRangeMask.Invert` + /// is `MaskInverted` (the relations every radial gradient and range mask + /// Lightroom writes holds). + pub fn build(self) -> Result { + let role = self.role.trim(); + if role.is_empty() { + return Err(BuildError::EmptyRole); + } + let Some((first, _)) = self.components.first() else { + return Err(BuildError::Empty); + }; + if !matches!(first, Combine::Add { .. }) { + return Err(BuildError::FirstNotAdd(*first)); + } + if self.local.is_empty() { + return Err(BuildError::NoAdjustments); + } + let masks = self + .components + .into_iter() + .enumerate() + .map(|(i, (combine, tool))| { + component(combine, tool, self.namespace.id(role, IdSlot::Component(i))) + }) + .collect::, _>>()?; + Ok(Correction { + name: Some(format!("{NAME_PREFIX}{role}")), + sync_id: Some(self.namespace.id(role, IdSlot::Correction)), + amount: Some(self.amount), + active: Some(true), + local: self.local, + masks, + extra: Fields::default(), + }) + } +} + +/// Builds a preset's corrections, in order: every builder's +/// [`build`](CorrectionBuilder::build), and no role twice (two corrections +/// with one role would share their sync ids, see the module docs). The +/// way to assemble the corrections of one preset. +pub fn corrections( + builders: impl IntoIterator, +) -> Result, BuildError> { + let mut roles = std::collections::HashSet::new(); + let mut out = Vec::new(); + for b in builders { + let role = b.role.trim().to_owned(); + if !role.is_empty() && !roles.insert(role.clone()) { + return Err(BuildError::DuplicateRole(role)); + } + out.push(b.build()?); + } + Ok(out) +} + +/// The registry id of an adjustment a builder may set. +fn writable_local(key: &str) -> Result { + let not = |reason| BuildError::NotWritable { + key: key.to_owned(), + reason, + }; + let id = registry::lookup(C, key).ok_or_else(|| not("not a correction key"))?; + let spec = id.spec(); + if !is_local_adjustment(spec) { + return Err(not("not an adjustment")); + } + if !spec.policy.is_learnable() { + return Err(not("its value semantics are not established (policy)")); + } + if matches!(spec.ui, UiScale::Unknown) { + return Err(not("its UI scale is not verified")); + } + Ok(id) +} + +/// Whether `MaskInverted` is set for this combination. +fn is_inverted(combine: Combine) -> bool { + combine.encode().is_some_and(|(_, _, inverted)| inverted) +} + +/// One checked component with its neutral name and id. +fn component( + combine: Combine, + tool: MaskTool, + sync_id: Hex32, +) -> Result { + if combine == Combine::Unrecognised { + return Err(BuildError::NotBuildable("an unrecognised combination")); + } + let mut extra = Fields::default(); + let (name, tool) = match tool { + MaskTool::Semantic(s) => (semantic::mask_name(s)?, MaskTool::Semantic(s)), + MaskTool::Linear(g) => { + gradient::check_linear(&g)?; + ("Linear Gradient", MaskTool::Linear(g)) + } + MaskTool::Radial(g) => { + gradient::check_radial(&g)?; + extra.values.insert( + registry::lookup(Level::MaskTool, "Version").expect("registry row"), + Value::Int(RADIAL_VERSION), + ); + let g = RadialGradient { + flipped: !is_inverted(combine), + ..g + }; + ("Radial Gradient", MaskTool::Radial(g)) + } + MaskTool::LuminanceRange(lr) => { + range::check(&lr)?; + // Lightroom writes `Invert` equal to `MaskInverted` on every range + // mask of the maintainer's sidecar corpus, intersections included + // (the local sweep counts it); a range that disagreed could be + // read as its opposite. + let lr = LuminanceRange { + invert: is_inverted(combine), + ..lr + }; + ("Luminance Range", MaskTool::LuminanceRange(lr)) + } + MaskTool::Opaque { .. } => { + return Err(BuildError::NotBuildable( + "an opaque mask tool (brush, object, colour range)", + )) + } + }; + Ok(MaskComponent { + tool, + combine, + name: Some(name.to_owned()), + sync_id: Some(sync_id), + active: Some(true), + extra, + }) +} + +impl From for MaskTool { + fn from(s: Semantic) -> MaskTool { + MaskTool::Semantic(s) + } +} + +impl From for MaskTool { + fn from(g: LinearGradient) -> MaskTool { + MaskTool::Linear(g) + } +} + +impl From for MaskTool { + fn from(g: RadialGradient) -> MaskTool { + MaskTool::Radial(g) + } +} + +impl From for MaskTool { + fn from(r: LuminanceRange) -> MaskTool { + MaskTool::LuminanceRange(r) + } +} + +#[cfg(test)] +mod tests; diff --git a/server-rs/crates/lrg-develop/src/build/range.rs b/server-rs/crates/lrg-develop/src/build/range.rs new file mode 100644 index 00000000..096a9dbf --- /dev/null +++ b/server-rs/crates/lrg-develop/src/build/range.rs @@ -0,0 +1,59 @@ +//! Luminance range masks (`Mask/RangeMask`, `CorrectionRangeMask.Type` 2). +//! +//! `LumRange` is four luminance levels in 0..1: low feather, low, high, high +//! feather. Tones between low and high are fully selected, the selection +//! fades out between each feather point and its edge. The builder checks +//! `0 ≤ low feather ≤ low ≤ high ≤ high feather ≤ 1`. `Version` 3 and +//! `SampleType` 0 are added by the preset writer; the eyedropper sample +//! (`LuminanceDepthSampleInfo`) is photo-specific and never built, although +//! every luminance range in Lightroom's own sidecars carries one (no preset +//! of Adobe's has a range mask, so the preset form without it is +//! unverified). `CorrectionRangeMask.Invert` is not the caller's: the builder +//! sets it to the component's `MaskInverted`, as Lightroom does. + +use super::BuildError; +use crate::model::correction::LuminanceRange; +use crate::model::value::{Finite, Struct}; +use crate::registry::StructKind; + +impl LuminanceRange { + /// A luminance range from `[low feather, low, high, high feather]`. + pub fn new(range: [f64; 4]) -> Result { + let err = || BuildError::LumRangeOrder(range); + let finite = range + .iter() + .map(|&x| Finite::new(x).map_err(|_| err())) + .collect::, _>>()?; + let r = LuminanceRange { + range: finite.try_into().expect("four values"), + invert: false, + rest: Struct::new(StructKind::CorrectionRangeMask), + }; + check(&r)?; + Ok(r) + } + + /// The shadows: black up to `upto`, fading out over `feather` above it. + pub fn lows(upto: f64, feather: f64) -> Result { + LuminanceRange::new([0.0, 0.0, upto, upto + feather]) + } + + /// The highlights: `from` up to white, fading in over `feather` below + /// it. + pub fn highs(from: f64, feather: f64) -> Result { + LuminanceRange::new([from - feather, from, 1.0, 1.0]) + } +} + +pub(super) fn check(r: &LuminanceRange) -> Result<(), BuildError> { + let [fl, l, h, fh] = r.range.map(Finite::get); + if !(0.0 <= fl && fl <= l && l <= h && h <= fh && fh <= 1.0) { + return Err(BuildError::LumRangeOrder([fl, l, h, fh])); + } + if !r.rest.fields.is_empty() { + return Err(BuildError::NotBuildable( + "a luminance range with further CorrectionRangeMask fields", + )); + } + Ok(()) +} diff --git a/server-rs/crates/lrg-develop/src/build/recipe.rs b/server-rs/crates/lrg-develop/src/build/recipe.rs new file mode 100644 index 00000000..fc97a747 --- /dev/null +++ b/server-rs/crates/lrg-develop/src/build/recipe.rs @@ -0,0 +1,661 @@ +//! An edit recipe → [`DevelopSettings`]: what AI Edit's retoucher loop hands +//! to Lightroom. +//! +//! The recipe is the vocabulary a language model edits in: UI units, the +//! `global.*` names of the registry's recipe aliases, and semantic mask +//! targets by name. This module turns it into the model, so the Lua writer +//! ([`crate::lua::to_lua_value`]) can produce the table +//! `photo:applyDevelopSettings()` takes, masks included. +//! +//! ```json +//! { +//! "global": { +//! "exposure": 0.35, "highlights": -40, "temperature": 5600, "tint": 4, +//! "tone_curve": {"lights": 8, "darks": -6}, +//! "hsl": {"orange": {"saturation": -6}}, +//! "color_grading": {"midtones": {"hue": 40, "saturation": 6}}, +//! "treatment": "black_and_white", "black_and_white_mix": {"red": 10} +//! }, +//! "masks": [ +//! {"target": "sky", "adjustments": {"exposure": -0.4, "saturation": 12}} +//! ] +//! } +//! ``` +//! +//! What goes where: +//! +//! - Every global key with a recipe alias (`global.exposure`, +//! `global.hsl.red.hue`, ...) is read at its alias path, plus +//! [`EXTRA_GLOBALS`]: the colour-grading midtones, luminances and blending, +//! and the black-and-white mix, which no training example teaches and so +//! carry no alias. +//! - `temperature`/`tint` go into the photo's own white-balance family with +//! `WhiteBalance = "Custom"`: Kelvin and an absolute tint on a raw file, +//! the incremental offsets on everything else. A number on the other +//! family's scale is dropped, never clamped (6200 clamped to +100 is a +//! hard orange cast). +//! - `treatment` sets `ConvertToGrayscale`. +//! - Each mask becomes one correction from [`CorrectionBuilder`], named +//! `LrGenius · ` with sync ids derived from the target, so a later +//! round's sky replaces this round's instead of stacking a second one. +//! +//! Numbers outside a key's range are clamped and reported: the recipe has +//! been checked against the same ranges before it gets here, so a note from +//! this module means the two disagree. + +use serde_json::{Map, Value as J}; + +use super::{CorrectionBuilder, LandscapeClass, PeoplePart, Semantic, SyncNamespace}; +use crate::model::value::{Finite, Value}; +use crate::model::{DevelopSettings, FileKind}; +use crate::registry::{self, from_ui_value, to_ui, KeySpec, Level}; + +/// Mask targets a recipe may name, in the order a prompt lists them, with the +/// role their correction is named after. +pub const MASK_TARGETS: &[(&str, &str)] = &[ + ("subject", "Subject"), + ("sky", "Sky"), + ("background", "Background"), + ("face_skin", "Face Skin"), + ("body_skin", "Body Skin"), + ("hair", "Hair"), + ("lips", "Lips"), + ("teeth", "Teeth"), + ("iris_and_pupil", "Iris and Pupil"), + ("eye_sclera", "Eye Sclera"), + ("eyebrows", "Eyebrows"), + ("facial_hair", "Facial Hair"), + ("clothes", "Clothes"), + ("architecture", "Architecture"), + ("mountains", "Mountains"), + ("vegetation", "Vegetation"), + ("water", "Water"), + ("natural_ground", "Natural Ground"), + ("artificial_ground", "Artificial Ground"), + ("snow", "Snow"), +]; + +/// The AI mask a recipe's target names, or `None` for a name not in +/// [`MASK_TARGETS`]. +/// +/// The landscape class "sky" is left out on purpose: `sky` is Lightroom's +/// own Select Sky, which every version since masks has, and two masks for +/// the same thing would only confuse the model. +pub fn mask_target(name: &str) -> Option { + Some(match name { + "subject" => Semantic::Subject, + "sky" => Semantic::Sky, + "background" => Semantic::Background, + "face_skin" => Semantic::people_part(PeoplePart::FaceSkin), + "body_skin" => Semantic::people_part(PeoplePart::BodySkin), + "hair" => Semantic::people_part(PeoplePart::Hair), + "lips" => Semantic::people_part(PeoplePart::Lips), + "teeth" => Semantic::people_part(PeoplePart::Teeth), + "iris_and_pupil" => Semantic::people_part(PeoplePart::IrisAndPupil), + "eye_sclera" => Semantic::people_part(PeoplePart::EyeSclera), + "eyebrows" => Semantic::people_part(PeoplePart::Eyebrows), + "facial_hair" => Semantic::people_part(PeoplePart::FacialHair), + "clothes" => Semantic::people_part(PeoplePart::Clothes), + "architecture" => Semantic::landscape(LandscapeClass::Architecture), + "mountains" => Semantic::landscape(LandscapeClass::Mountains), + "vegetation" => Semantic::landscape(LandscapeClass::Vegetation), + "water" => Semantic::landscape(LandscapeClass::Water), + "natural_ground" => Semantic::landscape(LandscapeClass::NaturalGround), + "artificial_ground" => Semantic::landscape(LandscapeClass::ArtificialGround), + "snow" => Semantic::landscape(LandscapeClass::Snow), + _ => return None, + }) +} + +/// A mask's adjustments: recipe name → correction key. Exposure is in EV +/// (±4), the rest are the panel's ±100 sliders. +pub const LOCAL_ADJUSTMENTS: &[(&str, &str)] = &[ + ("exposure", "LocalExposure2012"), + ("contrast", "LocalContrast2012"), + ("highlights", "LocalHighlights2012"), + ("shadows", "LocalShadows2012"), + ("whites", "LocalWhites2012"), + ("blacks", "LocalBlacks2012"), + ("temperature", "LocalTemperature"), + ("tint", "LocalTint"), + ("texture", "LocalTexture"), + ("clarity", "LocalClarity2012"), + ("dehaze", "LocalDehaze"), + ("saturation", "LocalSaturation"), + ("sharpness", "LocalSharpness"), + ("noise", "LocalLuminanceNoise"), +]; + +/// Global keys a recipe may set that have no recipe alias: recipe path below +/// `global` → registry name. +pub const EXTRA_GLOBALS: &[(&str, &str)] = &[ + ("color_grading.midtones.hue", "ColorGradeMidtoneHue"), + ("color_grading.midtones.saturation", "ColorGradeMidtoneSat"), + ("color_grading.midtones.luminance", "ColorGradeMidtoneLum"), + ("color_grading.shadows.luminance", "ColorGradeShadowLum"), + ( + "color_grading.highlights.luminance", + "ColorGradeHighlightLum", + ), + ("color_grading.blending", "ColorGradeBlending"), + ("black_and_white_mix.red", "GrayMixerRed"), + ("black_and_white_mix.orange", "GrayMixerOrange"), + ("black_and_white_mix.yellow", "GrayMixerYellow"), + ("black_and_white_mix.green", "GrayMixerGreen"), + ("black_and_white_mix.aqua", "GrayMixerAqua"), + ("black_and_white_mix.blue", "GrayMixerBlue"), + ("black_and_white_mix.purple", "GrayMixerPurple"), + ("black_and_white_mix.magenta", "GrayMixerMagenta"), +]; + +/// Above this, a temperature can only be Kelvin; below it, only an offset. +/// Lightroom's relative scale ends at ±100 and no daylight is under 1000 K. +const KELVIN_LOOKING: f64 = 500.0; + +/// [`settings_from_recipe`]'s result. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct FromRecipe { + /// The settings, with `file_kind` set to the photo's. + pub settings: DevelopSettings, + /// What did not make it, or not as given, in words for the user and the + /// model. + pub notes: Vec, +} + +/// Every global key a recipe can set: path below `global` (dots) → registry +/// spec, aliases first, then [`EXTRA_GLOBALS`]. +pub fn recipe_globals() -> impl Iterator { + let aliased = registry::iter() + .filter(|(_, spec)| spec.level == Level::Global) + .filter_map(|(_, spec)| Some((spec.recipe_alias?.strip_prefix("global.")?, spec))); + let extra = EXTRA_GLOBALS.iter().map(|(path, name)| { + let id = registry::lookup(Level::Global, name) + .unwrap_or_else(|| panic!("EXTRA_GLOBALS names a key the registry lacks: {name}")); + (*path, id.spec()) + }); + aliased.chain(extra) +} + +/// A key's range in UI units, when it has one. +pub fn ui_range(spec: &KeySpec) -> Option<(f64, f64)> { + let (lo, hi) = spec.range?; + Some((to_ui(spec, lo).ok()?.get(), to_ui(spec, hi).ok()?.get())) +} + +/// Builds the develop settings of `recipe` for a photo of `file_kind`. +/// +/// `file_kind` decides the white-balance family; unknown means no white +/// balance is written, with a note. +pub fn settings_from_recipe(recipe: &J, file_kind: Option) -> FromRecipe { + let mut out = FromRecipe::default(); + out.settings.file_kind = file_kind; + let empty = Map::new(); + let global = recipe + .get("global") + .and_then(J::as_object) + .unwrap_or(&empty); + + for (path, spec) in recipe_globals() { + let Some(ui) = number_at(global, path) else { + continue; + }; + insert_ui(&mut out, spec, ui, path); + } + white_balance(global, file_kind, &mut out); + match global.get("treatment").and_then(J::as_str) { + Some("black_and_white") => insert_value(&mut out, "ConvertToGrayscale", Value::Bool(true)), + Some("color") => insert_value(&mut out, "ConvertToGrayscale", Value::Bool(false)), + Some(other) => out.notes.push(format!( + "Treatment \"{other}\" is not one Lightroom has; it was left as it was." + )), + None => {} + } + + if let Some(masks) = recipe.get("masks").and_then(J::as_array) { + masks_into(masks, &mut out); + } + out +} + +/// The sliders always reported by [`starting_values`], set or not: the ones a +/// model reasons from first. +const ALWAYS_REPORTED: &[&str] = &[ + "exposure", + "contrast", + "highlights", + "shadows", + "whites", + "blacks", + "texture", + "clarity", + "dehaze", + "vibrance", + "saturation", +]; + +/// Where a photo starts, in the recipe's vocabulary and UI units: the basic +/// sliders always, every other recipe global that is not 0, then the white +/// balance (`temperature`/`tint` on the photo's own scale) and +/// `treatment` = 1 for a black-and-white photo. +/// +/// The counterpart of [`settings_from_recipe`] for telling a model the +/// values its edit replaces. +pub fn starting_values(settings: &DevelopSettings) -> Vec<(String, f64)> { + let mut out = Vec::new(); + for (path, spec) in recipe_globals() { + let ui = settings + .get_by_name(spec.name) + .and_then(Value::as_finite) + .and_then(|stored| to_ui(spec, stored).ok()) + .map(Finite::get); + match ui { + Some(v) if v != 0.0 || ALWAYS_REPORTED.contains(&path) => { + out.push((path.to_string(), v)); + } + None if ALWAYS_REPORTED.contains(&path) => out.push((path.to_string(), 0.0)), + _ => {} + } + } + if let Some(wb) = settings.wb() { + if let Some(t) = wb.temperature { + out.push(("temperature".to_string(), t.get())); + } + if let Some(t) = wb.tint { + out.push(("tint".to_string(), t.get())); + } + } + if settings + .get_by_name("ConvertToGrayscale") + .and_then(Value::as_bool) + == Some(true) + { + out.push(("treatment_black_and_white".to_string(), 1.0)); + } + out +} + +fn number_at(map: &Map, path: &str) -> Option { + let mut parts = path.split('.'); + let mut node = map.get(parts.next()?)?; + for part in parts { + node = node.get(part)?; + } + node.as_f64().filter(|v| v.is_finite()) +} + +fn insert_value(out: &mut FromRecipe, name: &str, value: Value) { + let id = registry::lookup(Level::Global, name) + .unwrap_or_else(|| panic!("registry row Global/{name}")); + out.settings + .insert(id, value) + .unwrap_or_else(|e| panic!("{name} is a plain global key: {e}")); +} + +fn insert_ui(out: &mut FromRecipe, spec: &'static KeySpec, ui: f64, label: &str) { + let Ok(ui) = Finite::new(ui) else { + return; + }; + match from_ui_value(spec, ui) { + Ok((value, clamped)) => { + if clamped.is_some() { + out.notes.push(format!( + "{label} {} is outside Lightroom's range and was limited to it.", + ui.get() + )); + } + insert_value(out, spec.name, value); + } + // Every key reached here is a numeric slider with a known scale, so + // this is a registry bug; the words say what happened to the edit. + Err(e) => out.notes.push(format!( + "{label} was left as it was: Lightroom's {} did not take the value ({e}).", + spec.name + )), + } +} + +fn white_balance(global: &Map, file_kind: Option, out: &mut FromRecipe) { + let temperature = global.get("temperature").and_then(J::as_f64); + let tint = global.get("tint").and_then(J::as_f64); + if temperature.is_none() && tint.is_none() { + return; + } + let Some(kind) = file_kind else { + out.notes.push( + "White balance was not applied: it is not known whether this photo is a raw file, \ + so it is unclear which white-balance scale it uses." + .to_string(), + ); + return; + }; + let (temperature_key, tint_key) = match kind { + FileKind::Raw => ("Temperature", "Tint"), + FileKind::NonRaw => ("IncrementalTemperature", "IncrementalTint"), + }; + if let Some(t) = temperature { + let kelvin = t.abs() > KELVIN_LOOKING; + let wrong_scale = match kind { + FileKind::Raw => !kelvin, + FileKind::NonRaw => kelvin, + }; + if wrong_scale { + out.notes.push(match kind { + FileKind::Raw => format!( + "White balance was not applied: temperature {t} is a relative value, but this \ + raw file takes Kelvin." + ), + FileKind::NonRaw => format!( + "White balance was not applied: temperature {t} is in Kelvin, but this photo \ + is not a raw file and takes a relative -100 to +100." + ), + }); + return; + } + } + let spec = |name| { + registry::lookup(Level::Global, name) + .unwrap_or_else(|| panic!("registry row Global/{name}")) + .spec() + }; + if let Some(t) = temperature { + insert_ui(out, spec(temperature_key), t, "temperature"); + } + if let Some(t) = tint { + insert_ui(out, spec(tint_key), t, "tint"); + } + insert_value(out, "WhiteBalance", Value::Str("Custom".to_string())); +} + +fn masks_into(masks: &[J], out: &mut FromRecipe) { + let namespace = SyncNamespace::lrgenius(); + let mut seen: Vec<&str> = Vec::new(); + 'masks: for mask in masks { + let Some(object) = mask.as_object() else { + continue; + }; + let Some(target) = object.get("target").and_then(J::as_str) else { + out.notes + .push("A mask without a target was left out.".to_string()); + continue; + }; + let (Some(semantic), Some((_, role))) = ( + mask_target(target), + MASK_TARGETS.iter().find(|(name, _)| *name == target), + ) else { + out.notes.push(format!( + "A mask on \"{target}\" was left out: Lightroom has no AI mask by that name." + )); + continue; + }; + if seen.contains(&target) { + out.notes.push(format!( + "A second mask on {target} was left out: one correction per target." + )); + continue; + } + seen.push(target); + + let adjustments = object + .get("adjustments") + .and_then(J::as_object) + .unwrap_or(object); + let mut builder = CorrectionBuilder::new(*role, &namespace); + let mut any = false; + for (name, key) in LOCAL_ADJUSTMENTS { + let Some(mut ui) = adjustments.get(*name).and_then(J::as_f64) else { + continue; + }; + if !ui.is_finite() || ui == 0.0 { + continue; + } + let spec = registry::lookup(Level::Correction, key) + .unwrap_or_else(|| panic!("registry row Correction/{key}")) + .spec(); + if let Some((lo, hi)) = ui_range(spec) { + if ui < lo || ui > hi { + out.notes.push(format!( + "The {target} mask's {name} {ui} is outside Lightroom's range and was \ + limited to it." + )); + ui = ui.clamp(lo, hi); + } + } + builder = match builder.local_ui(key, ui) { + Ok(b) => b, + Err(e) => { + out.notes + .push(format!("The {target} mask was left out: {e}.")); + continue 'masks; + } + }; + any = true; + } + if !any { + continue; + } + match builder.add(semantic).build() { + Ok(correction) => out.settings.corrections.push(correction), + Err(e) => out + .notes + .push(format!("The {target} mask was left out: {e}.")), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::build::MaskTool; + use crate::lua::{to_lua_value, LuaMode, LuaOptions, PhotoContext}; + use serde_json::json; + + fn get(f: &FromRecipe, name: &str) -> Option { + f.settings.get_by_name(name).cloned() + } + + #[test] + fn globals_land_on_their_registry_keys_in_the_stored_unit() { + let f = settings_from_recipe( + &json!({"global": { + "exposure": 0.35, + "contrast": 12, + "tone_curve": {"lights": 8}, + "hsl": {"orange": {"saturation": -6}}, + "color_grading": {"shadows": {"hue": 210, "saturation": 9}, + "midtones": {"hue": 40, "saturation": 6}} + }}), + Some(FileKind::Raw), + ); + assert!(f.notes.is_empty(), "{:?}", f.notes); + assert_eq!( + get(&f, "Exposure2012"), + Some(Value::Real(Finite::new(0.35).unwrap())) + ); + assert_eq!(get(&f, "Contrast2012"), Some(Value::Int(12))); + assert_eq!(get(&f, "ParametricLights"), Some(Value::Int(8))); + assert_eq!(get(&f, "SaturationAdjustmentOrange"), Some(Value::Int(-6))); + assert_eq!(get(&f, "SplitToningShadowHue"), Some(Value::Int(210))); + assert_eq!(get(&f, "ColorGradeMidtoneHue"), Some(Value::Int(40))); + assert_eq!(f.settings.file_kind, Some(FileKind::Raw)); + } + + #[test] + fn a_number_out_of_range_is_limited_and_reported() { + let f = settings_from_recipe(&json!({"global": {"contrast": 180}}), None); + assert_eq!(get(&f, "Contrast2012"), Some(Value::Int(100))); + assert_eq!(f.notes.len(), 1); + } + + #[test] + fn white_balance_follows_the_photos_family() { + let raw = settings_from_recipe( + &json!({"global": {"temperature": 5600, "tint": 6}}), + Some(FileKind::Raw), + ); + assert_eq!(get(&raw, "Temperature"), Some(Value::Int(5600))); + assert_eq!(get(&raw, "Tint"), Some(Value::Int(6))); + assert_eq!(get(&raw, "WhiteBalance"), Some(Value::Str("Custom".into()))); + assert!(get(&raw, "IncrementalTemperature").is_none()); + + let jpeg = settings_from_recipe( + &json!({"global": {"temperature": -8, "tint": 3}}), + Some(FileKind::NonRaw), + ); + assert_eq!(get(&jpeg, "IncrementalTemperature"), Some(Value::Int(-8))); + assert_eq!(get(&jpeg, "IncrementalTint"), Some(Value::Int(3))); + assert!(get(&jpeg, "Temperature").is_none()); + } + + #[test] + fn a_temperature_on_the_other_scale_is_dropped_not_clamped() { + for (t, kind) in [(6200.0, FileKind::NonRaw), (12.0, FileKind::Raw)] { + let f = settings_from_recipe(&json!({"global": {"temperature": t}}), Some(kind)); + assert!(get(&f, "WhiteBalance").is_none(), "{t}"); + assert!(get(&f, "Temperature").is_none(), "{t}"); + assert!(get(&f, "IncrementalTemperature").is_none(), "{t}"); + assert_eq!(f.notes.len(), 1, "{t}"); + } + } + + #[test] + fn an_unknown_file_kind_gets_no_white_balance() { + let f = settings_from_recipe(&json!({"global": {"temperature": 5600}}), None); + assert!(get(&f, "WhiteBalance").is_none()); + assert_eq!(f.notes.len(), 1); + } + + #[test] + fn treatment_switches_black_and_white() { + let f = settings_from_recipe( + &json!({"global": {"treatment": "black_and_white", + "black_and_white_mix": {"blue": -20}}}), + Some(FileKind::Raw), + ); + assert_eq!(get(&f, "ConvertToGrayscale"), Some(Value::Bool(true))); + assert_eq!(get(&f, "GrayMixerBlue"), Some(Value::Int(-20))); + } + + #[test] + fn masks_become_named_corrections_with_stable_ids() { + let recipe = json!({"masks": [ + {"target": "sky", "adjustments": {"exposure": -0.4, "saturation": 12}}, + {"target": "face_skin", "adjustments": {"exposure": 0.2}}, + {"target": "sky", "adjustments": {"exposure": 1.0}}, + {"target": "moon", "adjustments": {"exposure": 1.0}}, + {"target": "subject", "adjustments": {"exposure": 0}} + ]}); + let f = settings_from_recipe(&recipe, Some(FileKind::Raw)); + let names: Vec<_> = f + .settings + .corrections + .iter() + .map(|c| c.name.clone().unwrap()) + .collect(); + assert_eq!(names, ["LrGenius · Sky", "LrGenius · Face Skin"]); + // The duplicate sky and the unknown target are reported; an all-zero + // mask changes nothing and is dropped without a word. + assert_eq!(f.notes.len(), 2, "{:?}", f.notes); + assert!(matches!( + f.settings.corrections[0].masks[0].tool, + MaskTool::Semantic(Semantic::Sky) + )); + + let again = settings_from_recipe(&recipe, Some(FileKind::Raw)); + assert_eq!( + f.settings.corrections[0].sync_id, + again.settings.corrections[0].sync_id + ); + } + + #[test] + fn every_target_builds() { + for (name, _) in MASK_TARGETS { + let f = settings_from_recipe( + &json!({"masks": [{"target": name, "adjustments": {"exposure": 0.1}}]}), + Some(FileKind::Raw), + ); + assert_eq!(f.settings.corrections.len(), 1, "{name}: {:?}", f.notes); + } + } + + #[test] + fn the_settings_write_as_an_apply_table_with_the_ai_update() { + let f = settings_from_recipe( + &json!({"global": {"exposure": 0.2, "temperature": 5400}, + "masks": [{"target": "subject", "adjustments": {"shadows": 15}}]}), + Some(FileKind::Raw), + ); + let photo = PhotoContext { + file_kind: Some(FileKind::Raw), + ..PhotoContext::default() + }; + let written = + to_lua_value(&f.settings, &LuaMode::Apply(photo), &LuaOptions::default()).unwrap(); + assert!(written.call.update_ai_settings); + assert_eq!(written.table["Exposure2012"], json!(0.2)); + assert_eq!(written.table["Temperature"], json!(5400)); + assert_eq!(written.table["WhiteBalance"], json!("Custom")); + let corrections = written.table["MaskGroupBasedCorrections"] + .as_array() + .unwrap(); + assert_eq!(corrections.len(), 1); + assert_eq!( + corrections[0]["CorrectionName"], + json!("LrGenius · Subject") + ); + assert!(written.skipped.is_empty(), "{:?}", written.skipped); + } + + #[test] + fn starting_values_read_back_what_a_recipe_wrote() { + let f = settings_from_recipe( + &json!({"global": {"exposure": 0.5, "hsl": {"blue": {"luminance": -12}}, + "temperature": 5200, "tint": 3}}), + Some(FileKind::Raw), + ); + let values = starting_values(&f.settings); + let get = |name: &str| values.iter().find(|(p, _)| p == name).map(|(_, v)| *v); + assert_eq!(get("exposure"), Some(0.5)); + assert_eq!(get("contrast"), Some(0.0), "the basics are always reported"); + assert_eq!(get("hsl.blue.luminance"), Some(-12.0)); + assert_eq!( + get("hsl.red.hue"), + None, + "a zero beyond the basics is left out" + ); + assert_eq!(get("temperature"), Some(5200.0)); + assert_eq!(get("tint"), Some(3.0)); + } + + #[test] + fn starting_values_read_a_lightroom_table() { + let (settings, _) = crate::lua::from_lua_value( + &json!({ + "ProcessVersion": "15.4", + "Exposure2012": 0.7, + "Contrast2012": 15, + "WhiteBalance": "As Shot", + "IncrementalTemperature": 0, + "IncrementalTint": 0, + "ConvertToGrayscale": true + }), + crate::FileKindHint::Unknown, + ) + .unwrap(); + let values = starting_values(&settings); + assert!(values.contains(&("exposure".to_string(), 0.7))); + assert!(values.contains(&("contrast".to_string(), 15.0))); + assert!(values.contains(&("temperature".to_string(), 0.0))); + assert!(values.contains(&("treatment_black_and_white".to_string(), 1.0))); + } + + #[test] + fn every_extra_global_is_a_registry_key() { + assert_eq!( + recipe_globals() + .filter(|(path, _)| path.starts_with("black_and_white_mix")) + .count(), + 8 + ); + } +} diff --git a/server-rs/crates/lrg-develop/src/build/semantic.rs b/server-rs/crates/lrg-develop/src/build/semantic.rs new file mode 100644 index 00000000..bb38d7ef --- /dev/null +++ b/server-rs/crates/lrg-develop/src/build/semantic.rs @@ -0,0 +1,201 @@ +//! Semantic (AI) mask components: typed people parts and landscape +//! classes, and neutral English names close to the ones Adobe's presets +//! give them (Adobe's own Subject and Sky presets say "Subject 1"/"Sky 1"). +//! +//! | [`Semantic`] | `MaskSubType` | `MaskSubCategoryID` | Policy | +//! |---|---|---|---| +//! | `Subject` | 1 | – | LEARN | +//! | `Sky` | 2 | – | LEARN | +//! | `Background` | 0 | 22 | LEARN | +//! | `PeoplePart(part)` (preset form, "all people" unconfirmed) | 3 | [`PeoplePart`] | LEARN | +//! | `Landscape(class)` | 0 | [`LandscapeClass`] | LEARN | +//! | `PersonPartAt { part, point }` | 0 | [`PeoplePart`] + a real `ReferencePoint` | PHOTO, unverified until E2/E8 | + +use super::BuildError; +use crate::model::correction::{Semantic, SensorPoint}; + +/// A people part (`MaskSubCategoryID` 2–9, 11, 12). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum PeoplePart { + /// 2. + FaceSkin, + /// 3. + IrisAndPupil, + /// 4. + BodySkin, + /// 5. + Hair, + /// 6. + Lips, + /// 7. + FacialHair, + /// 8. + EyeSclera, + /// 9. + Eyebrows, + /// 11. + Clothes, + /// 12. + Teeth, +} + +impl PeoplePart { + /// Every part, in id order. + pub const ALL: [PeoplePart; 10] = [ + PeoplePart::FaceSkin, + PeoplePart::IrisAndPupil, + PeoplePart::BodySkin, + PeoplePart::Hair, + PeoplePart::Lips, + PeoplePart::FacialHair, + PeoplePart::EyeSclera, + PeoplePart::Eyebrows, + PeoplePart::Clothes, + PeoplePart::Teeth, + ]; + + /// `MaskSubCategoryID`. + pub fn id(self) -> i64 { + match self { + PeoplePart::FaceSkin => 2, + PeoplePart::IrisAndPupil => 3, + PeoplePart::BodySkin => 4, + PeoplePart::Hair => 5, + PeoplePart::Lips => 6, + PeoplePart::FacialHair => 7, + PeoplePart::EyeSclera => 8, + PeoplePart::Eyebrows => 9, + PeoplePart::Clothes => 11, + PeoplePart::Teeth => 12, + } + } + + /// The part with this `MaskSubCategoryID`. + pub fn from_id(id: i64) -> Option { + PeoplePart::ALL.into_iter().find(|p| p.id() == id) + } + + /// The `MaskName` Adobe's portrait presets use. + pub fn mask_name(self) -> &'static str { + match self { + PeoplePart::FaceSkin => "Face Skin", + PeoplePart::IrisAndPupil => "Iris and Pupil", + PeoplePart::BodySkin => "Body Skin", + PeoplePart::Hair => "Hair", + PeoplePart::Lips => "Lips", + PeoplePart::FacialHair => "Facial Hair", + PeoplePart::EyeSclera => "Eye Sclera", + PeoplePart::Eyebrows => "Eyebrows", + PeoplePart::Clothes => "Clothes", + PeoplePart::Teeth => "Teeth", + } + } +} + +/// A landscape class (`MaskSubCategoryID` 50001–50008). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum LandscapeClass { + /// 50001. + Architecture, + /// 50002. + Mountains, + /// 50003. + ArtificialGround, + /// 50004. + NaturalGround, + /// 50005. + Vegetation, + /// 50006 (a class of its own, not [`Semantic::Sky`]). + Sky, + /// 50007. + Water, + /// 50008. + Snow, +} + +impl LandscapeClass { + /// Every class, in id order. + pub const ALL: [LandscapeClass; 8] = [ + LandscapeClass::Architecture, + LandscapeClass::Mountains, + LandscapeClass::ArtificialGround, + LandscapeClass::NaturalGround, + LandscapeClass::Vegetation, + LandscapeClass::Sky, + LandscapeClass::Water, + LandscapeClass::Snow, + ]; + + /// `MaskSubCategoryID`. + pub fn id(self) -> i64 { + 50001 + + LandscapeClass::ALL + .iter() + .position(|c| *c == self) + .expect("listed") as i64 + } + + /// The class with this `MaskSubCategoryID`. + pub fn from_id(id: i64) -> Option { + LandscapeClass::ALL.into_iter().find(|c| c.id() == id) + } + + /// The `MaskName` Adobe's landscape presets use. + pub fn mask_name(self) -> &'static str { + match self { + LandscapeClass::Architecture => "Architecture", + LandscapeClass::Mountains => "Mountains", + LandscapeClass::ArtificialGround => "Artificial Ground", + LandscapeClass::NaturalGround => "Natural Ground", + LandscapeClass::Vegetation => "Vegetation", + LandscapeClass::Sky => "Sky", + LandscapeClass::Water => "Water", + LandscapeClass::Snow => "Snow", + } + } +} + +impl Semantic { + /// A people part of everyone in the photo (the preset form, + /// `MaskSubType` 3; "everyone" is unconfirmed until the hand test). + pub fn people_part(part: PeoplePart) -> Semantic { + Semantic::PeoplePart(part.id()) + } + + /// A landscape class. + pub fn landscape(class: LandscapeClass) -> Semantic { + Semantic::Landscape(class.id()) + } + + /// A people part of the one person at `point` (PHOTO; unverified until + /// experiments E2/E8). + pub fn person_part_at(part: PeoplePart, point: SensorPoint) -> Semantic { + Semantic::PersonPartAt { + part: part.id(), + point, + } + } +} + +/// The neutral English `MaskName` of a semantic component, checking its +/// category id. +pub(super) fn mask_name(s: Semantic) -> Result<&'static str, BuildError> { + let part = |variant, id| { + PeoplePart::from_id(id) + .map(PeoplePart::mask_name) + .ok_or(BuildError::InvalidCategory { variant, id }) + }; + match s { + Semantic::Subject => Ok("Subject"), + Semantic::Sky => Ok("Sky"), + Semantic::Background => Ok("Background"), + Semantic::PeoplePart(id) => part("PeoplePart", id), + Semantic::PersonPartAt { part: id, .. } => part("PersonPartAt", id), + Semantic::Landscape(id) => LandscapeClass::from_id(id) + .map(LandscapeClass::mask_name) + .ok_or(BuildError::InvalidCategory { + variant: "Landscape", + id, + }), + } +} diff --git a/server-rs/crates/lrg-develop/src/build/tests.rs b/server-rs/crates/lrg-develop/src/build/tests.rs new file mode 100644 index 00000000..2dffe139 --- /dev/null +++ b/server-rs/crates/lrg-develop/src/build/tests.rs @@ -0,0 +1,783 @@ +use std::collections::HashSet; + +use super::*; +use crate::model::policy::{SkipReason, Skipped}; +use crate::model::DevelopSettings; +use crate::registry::{Policy, ProcessVersion}; +use crate::xmp::{self, parse, PresetHeader, PresetSpec, WriteMode, XmpKind}; + +fn ns() -> SyncNamespace { + SyncNamespace::lrgenius() +} + +fn f(x: f64) -> Finite { + Finite::new(x).unwrap() +} + +fn key(name: &str) -> KeyId { + registry::lookup(C, name).unwrap() +} + +/// A correction with one adjustment and the given components. +fn with(role: &str) -> CorrectionBuilder { + CorrectionBuilder::new(role, &ns()) + .local_ui("LocalExposure2012", 0.25) + .unwrap() +} + +fn one(tool: impl Into) -> Correction { + with("Role").add(tool).build().unwrap() +} + +fn radial() -> RadialGradient { + RadialGradient::new(0.2, 0.3, 0.8, 0.7, 40).unwrap() +} + +// ---------- the correction ---------- + +#[test] +fn a_built_correction_has_its_role_name_ids_amount_and_stored_values() { + let c = CorrectionBuilder::new("Subject", &ns()) + .amount_ui(150.0) + .unwrap() + .local_ui("LocalExposure2012", 0.25) + .unwrap() + .local_ui("LocalShadows2012", 12.0) + .unwrap() + .add(Semantic::Subject) + .build() + .unwrap(); + assert_eq!(c.name.as_deref(), Some("LrGenius · Subject")); + assert_eq!(c.sync_id, Some(ns().id("Subject", IdSlot::Correction))); + assert_eq!(c.amount, Some(f(1.5))); + assert_eq!(c.active, Some(true)); + assert_eq!( + c.local.get(&key("LocalExposure2012")), + Some(&Value::Real(f(0.0625))) + ); + assert_eq!( + c.local.get(&key("LocalShadows2012")), + Some(&Value::Real(f(0.12))) + ); + assert!(c.extra.is_empty()); + let m = &c.masks[0]; + assert_eq!(m.name.as_deref(), Some("Subject")); + assert_eq!(m.sync_id, Some(ns().id("Subject", IdSlot::Component(0)))); + assert_eq!(m.active, Some(true)); + assert!(m.extra.is_empty()); +} + +#[test] +fn the_amount_defaults_to_one_and_out_of_range_is_an_error() { + assert_eq!(one(Semantic::Sky).amount, Some(f(1.0))); + for bad in [-1.0, 201.0] { + assert!(matches!( + with("R").amount_ui(bad), + Err(BuildError::Ui(UiError::OutOfRange { .. })) + )); + } + assert!(matches!( + with("R").amount_ui(f64::NAN), + Err(BuildError::NotFinite { .. }) + )); +} + +#[test] +fn an_empty_correction_is_an_error() { + assert_eq!(with("Role").build(), Err(BuildError::Empty)); + assert_eq!( + CorrectionBuilder::new("Role", &ns()) + .add(Semantic::Sky) + .build(), + Err(BuildError::NoAdjustments) + ); + assert_eq!( + with(" ").add(Semantic::Sky).build(), + Err(BuildError::EmptyRole) + ); +} + +#[test] +fn the_first_component_must_be_added() { + let lr = || LuminanceRange::lows(0.4, 0.1).unwrap(); + assert_eq!( + with("R").subtract(Semantic::Sky).build(), + Err(BuildError::FirstNotAdd(Combine::Subtract)) + ); + assert_eq!( + with("R").intersect(lr()).build(), + Err(BuildError::FirstNotAdd(Combine::Intersect)) + ); + assert!(with("R").add_inverted(Semantic::Sky).build().is_ok()); + assert!(with("R") + .add(Semantic::Sky) + .subtract(Semantic::Subject) + .intersect(lr()) + .build() + .is_ok()); +} + +// ---------- adjustments ---------- + +#[test] +fn local_values_go_through_the_registry_ui_scale() { + let c = with("R") + .local_ui("LocalDehaze", 15.0) + .unwrap() + .local_ui("LocalHighlights2012", -40.0) + .unwrap() + .local_ui("LocalToningHue", 240.0) + .unwrap() + .local_ui("LocalCurveRefineSaturation", 30.0) + .unwrap() + .add(Semantic::Sky) + .build() + .unwrap(); + let get = |k: &str| c.local.get(&key(k)).cloned(); + assert_eq!(get("LocalDehaze"), Some(Value::Real(f(0.15)))); + assert_eq!(get("LocalHighlights2012"), Some(Value::Real(f(-0.4)))); + assert_eq!(get("LocalToningHue"), Some(Value::Real(f(240.0)))); + assert_eq!( + get("LocalCurveRefineSaturation"), + Some(Value::Real(f(30.0))) + ); +} + +#[test] +fn setting_a_local_value_twice_keeps_the_last() { + let c = with("R") + .local_ui("LocalExposure2012", -1.0) + .unwrap() + .add(Semantic::Sky) + .build() + .unwrap(); + assert_eq!( + c.local.get(&key("LocalExposure2012")), + Some(&Value::Real(f(-0.25))) + ); +} + +#[test] +fn out_of_range_local_values_are_errors_not_clamped() { + assert!(matches!( + with("R").local_ui("LocalExposure2012", 4.5), + Err(BuildError::Ui(UiError::OutOfRange { .. })) + )); + assert!(matches!( + with("R").local_ui("LocalShadows2012", -101.0), + Err(BuildError::Ui(UiError::OutOfRange { .. })) + )); + assert!(matches!( + with("R").local_ui("LocalShadows2012", f64::INFINITY), + Err(BuildError::NotFinite { .. }) + )); +} + +#[test] +fn keys_without_established_semantics_or_bookkeeping_are_not_writable() { + for k in [ + "LocalHue", // unverified UI scale + "LocalGrain", // unverified UI scale + "LocalPointColors", // UNKNOWN + "LocalExposure", // legacy, COMPUTED + "CorrectionAmount", // bookkeeping: amount_ui + "CorrectionSyncID", // bookkeeping + "What", // bookkeeping + "CorrectionID", // runtime id + "LocalGlow", // never observed + "NoSuchKey", // not a correction key + "Exposure2012", // a global key + ] { + assert!( + matches!( + with("R").local_ui(k, 1.0), + Err(BuildError::NotWritable { ref key, .. }) if key == k + ), + "{k}" + ); + } + assert!(matches!( + with("R").local_ui("MainCurve", 1.0), + Err(BuildError::NotWritable { .. }) + )); +} + +#[test] +fn local_curves_need_increasing_points_on_a_curve_key() { + let c = with("R") + .local_curve("MainCurve", &[(0, 0), (128, 140), (255, 255)]) + .unwrap() + .add(Semantic::Subject) + .build() + .unwrap(); + assert_eq!( + c.local.get(&key("MainCurve")), + Some(&Value::Curve(vec![ + (f(0.0), f(0.0)), + (f(128.0), f(140.0)), + (f(255.0), f(255.0)) + ])) + ); + for bad in [&[(0, 0)][..], &[(0, 0), (0, 10)], &[(10, 0), (5, 10)]] { + assert!(matches!( + with("R").local_curve("BlueCurve", bad), + Err(BuildError::Curve { .. }) + )); + } + assert!(matches!( + with("R").local_curve("LocalExposure2012", &[(0, 0), (255, 255)]), + Err(BuildError::NotWritable { .. }) + )); +} + +// ---------- semantic masks ---------- + +#[test] +fn every_semantic_variant_builds_with_its_encoding_and_adobes_name() { + let point = SensorPoint::new(0.4, 0.3).unwrap(); + let mut cases = vec![ + (Semantic::Subject, (1, None), "Subject"), + (Semantic::Sky, (2, None), "Sky"), + (Semantic::Background, (0, Some(22)), "Background"), + ]; + for p in PeoplePart::ALL { + cases.push((Semantic::people_part(p), (3, Some(p.id())), p.mask_name())); + cases.push(( + Semantic::person_part_at(p, point), + (0, Some(p.id())), + p.mask_name(), + )); + } + for l in LandscapeClass::ALL { + cases.push((Semantic::landscape(l), (0, Some(l.id())), l.mask_name())); + } + assert_eq!(cases.len(), 3 + 2 * 10 + 8); + for (s, encoding, name) in cases { + let c = one(s); + let m = &c.masks[0]; + assert_eq!(m.tool, MaskTool::Semantic(s)); + assert_eq!(s.encoding(), encoding); + assert_eq!(m.name.as_deref(), Some(name)); + } +} + +#[test] +fn people_part_and_landscape_ids_follow_the_findings_tables() { + let parts: Vec = PeoplePart::ALL.iter().map(|p| p.id()).collect(); + assert_eq!(parts, crate::model::correction::PEOPLE_PARTS); + let classes: Vec = LandscapeClass::ALL.iter().map(|c| c.id()).collect(); + assert_eq!( + classes, + crate::model::correction::LANDSCAPE_CLASSES.collect::>() + ); + assert_eq!(LandscapeClass::Vegetation.id(), 50005); + assert_eq!(PeoplePart::IrisAndPupil.id(), 3); + for p in PeoplePart::ALL { + assert_eq!(PeoplePart::from_id(p.id()), Some(p)); + } + for c in LandscapeClass::ALL { + assert_eq!(LandscapeClass::from_id(c.id()), Some(c)); + } + assert_eq!(PeoplePart::from_id(10), None); + assert_eq!(LandscapeClass::from_id(50009), None); +} + +#[test] +fn unknown_categories_are_errors() { + let point = SensorPoint::new(0.5, 0.5).unwrap(); + for (s, id) in [ + (Semantic::PeoplePart(1), 1), + (Semantic::PeoplePart(10), 10), + (Semantic::PeoplePart(50001), 50001), + (Semantic::Landscape(50009), 50009), + (Semantic::Landscape(22), 22), + ( + Semantic::PersonPartAt { + part: 20036, // whole person: meaning unconfirmed + point, + }, + 20036, + ), + ] { + assert!( + matches!( + with("R").add(s).build(), + Err(BuildError::InvalidCategory { id: got, .. }) if got == id + ), + "{s:?}" + ); + } +} + +// ---------- the combination table ---------- + +#[test] +fn combinations_encode_as_the_plan_table() { + let lr = LuminanceRange::lows(0.5, 0.1).unwrap(); + let c = with("R") + .add(Semantic::Subject) + .add_inverted(Semantic::Sky) + .subtract(Semantic::Background) + .intersect(lr) + .build() + .unwrap(); + let encoded: Vec<_> = c + .masks + .iter() + .map(|m| { + let (mode, value, inverted) = m.combine.encode().unwrap(); + (mode, value.get(), inverted) + }) + .collect(); + assert_eq!( + encoded, + [ + (0, 1.0, false), + (0, 1.0, true), + (1, 0.0, false), + (1, 0.0, true) + ] + ); + for (mode, value, _) in encoded { + assert_eq!(value == 0.0, mode == 1, "MaskValue 0 <=> MaskBlendMode 1"); + } +} + +#[test] +fn a_range_invert_is_mask_inverted_under_every_combination() { + let lr = || LuminanceRange::highs(0.6, 0.1).unwrap(); + let c = with("R") + .add(lr()) + .add_inverted(lr()) + .subtract(lr()) + .intersect(lr()) + .build() + .unwrap(); + let mut inverts = Vec::new(); + for m in &c.masks { + let MaskTool::LuminanceRange(r) = &m.tool else { + panic!("range expected") + }; + let (_, _, inverted) = m.combine.encode().unwrap(); + assert_eq!(r.invert, inverted, "{:?}", m.combine); + inverts.push(r.invert); + } + assert_eq!(inverts, [false, true, false, true]); + // Whatever the caller put in `invert`, the combination decides. + let mut odd = lr(); + odd.invert = true; + let c = with("R").add(Semantic::Sky).subtract(odd).build().unwrap(); + assert!(matches!(&c.masks[1].tool, MaskTool::LuminanceRange(r) if !r.invert)); +} + +#[test] +fn radial_flipped_is_the_inverse_of_mask_inverted() { + let c = with("R") + .add(radial()) + .add_inverted(radial()) + .subtract(radial()) + .intersect(radial()) + .build() + .unwrap(); + for m in &c.masks { + let MaskTool::Radial(g) = &m.tool else { + panic!("radial expected") + }; + let (_, _, inverted) = m.combine.encode().unwrap(); + assert_eq!(g.flipped, !inverted, "{:?}", m.combine); + } + let flipped: Vec = c + .masks + .iter() + .map(|m| matches!(m.tool, MaskTool::Radial(g) if g.flipped)) + .collect(); + assert_eq!(flipped, [true, false, true, false]); +} + +#[test] +fn an_unrecognised_combination_or_opaque_tool_cannot_be_built() { + let mut b = with("R").add(Semantic::Sky); + b.components + .push((Combine::Unrecognised, Semantic::Sky.into())); + assert!(matches!(b.build(), Err(BuildError::NotBuildable(_)))); + let opaque = MaskTool::Opaque { + what: Some("Mask/Aggregate".into()), + }; + assert!(matches!( + with("R").add(opaque).build(), + Err(BuildError::NotBuildable(_)) + )); +} + +// ---------- gradients ---------- + +#[test] +fn a_radial_gradient_is_unrotated_with_lightrooms_fixed_fields() { + let c = one(radial()); + let m = &c.masks[0]; + assert_eq!( + m.tool, + MaskTool::Radial(RadialGradient { + top: f(0.2), + left: f(0.3), + bottom: f(0.8), + right: f(0.7), + feather: 40, + midpoint: 50, + roundness: 0, + flipped: true, + }) + ); + assert_eq!(m.name.as_deref(), Some("Radial Gradient")); + assert_eq!( + m.extra.get(Level::MaskTool, "Version"), + Some(&Value::Int(RADIAL_VERSION)) + ); + assert_eq!(m.extra.values.len(), 1); +} + +#[test] +fn radial_bounds_and_feather_are_checked() { + for (t, l, b, r, feather) in [ + (0.8, 0.3, 0.2, 0.7, 40), // top below bottom + (0.2, 0.7, 0.8, 0.3, 40), // left right of right + (0.2, 0.3, 0.2, 0.7, 40), // no height + (0.2, 0.3, 0.8, 0.7, -1), + (0.2, 0.3, 0.8, 0.7, 101), + ] { + assert!(matches!( + RadialGradient::new(t, l, b, r, feather), + Err(BuildError::Geometry(_)) + )); + } + assert!(matches!( + RadialGradient::new(f64::NAN, 0.3, 0.8, 0.7, 0), + Err(BuildError::NotFinite { .. }) + )); + let rounder = RadialGradient { + roundness: 20, + ..radial() + }; + assert!(matches!( + with("R").add(rounder).build(), + Err(BuildError::NotBuildable(_)) + )); +} + +#[test] +fn a_linear_gradient_needs_two_points() { + let a = SensorPoint::new(0.5, 0.0).unwrap(); + let b = SensorPoint::new(0.5, 0.6).unwrap(); + let c = one(LinearGradient::new(a, b).unwrap()); + assert_eq!( + c.masks[0].tool, + MaskTool::Linear(LinearGradient { zero: a, full: b }) + ); + assert_eq!(c.masks[0].name.as_deref(), Some("Linear Gradient")); + assert!(c.masks[0].extra.is_empty()); + assert!(matches!( + LinearGradient::new(a, a), + Err(BuildError::Geometry(_)) + )); + // Points may leave 0..1 (a gradient starting outside the frame). + assert!(SensorPoint::new(-0.2, 1.3).is_ok()); + assert!(matches!( + SensorPoint::new(0.1, f64::INFINITY), + Err(BuildError::NotFinite { .. }) + )); +} + +// ---------- luminance ranges ---------- + +#[test] +fn luminance_ranges_are_ordered_between_zero_and_one() { + let lows = LuminanceRange::lows(0.45, 0.1).unwrap(); + assert_eq!(lows.range, [f(0.0), f(0.0), f(0.45), f(0.55)]); + let highs = LuminanceRange::highs(0.7, 0.2).unwrap(); + assert_eq!(highs.range.map(Finite::get)[1..], [0.7, 1.0, 1.0]); + assert!(!highs.invert, "set by the builder, from the combination"); + assert!(LuminanceRange::new([0.0, 0.0, 1.0, 1.0]).is_ok()); + assert!(LuminanceRange::new([0.3, 0.3, 0.3, 0.3]).is_ok()); + for bad in [ + [0.2, 0.1, 0.8, 1.0], // feather above low + [0.0, 0.8, 0.2, 1.0], // low above high + [0.0, 0.2, 0.8, 0.7], // high above its feather + [-0.1, 0.2, 0.8, 1.0], + [0.0, 0.2, 0.8, 1.1], + [0.0, f64::NAN, 0.8, 1.0], + ] { + assert!( + matches!(LuminanceRange::new(bad), Err(BuildError::LumRangeOrder(_))), + "{bad:?}" + ); + } + assert!(LuminanceRange::lows(0.95, 0.1).is_err()); + assert!(LuminanceRange::highs(0.05, 0.1).is_err()); +} + +#[test] +fn a_luminance_range_is_checked_again_when_built() { + let mut lr = LuminanceRange::lows(0.4, 0.1).unwrap(); + lr.range.swap(1, 2); + assert!(matches!( + with("R").add(Semantic::Sky).intersect(lr).build(), + Err(BuildError::LumRangeOrder(_)) + )); + let mut sampled = LuminanceRange::lows(0.4, 0.1).unwrap(); + sampled.rest.fields.values.insert( + registry::lookup( + Level::Struct(registry::StructKind::CorrectionRangeMask), + "LuminanceDepthSampleInfo", + ) + .unwrap(), + Value::Str("0 0 0".into()), + ); + assert!(matches!( + with("R").add(sampled).build(), + Err(BuildError::NotBuildable(_)) + )); + let c = one(LuminanceRange::lows(0.4, 0.1).unwrap()); + assert_eq!(c.masks[0].name.as_deref(), Some("Luminance Range")); +} + +// ---------- sync ids ---------- + +#[test] +fn sync_ids_are_32_upper_case_hex_and_deterministic() { + let id = ns().id("Subject", IdSlot::Correction); + assert_eq!(id.as_str().len(), 32); + assert!(id + .as_str() + .bytes() + .all(|b| b.is_ascii_digit() || (b'A'..=b'F').contains(&b))); + assert_eq!(id, ns().id("Subject", IdSlot::Correction)); + let a = with("Subject").add(Semantic::Subject).build().unwrap(); + let b = with("Subject").add(Semantic::Subject).build().unwrap(); + assert_eq!(a, b, "same role, same correction"); +} + +#[test] +fn sync_ids_are_unique_per_namespace_role_and_slot() { + let mut seen = HashSet::new(); + for n in [ns(), SyncNamespace::new("other")] { + for role in ["Subject", "Sky", "Background", "Subject 2"] { + assert!(seen.insert(n.id(role, IdSlot::Correction))); + for i in 0..8 { + assert!(seen.insert(n.id(role, IdSlot::Component(i)))); + } + } + } + // The parts are length-prefixed, so shifting a boundary changes the id. + assert_ne!( + SyncNamespace::new("ab").id("c", IdSlot::Correction), + SyncNamespace::new("a").id("bc", IdSlot::Correction) + ); +} + +/// Known answers: these ids are what a released LrGeniusAI wrote. Changing +/// the domain tag, the length prefixes or the slot texts changes every +/// `CorrectionSyncID` and `MaskSyncID`, and a later release could no longer +/// replace the corrections an earlier one wrote. (The values were computed +/// independently: Python's `hashlib.sha256` over the same bytes.) +#[test] +fn sync_ids_match_their_known_answers() { + let lrg = SyncNamespace::lrgenius(); + assert_eq!( + lrg.id("Subject", IdSlot::Correction).as_str(), + "1578F53D074D6B0E08DC6ADE4F32DDC1" + ); + assert_eq!( + lrg.id("Subject", IdSlot::Component(0)).as_str(), + "539FD64FFFDD00578B847D3FCCCB36E5" + ); + assert_eq!( + SyncNamespace::new("other") + .id("Sky", IdSlot::Component(2)) + .as_str(), + "474C41A0AACF4F7EE44AA68BBAC56026" + ); +} + +#[test] +fn a_preset_s_corrections_need_distinct_roles() { + let sky = || with("Sky").add(Semantic::Sky); + assert_eq!( + corrections([sky(), with(" Sky ").add(Semantic::Subject)]), + Err(BuildError::DuplicateRole("Sky".into())) + ); + let both = corrections([sky(), with("Subject").add(Semantic::Subject)]).unwrap(); + assert_eq!(both.len(), 2); + assert_ne!(both[0].sync_id, both[1].sync_id); + let ids: HashSet<_> = both + .iter() + .flat_map(|c| c.masks.iter().map(|m| m.sync_id.clone())) + .collect(); + assert_eq!(ids.len(), 2); + // A builder's own error comes through. + assert_eq!(corrections([with("Sky")]), Err(BuildError::Empty)); +} + +#[test] +fn a_built_correction_has_distinct_ids_for_every_component() { + let c = with("Sky") + .add(Semantic::Sky) + .subtract(Semantic::Subject) + .intersect(LuminanceRange::highs(0.5, 0.1).unwrap()) + .build() + .unwrap(); + let mut ids: Vec<_> = c.masks.iter().map(|m| m.sync_id.clone()).collect(); + ids.push(c.sync_id.clone()); + let unique: HashSet<_> = ids.iter().collect(); + assert_eq!(unique.len(), 4); + for (i, m) in c.masks.iter().enumerate() { + assert_eq!(m.sync_id, Some(ns().id("Sky", IdSlot::Component(i)))); + } +} + +// ---------- through the writer ---------- + +fn preset(corrections: Vec) -> xmp::Written { + let mut s = DevelopSettings::new(); + s.corrections = corrections; + let header = PresetHeader::lrgenius(ns().id("preset", IdSlot::Correction), "Built"); + let mut spec = PresetSpec::new(header); + spec.process_version = Some(ProcessVersion::V6); + xmp::write(&s, &WriteMode::Preset(spec)).unwrap() +} + +#[test] +fn built_semantic_corrections_survive_a_preset_unchanged() { + let built = vec![ + CorrectionBuilder::new("Subject", &ns()) + .local_ui("LocalExposure2012", 0.3) + .unwrap() + .add(Semantic::Subject) + .build() + .unwrap(), + CorrectionBuilder::new("Sky", &ns()) + .local_ui("LocalHighlights2012", -40.0) + .unwrap() + .local_ui("LocalDehaze", 15.0) + .unwrap() + .add(Semantic::Sky) + .intersect(LuminanceRange::highs(0.5, 0.2).unwrap()) + .build() + .unwrap(), + CorrectionBuilder::new("Eyes", &ns()) + .local_ui("LocalSaturation", 30.0) + .unwrap() + .add(Semantic::people_part(PeoplePart::IrisAndPupil)) + .build() + .unwrap(), + ]; + let written = preset(built.clone()); + assert!(written.skipped.is_empty(), "{:?}", written.skipped); + let doc = parse(written.xmp.as_bytes()).unwrap(); + assert_eq!(doc.kind, XmpKind::Preset); + assert!(doc.warnings.is_empty(), "{:?}", doc.warnings); + let read = &doc.develop.corrections; + assert_eq!(read.len(), built.len()); + for (r, b) in read.iter().zip(&built) { + assert_eq!( + (&r.name, &r.sync_id, r.amount, r.active, &r.local), + (&b.name, &b.sync_id, b.amount, b.active, &b.local) + ); + assert_eq!(r.masks.len(), b.masks.len(), "{:?}", b.name); + for (rm, bm) in r.masks.iter().zip(&b.masks) { + // The writer completes a range mask's Version 3 and SampleType 0. + let mut tool = rm.tool.clone(); + if let MaskTool::LuminanceRange(lr) = &mut tool { + let crm = |n| lr.rest.get(n).cloned(); + assert_eq!( + (crm("Version"), crm("SampleType")), + (Some(Value::Int(3)), Some(Value::Int(0))) + ); + lr.rest.fields = Fields::default(); + } + assert_eq!( + (&tool, rm.combine, &rm.name, &rm.sync_id, rm.active), + (&bm.tool, bm.combine, &bm.name, &bm.sync_id, bm.active) + ); + } + } + // Adobe's AI-mask form, completed by the writer. + assert!(written.xmp.contains("crs:MaskSubType=\"3\"")); + assert!(written.xmp.contains("crs:MaskSubCategoryID=\"3\"")); + assert!(written + .xmp + .contains("crs:ReferencePoint=\"0.500000 0.500000\"")); + assert!(written + .xmp + .contains("crs:CorrectionName=\"LrGenius · Sky\"")); + // The intersected range mirrors its MaskInverted, as Lightroom writes it. + assert!(written + .xmp + .contains("crs:MaskBlendMode=\"1\"\n crs:MaskInverted=\"true\"")); + assert!(written.xmp.contains("crs:Invert=\"true\"")); + assert!(!written.xmp.contains("crs:Invert=\"false\"")); +} + +#[test] +fn photo_components_keep_their_correction_out_of_a_preset_and_say_so() { + let point = SensorPoint::new(0.4, 0.3).unwrap(); + let written = preset(vec![ + with("Vignette").add(radial()).build().unwrap(), + with("Hair") + .add(Semantic::person_part_at(PeoplePart::Hair, point)) + .build() + .unwrap(), + ]); + assert!(!written.xmp.contains("MaskGroupBasedCorrections")); + assert_eq!( + written.skipped, + [0, 1] + .map(|i| Skipped { + path: format!("MaskGroupBasedCorrections[{i}]"), + reason: SkipReason::Policy(Policy::Photo), + }) + .to_vec() + ); +} + +#[test] +fn preset_output_of_built_corrections_is_byte_deterministic() { + let build = || { + vec![with("Sky") + .add(Semantic::Sky) + .subtract(Semantic::landscape(LandscapeClass::Mountains)) + .build() + .unwrap()] + }; + assert_eq!(preset(build()).xmp, preset(build()).xmp); +} + +/// Every built tool, photo-specific ones included, reads back as exactly +/// the built correction through the lossless test mode. +#[cfg(feature = "test-roundtrip")] +#[test] +fn every_built_tool_round_trips_exactly() { + let a = SensorPoint::new(0.5, 0.1).unwrap(); + let b = SensorPoint::new(0.5, 0.7).unwrap(); + let mut s = DevelopSettings::new(); + s.corrections = vec![ + with("Gradients") + .add(LinearGradient::new(a, b).unwrap()) + .add_inverted(radial()) + .intersect(radial()) + .build() + .unwrap(), + with("People") + .local_curve("RedCurve", &[(0, 10), (255, 240)]) + .unwrap() + .add(Semantic::person_part_at(PeoplePart::Lips, a)) + .add(Semantic::people_part(PeoplePart::Teeth)) + .subtract(Semantic::Background) + .intersect(LuminanceRange::new([0.1, 0.2, 0.6, 0.9]).unwrap()) + .build() + .unwrap(), + ]; + let written = xmp::write(&s, &WriteMode::TestRoundTrip(None)).unwrap(); + let doc = parse(written.xmp.as_bytes()).unwrap(); + assert!(doc.warnings.is_empty(), "{:?}", doc.warnings); + assert_eq!(doc.develop.corrections, s.corrections); +} diff --git a/server-rs/crates/lrg-develop/src/lib.rs b/server-rs/crates/lrg-develop/src/lib.rs new file mode 100644 index 00000000..f159a388 --- /dev/null +++ b/server-rs/crates/lrg-develop/src/lib.rs @@ -0,0 +1,35 @@ +//! The backend's native Lightroom develop model. +//! +//! A leaf crate (no image, network or async dependencies) holding facts and +//! shapes of Lightroom Classic's develop settings: +//! +//! - [`registry`]: every `crs:` develop key with its type, level, stored +//! range, UI scaling, number format, defaults, policy class, frame scope, +//! file kind, presence (Lua/XMP), minimum process version and recipe alias; +//! - [`model`]: the typed settings the readers and writers exchange +//! ([`DevelopSettings`], corrections and masks, the profile, white +//! balance) and the policy filter ([`model::policy`]); +//! - [`lua`]: the `getDevelopSettings()` table form as the plugin's JSON.lua +//! encodes it (reader); +//! - [`xmp`]: sidecars, develop presets and profiles in XMP (reader), and +//! the deterministic develop-preset writer. Both readers share their typing +//! and classification rules, so the same settings land in the same model +//! from either form; +//! - [`build`]: builders for local corrections (semantic, gradient and +//! luminance-range masks) with deterministic sync ids; +//! - [`parse`]: the readers' errors and warnings. +//! +//! Learning and blending logic stays in `lrg-analysis::style_engine`; the LLM +//! recipe schema stays in `lrg-providers::edit_recipe`. + +pub mod build; +pub mod lua; +pub mod model; +pub mod parse; +mod reader; +pub mod registry; +pub mod xmp; + +pub use model::value::Finite; +pub use model::{DevelopSettings, FileKind, FileKindHint}; +pub use parse::{ParseError, ParseWarning, WarningKind}; diff --git a/server-rs/crates/lrg-develop/src/lua/mod.rs b/server-rs/crates/lrg-develop/src/lua/mod.rs new file mode 100644 index 00000000..7e9808a0 --- /dev/null +++ b/server-rs/crates/lrg-develop/src/lua/mod.rs @@ -0,0 +1,21 @@ +//! The Lua table form: what `photo:getDevelopSettings()` returns and +//! `photo:applyDevelopSettings()` takes, as the plugin's `JSON.lua` encodes +//! it (the `develop_settings` blob of a training example, and the table the +//! backend sends to be applied). +//! +//! - [`read`]: JSON.lua output → model + warnings. +//! - [`write`](mod@write): model → the table to apply to one photo (or to +//! store as a plugin preset for it). **Provisional** while experiments E1, +//! E2, E4 and E11 leave fields open; every form they measure is a +//! [`LuaOptions`] switch, the defaults are [`LuaOptions::PROVISIONAL`] and +//! how far the evidence carries each is [`LuaOptions::EVIDENCE`]. + +pub mod read; +pub mod write; + +pub use read::{from_lua_str, from_lua_value, MAX_LUA_JSON_BYTES}; +pub use write::{ + check_wire, lua_never_written, to_lua_value, ApplyCall, EnumAs, EvidenceStatus, FlagAs, + LocalForm, LookForm, LuaMode, LuaOptions, LuaWritten, MaskForm, OptionEvidence, PanelSwitches, + PhotoContext, WireError, ADAPTIVE_PRESET_LOCALS, FIRST_RUN, MASK_SWITCH, PANEL_SWITCHES, +}; diff --git a/server-rs/crates/lrg-develop/src/lua/read.rs b/server-rs/crates/lrg-develop/src/lua/read.rs new file mode 100644 index 00000000..ee97093d --- /dev/null +++ b/server-rs/crates/lrg-develop/src/lua/read.rs @@ -0,0 +1,706 @@ +//! JSON.lua output → [`DevelopSettings`] + warnings. +//! +//! Accepts exactly what `JSON.lua` makes of `photo:getDevelopSettings()` +//! (checked against 1,294 real training rows): +//! +//! - top-level `[]` is the empty table (JSON.lua cannot tell `{}` from `[]`); +//! - an empty table (`[]` or `{}`) as the value of any key, at any depth, +//! means "key absent", without a warning (`LensBlur`, `AILook`, +//! `FilterList`, `RedEyeInfo`, `PointColors`, ...); an empty table as an +//! element of a list of objects (masks, corrections, structures) is no +//! element, also without a warning; +//! - an integer where a real is expected is fine; a non-integer on an integer +//! key is rounded half-to-even ([`KeySpec::coerce`]) and warned about; +//! - booleans and 0/1 are both accepted on bool and 0/1-flag keys and +//! normalised to the key's kind; +//! - an integer string (`"1"`) on a mask enum ([`KeySpec::is_mask_enum`]: +//! `MaskSubType`, `MaskBlendMode`, `CorrectionRangeMask.Type`, ...) is +//! read as that integer, without a warning: the Lua writer's +//! `EnumAs::String` form (`lua::LuaOptions::mask_enum_as`), so whichever +//! form experiment E2 settles on reads back; +//! - a known key whose value has the wrong shape (`null` in an array, a mixed +//! array, a string for a number) is kept verbatim as [`Opaque::Json`] with a +//! [`WarningKind::WrongType`], and never written; +//! - an unknown key is kept verbatim with a [`WarningKind::UnknownKey`]; +//! never an error. Keys of a pattern family (`Table_`, `pm_*`, +//! `UprightTransform_N`, the FilterList payload) are kept verbatim without +//! a warning; +//! - masks are identified by structure, never by their (localised) names; +//! - the file kind comes from the white-balance key family (`Temperature` ⇒ +//! raw, `IncrementalTemperature` ⇒ non-raw); a caller hint that +//! contradicts it is warned about and loses; +//! - `ProcessVersion` below 6.7 (PV2003/PV2010) is warned about: such +//! settings are read but must not be learned; +//! - keys are read in name order, so opaque entries and warnings come out in +//! the same order in every build (`serde_json`'s map order depends on its +//! `preserve_order` feature, which the workspace turns on and a lone +//! `cargo test -p lrg-develop` does not). +//! +//! Typing, the file-kind and process-version checks and the assembly of the +//! settings are shared with the XMP reader (`crate::reader`), and so is the +//! correction and mask classification (`crate::model::correction`), so +//! both formats land in the same model. + +use serde_json::{Map, Value as J}; + +use crate::model::correction::Correction; +use crate::model::value::{Fields, Finite, Opaque, OpaqueEntry, Struct, Value}; +use crate::model::{DevelopSettings, FileKindHint}; +use crate::parse::{ParseError, ParseWarning, WarningKind}; +use crate::reader::{self, child, parse_point, ScalarIn}; +use crate::registry::{self, CurveKind, KeySpec, Level, Resolved, ValueKind}; + +/// Largest `develop_settings` text [`from_lua_str`] accepts (4 MiB; real +/// blobs are at most ~50 KB). +pub const MAX_LUA_JSON_BYTES: usize = 4 * 1024 * 1024; + +/// Parses a `develop_settings` JSON text. See [`from_lua_value`]. +pub fn from_lua_str( + text: &str, + hint: FileKindHint, +) -> Result<(DevelopSettings, Vec), ParseError> { + if text.len() > MAX_LUA_JSON_BYTES { + return Err(ParseError::TooLarge { + size: text.len(), + max: MAX_LUA_JSON_BYTES, + }); + } + let value: J = serde_json::from_str(text)?; + from_lua_value(&value, hint) +} + +/// Reads one decoded `develop_settings` value into the model. +/// +/// Errors only when the top level is not a table; every problem below it is +/// a [`ParseWarning`] and the value is kept (see the module docs for the +/// rules). +pub fn from_lua_value( + value: &J, + hint: FileKindHint, +) -> Result<(DevelopSettings, Vec), ParseError> { + let obj = match value { + J::Object(obj) => obj, + J::Array(a) if a.is_empty() => { + let mut settings = DevelopSettings::default(); + settings.file_kind = hint.kind(); + return Ok((settings, Vec::new())); + } + J::Array(_) => { + return Err(ParseError::NotATable { + found: "a non-empty array", + }) + } + J::String(_) => return Err(ParseError::NotATable { found: "a string" }), + J::Number(_) => return Err(ParseError::NotATable { found: "a number" }), + J::Bool(_) => return Err(ParseError::NotATable { found: "a boolean" }), + J::Null => return Err(ParseError::NotATable { found: "null" }), + }; + let mut reader = Reader::default(); + let fields = reader.fields(obj, Level::Global, ""); + let settings = reader::finish(fields, hint, &mut reader.warnings); + Ok((settings, reader.warnings)) +} + +/// `"3"`, `"-1"`: an optional minus and ASCII digits, nothing else. +fn is_integer_text(t: &str) -> bool { + let digits = t.strip_prefix('-').unwrap_or(t); + !digits.is_empty() && digits.len() <= 18 && digits.bytes().all(|b| b.is_ascii_digit()) +} + +#[derive(Default)] +struct Reader { + warnings: Vec, +} + +fn is_empty_table(v: &J) -> bool { + match v { + J::Array(a) => a.is_empty(), + J::Object(o) => o.is_empty(), + _ => false, + } +} + +/// The value as compact JSON, cut to a readable length. +fn snippet(v: &J) -> String { + const MAX: usize = 80; + let s = v.to_string(); + if s.len() <= MAX { + return s; + } + let mut end = MAX; + while !s.is_char_boundary(end) { + end -= 1; + } + format!("{}…", &s[..end]) +} + +fn describe(kind: ValueKind) -> String { + match kind { + ValueKind::Int => "an integer".into(), + ValueKind::Real => "a number".into(), + ValueKind::Bool(_) => "a boolean (or 0/1)".into(), + ValueKind::IntFlag => "0/1 (or a boolean)".into(), + ValueKind::Enum(set) => format!("one of {set:?}"), + ValueKind::EnumInt(set) => format!("one of {set:?}"), + ValueKind::Str | ValueKind::VersionStr | ValueKind::Hex32 => "a string".into(), + ValueKind::VersionU32 => "a packed version number".into(), + ValueKind::Curve(CurveKind::Global) => "a flat list of numbers (x, y, x, y, ...)".into(), + ValueKind::Curve(CurveKind::Local) => "a list of \"x,y\" strings".into(), + ValueKind::StrSeq => "a list of strings".into(), + ValueKind::LangAlt => "an object of language strings".into(), + ValueKind::Struct(k) => format!("a {} object", k.name()), + ValueKind::StructSeq(k) => format!("a list of {} objects", k.name()), + ValueKind::CorrectionSeq => "a list of correction objects".into(), + ValueKind::ComponentSeq => "a list of mask objects".into(), + ValueKind::Settings | ValueKind::Any | ValueKind::PerFormat { .. } => "any value".into(), + } +} + +fn opaque(name: &str, v: &J) -> OpaqueEntry { + OpaqueEntry { + ns: None, + name: name.to_owned(), + value: Opaque::Json(v.clone()), + } +} + +impl Reader { + fn warn(&mut self, path: &str, key: &str, raw: Option<&J>, kind: WarningKind) { + reader::warn(&mut self.warnings, path, key, raw.map(snippet), kind); + } + + /// Reads one table at `level`, in key-name order. Empty tables below it + /// are absent keys. + fn fields(&mut self, obj: &Map, level: Level, path: &str) -> Fields { + let mut out = Fields::default(); + let mut entries: Vec<(&String, &J)> = obj.iter().collect(); + entries.sort_unstable_by(|a, b| a.0.cmp(b.0)); + for (key, v) in entries { + if is_empty_table(v) { + continue; + } + let p = child(path, key); + match registry::resolve(level, key) { + Resolved::Key(id) => match self.value(id.spec(), v, &p, key) { + Some(value) => { + out.values.insert(id, value); + } + None => out.opaque.push(opaque(key, v)), + }, + Resolved::Pattern(_) => out.opaque.push(opaque(key, v)), + Resolved::Unknown => { + self.warn(&p, key, Some(v), WarningKind::UnknownKey); + out.opaque.push(opaque(key, v)); + } + } + } + out + } + + fn correction(&mut self, obj: &Map, path: &str) -> Correction { + let fields = self.fields(obj, Level::Correction, path); + Correction::from_fields(fields, path, &mut self.warnings) + } + + /// Reads `v` as `spec`'s kind; `None` (after a warning) when it does not + /// have that shape. + fn value(&mut self, spec: &KeySpec, v: &J, path: &str, key: &str) -> Option { + let kind = spec.kind.lua_form(); + let value = match kind { + ValueKind::Int + | ValueKind::Real + | ValueKind::IntFlag + | ValueKind::EnumInt(_) + | ValueKind::VersionU32 + | ValueKind::Bool(_) => self.scalar(spec, v, path, key), + ValueKind::Enum(_) | ValueKind::Str | ValueKind::VersionStr | ValueKind::Hex32 => v + .as_str() + .and_then(|s| reader::text(kind, s, path, key, || snippet(v), &mut self.warnings)), + ValueKind::Curve(CurveKind::Global) => v.as_array().and_then(|a| { + if a.len() % 2 != 0 { + return None; + } + let nums: Option> = a + .iter() + .map(|x| x.as_f64().and_then(|f| Finite::new(f).ok())) + .collect(); + Some(Value::Curve( + nums? + .as_chunks::<2>() + .0 + .iter() + .map(|&[x, y]| (x, y)) + .collect(), + )) + }), + ValueKind::Curve(CurveKind::Local) => v.as_array().and_then(|a| { + a.iter() + .map(|x| x.as_str().and_then(parse_point)) + .collect::>>() + .map(Value::Curve) + }), + ValueKind::StrSeq => v.as_array().and_then(|a| { + a.iter() + .map(|x| x.as_str().map(str::to_owned)) + .collect::>>() + .map(Value::StrList) + }), + ValueKind::LangAlt => v.as_object().and_then(|o| { + if !o.values().all(J::is_string) { + None + } else if let (1, Some(J::String(s))) = (o.len(), o.get("x-default")) { + Some(Value::Alt(s.clone())) + } else { + // Several languages: a valid shape the model keeps whole. + Some(Value::Opaque(Opaque::Json(v.clone()))) + } + }), + ValueKind::Struct(k) => v.as_object().map(|o| { + Value::Struct(Struct { + kind: k, + fields: self.fields(o, Level::Struct(k), path), + }) + }), + ValueKind::StructSeq(k) => self.objects(v).map(|items| { + Value::StructList( + items + .into_iter() + .enumerate() + .map(|(i, o)| Struct { + kind: k, + fields: self.fields(o, Level::Struct(k), &format!("{path}[{i}]")), + }) + .collect(), + ) + }), + ValueKind::CorrectionSeq => self.objects(v).map(|items| { + Value::Corrections( + items + .into_iter() + .enumerate() + .map(|(i, o)| self.correction(o, &format!("{path}[{i}]"))) + .collect(), + ) + }), + ValueKind::ComponentSeq => self.objects(v).map(|items| { + Value::Tools( + items + .into_iter() + .enumerate() + .map(|(i, o)| self.fields(o, Level::MaskTool, &format!("{path}[{i}]"))) + .collect(), + ) + }), + ValueKind::Settings | ValueKind::Any | ValueKind::PerFormat { .. } => { + Some(Value::Opaque(Opaque::Json(v.clone()))) + } + }; + if value.is_none() { + self.warn( + path, + key, + Some(v), + WarningKind::WrongType { + expected: describe(kind), + }, + ); + } + value + } + + /// `Some` when `v` is an array of objects only; empty tables in it are + /// no elements (JSON.lua writes an empty Lua table as `[]`). + fn objects<'v>(&self, v: &'v J) -> Option>> { + v.as_array()? + .iter() + .filter(|x| !is_empty_table(x)) + .map(J::as_object) + .collect() + } + + fn scalar(&mut self, spec: &KeySpec, v: &J, path: &str, key: &str) -> Option { + let input = match v { + J::Bool(b) => ScalarIn::Bool(*b), + J::Number(n) => ScalarIn::Num(n.as_f64().and_then(|f| Finite::new(f).ok())?), + // The writer's `EnumAs::String` form; plain digits only. + J::String(t) if spec.is_mask_enum() && is_integer_text(t) => { + ScalarIn::Num(Finite::from_i64(t.parse().ok()?)) + } + _ => return None, + }; + reader::scalar(spec, input, path, key, || snippet(v), &mut self.warnings) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{Combine, FileKind, MaskTool, Semantic}; + use crate::registry::ProcessVersion; + use serde_json::json; + + fn read(v: J) -> (DevelopSettings, Vec) { + from_lua_value(&v, FileKindHint::Unknown).unwrap() + } + + fn kinds(w: &[ParseWarning]) -> Vec<&'static str> { + w.iter().map(|w| w.kind.name()).collect() + } + + #[test] + fn top_level_empty_array_is_empty_settings() { + let (s, w) = from_lua_value(&json!([]), FileKindHint::Raw).unwrap(); + assert!(s.is_empty()); + assert!(w.is_empty()); + assert_eq!(s.file_kind, Some(FileKind::Raw)); + } + + #[test] + fn other_top_levels_are_errors() { + for v in [json!([1]), json!("x"), json!(1), json!(null), json!(true)] { + assert!(matches!( + from_lua_value(&v, FileKindHint::Unknown), + Err(ParseError::NotATable { .. }) + )); + } + assert!(matches!( + from_lua_str("{", FileKindHint::Unknown), + Err(ParseError::Json(_)) + )); + } + + #[test] + fn oversized_text_is_refused() { + let text = format!( + "{{\"CameraProfile\":\"{}\"}}", + "x".repeat(MAX_LUA_JSON_BYTES) + ); + assert!(matches!( + from_lua_str(&text, FileKindHint::Unknown), + Err(ParseError::TooLarge { .. }) + )); + } + + #[test] + fn empty_tables_are_absent_keys_without_warning() { + let (s, w) = read(json!({ + "LensBlur": [], "FilterList": {}, "PointColors": [], "AILook": [], + "MaskGroupBasedCorrections": [], "Look": {} + })); + assert!(s.is_empty(), "{s:?}"); + assert!(w.is_empty()); + } + + #[test] + fn integers_are_fine_for_reals_and_non_integers_are_rounded_on_ints() { + let (s, w) = read(json!({"Exposure2012": 1, "Contrast2012": 12.5})); + assert_eq!( + s.get_by_name("Exposure2012"), + Some(&Value::Real(Finite::new_const(1.0))) + ); + assert_eq!(s.get_by_name("Contrast2012"), Some(&Value::Int(12))); + assert_eq!(kinds(&w), ["NonIntegerForIntKey"]); + assert_eq!(w[0].path, "Contrast2012"); + } + + #[test] + fn bools_and_flags_are_normalised() { + let (s, w) = read(json!({ + "ConvertToGrayscale": 1, "AutoLateralCA": true, "LensProfileEnable": 0 + })); + assert!(w.is_empty(), "{w:?}"); + assert_eq!( + s.get_by_name("ConvertToGrayscale"), + Some(&Value::Bool(true)) + ); + assert_eq!(s.get_by_name("AutoLateralCA"), Some(&Value::Int(1))); + assert_eq!(s.get_by_name("LensProfileEnable"), Some(&Value::Int(0))); + } + + #[test] + fn wrong_shapes_are_kept_verbatim_with_a_warning() { + let (s, w) = read(json!({ + "ToneCurvePV2012": [0, 0, null, 255], + "Exposure2012": "abc", + "LensProfileEnable": 2 + })); + assert_eq!(kinds(&w), ["WrongType", "WrongType", "WrongType"]); + assert_eq!(s.value_count(), 0); + assert_eq!(s.opaque.len(), 3); + assert!(s + .opaque + .iter() + .any(|o| o.name == "ToneCurvePV2012" + && o.value == Opaque::Json(json!([0, 0, null, 255])))); + } + + #[test] + fn unknown_keys_are_kept_and_warned_about_never_an_error() { + let (s, w) = read(json!({"BrandNewSlider": 5, "Nested": {"a": [1, null]}})); + assert_eq!(kinds(&w), ["UnknownKey", "UnknownKey"]); + assert_eq!(s.opaque.len(), 2); + } + + #[test] + fn pattern_keys_are_kept_without_a_warning() { + let (s, w) = read(json!({"UprightTransform_0": "1 0 0 0 1 0 0 0 1"})); + assert!(w.is_empty()); + assert_eq!(s.opaque[0].name, "UprightTransform_0"); + } + + #[test] + fn warning_paths_point_into_corrections() { + let (s, w) = read(json!({"MaskGroupBasedCorrections": [ + {"What": "Correction", "CorrectionMasks": [ + {"What": "Mask/Image", "MaskSubType": 1, "MaskBlendMode": 0, + "MaskValue": 1, "MaskInverted": false, "Mystery": 1} + ]}, + {"What": "Correction", "CorrectionMasks": [ + {"What": "Mask/Image", "MaskSubType": 2, "MaskBlendMode": 0, + "MaskValue": 1, "MaskInverted": false, "Mystery": 2} + ]} + ]})); + assert_eq!(kinds(&w), ["UnknownKey", "UnknownKey"]); + assert_eq!( + w[1].path, + "MaskGroupBasedCorrections[1].CorrectionMasks[0].Mystery" + ); + assert_eq!(s.corrections.len(), 2); + assert_eq!( + s.corrections[1].masks[0].tool, + MaskTool::Semantic(Semantic::Sky) + ); + assert_eq!( + s.corrections[1].masks[0].combine, + Combine::Add { inverted: false } + ); + } + + #[test] + fn masks_are_never_identified_by_name() { + let (s, _) = read(json!({"MaskGroupBasedCorrections": [ + {"CorrectionName": "Himmel", "CorrectionMasks": [ + {"What": "Mask/Image", "MaskSubType": 1, "MaskName": "Himmel", + "MaskBlendMode": 0, "MaskValue": 1, "MaskInverted": false} + ]} + ]})); + assert_eq!( + s.corrections[0].masks[0].tool, + MaskTool::Semantic(Semantic::Subject) + ); + } + + #[test] + fn unrecognised_combination_is_kept_with_a_warning() { + let (s, w) = read(json!({"MaskGroupBasedCorrections": [ + {"CorrectionMasks": [ + {"What": "Mask/Image", "MaskSubType": 1, "MaskBlendMode": 1, + "MaskValue": 1, "MaskInverted": false} + ]} + ]})); + assert_eq!(kinds(&w), ["UnrecognisedMaskCombine"]); + let m = &s.corrections[0].masks[0]; + assert_eq!(m.combine, Combine::Unrecognised); + assert!(m.extra.get(Level::MaskTool, "MaskBlendMode").is_some()); + } + + #[test] + fn file_kind_comes_from_the_white_balance_family() { + let v = json!({"IncrementalTemperature": 5, "IncrementalTint": 0}); + let (s, w) = from_lua_value(&v, FileKindHint::Raw).unwrap(); + assert_eq!(s.file_kind, Some(FileKind::NonRaw)); + assert_eq!(kinds(&w), ["FileKindMismatch"]); + let (s, w) = from_lua_value(&v, FileKindHint::NonRaw).unwrap(); + assert_eq!(s.file_kind, Some(FileKind::NonRaw)); + assert!(w.is_empty()); + let (s, w) = from_lua_value(&json!({"Exposure2012": 0}), FileKindHint::Raw).unwrap(); + assert_eq!( + s.file_kind, + Some(FileKind::Raw), + "hint when the keys say nothing" + ); + assert!(w.is_empty()); + let (s, w) = read(json!({"Temperature": 5000, "IncrementalTint": 3})); + assert_eq!(s.file_kind, None); + assert_eq!(kinds(&w), ["ConflictingFileKind"]); + assert_eq!(w[0].path, "Temperature/IncrementalTint"); + } + + #[test] + fn the_raw_family_read_with_a_non_raw_hint_warns_and_the_family_wins() { + let v = json!({"Temperature": 5600, "Tint": 4}); + let (s, w) = from_lua_value(&v, FileKindHint::NonRaw).unwrap(); + assert_eq!(s.file_kind, Some(FileKind::Raw)); + assert_eq!(kinds(&w), ["FileKindMismatch"]); + assert_eq!(w[0].path, "Temperature"); + } + + #[test] + fn opaque_entries_and_warnings_come_out_in_key_order() { + // `serde_json` keeps input order with `preserve_order` (on in the + // workspace build) and sorts without it; the reader must not care. + let (s, w) = read(json!({ + "ZetaUnknown": 1, "UprightTransform_0": "1 0 0", "AlphaUnknown": 2, + "Exposure2012": "x", "Contrast2012": 1.5 + })); + let names: Vec<&str> = s.opaque.iter().map(|o| o.name.as_str()).collect(); + assert_eq!( + names, + [ + "AlphaUnknown", + "Exposure2012", + "UprightTransform_0", + "ZetaUnknown" + ] + ); + let keys: Vec<&str> = w.iter().map(|w| w.key.as_str()).collect(); + assert_eq!( + keys, + [ + "AlphaUnknown", + "Contrast2012", + "Exposure2012", + "ZetaUnknown" + ] + ); + } + + #[test] + fn nested_empty_tables_are_absent_keys_without_warning() { + let (s, w) = read(json!({ + "LensBlur": {"Active": true, "FocalRange": []}, + "Look": {"Name": "Synthetic Profile", "Group": {}}, + "MaskGroupBasedCorrections": [ + {"What": "Correction", "LocalExposure2012": 0.1, "CorrectionName": [], + "CorrectionMasks": [ + {"What": "Mask/Image", "MaskSubType": 1, "MaskBlendMode": 0, + "MaskValue": 1, "MaskInverted": false, "Gesture": []} + ]} + ] + })); + assert!(w.is_empty(), "{w:?}"); + let Some(Value::Struct(blur)) = s.get_by_name("LensBlur") else { + panic!("LensBlur"); + }; + assert!(blur.get("FocalRange").is_none()); + assert!(blur.fields.opaque.is_empty()); + let look = s.look.as_ref().unwrap(); + assert!(look.rest.fields.is_empty(), "{look:?}"); + let c = &s.corrections[0]; + assert_eq!(c.name, None); + assert!(c.extra.get(Level::Correction, "CorrectionName").is_none()); + let m = &c.masks[0]; + assert_eq!(m.tool, MaskTool::Semantic(Semantic::Subject), "no gesture"); + assert!(m.extra.is_empty()); + } + + #[test] + fn empty_tables_in_lists_of_objects_are_no_elements() { + let (s, w) = read(json!({"MaskGroupBasedCorrections": [ + [], + {"What": "Correction", "CorrectionMasks": [ + [], + {"What": "Mask/Image", "MaskSubType": 2, "MaskBlendMode": 0, + "MaskValue": 1, "MaskInverted": false} + ]} + ]})); + assert!(w.is_empty(), "{w:?}"); + assert_eq!(s.corrections.len(), 1); + assert_eq!(s.corrections[0].masks.len(), 1); + assert_eq!( + s.corrections[0].masks[0].tool, + MaskTool::Semantic(Semantic::Sky) + ); + } + + #[test] + fn struct_bools_and_mask_enums_accept_the_lua_forms() { + let (s, w) = read(json!({ + "LensBlur": {"Active": 1}, + "Look": {"Name": "Synthetic Profile", "isAdobeAdaptive": 0}, + "MaskGroupBasedCorrections": [{"CorrectionMasks": [ + {"What": "Mask/Image", "MaskSubType": 1.5, "MaskBlendMode": 0, + "MaskValue": 1, "MaskInverted": false} + ]}] + })); + let Some(Value::Struct(blur)) = s.get_by_name("LensBlur") else { + panic!("LensBlur"); + }; + assert_eq!(blur.get("Active"), Some(&Value::Bool(true))); + assert_eq!( + s.look.as_ref().unwrap().rest.get("isAdobeAdaptive"), + Some(&Value::Bool(false)) + ); + // 1.5 rounds half-to-even to 2 (Sky), with a warning. + assert_eq!(kinds(&w), ["NonIntegerForIntKey"]); + assert_eq!( + w[0].path, + "MaskGroupBasedCorrections[0].CorrectionMasks[0].MaskSubType" + ); + assert_eq!( + s.corrections[0].masks[0].tool, + MaskTool::Semantic(Semantic::Sky) + ); + } + + #[test] + fn an_empty_camera_restriction_stays_in_the_look() { + let (s, w) = read(json!({"Look": {"Name": "x", "CameraModelRestriction": ""}})); + assert!(w.is_empty()); + let look = s.look.unwrap(); + assert_eq!(look.camera_restriction, None); + assert_eq!( + look.rest.get("CameraModelRestriction"), + Some(&Value::Str(String::new())) + ); + let (s, _) = + read(json!({"Look": {"Name": "x", "CameraModelRestriction": "Synthetic Camera"}})); + let look = s.look.unwrap(); + assert_eq!(look.camera_restriction.as_deref(), Some("Synthetic Camera")); + assert!(look.rest.get("CameraModelRestriction").is_none()); + } + + #[test] + fn integer_strings_are_read_on_mask_enums_only() { + // The Lua writer's `EnumAs::String` form reads back as numbers. + let (s, w) = read(json!({"MaskGroupBasedCorrections": [{ + "What": "Correction", "CorrectionAmount": 1, "CorrectionActive": true, + "LocalExposure2012": 0.1, + "CorrectionMasks": [ + {"What": "Mask/Image", "MaskActive": true, "MaskBlendMode": "0", + "MaskInverted": false, "MaskValue": 1, "MaskSubType": "3", + "MaskSubCategoryID": "5", "ErrorReason": "0"}, + {"What": "Mask/RangeMask", "MaskActive": true, "MaskBlendMode": "1", + "MaskInverted": true, "MaskValue": 0, + "CorrectionRangeMask": {"Type": "2", "Invert": true, "Version": 3, + "SampleType": "0", "LumRange": "0.5 0.7 1 1"}} + ] + }]})); + assert!(w.is_empty(), "{w:?}"); + let masks = &s.corrections[0].masks; + assert_eq!(masks[0].tool, MaskTool::Semantic(Semantic::PeoplePart(5))); + assert_eq!(masks[1].combine, Combine::Intersect); + assert!(matches!(masks[1].tool, MaskTool::LuminanceRange(_))); + // Not a mask enum, or not an integer: the wrong type, as before. + let (_, w) = read(json!({"PostCropVignetteStyle": "1"})); + assert_eq!(kinds(&w), ["WrongType"]); + for bad in ["1.0", " 1", "x", "", "+1"] { + let (_, w) = read(json!({"MaskGroupBasedCorrections": [{ + "What": "Correction", + "CorrectionMasks": [{"What": "Mask/Image", "MaskSubType": bad}] + }]})); + assert!(kinds(&w).contains(&"WrongType"), "{bad:?}: {w:?}"); + } + } + + #[test] + fn old_process_versions_are_flagged() { + let (s, w) = read(json!({"ProcessVersion": "5.7"})); + assert_eq!(s.process_version(), Some(ProcessVersion::PV2010)); + assert_eq!(kinds(&w), ["UnsupportedProcessVersion"]); + let (_, w) = read(json!({"ProcessVersion": "6.7"})); + assert!(w.is_empty()); + let (_, w) = read(json!({"ProcessVersion": "abc"})); + assert_eq!(kinds(&w), ["InvalidProcessVersion"]); + } +} diff --git a/server-rs/crates/lrg-develop/src/lua/write.rs b/server-rs/crates/lrg-develop/src/lua/write.rs new file mode 100644 index 00000000..003d04a0 --- /dev/null +++ b/server-rs/crates/lrg-develop/src/lua/write.rs @@ -0,0 +1,1549 @@ +//! [`DevelopSettings`] → the Lua table `photo:applyDevelopSettings()` takes, +//! as the JSON the plugin's `JSON.lua` decodes into that table. +//! +//! **Provisional.** Experiments E1, E2, E4 and E11 (the plugin task +//! `TaskDevelopExperiments.lua`) show which write-side forms Lightroom +//! accepts. Every alternative they measure that shapes the table is a +//! switch in [`LuaOptions`]; the writer decides none of them. The defaults +//! sit in one place, [`LuaOptions::PROVISIONAL`], and how far the evidence +//! carries each of them in [`LuaOptions::EVIDENCE`]. One run so far +//! (Lightroom Classic 15.6, October 2026, a single non-raw TIFF, run from the +//! Library module) settled the AI-mask forms for subject, sky and background +//! and the non-raw mode-only white-balance choices (`wb_mode_only`, +//! `flatten_auto_now`); `wb_custom_with_numbers` is only supported, because +//! `Custom` with the incremental numbers (the writer's non-raw form) was +//! never applied. People-part masks, the raw side, the `Look` (E11), the +//! flag encoding and `panel_switches` are still open, and the wire goldens +//! in `server-rs/testdata/develop/wire/` are not frozen. +//! +//! # Modes +//! +//! - [`LuaMode::Apply`]: what production writes, for one photo +//! ([`PhotoContext`]). The settings go through [`DevelopSettings::filtered`] +//! with [`Target::ApplyPhoto`] first, so PHOTO values pass (the settings +//! are meant for this photo, as the policy table says) and every value +//! that does not reach the photo is returned in [`LuaWritten::skipped`]. +//! - [`LuaMode::Preset`]: the same table for a plugin preset applied to +//! that one photo (the E4 route), plus the amount flags when +//! [`LuaOptions::preset_amount_flags`] asks for them. +//! - `LuaMode::TestRoundTrip` (crate feature `test-roundtrip`, enabled only +//! through this crate's own dev-dependency): everything the model holds, +//! opaque content read from a Lua table included, so Lua → model → Lua → +//! model compares equal (plan §7.3, round trip 2). Content read from XMP +//! that has no Lua form is an error ([`WireError::NotLua`]). +//! +//! # Rules (plan §4.1, writer rules 1–10) +//! +//! 1. Keys without the `crs:` prefix; only what the policy filter lets +//! through for the photo, then a never-write guard ([`lua_never_written`]): +//! digests, `FullMaskSize`, `WholeImageArea`, `Origin`, `ModelVersion`, +//! `CorrectionID`, `MaskID`, `CorrectionReferenceX/Y`, brush and retouch +//! payloads, XMP-only keys. A value the guard drops is reported. +//! 2. Integer keys as JSON integers (a real on an integer key goes through +//! [`KeySpec::coerce`], the one rounding place), real keys as JSON +//! numbers; every number is a [`Finite`], so never `null`. +//! 3. Booleans as JSON booleans; 0/1 flag keys (`ValueKind::IntFlag`) as +//! numbers or booleans ([`LuaOptions::int_flag_as`]). +//! 4. Numeric-looking strings stay strings: `ProcessVersion`, +//! `ReferencePoint`, `LumRange`, `FocalRange`, `Look.UUID`, local curve +//! points. +//! 5. Global curves as a flat number list (`[x, y, x, y, ...]`, at least two +//! points); local curves as a list of `"x,y"` strings. +//! 6. `MaskGroupBasedCorrections` as a list in panel order, `Local*` values +//! directly on the correction ([`LuaOptions::local_form`]: zero-filled +//! as Adobe's adaptive presets, or the model's own only), +//! `CorrectionMasks` never empty. +//! 7. Never an empty container: JSON.lua encodes `{}` as `[]`, and an empty +//! `MaskGroupBasedCorrections` would delete every mask of the photo. +//! Corrections that do not reach the photo leave the key out entirely. +//! 8. No mixed tables, no sparse arrays (no `null`, one JSON type per list). +//! 9. White balance as a family: the numbers of the photo's family only, +//! never next to a mode other than `Custom`, never `Temp`. +//! 10. The forms the experiments decide are [`LuaOptions`]. +//! +//! Rules 7, 8 and 9 are checked once more on the finished table +//! ([`check_wire`]); a failure there is a writer bug and an error, never a +//! table handed to Lightroom. +//! +//! Like the XMP preset writer, apply mode sets what describes the settings' +//! context rather than the settings: `ToneCurveName2012` next to a point +//! curve, `WhiteBalance = "Custom"` next to numbers, and the fixed form of a +//! correction (`What`, `CorrectionAmount` 1, `CorrectionActive`; the zero +//! `Local*` keys with [`LocalForm::AdaptivePreset`]) and of a component +//! (`MaskActive`; more with [`MaskForm::PresetForm`]). The settings' own +//! `ProcessVersion` is never written (no process-version stamp either: +//! the photo keeps its own). +//! +//! Number spelling is not part of the contract: JSON.lua under Lua 5.1 (the +//! Lightroom runtime) re-encodes numbers with `%.14g`, a local Lua 5.5 with +//! the shortest exact form. Tests compare **decoded** values, never encoded +//! text. + +use serde_json::{Map, Number, Value as J}; + +use crate::model::correction::{Correction, MaskComponent, MaskTool, Semantic}; +use crate::model::policy::{SkipReason, Skipped, Target, WB_NUMBERS}; +use crate::model::value::{Fields, Finite, Opaque, OpaqueEntry, Struct, Value}; +use crate::model::whitebalance::WbFamily; +use crate::model::{DevelopSettings, FileKind, Look}; +use crate::registry::{ + self, CurveKind, Def, Gate, KeyId, KeySpec, Level, NumFmt, Policy, Presence, ProcessVersion, + Scalar, StructKind, ValueKind, +}; +use crate::xmp::format::{format_compound, hex32_upper, reformat_compound}; +use crate::xmp::write::{look_struct, NEVER_WRITE}; + +/// The photo the settings are applied to: what the policy filter checks +/// gates, file-kind keys, process versions and camera-restricted profiles +/// against. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct PhotoContext { + /// Raw or non-raw, from the photo's own white-balance keys (a DNG + /// converted from a JPEG is non-raw). Unknown: no white-balance numbers + /// and no raw-only or non-raw-only key are written. + pub file_kind: Option, + /// The photo's process version: keys newer than it are skipped. It is + /// never written. + pub process_version: Option, + /// The photo's camera model, for a camera-restricted `Look`. + pub camera: Option, +} + +impl PhotoContext { + /// The policy target for this photo. + pub fn target(&self) -> Target { + Target::ApplyPhoto { + file_kind: self.file_kind, + process_version: self.process_version, + camera: self.camera.clone(), + } + } +} + +/// How [`to_lua_value`] writes. +/// +/// `#[non_exhaustive]` for the same reason as `xmp::WriteMode`: +/// `TestRoundTrip` exists only with the crate feature `test-roundtrip`, so a +/// match outside this crate needs a wildcard arm and no other crate may name +/// it. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum LuaMode { + /// `photo:applyDevelopSettings()` on this photo: policy-filtered, + /// guarded, completed. + Apply(PhotoContext), + /// A plugin preset (`LrApplication.addDevelopPresetForPlugin`, the E4 + /// route) for this photo: the same table as [`LuaMode::Apply`], plus + /// `SupportsAmount`/`SupportsAmount2` when + /// [`LuaOptions::preset_amount_flags`] asks for them. The only mode that + /// honours that field, so a preset-route answer cannot leak into the + /// `applyDevelopSettings` route. + Preset(PhotoContext), + /// Tests only: everything the model holds, verbatim. Of the options only + /// the encodings ([`LuaOptions::mask_enum_as`], [`LuaOptions::int_flag_as`]) + /// apply, and the reader reads every one of them back; nothing is added, + /// completed or filtered. + #[cfg(feature = "test-roundtrip")] + TestRoundTrip, +} + +/// How a mask enum (`MaskSubType`, `MaskSubCategoryID`, `MaskBlendMode`, +/// `ErrorReason`, `CorrectionRangeMask.Type`, ...; see +/// [`KeySpec::is_mask_enum`]) is written. The Lua reader accepts both forms. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum EnumAs { + /// A JSON number (`"MaskSubType": 1`). + Number, + /// A numeric string (`"MaskSubType": "1"`). + String, +} + +/// How a 0/1 flag key (`LensProfileEnable`, `AutoLateralCA`, +/// `CropConstrainToWarp`, `HDREditMode`) is written. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum FlagAs { + /// `0`/`1`. + Number, + /// `false`/`true`. + Bool, +} + +/// Which fields a written mask component carries beyond what the model +/// holds. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum MaskForm { + /// The "compute me" form of Adobe's adaptive presets, the form + /// `DevelopExperiments.aiMaskTool` (E2, E4) applies: an AI mask gets + /// `MaskVersion` 1, `ReferencePoint` `"0.500000 0.500000"` and + /// `ErrorReason` 0, a luminance range `CorrectionRangeMask.Version` 3 and + /// `SampleType` 0, when the model does not have them. (No experiment + /// applies a luminance range.) + PresetForm, + /// Only what the model holds (plus `What` and `MaskActive`, which every + /// form carries). Applied by no experiment. + Minimal, +} + +/// Which `Local*` values a written correction carries. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum LocalForm { + /// Every key of [`ADAPTIVE_PRESET_LOCALS`], at 0 unless the model has a + /// value for it: the correction form of Adobe's adaptive presets, which + /// E2 and E4 apply (`DevelopExperiments.aiMaskCorrection`). The legacy + /// PV2010 keys in it (`LocalExposure`, `LocalClarity`, ...) are written + /// only as this 0; a value of theirs never passes the policy filter. + AdaptivePreset, + /// The model's own values only. Applied by no experiment. + Sparse, +} + +/// The `Local*` keys every correction of Adobe's adaptive presets carries, +/// in their order; [`LocalForm::AdaptivePreset`] writes each at 0 unless +/// the model has a value. The same list as `LOCAL_KEYS` in +/// `plugin/LrGeniusAI.lrdevplugin/DevelopExperiments.lua` (pinned by +/// `tests/lua_wire_goldens.rs`), so the writer can produce the correction +/// form E2 and E4 apply. +pub const ADAPTIVE_PRESET_LOCALS: &[&str] = &[ + "LocalExposure", + "LocalHue", + "LocalSaturation", + "LocalContrast", + "LocalClarity", + "LocalSharpness", + "LocalBrightness", + "LocalToningHue", + "LocalToningSaturation", + "LocalExposure2012", + "LocalContrast2012", + "LocalHighlights2012", + "LocalShadows2012", + "LocalWhites2012", + "LocalBlacks2012", + "LocalClarity2012", + "LocalDehaze", + "LocalLuminanceNoise", + "LocalMoire", + "LocalDefringe", + "LocalTemperature", + "LocalTint", + "LocalTexture", +]; + +/// Which `Enable*` panel switches a table carries. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum PanelSwitches { + /// None (plan §4.1 rule 10: `include_panel_switches = false`). Applied + /// by no experiment. + None, + /// [`MASK_SWITCH`] (`EnableMaskGroupBasedCorrections = true`) next to + /// corrections, nothing else: what E2 and E4 send with every mask, and + /// the form E2 applied (a no-op on that photo: the switch was already + /// `true` there, E13). + MaskOnly, + /// [`MASK_SWITCH`] next to corrections and the switch of every panel + /// the table touches ([`PANEL_SWITCHES`], an unverified map). + All, +} + +/// How the `Look` (creative profile) is written. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum LookForm { + /// `Name`, `UUID`, `Amount` and `Stubbed = true`: the reference Adobe's + /// own presets carry (E11a). + Stub, + /// `Name`, `UUID` and `Amount` only (E11c). + BareStub, + /// The whole profile record as the source has it, `Parameters` + /// included when it was read from a Lua table and is itself a valid + /// wire table (E11b). The one place the writer passes kept-verbatim + /// content to Lightroom, and only on request. + Full, +} + +/// The write-side forms experiments E1, E2, E4 and E11 bear on. +/// +/// Every alternative an experiment measures that shapes the table (or the +/// call that applies it) is one value of one field, so the model can +/// represent whichever answer comes back; some fields also hold forms no +/// experiment applies (see [`LuaOptions::PROVISIONAL`] for which). The +/// defaults are [`LuaOptions::PROVISIONAL`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct LuaOptions { + /// Mask enums ([`KeySpec::is_mask_enum`]) as numbers or numeric + /// strings. E2 and E4 apply numbers only, and they work. + pub mask_enum_as: EnumAs, + /// 0/1 flag keys as numbers or booleans. No experiment writes a flag + /// key. + pub int_flag_as: FlagAs, + /// Which `Enable*` panel switches the table carries (the plan's + /// `include_panel_switches`). E2 and E4 always send + /// [`PanelSwitches::MaskOnly`], on a photo where the switch was already + /// on, so no run has told it from [`PanelSwitches::None`]. + pub panel_switches: PanelSwitches, + /// Complete AI and range masks in Adobe's preset form or write them + /// minimal. E2 and E4 apply the preset form only. + pub mask_form: MaskForm, + /// Zero-fill a correction's `Local*` keys in Adobe's adaptive-preset + /// form or write the model's own only. E2 and E4 apply the zero-filled + /// form only. + pub local_form: LocalForm, + /// Write `WhiteBalance = "Custom"` next to white-balance numbers when the + /// settings name no mode. Raw: E1c (with) against E1b (without); + /// non-raw: only numbers alone (E1b) are applied, and they leave the + /// mode at `"As Shot"`; `Custom` with the incremental numbers in one + /// call is applied by no experiment. + pub wb_custom_with_numbers: bool, + /// Write a named white-balance mode (`"Auto"`, `"Daylight"`, ...) on + /// its own, without numbers, for Lightroom to resolve (E1e, E1f, E1g + /// apply `Daylight` and `Auto`; `"As Shot"` and the other named modes + /// alone are applied by no experiment). Off: such a mode and the + /// numbers next to it are skipped and reported. Honoured for raw (and + /// unknown) photos only: on a non-raw photo a mode alone is always + /// skipped and reported, because `"Auto"` alone left the old + /// incremental numbers in place there (E1f/E1g). `"Custom"` without + /// numbers is always skipped and reported: it would pin whatever the + /// photo has. + pub wb_mode_only: bool, + /// Ask for `applyDevelopSettings(..., optFlattenAutoNow = true)` when the + /// table writes `WhiteBalance = "Auto"` ([`ApplyCall::flatten_auto_now`]) + /// (E1f against E1g). + pub flatten_auto_now: bool, + /// Ask for `photo:updateAISettings()` after applying a table with AI + /// content ([`ApplyCall::update_ai_settings`]) (E2b against E2c). + pub ai_update: bool, + /// How the `Look` is written (E11: E11a, E11b, E11c). + pub look_form: LookForm, + /// Write `SupportsAmount = true` and `SupportsAmount2 = true`, for a + /// table handed to `LrApplication.addDevelopPresetForPlugin` so that the + /// preset's amount applies (E4e, plain against flagged). Honoured only + /// by [`LuaMode::Preset`]; [`LuaMode::Apply`] (the + /// `applyDevelopSettings` route) never writes the flags, whatever this + /// says. In Lightroom 15.6 the amount of a plugin preset changed nothing + /// with or without the flags (E4e). + pub preset_amount_flags: bool, +} + +impl LuaOptions { + /// The write-side defaults. **The one place every write-side decision is + /// made**; [`LuaOptions::EVIDENCE`] says, per field, how far the + /// experiments carry it. The type keeps its provisional name while + /// fields are open (see the Status column). + /// + /// The first run (Lightroom Classic 15.6, 2026-10-03, **one non-raw + /// TIFF** without develop edits, a virtual copy, run from the Library + /// module; no raw file) answered E1 (non-raw), E2 and E4; E1e and E11 + /// need a raw file and did not run. Whether the photo shows a person the + /// report does not say. "Settled" below means: applied in that run, did + /// what the writer needs; it is not a claim about raw files, other + /// Lightroom versions or the Develop module. + /// + /// | Field | Default | Status | Evidence | Still open | + /// |---|---|---|---|---| + /// | `mask_enum_as` | `Number` | settled (E2, 15.6, non-raw) | E2a–E2d apply mask enums as numbers: the masks are kept and computed (subject, sky, background; a hair mask ended `failed`, `ErrorReason` 1) | `String` is applied by no experiment, and is not needed | + /// | `int_flag_as` | `Number` | open | no experiment writes a flag key; read side only: numbers in 1,294 of 1,294 training rows and in the E13 readback (`AutoLateralCA`, `LensProfileEnable`, `CropConstrainToWarp`, `HDREditMode` all `0`) | a write of a 0/1 flag key, numbers against booleans | + /// | `panel_switches` | `MaskOnly` | supported (E2, 15.6, non-raw) | E2 sent the switch, but E13 shows it was already `true` on the source photo (E2's copies inherit it), so the run does not tell `None` from `MaskOnly`; the default is the form E2 applied | `None` against `MaskOnly` (the blocking wire-golden variant, with and without the switch); `All` and the `PANEL_SWITCHES` map are unverified | + /// | `mask_form` | `PresetForm` | settled for subject, sky and background AI masks (E2, 15.6, non-raw) | E2a: a digest-less subject mask in this form is kept, pending until `updateAISettings()`, then computed; E2d: sky and background likewise | people parts: the one applied (E2d hair, `MaskSubType` 3 / `MaskSubCategoryID` 5, the writer's form) ended `failed`, `ErrorReason` 1; form or photo is not known. `Minimal`, and a luminance range's `Version`/`SampleType`, applied by no experiment | + /// | `local_form` | `AdaptivePreset` | settled (E2, 15.6, non-raw) | E2 applies the 23 zero-filled `Local*` keys and the correction works | `Sparse` applied by no experiment | + /// | `wb_custom_with_numbers` | `true` | supported on non-raw (E1b, E1d, 15.6); open on raw | E1b: incremental numbers alone are stored, and leave the mode at `"As Shot"` in the readback, so the writer keeps writing `Custom` with them (whether `"As Shot"` + numbers renders them is the open Basic-panel check); E1d: `"Custom"` is taken as a mode | non-raw: `Custom` + incremental numbers in one call; raw: E1b/E1c on a raw file | + /// | `wb_mode_only` | `false` | settled on non-raw (E1f/E1g, 15.6); open on raw | E1f/E1g: `"Auto"` alone sets the mode, and the readback showed the previous incremental numbers unchanged within 15 s (Library module; the Basic-panel check is open). Never honoured for a non-raw photo, whatever this says. Stays together with `lrg_analysis::style_engine::EMIT_NAMED_WB_MODES` | raw: E1e (`Daylight`) and `Auto` on a raw file (a positive answer turns it on for raw targets only); `"As Shot"` and the other named modes alone are applied by no experiment | + /// | `flatten_auto_now` | `false` | settled on non-raw (E1f against E1g, 15.6) | `optFlattenAutoNow = true` made no difference to the readback within 15 s (Library module) | raw (matters only with `wb_mode_only`) | + /// | `ai_update` | `true` | settled for AI masks (E2b against E2c, 15.6, non-raw) | without the call the subject mask is still pending after 30 s (Library module); after `photo:updateAISettings()` it is computed (call 0.6 s) | an Adobe Adaptive `Look` (E11 rejects adaptive Looks); people parts (the subType-3 form, on a photo with a visible person) | + /// | `look_form` | `Full` | settled on raw (E11, 15.6) | E11b: a complete Look with `Parameters` from another photo is honoured; E11a/E11c: a stub, with or without `Stubbed`, sets no Look. A Look without `Parameters` is reported (`Look.Parameters`) | camera-restricted and adaptive Looks by no experiment | + /// | `preset_amount_flags` | `false` | settled: no effect (E4e, 15.6, non-raw) | amount 50 and 200 leave the globals and the mask values at the preset's own, with and without `SupportsAmount`/`SupportsAmount2` (read back; the visual check is manual) | the preset route is not chosen (E4: preset masks replace the photo's, `updateAI = true` left the mask pending) | + /// + /// When a run answers more, in one change: + /// + /// 1. change the field here, its row in [`LuaOptions::EVIDENCE`] and + /// `the_provisional_choices_are_pinned`; + /// 2. E1 (mode only): flip `wb_mode_only` together with + /// `lrg_analysis::style_engine::EMIT_NAMED_WB_MODES` and its const + /// assert in `a_named_mode_majority_is_not_sent_while_the_switch_is_off`. + /// A positive raw E1e turns mode-only on for raw targets only: both + /// refuse a mode alone for a non-raw photo before reading the switch + /// (E1f/E1g), and the tests `custom_without_numbers_is_never_written_alone` + /// and `a_non_raw_photo_gets_no_mode_alone_whatever_the_switch` pin it; + /// 3. re-bless the wire goldens (`server-rs/testdata/develop/wire/README.md`) + /// and run `busted`; + /// 4. set the field's Status on the wiki page Dev-Develop-Model + /// ("Options and what decides them"), and correct the field doc + /// above if it names the experiment differently. + pub const PROVISIONAL: LuaOptions = LuaOptions { + // Settled (E2, 15.6, non-raw): numbers work; strings never applied. + mask_enum_as: EnumAs::Number, + // Open: no experiment writes a flag key; read-back evidence only. + int_flag_as: FlagAs::Number, + // Supported (E2, 15.6): the form E2 applied, a no-op on that photo. + panel_switches: PanelSwitches::MaskOnly, + // Settled for subject/sky/background (E2, 15.6, non-raw); people + // parts open; Minimal never applied. + mask_form: MaskForm::PresetForm, + // Settled (E2, 15.6, non-raw); Sparse never applied. + local_form: LocalForm::AdaptivePreset, + // Supported on non-raw (E1b/E1d, 15.6); raw E1b vs E1c open. + wb_custom_with_numbers: true, + // Settled on non-raw (E1f/E1g, 15.6; never honoured for non-raw); + // raw E1e-E1g not recomputed either. With EMIT_NAMED_WB_MODES. + wb_mode_only: false, + // Settled on non-raw (E1f vs E1g, 15.6): no effect. + flatten_auto_now: false, + // Settled for AI masks (E2b vs E2c, 15.6, non-raw). + ai_update: true, + // Settled on raw (E11a/E11c, E11b): a stub sets no Look, a complete Look with + // Parameters does. + look_form: LookForm::Full, + // Settled (E4e, 15.6): the amount changes nothing either way. + preset_amount_flags: false, + }; + + /// How far the experiments carry each field of + /// [`LuaOptions::PROVISIONAL`], in field order: the table above, typed, + /// so a test can require one row per field. + pub const EVIDENCE: &'static [OptionEvidence] = &[ + OptionEvidence { + field: "mask_enum_as", + status: EvidenceStatus::Settled, + by: "E2a-E2d (numbers; a hair mask ended failed, ErrorReason 1)", + scope: FIRST_RUN, + open: "String is applied by no experiment (not needed)", + }, + OptionEvidence { + field: "int_flag_as", + status: EvidenceStatus::Open, + by: "", + scope: "", + open: "no experiment writes a 0/1 flag key; numbers only on the read side (training rows, E13)", + }, + OptionEvidence { + field: "panel_switches", + status: EvidenceStatus::Supported, + by: "E2a-E2d sent the switch, but E13 shows it was already true on the source photo (E2's copies inherit it)", + scope: FIRST_RUN, + open: "the run does not tell None from MaskOnly (the wire-golden variant with and without the switch); All and PANEL_SWITCHES unverified", + }, + OptionEvidence { + field: "mask_form", + status: EvidenceStatus::Settled, + by: "E2a-E2d (subject, sky, background)", + scope: FIRST_RUN, + open: "people parts: the hair mask (MaskSubType 3 / MaskSubCategoryID 5, the writer's form) failed with ErrorReason 1 on the non-raw TIFF but computed on the raw run, so the form works and the first failure was the photo; other parts and several people are open. Minimal and a luminance range's preset form are applied by no experiment", + }, + OptionEvidence { + field: "local_form", + status: EvidenceStatus::Settled, + by: "E2a-E2d", + scope: FIRST_RUN, + open: "Sparse is applied by no experiment", + }, + OptionEvidence { + field: "wb_custom_with_numbers", + status: EvidenceStatus::Supported, + by: "E1b (numbers alone leave As Shot in the readback), E1d (Custom is taken); raw E1c (Custom + Temperature + Tint is taken whole)", + scope: FIRST_RUN_AND_RAW, + open: "non-raw Custom + incremental numbers in one call (the writer's non-raw form); whether As Shot + numbers renders them (Basic panel)", + }, + OptionEvidence { + field: "wb_mode_only", + status: EvidenceStatus::Settled, + by: "E1f/E1g (non-raw) and E1e-E1g (raw): a mode alone sets the mode, Temperature/Tint stay as they were for 15 s", + scope: FIRST_RUN_AND_RAW, + open: "the Basic-panel check (Lightroom may resolve a mode only when Develop renders the photo)", + }, + OptionEvidence { + field: "flatten_auto_now", + status: EvidenceStatus::Settled, + by: "E1f against E1g (no difference), on non-raw and on raw", + scope: FIRST_RUN_AND_RAW, + open: "the Basic-panel check", + }, + OptionEvidence { + field: "ai_update", + status: EvidenceStatus::Settled, + by: "E2b (still pending after 30 s) against E2c (computed after the call)", + scope: FIRST_RUN, + open: "an Adobe Adaptive Look; people parts (the subType-3 form, on a photo with a visible person)", + }, + OptionEvidence { + field: "look_form", + status: EvidenceStatus::Settled, + by: "E11b (a complete Look with Parameters from another photo is honoured); E11a/E11c (a stub, with or without Stubbed, sets no Look)", + scope: RAW_LOOK_RUN, + open: "Full without Parameters (reported, not written as a working Look), camera-restricted and adaptive Looks (applied by no experiment), and the Basic-panel check", + }, + OptionEvidence { + field: "preset_amount_flags", + status: EvidenceStatus::Settled, + by: "E4e (amount 50 and 200 change nothing, with and without the flags)", + scope: FIRST_RUN, + open: "visual check of the amount copies (manual)", + }, + ]; +} + +/// Where the first run's answers hold: the scope of every +/// [`EvidenceStatus::Settled`] and [`EvidenceStatus::Supported`] row so far. +pub const FIRST_RUN: &str = + "Lightroom Classic 15.6, one non-raw TIFF without develop edits, run from the Library module"; + +/// Where the Look answers hold: the E11 runs of 2026-10-23. +pub const RAW_LOOK_RUN: &str = + "Lightroom Classic 15.6, Canon CR3 raws, Library module; Looks \"Adobe Color\" onto a copy with no Look"; + +/// Rows settled by both runs. +pub const FIRST_RUN_AND_RAW: &str = "Lightroom Classic 15.6, Library module: one non-raw TIFF without edits, and one edited Canon CR3"; + +/// How far the experiments carry one [`LuaOptions`] field. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum EvidenceStatus { + /// The default was applied by an experiment and did what the writer + /// needs, within [`OptionEvidence::scope`]; alternatives that were never + /// applied are named in [`OptionEvidence::open`]. + Settled, + /// The evidence points to the default without settling it: the default + /// was not applied as written, or was applied where it could make no + /// difference. + Supported, + /// No experiment has answered it: the default is a provisional guess. + Open, +} + +/// Whether the profile is an Adobe Adaptive Look (`isAdobeAdaptive`). +fn is_adaptive(look: &Look) -> bool { + look.rest + .fields + .values + .get(&id(Level::Struct(StructKind::Look), "isAdobeAdaptive")) + .is_some_and(|v| *v == Value::Bool(true)) +} + +/// One row of [`LuaOptions::EVIDENCE`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct OptionEvidence { + /// The [`LuaOptions`] field. + pub field: &'static str, + /// How far the evidence goes. + pub status: EvidenceStatus, + /// Which experiments (empty when open). + pub by: &'static str, + /// Lightroom version and file kind they ran on (empty when open). + pub scope: &'static str, + /// What is still open about the field. + pub open: &'static str, +} + +impl Default for LuaOptions { + /// [`LuaOptions::PROVISIONAL`]. + fn default() -> LuaOptions { + LuaOptions::PROVISIONAL + } +} + +/// How the table must be applied: the call arguments that go with it. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct ApplyCall { + /// Call `photo:updateAISettings()` after applying (for a preset: pass + /// `updateAI = true`): the table adds an AI mask (`Mask/Image`), + /// another key whose gate needs it (`LensBlur`) or an Adobe Adaptive + /// `Look` (`isAdobeAdaptive`; its `AILook` state is never written), and + /// [`LuaOptions::ai_update`] is on. The adaptive `Look` case is applied + /// by no experiment (E11 rejects adaptive Looks). + pub update_ai_settings: bool, + /// Pass `optFlattenAutoNow = true`: the table sets + /// `WhiteBalance = "Auto"` and [`LuaOptions::flatten_auto_now`] is on. + pub flatten_auto_now: bool, +} + +/// A written table. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LuaWritten { + /// The settings table, always a JSON object (empty when nothing + /// reaches the photo; check [`LuaWritten::is_empty`] before applying). + pub table: J, + /// Every value that was not written although it differs from its + /// default: the policy filter's reports, then the writer's own (the + /// white-balance family, the `Look` form, the never-write guard). + /// Empty in a test round trip. + pub skipped: Vec, + /// How to apply it. + pub call: ApplyCall, +} + +impl LuaWritten { + /// True when the table holds nothing (applying it would change nothing). + pub fn is_empty(&self) -> bool { + self.table.as_object().is_none_or(Map::is_empty) + } +} + +/// The settings cannot be written as a Lua table. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum WireError { + /// A value whose variant does not fit its registry key. + #[error("{path}: expected {expected}, found {found}")] + WrongValue { + /// Where (`"MaskGroupBasedCorrections[0].LocalExposure2012"`). + path: String, + /// The registry's kind. + expected: String, + /// The model value's variant. + found: &'static str, + }, + /// An id that is not 32 hex digits, in apply mode. + #[error("{path}: {value:?} is not 32 hex digits")] + NotHex32 { + /// Where. + path: String, + /// The value. + value: String, + }, + /// A compound number string (`ReferencePoint`, `LumRange`) that is not + /// a list of numbers, in apply mode. + #[error("{path}: {value:?} is not a list of numbers")] + NotCompound { + /// Where. + path: String, + /// The value. + value: String, + }, + /// A tone curve with fewer than two points, in apply mode. + #[error("{path}: a tone curve needs at least two points, found {points}")] + ShortCurve { + /// Where. + path: String, + /// How many it has. + points: usize, + }, + /// Content kept from an XMP file (`Opaque::Xmp`) has no Lua form; a test + /// round trip cannot write it (apply mode skips and reports it). + #[error("{path}: content read from XMP has no Lua table form")] + NotLua { + /// Where. + path: String, + }, + /// The finished table breaks a wire rule (an empty container, `null`, a + /// mixed list, a `Temp` key). A writer bug: the table is not handed out. + #[error("{path}: {rule}")] + NotWireSafe { + /// Where in the table. + path: String, + /// The broken rule. + rule: &'static str, + }, +} + +/// Panel switches [`PanelSwitches::All`] writes: the switch, and the global +/// key-name prefixes of the panel it enables. A switch is written (`true`) +/// when the table carries a key with one of its prefixes. +/// +/// **Provisional and unverified**, like [`LuaOptions::PROVISIONAL`]: only +/// [`MASK_SWITCH`] is ever applied by an experiment (E2, E4); check these +/// mappings in Lightroom before turning [`PanelSwitches::All`] on. +pub const PANEL_SWITCHES: &[(&str, &[&str])] = &[ + ( + "EnableToneCurve", + &["ToneCurvePV2012", "Parametric", "CurveRefineSaturation"], + ), + ( + "EnableColorAdjustments", + &[ + "HueAdjustment", + "SaturationAdjustment", + "LuminanceAdjustment", + ], + ), + ("EnableGrayscaleMix", &["GrayMixer"]), + ("EnableSplitToning", &["SplitToning", "ColorGrade"]), + ( + "EnableDetail", + &[ + "Sharpness", + "Sharpen", + "LuminanceSmoothing", + "LuminanceNoiseReduction", + "ColorNoiseReduction", + ], + ), + ("EnableEffects", &["PostCropVignette", "Grain"]), + ( + "EnableLensCorrections", + &[ + "LensProfile", + "LensManualDistortion", + "AutoLateralCA", + "Defringe", + "VignetteAmount", + "VignetteMidpoint", + ], + ), + ("EnableTransform", &["Perspective", "Upright"]), + ( + "EnableCalibration", + &[ + "ShadowTint", + "RedHue", + "RedSaturation", + "GreenHue", + "GreenSaturation", + "BlueHue", + "BlueSaturation", + ], + ), +]; + +/// The switch of the masking panel, written next to corrections by +/// [`PanelSwitches::MaskOnly`] and [`PanelSwitches::All`]. +pub const MASK_SWITCH: &str = "EnableMaskGroupBasedCorrections"; + +/// No Lightroom develop key: AI Edit once wrote it for white balance, and +/// experiment E1 asks what Lightroom does with it. [`check_wire`] refuses it +/// at any depth; white balance goes as a family (rule 9). The one place the +/// name is spelled (allowed by `tests/no_temp_key.rs`). +pub const RETIRED_TEMP_KEY: &str = "Temp"; + +/// Whether `spec` never reaches a Lua table Lightroom applies, whatever its +/// policy says (checked after the policy filter, as a last guard): the XMP +/// writer's never-write list ([`NEVER_WRITE`]: runtime ids and reference +/// points, mask payload bookkeeping, brush dabs, retouch and filter +/// payloads, ...), every key with `Digest` in its name, `MaskBrushTable*`, +/// the global `Enable*` panel switches (only [`LuaOptions::panel_switches`] +/// writes those), and keys Lightroom +/// only has in XMP or was never seen writing. +pub fn lua_never_written(spec: &KeySpec) -> bool { + NEVER_WRITE.contains(&spec.name) + || spec.name.contains("Digest") + || spec.name.starts_with("MaskBrushTable") + || (spec.level == Level::Global && spec.name.starts_with("Enable")) + || matches!(spec.presence, Presence::XmpOnly | Presence::Unobserved) +} + +/// Writes `settings` as a Lua settings table. +/// +/// In [`LuaMode::Apply`] and [`LuaMode::Preset`], see the module docs for +/// the rules; the result's +/// [`LuaWritten::skipped`] names everything that did not reach the photo, +/// and [`LuaWritten::call`] how to apply the table. +pub fn to_lua_value( + settings: &DevelopSettings, + mode: &LuaMode, + options: &LuaOptions, +) -> Result { + match mode { + LuaMode::Apply(photo) => write_apply(settings, photo, options, false), + LuaMode::Preset(photo) => write_apply(settings, photo, options, true), + #[cfg(feature = "test-roundtrip")] + LuaMode::TestRoundTrip => write_round_trip(settings, options), + } +} + +fn id(level: Level, name: &str) -> KeyId { + registry::lookup(level, name).unwrap_or_else(|| panic!("registry row {level}/{name}")) +} + +fn child(path: &str, key: &str) -> String { + if path.is_empty() { + key.to_owned() + } else { + format!("{path}.{key}") + } +} + +fn variant(v: &Value) -> &'static str { + match v { + Value::Int(_) => "an integer", + Value::Real(_) => "a real", + Value::Bool(_) => "a boolean", + Value::Str(_) => "text", + Value::Curve(_) => "a curve", + Value::StrList(_) => "a text list", + Value::Alt(_) => "a language alternative", + Value::Struct(_) => "a structure", + Value::StructList(_) => "a structure list", + Value::Tools(_) => "mask components", + Value::Corrections(_) => "corrections", + Value::Opaque(_) => "opaque content", + } +} + +fn real(x: Finite) -> J { + // `Finite` is never NaN or infinite, the only values `from_f64` refuses. + J::Number(Number::from_f64(x.get()).expect("finite")) +} + +/// Integral and small enough to be exact as an `i64`. +fn as_integral(x: Finite) -> Option { + let v = x.get(); + (v.fract() == 0.0 && v.abs() < 1e15).then_some(v as i64) +} + +/// A global curve coordinate: an integer when it is one (the read-back +/// form, `[0, 0, 255, 255]`), a real otherwise. +fn coordinate(x: Finite) -> J { + as_integral(x).map_or_else(|| real(x), J::from) +} + +/// A coordinate in a local curve point string: the integer, or the +/// shortest text that reads back as the same number. +fn coordinate_text(x: Finite) -> String { + as_integral(x).map_or_else(|| x.get().to_string(), |i| i.to_string()) +} + +fn write_apply( + settings: &DevelopSettings, + photo: &PhotoContext, + opt: &LuaOptions, + preset: bool, +) -> Result { + let (filtered, skipped) = settings.filtered(&photo.target()); + let mut w = Writer { + apply: true, + opt, + file_kind: photo.file_kind, + skipped, + ai: false, + }; + let mut table = Map::new(); + + // White balance first: it is written (or skipped) as a family. + w.white_balance(&filtered, &mut table)?; + for (kid, v) in filtered.values() { + let spec = kid.spec(); + if spec.name == "WhiteBalance" || WB_NUMBERS.contains(&spec.name) { + continue; + } + if let Some(j) = w.keyed(spec, v, spec.name)? { + if spec.policy.gate() == Some(Gate::NeedsAiUpdate) { + w.ai = true; + } + table.insert(spec.name.to_owned(), j); + } + } + // Set from context, as the XMP preset writer does: the curve's name. + if table.contains_key("ToneCurvePV2012") { + let linear = filtered + .get_by_name("ToneCurvePV2012") + .is_some_and(|c| c.equals_lit(®istry::Lit::IntList(&[0, 0, 255, 255]))); + table.insert( + "ToneCurveName2012".into(), + J::from(if linear { "Linear" } else { "Custom" }), + ); + } + if let Some(look) = &filtered.look { + if let Some(j) = w.look(look)? { + table.insert("Look".into(), j); + } + } + let mut corrections = Vec::new(); + for (i, c) in filtered.corrections.iter().enumerate() { + if let Some(j) = w.correction(c, &format!("MaskGroupBasedCorrections[{i}]"))? { + corrections.push(j); + } + } + // Rule 7: no corrections, no key (an empty list deletes every mask). + if !corrections.is_empty() { + table.insert("MaskGroupBasedCorrections".into(), J::Array(corrections)); + } + if opt.panel_switches == PanelSwitches::All { + for (switch, prefixes) in PANEL_SWITCHES { + if table + .keys() + .any(|k| prefixes.iter().any(|p| k.starts_with(p))) + { + table.insert((*switch).to_owned(), J::Bool(true)); + } + } + } + if opt.panel_switches != PanelSwitches::None && table.contains_key("MaskGroupBasedCorrections") + { + table.insert(MASK_SWITCH.to_owned(), J::Bool(true)); + } + // The preset route only (`LuaMode::Preset`): `applyDevelopSettings` has + // no amount. + if preset && opt.preset_amount_flags && !table.is_empty() { + table.insert("SupportsAmount".into(), J::Bool(true)); + table.insert("SupportsAmount2".into(), J::Bool(true)); + } + let call = ApplyCall { + update_ai_settings: opt.ai_update && w.ai, + flatten_auto_now: opt.flatten_auto_now + && table.get("WhiteBalance").and_then(J::as_str) == Some("Auto"), + }; + let table = J::Object(table); + check_wire(&table)?; + Ok(LuaWritten { + table, + skipped: w.skipped, + call, + }) +} + +#[cfg(feature = "test-roundtrip")] +fn write_round_trip(settings: &DevelopSettings, opt: &LuaOptions) -> Result { + let mut w = Writer { + apply: false, + opt, + file_kind: None, + skipped: Vec::new(), + ai: false, + }; + let mut f = Fields { + values: settings.values().map(|(k, v)| (k, v.clone())).collect(), + opaque: settings.opaque.clone(), + }; + if let Some(look) = &settings.look { + f.values + .insert(id(Level::Global, "Look"), Value::Struct(look_struct(look))); + } + if !settings.corrections.is_empty() { + f.values.insert( + id(Level::Global, "MaskGroupBasedCorrections"), + Value::Corrections(settings.corrections.clone()), + ); + } + let table = J::Object(w.fields(&f, "")?); + Ok(LuaWritten { + table, + skipped: Vec::new(), + call: ApplyCall::default(), + }) +} + +struct Writer<'a> { + /// Apply mode: guard, complete, upper-case and check ids, validate + /// compound strings (kept as spelled). Off in a test round trip. + apply: bool, + opt: &'a LuaOptions, + /// The target photo's file kind (apply mode); `None` in a round trip. + file_kind: Option, + skipped: Vec, + /// Something written needs `updateAISettings()`. + ai: bool, +} + +impl Writer<'_> { + fn skip(&mut self, path: impl Into, reason: SkipReason) { + self.skipped.push(Skipped { + path: path.into(), + reason, + }); + } + + /// One registry value at `path`, through the never-write guard in apply + /// mode; `None` when it is not written. + fn keyed( + &mut self, + spec: &'static KeySpec, + v: &Value, + path: &str, + ) -> Result, WireError> { + if self.apply && lua_never_written(spec) { + self.skip(path, SkipReason::Policy(spec.policy)); + return Ok(None); + } + self.value(spec, v, path) + } + + /// The registry values and opaque entries of one level as a table. + fn fields(&mut self, f: &Fields, path: &str) -> Result, WireError> { + let mut out = Map::new(); + for (kid, v) in &f.values { + let spec = kid.spec(); + if let Some(j) = self.keyed(spec, v, &child(path, spec.name))? { + out.insert(spec.name.to_owned(), j); + } + } + for e in &f.opaque { + if let Some(j) = self.opaque_entry(e, path)? { + out.insert(e.name.clone(), j); + } + } + Ok(out) + } + + /// An opaque entry: verbatim in a test round trip (Lua content only), + /// skipped and reported in apply mode (the policy filter removes them + /// first; this is the guard). + fn opaque_entry(&mut self, e: &OpaqueEntry, path: &str) -> Result, WireError> { + let p = child(path, &e.name); + if self.apply { + self.skip(p, SkipReason::Opaque); + return Ok(None); + } + match &e.value { + Opaque::Json(j) if e.ns.is_none() => Ok(Some(j.clone())), + _ => Err(WireError::NotLua { path: p }), + } + } + + fn integer(&self, spec: &KeySpec, i: i64) -> J { + if spec.kind == ValueKind::IntFlag && self.opt.int_flag_as == FlagAs::Bool { + J::Bool(i != 0) + } else if spec.is_mask_enum() && self.opt.mask_enum_as == EnumAs::String { + J::String(i.to_string()) + } else { + J::from(i) + } + } + + /// One registry value as JSON; `None` when it is not written (an empty + /// container is an absent key; opaque content in apply mode is skipped + /// and reported). + fn value( + &mut self, + spec: &'static KeySpec, + v: &Value, + path: &str, + ) -> Result, WireError> { + let kind = spec.kind.lua_form(); + let wrong = || WireError::WrongValue { + path: path.to_owned(), + expected: format!("{kind:?}"), + found: variant(v), + }; + let integer_kind = matches!( + kind, + ValueKind::Int | ValueKind::IntFlag | ValueKind::EnumInt(_) | ValueKind::VersionU32 + ); + let j = match (kind, v) { + (_, Value::Opaque(_)) if self.apply => { + self.skip(path, SkipReason::Opaque); + return Ok(None); + } + (_, Value::Opaque(Opaque::Json(j))) => j.clone(), + (_, Value::Opaque(Opaque::Xmp(_))) => { + return Err(WireError::NotLua { + path: path.to_owned(), + }) + } + (_, Value::Int(i)) if integer_kind => self.integer(spec, *i), + // Rule 2: an integer key takes an integer, rounded in the one + // place that rounds. + (_, Value::Real(x)) if integer_kind => match spec.coerce(*x).map(|c| c.value) { + Ok(Scalar::Int(i)) => self.integer(spec, i), + _ => return Err(wrong()), + }, + (ValueKind::Real, Value::Real(x)) => real(*x), + (ValueKind::Real, Value::Int(i)) => real(Finite::from_i64(*i)), + (ValueKind::Bool(_), Value::Bool(b)) => J::Bool(*b), + (ValueKind::Hex32, Value::Str(s)) => J::String(self.hex(s, path)?), + (ValueKind::Enum(_) | ValueKind::Str | ValueKind::VersionStr, Value::Str(s)) => { + J::String(self.compound(spec, s, path)?) + } + (ValueKind::Curve(curve), Value::Curve(points)) => { + if points.is_empty() { + return Ok(None); + } + if self.apply && points.len() < 2 { + return Err(WireError::ShortCurve { + path: path.to_owned(), + points: points.len(), + }); + } + J::Array(match curve { + CurveKind::Global => points + .iter() + .flat_map(|&(x, y)| [coordinate(x), coordinate(y)]) + .collect(), + CurveKind::Local => points + .iter() + .map(|&(x, y)| { + J::String(format!("{},{}", coordinate_text(x), coordinate_text(y))) + }) + .collect(), + }) + } + (ValueKind::StrSeq, Value::StrList(items)) => { + if items.is_empty() { + return Ok(None); + } + let mut out = Vec::with_capacity(items.len()); + for (i, s) in items.iter().enumerate() { + out.push(J::String(self.compound( + spec, + s, + &format!("{path}[{i}]"), + )?)); + } + J::Array(out) + } + (ValueKind::LangAlt, Value::Alt(s)) => { + let mut m = Map::new(); + m.insert("x-default".into(), J::String(s.clone())); + J::Object(m) + } + (ValueKind::Struct(_), Value::Struct(s)) => { + let m = self.fields(&s.fields, path)?; + if m.is_empty() { + return Ok(None); + } + J::Object(m) + } + (ValueKind::StructSeq(_), Value::StructList(items)) => { + let mut out = Vec::new(); + for (i, s) in items.iter().enumerate() { + let m = self.fields(&s.fields, &format!("{path}[{i}]"))?; + if !m.is_empty() { + out.push(J::Object(m)); + } + } + if out.is_empty() { + return Ok(None); + } + J::Array(out) + } + (ValueKind::ComponentSeq, Value::Tools(items)) => { + let mut out = Vec::new(); + for (i, f) in items.iter().enumerate() { + let m = self.fields(f, &format!("{path}[{i}]"))?; + if !m.is_empty() { + out.push(J::Object(m)); + } + } + if out.is_empty() { + return Ok(None); + } + J::Array(out) + } + (ValueKind::CorrectionSeq, Value::Corrections(items)) => { + let mut out = Vec::new(); + for (i, c) in items.iter().enumerate() { + if let Some(j) = self.correction(c, &format!("{path}[{i}]"))? { + out.push(j); + } + } + if out.is_empty() { + return Ok(None); + } + J::Array(out) + } + _ => return Err(wrong()), + }; + Ok(Some(j)) + } + + /// An id: upper-cased and checked in apply mode, verbatim in a round + /// trip (the reader keeps ids as read). + fn hex(&self, s: &str, path: &str) -> Result { + if !self.apply { + return Ok(s.to_owned()); + } + hex32_upper(s).ok_or_else(|| WireError::NotHex32 { + path: path.to_owned(), + value: s.to_owned(), + }) + } + + /// Text, verbatim. In apply mode a compound number string + /// (`ReferencePoint`, `LuminanceDepthSampleInfo`, ...) must be a list of + /// numbers; it keeps its spelling (Lightroom's own tables mix `0` and + /// `0.170630`), only what the writer builds itself is spelled `%.6f`. + fn compound(&self, spec: &KeySpec, s: &str, path: &str) -> Result { + if self.apply && spec.fmt == NumFmt::CompoundFixed6 && reformat_compound(s).is_none() { + return Err(WireError::NotCompound { + path: path.to_owned(), + value: s.to_owned(), + }); + } + Ok(s.to_owned()) + } + + /// White balance as a family (apply mode, rule 9). After the policy + /// filter only the photo's family can be left (an unknown file kind + /// leaves neither); the mode decides the rest: + /// + /// - numbers with `Custom` or no mode: the numbers, and `"Custom"` when + /// the settings say so or [`LuaOptions::wb_custom_with_numbers`]; + /// - numbers next to any other mode: they are what that mode resolved + /// to for the source, and are skipped ([`SkipReason::WhiteBalanceMode`]); + /// - a named mode without numbers (or next to skipped ones): written + /// alone with [`LuaOptions::wb_mode_only`] on a raw (or unknown) photo, + /// otherwise skipped and reported (`"As Shot"`, the default, without a + /// report). Never alone on a non-raw photo, whatever the option says: + /// there `"Auto"` alone left the old incremental numbers in place + /// (E1f/E1g, Lightroom 15.6); + /// - `Custom` without numbers, or a mode outside the registry's list: + /// always skipped and reported. + fn white_balance( + &mut self, + s: &DevelopSettings, + table: &mut Map, + ) -> Result<(), WireError> { + const KEY: &str = "WhiteBalance"; + let mode = match s.get_by_name(KEY) { + None => None, + Some(Value::Str(m)) => Some(m.clone()), + Some(other) => { + // Not a mode the model can read (kept whole by a reader): + // `value` skips and reports it, or refuses the variant. + let spec = id(Level::Global, KEY).spec(); + self.value(spec, other, KEY)?; + None + } + }; + let numbers: Vec<(&'static KeySpec, &Value)> = WB_NUMBERS + .iter() + .filter_map(|n| { + let kid = id(Level::Global, n); + s.get(kid).map(|v| (kid.spec(), v)) + }) + .collect(); + debug_assert!( + numbers + .iter() + .filter_map(|(spec, _)| WbFamily::of_key(spec)) + .collect::>() + .len() + <= 1, + "the policy filter leaves at most one white-balance family" + ); + let custom = mode.as_deref().is_none_or(|m| m == "Custom"); + if !numbers.is_empty() && custom { + for (spec, v) in numbers { + if let Some(j) = self.value(spec, v, spec.name)? { + table.insert(spec.name.to_owned(), j); + } + } + if mode.is_some() || self.opt.wb_custom_with_numbers { + table.insert(KEY.into(), J::from("Custom")); + } + return Ok(()); + } + let Some(mode) = mode else { + return Ok(()); + }; + for (spec, _) in numbers { + self.skip(spec.name, SkipReason::WhiteBalanceMode(mode.clone())); + } + // `Custom` without numbers (they belonged to the other family, or + // the file kind is unknown) is no mode Lightroom resolves: it would + // pin whatever the photo has. Never written alone, and neither is a + // mode outside the registry's list (kept with a reader warning). + let named = mode != "Custom" + && matches!(id(Level::Global, KEY).spec().kind, + ValueKind::Enum(set) if set.contains(&mode.as_str())); + // Non-raw: a mode alone did not recompute the numbers (E1f/E1g), so + // the switch, which a raw answer (E1e) may turn on, stops at raw. + let alone = self.opt.wb_mode_only && self.file_kind != Some(FileKind::NonRaw); + if alone && named { + table.insert(KEY.into(), J::String(mode)); + } else if mode != "As Shot" { + self.skip(KEY, SkipReason::WhiteBalanceMode(mode.clone())); + } + Ok(()) + } + + /// The `Look` in apply mode, per [`LuaOptions::look_form`] (a round + /// trip writes it whole through the global fields). + fn look(&mut self, look: &Look) -> Result, WireError> { + let written = self.look_table(look)?; + // An Adobe Adaptive profile needs an AI update to compute its + // `AILook` state, which is never written (COMPUTED), in every form. + // Applied by no experiment: E11 rejects adaptive Looks. + if written.is_some() && is_adaptive(look) { + self.ai = true; + } + Ok(written) + } + + fn look_table(&mut self, look: &Look) -> Result, WireError> { + const L: Level = Level::Struct(StructKind::Look); + if self.opt.look_form == LookForm::Full { + let mut record = look_struct(look); + let parameters = record.fields.take(L, "Parameters"); + let mut m = self.fields(&record.fields, "Look")?; + match parameters { + // A Look without `Parameters` is a stub, and Lightroom + // ignores a stub (E11a/E11c): say so. An adaptive Look has + // none by design (its `AILook` state is computed). + None => { + if !is_adaptive(look) { + self.skip("Look.Parameters", SkipReason::Opaque); + } + } + Some(Value::Opaque(Opaque::Json(j))) + if check_wire_at(&j, "Look.Parameters", false).is_ok() => + { + m.insert("Parameters".into(), j); + } + Some(_) => self.skip("Look.Parameters", SkipReason::Opaque), + } + return Ok((!m.is_empty()).then_some(J::Object(m))); + } + if look.name.is_none() && look.uuid.is_none() { + // A stub without a name or a UUID refers to nothing. + self.skip("Look", SkipReason::Policy(Policy::Unknown)); + return Ok(None); + } + let mut stub = Struct::new(StructKind::Look); + let mut put = |name: &str, v: Option| { + if let Some(v) = v { + stub.fields.values.insert(id(L, name), v); + } + }; + put("Name", look.name.clone().map(Value::Str)); + put( + "UUID", + look.uuid + .as_ref() + .map(|u| Value::Str(u.as_str().to_owned())), + ); + put("Amount", look.amount.map(Value::Real)); + if self.opt.look_form == LookForm::Stub { + put("Stubbed", Some(Value::Bool(true))); + } + // The rest of the record: COMPUTED fields (`Parameters`, `Group`, + // `Cluster`, `Copyright`, `Supports*`, `CameraModelRestriction`, + // `isAdobeAdaptive`, ...) are copies Lightroom takes from its profile + // library when it resolves the stub, so dropping them loses nothing + // and is not reported (as in a preset). The policy filter has + // already held a restricted Look back from any other camera, so the + // restriction describes nothing lost here (a camera-restricted stub + // is applied by no experiment: E11 uses Adobe Raw profiles). + // `Stubbed` is the form's own; anything else that is not its + // default is reported. + for (kid, v) in &look.rest.fields.values { + let spec = kid.spec(); + if spec.policy == Policy::Computed || spec.name == "Stubbed" { + continue; + } + let default = matches!(spec.default.raw, Def::Value(lit) if v.equals_lit(&lit)); + let empty_restriction = spec.name == "CameraModelRestriction" && v.as_str() == Some(""); + if !default && !empty_restriction { + self.skip(child("Look", spec.name), SkipReason::Policy(spec.policy)); + } + } + for o in &look.rest.fields.opaque { + self.skip(child("Look", &o.name), SkipReason::Opaque); + } + let m = self.fields(&stub.fields, "Look")?; + Ok((!m.is_empty()).then_some(J::Object(m))) + } + + /// One correction as a table; `None` when it has no mask to write. + fn correction(&mut self, c: &Correction, path: &str) -> Result, WireError> { + if self.apply && c.masks.is_empty() { + // The policy filter removes corrections without components + // (their class is UNKNOWN); this is the guard for rule 6. + self.skip(path, SkipReason::Policy(Policy::Unknown)); + return Ok(None); + } + let f = self.correction_fields(c); + let m = self.fields(&f, path)?; + if self.apply && !m.contains_key("CorrectionMasks") { + self.skip(path, SkipReason::Policy(Policy::Unknown)); + return Ok(None); + } + Ok((!m.is_empty()).then_some(J::Object(m))) + } + + /// A correction's typed parts back in their registry rows (the inverse + /// of `Correction::from_fields`), plus, in apply mode, the form every + /// applied correction carries. + fn correction_fields(&mut self, c: &Correction) -> Fields { + const C: Level = Level::Correction; + let mut f = c.extra.clone(); + for (kid, value) in &c.local { + f.values.insert(*kid, value.clone()); + } + if let Some(n) = &c.name { + f.values + .insert(id(C, "CorrectionName"), Value::Str(n.clone())); + } + if let Some(s) = &c.sync_id { + f.values + .insert(id(C, "CorrectionSyncID"), Value::Str(s.as_str().to_owned())); + } + if let Some(a) = c.amount { + f.values.insert(id(C, "CorrectionAmount"), Value::Real(a)); + } + if let Some(a) = c.active { + f.values.insert(id(C, "CorrectionActive"), Value::Bool(a)); + } + if !c.masks.is_empty() { + let tools = c.masks.iter().map(|m| self.component_fields(m)).collect(); + f.values + .insert(id(C, "CorrectionMasks"), Value::Tools(tools)); + } + if self.apply { + for (k, d) in [ + ("What", Value::Str("Correction".into())), + ("CorrectionAmount", Value::Real(Finite::new_const(1.0))), + ("CorrectionActive", Value::Bool(true)), + ] { + f.values.entry(id(C, k)).or_insert(d); + } + if self.opt.local_form == LocalForm::AdaptivePreset { + for k in ADAPTIVE_PRESET_LOCALS { + f.values + .entry(id(C, k)) + .or_insert(Value::Real(Finite::ZERO)); + } + } + } + f + } + + /// A mask component's typed parts back in their registry rows (the + /// inverse of `MaskComponent::from_fields`), plus, in apply mode, + /// `MaskActive` and the form of [`LuaOptions::mask_form`]. + fn component_fields(&mut self, m: &MaskComponent) -> Fields { + const M: Level = Level::MaskTool; + let lossless = !self.apply; + let preset_form = self.apply && self.opt.mask_form == MaskForm::PresetForm; + let mut f = m.extra.clone(); + let mut put = |name: &str, value: Value| { + f.values.insert(id(M, name), value); + }; + if let Some(n) = &m.name { + put("MaskName", Value::Str(n.clone())); + } + if let Some(s) = &m.sync_id { + put("MaskSyncID", Value::Str(s.as_str().to_owned())); + } + if let Some(a) = m.active { + put("MaskActive", Value::Bool(a)); + } + if let Some((mode, value, inverted)) = m.combine.encode() { + put("MaskBlendMode", Value::Int(mode)); + put("MaskValue", Value::Real(value)); + put("MaskInverted", Value::Bool(inverted)); + } + match &m.tool { + MaskTool::Semantic(s) => { + put("What", Value::Str("Mask/Image".into())); + let (sub, category) = s.encoding(); + put("MaskSubType", Value::Int(sub)); + if let Some(c) = category { + put("MaskSubCategoryID", Value::Int(c)); + } + if let Semantic::PersonPartAt { point, .. } = s { + put( + "ReferencePoint", + Value::Str(format_compound(&[point.x, point.y], lossless)), + ); + } + } + MaskTool::Linear(g) => { + put("What", Value::Str("Mask/Gradient".into())); + put("ZeroX", Value::Real(g.zero.x)); + put("ZeroY", Value::Real(g.zero.y)); + put("FullX", Value::Real(g.full.x)); + put("FullY", Value::Real(g.full.y)); + } + MaskTool::Radial(g) => { + put("What", Value::Str("Mask/CircularGradient".into())); + put("Top", Value::Real(g.top)); + put("Left", Value::Real(g.left)); + put("Bottom", Value::Real(g.bottom)); + put("Right", Value::Real(g.right)); + put("Angle", Value::Real(Finite::ZERO)); + put("Midpoint", Value::Int(g.midpoint)); + put("Roundness", Value::Int(g.roundness)); + put("Feather", Value::Int(g.feather)); + put("Flipped", Value::Bool(g.flipped)); + } + MaskTool::LuminanceRange(lr) => { + const R: Level = Level::Struct(StructKind::CorrectionRangeMask); + put("What", Value::Str("Mask/RangeMask".into())); + let mut crm = lr.rest.clone(); + let rv = &mut crm.fields.values; + rv.insert(id(R, "Type"), Value::Int(2)); + rv.insert(id(R, "Invert"), Value::Bool(lr.invert)); + rv.insert( + id(R, "LumRange"), + Value::Str(format_compound(&lr.range, lossless)), + ); + if preset_form { + rv.entry(id(R, "Version")).or_insert(Value::Int(3)); + rv.entry(id(R, "SampleType")).or_insert(Value::Int(0)); + } + put("CorrectionRangeMask", Value::Struct(crm)); + } + MaskTool::Opaque { what } => { + if let Some(w) = what { + put("What", Value::Str(w.clone())); + } + } + } + if self.apply { + // An AI mask, typed or not (Select Object), needs Lightroom to + // compute it. + self.ai |= matches!( + f.values.get(&id(M, "What")), + Some(Value::Str(w)) if w == "Mask/Image" + ); + let mut defaults = vec![("MaskActive", Value::Bool(true))]; + let semantic = matches!( + &m.tool, + MaskTool::Semantic(s) if !matches!(s, Semantic::PersonPartAt { .. }) + ); + if preset_form && semantic { + defaults.extend([ + ("MaskVersion", Value::Int(1)), + ("ReferencePoint", Value::Str("0.500000 0.500000".into())), + ("ErrorReason", Value::Int(0)), + ]); + } + for (k, d) in defaults { + f.values.entry(id(M, k)).or_insert(d); + } + } + f + } +} + +/// Checks a finished table against wire rules 7–9: the top level a table, +/// no `null`, no empty container below the top level, every list of one +/// JSON type, no [`RETIRED_TEMP_KEY`] at any depth. [`to_lua_value`] runs it on every +/// table it hands out in apply mode; the plugin-side spec checks the same on +/// the wire goldens. +pub fn check_wire(table: &J) -> Result<(), WireError> { + if !table.is_object() { + return Err(WireError::NotWireSafe { + path: String::new(), + rule: "the top level must be a table with string keys", + }); + } + check_wire_at(table, "", true) +} + +fn check_wire_at(j: &J, path: &str, top: bool) -> Result<(), WireError> { + let fail = |rule| { + Err(WireError::NotWireSafe { + path: path.to_owned(), + rule, + }) + }; + match j { + J::Null => fail("null (JSON.lua makes it a hole: a sparse array)"), + J::Object(m) => { + if m.is_empty() && !top { + return fail("an empty table (JSON.lua encodes it as [], an absent key)"); + } + for (k, v) in m { + if k == RETIRED_TEMP_KEY { + return fail("Temp is never written (white balance goes as a family)"); + } + check_wire_at(v, &child(path, k), false)?; + } + Ok(()) + } + J::Array(a) => { + let Some(first) = a.first() else { + return fail( + "an empty list (an empty MaskGroupBasedCorrections deletes every mask)", + ); + }; + let tag = std::mem::discriminant(first); + if a.iter().any(|v| std::mem::discriminant(v) != tag) { + return fail("a list mixing JSON types (a mixed Lua table)"); + } + for (i, v) in a.iter().enumerate() { + check_wire_at(v, &format!("{path}[{i}]"), false)?; + } + Ok(()) + } + _ => Ok(()), + } +} + +#[cfg(test)] +mod tests; diff --git a/server-rs/crates/lrg-develop/src/lua/write/tests.rs b/server-rs/crates/lrg-develop/src/lua/write/tests.rs new file mode 100644 index 00000000..eaa4721a --- /dev/null +++ b/server-rs/crates/lrg-develop/src/lua/write/tests.rs @@ -0,0 +1,1223 @@ +use serde_json::json; + +use super::*; +use crate::build::{CorrectionBuilder, LuminanceRange, SyncNamespace}; +use crate::lua::read::from_lua_value; +use crate::model::correction::{LinearGradient, RadialGradient, SensorPoint}; +use crate::model::value::Hex32; +use crate::model::FileKindHint; + +fn read(v: J) -> DevelopSettings { + let (s, w) = from_lua_value(&v, FileKindHint::Unknown).expect("a settings table"); + assert!(w.is_empty(), "{w:?}"); + s +} + +/// As [`read`], allowing warnings (unknown keys on purpose). +fn read_lenient(v: J) -> DevelopSettings { + from_lua_value(&v, FileKindHint::Unknown) + .expect("a settings table") + .0 +} + +fn raw_photo() -> LuaMode { + LuaMode::Apply(PhotoContext { + file_kind: Some(FileKind::Raw), + process_version: Some(ProcessVersion::V6), + camera: None, + }) +} + +fn photo(kind: Option) -> LuaMode { + LuaMode::Apply(PhotoContext { + file_kind: kind, + ..PhotoContext::default() + }) +} + +fn apply(s: &DevelopSettings) -> LuaWritten { + to_lua_value(s, &raw_photo(), &LuaOptions::default()).unwrap() +} + +fn apply_with(s: &DevelopSettings, opt: LuaOptions) -> LuaWritten { + to_lua_value(s, &raw_photo(), &opt).unwrap() +} + +fn paths(w: &LuaWritten) -> Vec<&str> { + w.skipped.iter().map(|k| k.path.as_str()).collect() +} + +/// Every key name anywhere in `j`. +fn all_keys(j: &J) -> Vec { + let mut out = Vec::new(); + fn walk(j: &J, out: &mut Vec) { + match j { + J::Object(m) => { + for (k, v) in m { + out.push(k.clone()); + walk(v, out); + } + } + J::Array(a) => a.iter().for_each(|v| walk(v, out)), + _ => {} + } + } + walk(j, &mut out); + out +} + +fn subject_correction() -> Correction { + CorrectionBuilder::new("Subject", &SyncNamespace::lrgenius()) + .local_ui("LocalExposure2012", 0.5) + .unwrap() + .add(Semantic::Subject) + .build() + .unwrap() +} + +fn sky_correction() -> Correction { + CorrectionBuilder::new("Sky", &SyncNamespace::lrgenius()) + .local_ui("LocalHighlights2012", -40.0) + .unwrap() + .add(Semantic::Sky) + .build() + .unwrap() +} + +fn mask(w: &LuaWritten, c: usize, m: usize) -> &Map { + w.table["MaskGroupBasedCorrections"][c]["CorrectionMasks"][m] + .as_object() + .unwrap() +} + +// --- the provisional options ------------------------------------------------ + +#[test] +fn the_default_options_are_the_provisional_ones() { + assert_eq!(LuaOptions::default(), LuaOptions::PROVISIONAL); +} + +/// Pins every provisional choice: changing one is a decision (an experiment +/// answered), which also re-blesses the wire goldens and updates the wiki. +#[test] +fn the_provisional_choices_are_pinned() { + let p = LuaOptions::PROVISIONAL; + assert_eq!(p.mask_enum_as, EnumAs::Number); + assert_eq!(p.int_flag_as, FlagAs::Number); + assert_eq!(p.panel_switches, PanelSwitches::MaskOnly); + assert_eq!(p.mask_form, MaskForm::PresetForm); + assert_eq!(p.local_form, LocalForm::AdaptivePreset); + assert!(p.wb_custom_with_numbers); + assert!(!p.wb_mode_only); + assert!(!p.flatten_auto_now); + assert!(p.ai_update); + assert_eq!(p.look_form, LookForm::Full); + assert!(!p.preset_amount_flags); +} + +/// One evidence row per field, in field order; a new field cannot be added +/// without saying how far the experiments carry it (the destructuring below +/// stops compiling). +#[test] +fn every_option_has_an_evidence_row() { + let LuaOptions { + mask_enum_as: _, + int_flag_as: _, + panel_switches: _, + mask_form: _, + local_form: _, + wb_custom_with_numbers: _, + wb_mode_only: _, + flatten_auto_now: _, + ai_update: _, + look_form: _, + preset_amount_flags: _, + } = LuaOptions::PROVISIONAL; + let fields: Vec<&str> = LuaOptions::EVIDENCE.iter().map(|e| e.field).collect(); + assert_eq!( + fields, + [ + "mask_enum_as", + "int_flag_as", + "panel_switches", + "mask_form", + "local_form", + "wb_custom_with_numbers", + "wb_mode_only", + "flatten_auto_now", + "ai_update", + "look_form", + "preset_amount_flags", + ] + ); + for e in LuaOptions::EVIDENCE { + assert!(!e.open.is_empty(), "{}: say what is still open", e.field); + match e.status { + EvidenceStatus::Open => { + assert!(e.by.is_empty() && e.scope.is_empty(), "{}", e.field); + } + EvidenceStatus::Settled | EvidenceStatus::Supported => { + assert!(!e.by.is_empty() && !e.scope.is_empty(), "{}", e.field); + } + } + } +} + +/// The first run's statuses: one non-raw photo, so nothing on the raw side +/// is settled, the fields no experiment answered stay open, and +/// `panel_switches` is only supported (E2 sent a switch the photo already +/// had on, E13). +#[test] +fn the_first_run_statuses_are_pinned() { + let status = |f: &str| { + LuaOptions::EVIDENCE + .iter() + .find(|e| e.field == f) + .unwrap() + .status + }; + assert_eq!(status("int_flag_as"), EvidenceStatus::Open); + assert_eq!(status("look_form"), EvidenceStatus::Settled); + for f in ["wb_custom_with_numbers", "panel_switches"] { + assert_eq!(status(f), EvidenceStatus::Supported, "{f}"); + } + for e in LuaOptions::EVIDENCE { + assert!( + e.status == EvidenceStatus::Open + || e.scope == FIRST_RUN + || e.scope == FIRST_RUN_AND_RAW + || e.scope == RAW_LOOK_RUN, + "{}", + e.field + ); + } +} + +// --- rule 1: keys, policy, guard ------------------------------------------- + +#[test] +fn keys_have_no_prefix_and_only_what_reaches_the_photo_is_written() { + let s = read_lenient(json!({ + "Exposure2012": 0.5, + "CropTop": 0.1, + "ProcessVersion": "15.4", + "EnableDetail": true, + "RetouchAreas": [{"Feather": 0.5}], + "SomethingNew": 3 + })); + let w = apply(&s); + let t = w.table.as_object().unwrap(); + assert_eq!(t["Exposure2012"], json!(0.5)); + // PHOTO reaches the photo the settings are for. + assert_eq!(t["CropTop"], json!(0.1)); + // Global META is set by the writer, never copied: no process version, + // no panel switch. + assert!(!t.contains_key("ProcessVersion")); + assert!(!t.contains_key("EnableDetail")); + assert!(t.keys().all(|k| !k.starts_with("crs:"))); + assert!(!t.contains_key("RetouchAreas")); + assert!(!t.contains_key("SomethingNew")); + // Every dropped non-default value is reported, META silently dropped. + assert_eq!(paths(&w), ["RetouchAreas", "SomethingNew"]); +} + +#[test] +fn the_guard_covers_every_never_write_key_and_the_xmp_only_ones() { + for name in NEVER_WRITE { + let id = registry::iter().find(|(_, s)| s.name == *name); + if let Some((_, spec)) = id { + assert!(lua_never_written(spec), "{name}"); + } + } + let spec = |level, name| registry::lookup(level, name).unwrap().spec(); + assert!(lua_never_written(spec(Level::Global, "HasSettings"))); + assert!(lua_never_written(spec(Level::Global, "EnableDetail"))); + assert!(lua_never_written(spec(Level::MaskTool, "MaskDigest"))); + assert!(!lua_never_written(spec(Level::Global, "Exposure2012"))); + assert!(!lua_never_written(spec(Level::MaskTool, "MaskSubType"))); +} + +#[test] +fn computed_mask_bookkeeping_never_reaches_the_table() { + // A computed subject mask as Lightroom reads it back: digests, runtime + // ids, sizes, origin, model version. + let s = read(json!({"MaskGroupBasedCorrections": [{ + "What": "Correction", "CorrectionAmount": 1, "CorrectionActive": true, + "CorrectionID": "00000000-0000-4000-8000-000000000001", + "CorrectionSyncID": "0000000000000000000000000000000A", + "CorrectionReferenceX": 0.5, "CorrectionReferenceY": 0.5, + "LocalExposure2012": 0.25, + "CorrectionMasks": [{ + "What": "Mask/Image", "MaskSubType": 1, "MaskBlendMode": 0, + "MaskValue": 1, "MaskInverted": false, "MaskActive": true, + "MaskID": "00000000-0000-4000-8000-000000000002", + "MaskSyncID": "0000000000000000000000000000000B", + "MaskDigest": "0000000000000000000000000000000C", + "InputDigest": "0000000000000000000000000000000D", + "FullMaskSize": "1024 768", "WholeImageArea": "0 0 1 1", + "Origin": "0 0", "ModelVersion": 123, "ErrorReason": 0, + "ReferencePoint": "0.433594 0.659824", "MaskVersion": 1 + }] + }]})); + let w = apply(&s); + let keys = all_keys(&w.table); + for never in [ + "CorrectionID", + "MaskID", + "CorrectionReferenceX", + "CorrectionReferenceY", + "FullMaskSize", + "WholeImageArea", + "Origin", + "ModelVersion", + "MaskDigest", + "InputDigest", + ] { + assert!(!keys.iter().any(|k| k == never), "{never} written"); + } + let m = mask(&w, 0, 0); + assert_eq!(m["MaskSubType"], json!(1)); + assert_eq!(m["MaskSyncID"], json!("0000000000000000000000000000000B")); + assert_eq!( + w.table["MaskGroupBasedCorrections"][0]["LocalExposure2012"], + json!(0.25) + ); +} + +// --- rules 2 and 3: numbers and booleans ---------------------------------- + +#[test] +fn integers_reals_booleans_and_flags_keep_their_json_types() { + let s = read(json!({ + "Contrast2012": 12, "Exposure2012": 1, "ConvertToGrayscale": false, + "LensProfileEnable": 1, "AutoLateralCA": 0, "PostCropVignetteStyle": 1 + })); + let t = apply(&s).table; + assert!(t["Contrast2012"].is_i64()); + assert!(t["Exposure2012"].is_f64(), "a real key is a real: {t}"); + assert_eq!(t["ConvertToGrayscale"], json!(false)); + assert_eq!(t["LensProfileEnable"], json!(1)); + assert_eq!(t["AutoLateralCA"], json!(0)); + assert!(t["PostCropVignetteStyle"].is_i64()); +} + +#[test] +fn flags_as_booleans_on_request() { + let s = read(json!({"LensProfileEnable": 1, "AutoLateralCA": 0})); + let opt = LuaOptions { + int_flag_as: FlagAs::Bool, + ..LuaOptions::default() + }; + let t = apply_with(&s, opt).table; + assert_eq!(t["LensProfileEnable"], json!(true)); + assert_eq!(t["AutoLateralCA"], json!(false)); +} + +#[test] +fn a_real_on_an_integer_key_is_coerced_half_to_even() { + let mut s = DevelopSettings::new(); + let contrast = registry::lookup(Level::Global, "Contrast2012").unwrap(); + s.insert(contrast, Value::Real(Finite::new_const(12.5))) + .unwrap(); + assert_eq!(apply(&s).table["Contrast2012"], json!(12)); + // A value no integer key can take is an error, not a guess. + let flag = registry::lookup(Level::Global, "LensProfileEnable").unwrap(); + s.insert(flag, Value::Real(Finite::new_const(3.0))).unwrap(); + assert!(matches!( + to_lua_value(&s, &raw_photo(), &LuaOptions::default()), + Err(WireError::WrongValue { .. }) + )); +} + +#[test] +fn a_value_of_the_wrong_variant_is_an_error() { + let mut s = DevelopSettings::new(); + let exposure = registry::lookup(Level::Global, "Exposure2012").unwrap(); + s.insert(exposure, Value::Str("bright".into())).unwrap(); + let err = to_lua_value(&s, &raw_photo(), &LuaOptions::default()).unwrap_err(); + assert!( + matches!(&err, WireError::WrongValue { path, .. } if path == "Exposure2012"), + "{err}" + ); +} + +// --- rule 4: numeric-looking strings --------------------------------------- + +#[test] +fn numeric_looking_strings_stay_strings() { + let mut s = read(json!({"Look": { + "Name": "Adobe Color", "UUID": "B952C231111CD8E0ECCF14B86BAA7077", "Amount": 1 + }})); + let range = CorrectionBuilder::new("Bright", &SyncNamespace::lrgenius()) + .local_ui("LocalExposure2012", 0.3) + .unwrap() + .add(Semantic::Sky) + .intersect(LuminanceRange::highs(0.5, 0.2).unwrap()) + .build() + .unwrap(); + let person = CorrectionBuilder::new("Person", &SyncNamespace::lrgenius()) + .local_ui("LocalSaturation", 10.0) + .unwrap() + .local_curve("MainCurve", &[(0, 0), (128, 140), (255, 255)]) + .unwrap() + .add(Semantic::PersonPartAt { + part: 5, + point: SensorPoint::new(0.25, 0.75).unwrap(), + }) + .build() + .unwrap(); + s.corrections = vec![range, person]; + let t = apply(&s).table; + assert_eq!(t["Look"]["UUID"], json!("B952C231111CD8E0ECCF14B86BAA7077")); + let crm = &t["MaskGroupBasedCorrections"][0]["CorrectionMasks"][1]["CorrectionRangeMask"]; + assert!(crm["LumRange"].is_string(), "{crm}"); + assert_eq!( + crm["LumRange"], + json!("0.300000 0.500000 1.000000 1.000000") + ); + let person = &t["MaskGroupBasedCorrections"][1]; + assert_eq!( + person["CorrectionMasks"][0]["ReferencePoint"], + json!("0.250000 0.750000") + ); + assert_eq!(person["MainCurve"], json!(["0,0", "128,140", "255,255"])); +} + +#[cfg(feature = "test-roundtrip")] +#[test] +fn the_process_version_and_focal_range_stay_strings_in_a_round_trip() { + let s = read(json!({ + "ProcessVersion": "15.4", + "LensBlur": {"Active": true, "FocalRange": "0 0 100 100"} + })); + let t = to_lua_value(&s, &LuaMode::TestRoundTrip, &LuaOptions::default()) + .unwrap() + .table; + assert_eq!(t["ProcessVersion"], json!("15.4")); + assert_eq!(t["LensBlur"]["FocalRange"], json!("0 0 100 100")); +} + +#[test] +fn compound_strings_keep_their_spelling_and_must_be_numbers() { + let mut s = DevelopSettings::new(); + let mut c = subject_correction(); + c.masks[0].extra.values.insert( + registry::lookup(Level::MaskTool, "ReferencePoint").unwrap(), + Value::Str("0.25 0.5".into()), + ); + s.corrections = vec![c.clone()]; + assert_eq!(mask(&apply(&s), 0, 0)["ReferencePoint"], json!("0.25 0.5")); + c.masks[0].extra.values.insert( + registry::lookup(Level::MaskTool, "ReferencePoint").unwrap(), + Value::Str("left top".into()), + ); + s.corrections = vec![c]; + assert!(matches!( + to_lua_value(&s, &raw_photo(), &LuaOptions::default()), + Err(WireError::NotCompound { .. }) + )); +} + +#[test] +fn ids_are_upper_cased_and_must_be_hex() { + let mut s = DevelopSettings::new(); + let mut c = subject_correction(); + c.sync_id = Hex32::parse("0000000000000000000000000000abcd"); + s.corrections = vec![c]; + let t = apply(&s).table; + assert_eq!( + t["MaskGroupBasedCorrections"][0]["CorrectionSyncID"], + json!("0000000000000000000000000000ABCD") + ); + let s = read(json!({"Look": {"Name": "x", "UUID": "b952c231111cd8e0eccf14b86baa7077"}})); + assert_eq!( + apply(&s).table["Look"]["UUID"], + json!("B952C231111CD8E0ECCF14B86BAA7077") + ); + // A malformed id held as text on a Hex32 key is refused, not written. + let mut s = DevelopSettings::new(); + let mut c = subject_correction(); + c.extra.values.insert( + registry::lookup(Level::Correction, "CorrectionSyncID").unwrap(), + Value::Str("12".into()), + ); + c.sync_id = None; + s.corrections = vec![c]; + assert!(matches!( + to_lua_value(&s, &raw_photo(), &LuaOptions::default()), + Err(WireError::NotHex32 { .. }) + )); +} + +// --- rule 5: curves --------------------------------------------------------- + +#[test] +fn global_curves_are_flat_even_number_lists_and_local_curves_strings() { + let s = read(json!({ + "ToneCurvePV2012": [0, 0, 64, 50, 192, 210, 255, 255], + "ToneCurvePV2012Red": [0, 0, 255, 255] + })); + let t = apply(&s).table; + let curve = t["ToneCurvePV2012"].as_array().unwrap(); + assert_eq!(curve.len(), 8); + assert!(curve.iter().all(J::is_number)); + assert_eq!(t["ToneCurvePV2012Red"], json!([0, 0, 255, 255])); + // The name the writer derives, as in a preset. + assert_eq!(t["ToneCurveName2012"], json!("Custom")); + let linear = read(json!({"ToneCurvePV2012": [0, 0, 255, 255]})); + assert_eq!(apply(&linear).table["ToneCurveName2012"], json!("Linear")); +} + +#[test] +fn a_fractional_curve_point_is_written_exactly() { + let mut s = DevelopSettings::new(); + let id = registry::lookup(Level::Global, "ToneCurvePV2012").unwrap(); + s.insert( + id, + Value::Curve(vec![ + (Finite::ZERO, Finite::ZERO), + (Finite::new_const(127.5), Finite::new_const(130.25)), + (Finite::new_const(255.0), Finite::new_const(255.0)), + ]), + ) + .unwrap(); + assert_eq!( + apply(&s).table["ToneCurvePV2012"], + json!([0, 0, 127.5, 130.25, 255, 255]) + ); +} + +#[test] +fn a_one_point_curve_is_an_error() { + let mut s = DevelopSettings::new(); + let id = registry::lookup(Level::Global, "ToneCurvePV2012").unwrap(); + s.insert(id, Value::Curve(vec![(Finite::ZERO, Finite::ZERO)])) + .unwrap(); + assert!(matches!( + to_lua_value(&s, &raw_photo(), &LuaOptions::default()), + Err(WireError::ShortCurve { points: 1, .. }) + )); +} + +// --- rule 6: corrections ---------------------------------------------------- + +#[test] +fn corrections_keep_panel_order_with_local_values_on_the_correction() { + let mut s = DevelopSettings::new(); + s.corrections = vec![subject_correction(), sky_correction()]; + let w = apply(&s); + let list = w.table["MaskGroupBasedCorrections"].as_array().unwrap(); + assert_eq!(list.len(), 2); + assert_eq!(list[0]["CorrectionName"], json!("LrGenius · Subject")); + assert_eq!(list[1]["CorrectionName"], json!("LrGenius · Sky")); + assert_eq!(list[0]["LocalExposure2012"], json!(0.125)); + assert_eq!(list[1]["LocalHighlights2012"], json!(-0.4)); + for c in list { + assert_eq!(c["What"], json!("Correction")); + assert_eq!(c["CorrectionAmount"], json!(1.0)); + assert_eq!(c["CorrectionActive"], json!(true)); + assert!(!c["CorrectionMasks"].as_array().unwrap().is_empty()); + } + assert!(w.skipped.is_empty(), "{:?}", w.skipped); +} + +#[test] +fn corrections_carry_the_adaptive_preset_locals_unless_sparse() { + let mut s = DevelopSettings::new(); + s.corrections = vec![subject_correction()]; + let c = &apply(&s).table["MaskGroupBasedCorrections"][0]; + for k in ADAPTIVE_PRESET_LOCALS { + let want = if *k == "LocalExposure2012" { + json!(0.125) + } else { + json!(0.0) + }; + assert_eq!(c[*k], want, "{k}"); + } + assert_eq!(ADAPTIVE_PRESET_LOCALS.len(), 23); + let sparse = apply_with( + &s, + LuaOptions { + local_form: LocalForm::Sparse, + ..LuaOptions::default() + }, + ); + let c = sparse.table["MaskGroupBasedCorrections"][0] + .as_object() + .unwrap(); + let locals: Vec<&String> = c.keys().filter(|k| k.starts_with("Local")).collect(); + assert_eq!(locals, ["LocalExposure2012"]); + // A legacy value the filter drops is reported; the form's 0 stays. + let legacy = read(json!({"MaskGroupBasedCorrections": [{ + "What": "Correction", "CorrectionAmount": 1, "CorrectionActive": true, + "LocalExposure": 0.3, "LocalExposure2012": 0.1, + "CorrectionMasks": [{"What": "Mask/Image", "MaskActive": true, "MaskBlendMode": 0, + "MaskInverted": false, "MaskValue": 1, "MaskSubType": 1}] + }]})); + let w = apply(&legacy); + assert_eq!( + w.table["MaskGroupBasedCorrections"][0]["LocalExposure"], + json!(0.0) + ); + assert!( + paths(&w).contains(&"MaskGroupBasedCorrections[0].LocalExposure"), + "{:?}", + w.skipped + ); +} + +#[test] +fn gradients_reach_the_photo_and_need_no_ai_update() { + let ns = SyncNamespace::lrgenius(); + let mut s = DevelopSettings::new(); + s.corrections = vec![ + CorrectionBuilder::new("Sky gradient", &ns) + .local_ui("LocalExposure2012", -0.5) + .unwrap() + .add( + LinearGradient::new( + SensorPoint::new(0.5, 0.6).unwrap(), + SensorPoint::new(0.5, 0.2).unwrap(), + ) + .unwrap(), + ) + .build() + .unwrap(), + CorrectionBuilder::new("Vignette", &ns) + .local_ui("LocalExposure2012", 0.3) + .unwrap() + .add(RadialGradient::new(0.2, 0.2, 0.8, 0.8, 50).unwrap()) + .build() + .unwrap(), + ]; + let w = apply(&s); + let linear = mask(&w, 0, 0); + assert_eq!(linear["What"], json!("Mask/Gradient")); + assert_eq!(linear["ZeroY"], json!(0.6)); + let radial = mask(&w, 1, 0); + assert_eq!(radial["What"], json!("Mask/CircularGradient")); + assert_eq!(radial["Angle"], json!(0.0)); + assert_eq!(radial["Feather"], json!(50)); + assert_eq!(radial["Flipped"], json!(true)); + assert!(!w.call.update_ai_settings); +} + +// --- rule 7: no empty containers ------------------------------------------- + +#[test] +fn without_corrections_the_key_is_absent_never_an_empty_list() { + let s = read(json!({"Exposure2012": 0.5})); + let w = apply(&s); + assert!(w.table.get("MaskGroupBasedCorrections").is_none()); + // Brush strokes never reach a photo: the correction is reported and the + // key stays out, rather than an empty list deleting the photo's masks. + let s = read(json!({ + "Exposure2012": 0.5, + "MaskGroupBasedCorrections": [{"What": "Correction", "LocalExposure2012": 0.1, + "CorrectionMasks": [{"What": "Mask/Aggregate", "MaskBlendMode": 0, + "MaskValue": 1, "MaskInverted": false}]}] + })); + let w = apply(&s); + assert!( + w.table.get("MaskGroupBasedCorrections").is_none(), + "{}", + w.table + ); + assert_eq!(paths(&w), ["MaskGroupBasedCorrections[0]"]); + check_wire(&w.table).unwrap(); +} + +#[test] +fn a_structure_left_empty_is_absent() { + // `LensBlur` with only a field that does not reach the photo. + let s = read(json!({"LensBlur": {"FocalRange": "0 0 100 100"}})); + let w = apply(&s); + assert!(w.table.get("LensBlur").is_none(), "{}", w.table); + assert_eq!(paths(&w), ["LensBlur.FocalRange"]); +} + +#[test] +fn nothing_to_write_is_an_empty_table() { + let w = apply(&DevelopSettings::new()); + assert!(w.is_empty()); + assert_eq!(w.table, json!({})); + assert!(w.skipped.is_empty()); +} + +// --- rule 8 and the guard --------------------------------------------------- + +/// `{ : 5000 }`. +fn temp_table() -> J { + let mut m = Map::new(); + m.insert(RETIRED_TEMP_KEY.to_owned(), json!(5000)); + J::Object(m) +} + +#[test] +fn check_wire_refuses_what_json_lua_cannot_carry() { + let bad = [ + (json!({"a": null}), "a"), + (json!({"a": [1, null]}), "a"), + (json!({"a": []}), "a"), + (json!({"a": {}}), "a"), + (json!({"a": [{"b": []}]}), "a[0].b"), + (json!({"a": [1, "x"]}), "a"), + (json!({"a": [{"x": 1}, 2]}), "a"), + (temp_table(), ""), + (json!({ "a": temp_table() }), "a"), + ]; + for (j, path) in bad { + match check_wire(&j) { + Err(WireError::NotWireSafe { path: p, .. }) => assert_eq!(p, path, "{j}"), + other => panic!("{j}: {other:?}"), + } + } + assert!(check_wire(&json!([])).is_err(), "the top level is a table"); + assert!( + check_wire(&json!({})).is_ok(), + "an empty top level applies nothing" + ); + assert!(check_wire(&json!({"a": [[1, 2], [3]]})).is_ok()); +} + +// --- rule 9: white balance -------------------------------------------------- + +#[test] +fn raw_custom_white_balance_goes_as_a_family() { + let s = read(json!({"WhiteBalance": "Custom", "Temperature": 5600, "Tint": 6})); + let w = apply(&s); + assert_eq!( + w.table, + json!({"WhiteBalance": "Custom", "Temperature": 5600, "Tint": 6}) + ); + assert!(w.skipped.is_empty()); +} + +#[test] +fn non_raw_white_balance_uses_the_incremental_keys() { + let s = read(json!({"IncrementalTemperature": 12, "IncrementalTint": -4})); + let w = to_lua_value(&s, &photo(Some(FileKind::NonRaw)), &LuaOptions::default()).unwrap(); + assert_eq!( + w.table, + json!({"WhiteBalance": "Custom", "IncrementalTemperature": 12, "IncrementalTint": -4}) + ); + // The other family never reaches the photo. + let w = to_lua_value(&s, &photo(Some(FileKind::Raw)), &LuaOptions::default()).unwrap(); + assert!(w.is_empty(), "{}", w.table); + assert_eq!(paths(&w), ["IncrementalTemperature", "IncrementalTint"]); +} + +#[test] +fn an_unknown_file_kind_gets_no_white_balance_numbers() { + let s = read(json!({"WhiteBalance": "Custom", "Temperature": 5600, "Tint": 6})); + let w = to_lua_value(&s, &photo(None), &LuaOptions::default()).unwrap(); + assert!(w.is_empty(), "{}", w.table); + assert_eq!(paths(&w), ["Temperature", "Tint", "WhiteBalance"]); +} + +#[test] +fn numbers_next_to_another_mode_are_skipped_with_the_mode() { + let s = read(json!({"WhiteBalance": "As Shot", "Temperature": 5200, "Tint": 3})); + let w = apply(&s); + assert!(w.is_empty(), "{}", w.table); + assert_eq!(paths(&w), ["Temperature", "Tint"], "As Shot is the default"); + assert!(w + .skipped + .iter() + .all(|k| k.reason == SkipReason::WhiteBalanceMode("As Shot".into()))); + + let s = read(json!({"WhiteBalance": "Daylight", "Temperature": 5500, "Tint": 10})); + let w = apply(&s); + assert!(w.is_empty()); + assert_eq!(paths(&w), ["Temperature", "Tint", "WhiteBalance"]); +} + +#[test] +fn a_mode_alone_on_request_and_auto_flattened_on_request() { + let s = read(json!({"WhiteBalance": "Auto", "Temperature": 5000, "Tint": 2})); + let opt = LuaOptions { + wb_mode_only: true, + ..LuaOptions::default() + }; + let w = apply_with(&s, opt); + assert_eq!(w.table, json!({"WhiteBalance": "Auto"})); + assert_eq!(paths(&w), ["Temperature", "Tint"]); + assert!(!w.call.flatten_auto_now); + let w = apply_with( + &s, + LuaOptions { + flatten_auto_now: true, + ..opt + }, + ); + assert!(w.call.flatten_auto_now); + // Not for a Custom white balance. + let custom = read(json!({"WhiteBalance": "Custom", "Temperature": 5000, "Tint": 2})); + let w = apply_with( + &custom, + LuaOptions { + flatten_auto_now: true, + ..opt + }, + ); + assert!(!w.call.flatten_auto_now); +} + +#[test] +fn numbers_without_a_mode_get_custom_unless_switched_off() { + let s = read(json!({"Temperature": 5600, "Tint": 6})); + assert_eq!(apply(&s).table["WhiteBalance"], json!("Custom")); + let opt = LuaOptions { + wb_custom_with_numbers: false, + ..LuaOptions::default() + }; + let w = apply_with(&s, opt); + assert_eq!(w.table, json!({"Temperature": 5600, "Tint": 6})); +} + +#[test] +fn custom_without_numbers_is_never_written_alone() { + // Raw numbers for a non-raw photo, and any numbers for an unknown file + // kind, are dropped by the filter; `Custom` alone would pin whatever the + // photo has, so it goes too, even with `wb_mode_only`. + let s = read(json!({"WhiteBalance": "Custom", "Temperature": 5600, "Tint": 6})); + let opt = LuaOptions { + wb_mode_only: true, + ..LuaOptions::default() + }; + for kind in [Some(FileKind::NonRaw), None] { + let w = to_lua_value(&s, &photo(kind), &opt).unwrap(); + assert!(w.is_empty(), "{kind:?}: {}", w.table); + assert!( + paths(&w).contains(&"WhiteBalance"), + "{kind:?}: {:?}", + w.skipped + ); + assert!(w.skipped.iter().any(|k| k.path == "WhiteBalance" + && k.reason == SkipReason::WhiteBalanceMode("Custom".into()))); + } + // A named mode still goes alone on request, to a raw photo. + let auto = read(json!({"WhiteBalance": "Auto"})); + let w = to_lua_value(&auto, &photo(Some(FileKind::Raw)), &opt).unwrap(); + assert_eq!(w.table, json!({"WhiteBalance": "Auto"})); + // Never to a non-raw one: there `Auto` alone kept the old numbers + // (E1f/E1g), so it is skipped and reported whatever the option says. + let w = to_lua_value(&auto, &photo(Some(FileKind::NonRaw)), &opt).unwrap(); + assert!(w.is_empty(), "{}", w.table); + assert_eq!( + w.skipped, + [Skipped { + path: "WhiteBalance".into(), + reason: SkipReason::WhiteBalanceMode("Auto".into()), + }] + ); + assert!(!w.call.flatten_auto_now); +} + +#[test] +fn temp_is_never_written() { + let mut input = temp_table(); + input["Exposure2012"] = json!(0.1); + let (s, _) = from_lua_value(&input, FileKindHint::Raw).unwrap(); + let w = apply(&s); + assert!(w.table.get(RETIRED_TEMP_KEY).is_none()); + assert_eq!(paths(&w), [RETIRED_TEMP_KEY]); +} + +// --- rule 10: the options --------------------------------------------------- + +#[test] +fn mask_enums_as_numeric_strings_on_request() { + let mut s = DevelopSettings::new(); + let hair = CorrectionBuilder::new("Hair", &SyncNamespace::lrgenius()) + .local_ui("LocalExposure2012", 0.3) + .unwrap() + .add(Semantic::PeoplePart(5)) + .intersect(LuminanceRange::highs(0.5, 0.2).unwrap()) + .build() + .unwrap(); + s.corrections = vec![hair]; + let opt = LuaOptions { + mask_enum_as: EnumAs::String, + ..LuaOptions::default() + }; + let w = apply_with(&s, opt); + let m = mask(&w, 0, 0); + assert_eq!(m["MaskSubType"], json!("3")); + assert_eq!(m["MaskSubCategoryID"], json!("5")); + assert_eq!(m["MaskBlendMode"], json!("0")); + assert_eq!(m["ErrorReason"], json!("0")); + assert_eq!(m["MaskVersion"], json!(1), "not an enum"); + let range = mask(&w, 0, 1); + assert_eq!(range["CorrectionRangeMask"]["Type"], json!("2")); + assert_eq!(range["CorrectionRangeMask"]["SampleType"], json!("0")); + // Global integer sets keep their numbers. + let v = read(json!({"PostCropVignetteStyle": 1})); + assert_eq!(apply_with(&v, opt).table["PostCropVignetteStyle"], json!(1)); + // And numbers by default. + assert_eq!(mask(&apply(&s), 0, 0)["MaskSubType"], json!(3)); +} + +#[test] +fn the_preset_form_completes_ai_and_range_masks_the_minimal_form_does_not() { + let mut s = DevelopSettings::new(); + let c = CorrectionBuilder::new("Bright sky", &SyncNamespace::lrgenius()) + .local_ui("LocalHighlights2012", -30.0) + .unwrap() + .add(Semantic::Sky) + .intersect(LuminanceRange::highs(0.5, 0.2).unwrap()) + .build() + .unwrap(); + s.corrections = vec![c]; + let w = apply(&s); + let ai = mask(&w, 0, 0); + assert_eq!(ai["MaskVersion"], json!(1)); + assert_eq!(ai["ReferencePoint"], json!("0.500000 0.500000")); + assert_eq!(ai["ErrorReason"], json!(0)); + assert_eq!(ai["MaskActive"], json!(true)); + let range = &mask(&w, 0, 1)["CorrectionRangeMask"]; + assert_eq!(range["Version"], json!(3)); + assert_eq!(range["SampleType"], json!(0)); + assert_eq!(range["Invert"], json!(true), "intersect"); + + let minimal = apply_with( + &s, + LuaOptions { + mask_form: MaskForm::Minimal, + ..LuaOptions::default() + }, + ); + let ai = mask(&minimal, 0, 0); + for k in ["MaskVersion", "ReferencePoint", "ErrorReason"] { + assert!(!ai.contains_key(k), "{k}"); + } + assert_eq!(ai["MaskActive"], json!(true), "every form"); + let range = &mask(&minimal, 0, 1)["CorrectionRangeMask"]; + assert!(range.get("Version").is_none() && range.get("SampleType").is_none()); +} + +#[test] +fn panel_switches_on_request_for_the_panels_written() { + let mut s = read(json!({ + "Exposure2012": 0.2, "Sharpness": 40, "HueAdjustmentRed": 5, + "ToneCurvePV2012": [0, 0, 128, 140, 255, 255] + })); + s.corrections = vec![subject_correction()]; + let none = LuaOptions { + panel_switches: PanelSwitches::None, + ..LuaOptions::default() + }; + assert!(all_keys(&apply_with(&s, none).table) + .iter() + .all(|k| !k.starts_with("Enable"))); + // What E2 and E4 send, and the default (a no-op on E2's photo, so not + // told from `None` yet): the mask switch next to corrections, nothing + // else. + let mask_only = apply(&s).table; + let switches: Vec<&String> = mask_only + .as_object() + .unwrap() + .keys() + .filter(|k| k.starts_with("Enable")) + .collect(); + assert_eq!(switches, [MASK_SWITCH]); + assert_eq!(mask_only[MASK_SWITCH], json!(true)); + let globals_only = read(json!({"Exposure2012": 0.2})); + assert!(apply_with( + &globals_only, + LuaOptions { + panel_switches: PanelSwitches::MaskOnly, + ..LuaOptions::default() + } + ) + .table + .get(MASK_SWITCH) + .is_none()); + let opt = LuaOptions { + panel_switches: PanelSwitches::All, + ..LuaOptions::default() + }; + let t = apply_with(&s, opt).table; + let switches: Vec<&str> = t + .as_object() + .unwrap() + .keys() + .filter(|k| k.starts_with("Enable")) + .map(String::as_str) + .collect(); + let mut switches = switches; + switches.sort_unstable(); + assert_eq!( + switches, + [ + "EnableColorAdjustments", + "EnableDetail", + "EnableMaskGroupBasedCorrections", + "EnableToneCurve" + ] + ); + assert!(t + .as_object() + .unwrap() + .iter() + .filter(|(k, _)| k.starts_with("Enable")) + .all(|(_, v)| *v == json!(true))); +} + +#[test] +fn every_panel_switch_and_prefix_names_registry_keys() { + let globals: Vec<&str> = registry::iter() + .filter(|(_, s)| s.level == Level::Global) + .map(|(_, s)| s.name) + .collect(); + for (switch, prefixes) in PANEL_SWITCHES { + assert!(globals.contains(switch), "{switch} is not a registry key"); + for p in *prefixes { + assert!( + globals + .iter() + .any(|g| g.starts_with(p) && !g.starts_with("Enable")), + "{switch}: no registry key starts with {p}" + ); + } + } + assert!(globals.contains(&MASK_SWITCH)); +} + +#[test] +fn ai_masks_and_lens_blur_ask_for_an_ai_update() { + let mut s = DevelopSettings::new(); + s.corrections = vec![subject_correction()]; + assert!(apply(&s).call.update_ai_settings); + let off = LuaOptions { + ai_update: false, + ..LuaOptions::default() + }; + assert!(!apply_with(&s, off).call.update_ai_settings); + let blur = read(json!({"LensBlur": {"Active": true, "BlurAmount": 50}})); + let w = apply(&blur); + assert!(w.table.get("LensBlur").is_some(), "{}", w.table); + assert!(w.call.update_ai_settings); + assert!( + !apply(&read(json!({"Exposure2012": 0.1}))) + .call + .update_ai_settings + ); +} + +fn look_settings() -> DevelopSettings { + read(json!({"Look": { + "Name": "Adobe Color", "UUID": "B952C231111CD8E0ECCF14B86BAA7077", + "Amount": 0.8, "Group": {"x-default": "Profiles"}, "SupportsAmount": true, + "Parameters": {"ConvertToGrayscale": false, "ProcessVersion": "15.4"} + }})) +} + +#[test] +fn the_stub_forms_are_still_there_on_request() { + let stub = LuaOptions { + look_form: LookForm::Stub, + ..LuaOptions::default() + }; + let w = apply_with(&look_settings(), stub); + assert_eq!( + w.table["Look"], + json!({"Name": "Adobe Color", "UUID": "B952C231111CD8E0ECCF14B86BAA7077", + "Amount": 0.8, "Stubbed": true}) + ); + // The profile's computed copies are not reported: Lightroom fills them in. + assert!(w.skipped.is_empty(), "{:?}", w.skipped); + let bare = apply_with( + &look_settings(), + LuaOptions { + look_form: LookForm::BareStub, + ..LuaOptions::default() + }, + ); + assert!(bare.table["Look"].get("Stubbed").is_none()); +} + +/// E11b: a complete Look with `Parameters` is the one that is applied. +#[test] +fn the_look_goes_whole_by_default_and_says_when_it_has_no_parameters() { + let w = apply(&look_settings()); + assert_eq!(w.table["Look"]["Name"], json!("Adobe Color")); + assert!(w.table["Look"].get("Parameters").is_some()); + assert!(w.table["Look"].get("Stubbed").is_none()); + assert!(w.skipped.is_empty(), "{:?}", w.skipped); + let bare = read(json!({"Look": {"Name": "Adobe Vivid", + "UUID": "EA1DE074F188405965EF399C72C221D9", "Amount": 1, "Stubbed": true}})); + assert_eq!(paths(&apply(&bare)), ["Look.Parameters"]); +} + +#[test] +fn the_full_look_carries_its_record_and_parameters_on_request() { + let w = apply_with( + &look_settings(), + LuaOptions { + look_form: LookForm::Full, + ..LuaOptions::default() + }, + ); + let look = &w.table["Look"]; + assert_eq!(look["Group"], json!({"x-default": "Profiles"})); + assert_eq!(look["SupportsAmount"], json!(true)); + assert_eq!( + look["Parameters"], + json!({"ConvertToGrayscale": false, "ProcessVersion": "15.4"}) + ); + assert!(w.skipped.is_empty(), "{:?}", w.skipped); +} + +#[test] +fn a_camera_restricted_look_only_reaches_that_camera() { + let s = read(json!({"Look": {"Name": "Synthetic Profile", + "UUID": "00000000000000000000000000000001", + "CameraModelRestriction": "Synthetic Camera"}})); + let w = apply(&s); + assert!(w.table.get("Look").is_none()); + assert_eq!(paths(&w), ["Look"]); + let same = LuaMode::Apply(PhotoContext { + camera: Some("Synthetic Camera".into()), + ..PhotoContext::default() + }); + let stub = LuaOptions { + look_form: LookForm::Stub, + ..LuaOptions::default() + }; + let w = to_lua_value(&s, &same, &stub).unwrap(); + assert!(w.table.get("Look").is_some()); + // The stub carries no restriction of its own: a COMPUTED copy Lightroom + // refills, and the filter has already kept the Look to its camera, so + // nothing is reported. + assert!(w.table["Look"].get("CameraModelRestriction").is_none()); + assert!(w.skipped.is_empty(), "{:?}", w.skipped); +} + +/// A synthetic Adobe Adaptive stub: the `AILook` state never goes (COMPUTED, +/// reported), so the table asks for the AI update that computes it. +#[test] +fn an_adaptive_look_asks_for_an_ai_update_in_every_form() { + let s = read(json!({ + "Look": {"Name": "Synthetic Adaptive", "UUID": "00000000000000000000000000000002", + "Amount": 1, "isAdobeAdaptive": true, "Stubbed": true}, + "AILook": {"Version": 1} + })); + for form in [LookForm::Stub, LookForm::BareStub, LookForm::Full] { + let opt = LuaOptions { + look_form: form, + ..LuaOptions::default() + }; + let w = apply_with(&s, opt); + assert!(w.table.get("Look").is_some(), "{form:?}"); + assert!(w.table.get("AILook").is_none(), "{form:?}"); + assert!(paths(&w).contains(&"AILook"), "{form:?}: {:?}", w.skipped); + assert!(w.call.update_ai_settings, "{form:?}"); + let off = apply_with( + &s, + LuaOptions { + ai_update: false, + ..opt + }, + ); + assert!(!off.call.update_ai_settings, "{form:?}"); + } + // An ordinary profile does not. + assert!(!apply(&look_settings()).call.update_ai_settings); +} + +#[test] +fn amount_flags_only_for_a_preset_on_request_and_never_on_an_empty_table() { + let s = read(json!({"Contrast2012": 40})); + let preset = LuaMode::Preset(PhotoContext { + file_kind: Some(FileKind::Raw), + process_version: Some(ProcessVersion::V6), + camera: None, + }); + let opt = LuaOptions { + preset_amount_flags: true, + ..LuaOptions::default() + }; + assert!(to_lua_value(&s, &preset, &LuaOptions::default()) + .unwrap() + .table + .get("SupportsAmount") + .is_none()); + let t = to_lua_value(&s, &preset, &opt).unwrap().table; + assert_eq!(t["SupportsAmount"], json!(true)); + assert_eq!(t["SupportsAmount2"], json!(true)); + assert!(to_lua_value(&DevelopSettings::new(), &preset, &opt) + .unwrap() + .is_empty()); + // The applyDevelopSettings route never carries them, whatever the + // options say; otherwise a preset table and an applied one are the same. + let applied = apply_with(&s, opt); + assert!(applied.table.get("SupportsAmount").is_none()); + let mut flagged = t.clone(); + flagged.as_object_mut().unwrap().remove("SupportsAmount"); + flagged.as_object_mut().unwrap().remove("SupportsAmount2"); + assert_eq!(flagged, applied.table); +} + +// --- determinism and reading back ------------------------------------------ + +#[test] +fn the_written_table_reads_back_into_the_filtered_model() { + let mut s = read(json!({ + "Exposure2012": 0.35, "Contrast2012": 12, "LensProfileEnable": 1, + "WhiteBalance": "Custom", "Temperature": 5600, "Tint": 6, + "ToneCurvePV2012": [0, 0, 64, 50, 255, 255] + })); + s.corrections = vec![subject_correction(), sky_correction()]; + let w = apply(&s); + let (back, warnings) = from_lua_value(&w.table, FileKindHint::Raw).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + for (id, v) in s.values() { + assert_eq!(back.get(id), Some(v), "{}", id.spec().name); + } + assert_eq!(back.corrections.len(), 2); + for (a, b) in back.corrections.iter().zip(&s.corrections) { + // The model's own values, and the adaptive-preset zeros (the + // legacy PV2010 ones read back as bookkeeping, `extra`). + let mut want = b.local.clone(); + for k in ADAPTIVE_PRESET_LOCALS { + let kid = id(Level::Correction, k); + if crate::model::correction::is_local_adjustment(kid.spec()) { + want.entry(kid).or_insert(Value::Real(Finite::ZERO)); + } else { + assert_eq!( + a.extra.values.get(&kid), + Some(&Value::Real(Finite::ZERO)), + "{k}" + ); + } + } + assert_eq!(a.local, want); + assert_eq!(a.sync_id, b.sync_id); + assert_eq!(a.masks.len(), b.masks.len()); + for (x, y) in a.masks.iter().zip(&b.masks) { + assert_eq!((&x.tool, x.combine), (&y.tool, y.combine)); + } + } + assert_eq!(w, apply(&s), "deterministic"); +} + +#[cfg(feature = "test-roundtrip")] +#[test] +fn the_round_trip_mode_keeps_opaque_lua_content_and_refuses_xmp_content() { + let s = read(json!({"Exposure2012": 0.5, "UprightTransform_0": "1 0 0"})); + let t = to_lua_value(&s, &LuaMode::TestRoundTrip, &LuaOptions::default()) + .unwrap() + .table; + assert_eq!(t["UprightTransform_0"], json!("1 0 0")); + let mut x = DevelopSettings::new(); + x.opaque.push(OpaqueEntry { + ns: None, + name: "Foreign".into(), + value: Opaque::Xmp(crate::model::XmpNode::text("x")), + }); + assert!(matches!( + to_lua_value(&x, &LuaMode::TestRoundTrip, &LuaOptions::default()), + Err(WireError::NotLua { .. }) + )); +} diff --git a/server-rs/crates/lrg-develop/src/model/correction.rs b/server-rs/crates/lrg-develop/src/model/correction.rs new file mode 100644 index 00000000..c96cd7de --- /dev/null +++ b/server-rs/crates/lrg-develop/src/model/correction.rs @@ -0,0 +1,564 @@ +//! Local corrections (`MaskGroupBasedCorrections`) and their mask components. +//! +//! A [`Correction`] is one entry of the Masking panel: its adjustments +//! (`Local*`, local curves) in the stored unit and its mask components in +//! evaluation order. Each [`MaskComponent`] is typed where the backend needs logic +//! (semantic AI masks, linear and radial gradients without rotation, +//! luminance ranges) and otherwise kept as [`MaskTool::Opaque`] with all of +//! its fields, so nothing is lost on a round trip. +//! +//! Components are recognised by their structure only (`What`, +//! `MaskSubType`, `MaskSubCategoryID`, ...), never by their names: Lightroom +//! localises mask names ("Künstlicher Boden", "Maske 1"). + +use std::collections::BTreeMap; + +use super::value::{Fields, Finite, Hex32, Struct, Value}; +use crate::parse::{ParseWarning, WarningKind}; +use crate::registry::{KeyId, KeySpec, Level, Policy, StructKind}; + +/// `MaskSubCategoryID`s of the people parts (face skin, iris/pupil, body +/// skin, hair, lips, beard, sclera, eyebrows, clothes, teeth). +pub const PEOPLE_PARTS: &[i64] = &[2, 3, 4, 5, 6, 7, 8, 9, 11, 12]; +/// `MaskSubCategoryID`s of the landscape classes (architecture ... snow). +pub const LANDSCAPE_CLASSES: std::ops::RangeInclusive = 50001..=50008; +/// `MaskSubCategoryID` of "Select Background". +pub const BACKGROUND_CATEGORY: i64 = 22; + +/// A point normalised per axis in the uncropped image in sensor orientation +/// (the frame gradient coordinates and `ReferencePoint` live in). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct SensorPoint { + /// 0 = left edge, 1 = right edge (may leave [0, 1]). + pub x: Finite, + /// 0 = top edge, 1 = bottom edge (may leave [0, 1]). + pub y: Finite, +} + +impl SensorPoint { + /// Parses a `ReferencePoint` string (`"0.433594 0.659824"`). + pub fn parse_pair(s: &str) -> Option { + let mut it = s + .split_whitespace() + .map(|t| t.parse::().ok().and_then(|x| Finite::new(x).ok())); + let (x, y) = (it.next()??, it.next()??); + it.next().is_none().then_some(SensorPoint { x, y }) + } +} + +/// One `MaskGroupBasedCorrections` item. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Correction { + /// `CorrectionName` (display only, possibly localised; never used to + /// identify anything). + pub name: Option, + /// `CorrectionSyncID`. + pub sync_id: Option, + /// `CorrectionAmount` (stored unit, 1 = 100 %). + pub amount: Option, + /// `CorrectionActive`. + pub active: Option, + /// The adjustments (`Local*`, local curves) in the stored unit. + pub local: BTreeMap, + /// `CorrectionMasks`, in evaluation order. + pub masks: Vec, + /// Every other field (`What`, runtime ids, legacy `LocalExposure`, ...) + /// and what the registry does not know, for the round trip. + pub extra: Fields, +} + +/// How a component combines with the ones before it (the table on the wiki +/// page Dev-Develop-Model, "Corrections and masks"). +/// +/// | `Combine` | `MaskBlendMode` | `MaskValue` | `MaskInverted` | +/// |---|---|---|---| +/// | `Add` | 0 | 1 | `inverted` | +/// | `Subtract` | 1 | 0 | false | +/// | `Intersect` | 1 | 0 | true (A ∩ B = A − ¬B) | +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Combine { + /// Adds the (optionally inverted) component. + Add { + /// `MaskInverted`. + inverted: bool, + }, + /// Subtracts the component. + Subtract, + /// Intersects with the component. + Intersect, + /// The three fields are missing or form none of the above; they stay in + /// [`MaskComponent::extra`] as they are. + Unrecognised, +} + +impl Combine { + /// Reads the combination from `(MaskBlendMode, MaskValue, MaskInverted)`. + pub fn decode(blend_mode: i64, value: Finite, inverted: bool) -> Option { + match (blend_mode, value.get(), inverted) { + (0, 1.0, inverted) => Some(Combine::Add { inverted }), + (1, 0.0, false) => Some(Combine::Subtract), + (1, 0.0, true) => Some(Combine::Intersect), + _ => None, + } + } + + /// `(MaskBlendMode, MaskValue, MaskInverted)`; `None` for + /// [`Combine::Unrecognised`]. + pub fn encode(self) -> Option<(i64, Finite, bool)> { + match self { + Combine::Add { inverted } => Some((0, Finite::new_const(1.0), inverted)), + Combine::Subtract => Some((1, Finite::ZERO, false)), + Combine::Intersect => Some((1, Finite::ZERO, true)), + Combine::Unrecognised => None, + } + } +} + +/// One `CorrectionMasks` item. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MaskComponent { + /// What the component selects. + pub tool: MaskTool, + /// How it combines with the components before it. + pub combine: Combine, + /// `MaskName` (display only, possibly localised). + pub name: Option, + /// `MaskSyncID`. + pub sync_id: Option, + /// `MaskActive`. + pub active: Option, + /// Every field the typed parts do not hold (digests, versions, reference + /// points, gestures, ...), for the round trip. + pub extra: Fields, +} + +/// The mask tool of a component. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum MaskTool { + /// `Mask/Image`: an AI mask in a recognised form. + Semantic(Semantic), + /// `Mask/Gradient`. + Linear(LinearGradient), + /// `Mask/CircularGradient` with `Angle` 0. + Radial(RadialGradient), + /// `Mask/RangeMask` with `CorrectionRangeMask.Type` 2. + LuminanceRange(LuminanceRange), + /// Anything else (brush aggregates, Select Object, colour and depth + /// ranges, rotated radial gradients, unknown tools): kept for the round + /// trip only; every field is in [`MaskComponent::extra`]. + Opaque { + /// The `What` value, if any. + what: Option, + }, +} + +/// The recognised forms of an AI (`Mask/Image`) mask. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Semantic { + /// `MaskSubType` 1. + Subject, + /// `MaskSubType` 2. + Sky, + /// `MaskSubType` 0, `MaskSubCategoryID` 22. + Background, + /// `MaskSubType` 3 with a people-part category: the preset form ("all + /// people"). + PeoplePart(i64), + /// `MaskSubType` 0 with a landscape class (50001..50008). + Landscape(i64), + /// `MaskSubType` 0 with a people-part category and a `ReferencePoint`: + /// the part of the one person at that point (photo-specific). + PersonPartAt { + /// People-part category. + part: i64, + /// The person's reference point. + point: SensorPoint, + }, +} + +impl Semantic { + /// `(MaskSubType, MaskSubCategoryID)`. + pub fn encoding(self) -> (i64, Option) { + match self { + Semantic::Subject => (1, None), + Semantic::Sky => (2, None), + Semantic::Background => (0, Some(BACKGROUND_CATEGORY)), + Semantic::PeoplePart(part) => (3, Some(part)), + Semantic::Landscape(class) => (0, Some(class)), + Semantic::PersonPartAt { part, .. } => (0, Some(part)), + } + } +} + +/// A linear gradient from `zero` to `full` (by the key names none at `zero`, +/// full at `full`; the direction is unverified until experiment E6). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct LinearGradient { + /// `ZeroX`/`ZeroY`. + pub zero: SensorPoint, + /// `FullX`/`FullY`. + pub full: SensorPoint, +} + +/// A radial gradient without rotation. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct RadialGradient { + /// `Top`. + pub top: Finite, + /// `Left`. + pub left: Finite, + /// `Bottom`. + pub bottom: Finite, + /// `Right`. + pub right: Finite, + /// `Feather` (0..100). + pub feather: i64, + /// `Midpoint`. + pub midpoint: i64, + /// `Roundness`. + pub roundness: i64, + /// `Flipped` (true: the effect is inside the ellipse). + pub flipped: bool, +} + +/// A luminance range mask. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LuminanceRange { + /// `LumRange`: low feather, low, high, high feather (0..1). + pub range: [Finite; 4], + /// `CorrectionRangeMask.Invert`. + pub invert: bool, + /// The other `CorrectionRangeMask` fields (`Version`, `SampleType`, + /// `LuminanceDepthSampleInfo`, ...). + pub rest: Struct, +} + +/// Whether a correction-level key is one of the adjustments +/// ([`Correction::local`]) rather than bookkeeping. +pub fn is_local_adjustment(spec: &KeySpec) -> bool { + spec.level == Level::Correction + && !matches!(spec.policy, Policy::Computed | Policy::Meta | Policy::Never) + && !matches!(spec.name, "CorrectionAmount" | "CorrectionMasks") +} + +const C: Level = Level::Correction; +const M: Level = Level::MaskTool; + +fn string(v: &Value) -> Option { + v.as_str().map(str::to_owned) +} + +fn hex(v: &Value) -> Option { + v.as_str().and_then(Hex32::parse) +} + +impl Correction { + /// Builds the typed view of a correction's fields. `path` is the + /// correction's own path (`MaskGroupBasedCorrections[2]`). + pub(crate) fn from_fields( + mut f: Fields, + path: &str, + warnings: &mut Vec, + ) -> Correction { + let name = f.take_if(C, "CorrectionName", string); + let sync_id = f.take_if(C, "CorrectionSyncID", hex); + let amount = f.take_if(C, "CorrectionAmount", Value::as_finite); + let active = f.take_if(C, "CorrectionActive", Value::as_bool); + let masks = match f.take(C, "CorrectionMasks") { + Some(Value::Tools(items)) => items + .into_iter() + .enumerate() + .map(|(i, item)| { + MaskComponent::from_fields( + item, + &format!("{path}.CorrectionMasks[{i}]"), + warnings, + ) + }) + .collect(), + Some(other) => { + // The XMP reader keeps CorrectionMasks whole (Value::Opaque) + // when it is an rdf:Bag or carries qualifiers; keep the value + // for the round trip, no typed masks. + let id = crate::registry::lookup(C, "CorrectionMasks").expect("registry row"); + f.values.insert(id, other); + Vec::new() + } + None => Vec::new(), + }; + let local_ids: Vec = f + .values + .keys() + .copied() + .filter(|id| is_local_adjustment(id.spec())) + .collect(); + let local = local_ids + .into_iter() + .filter_map(|id| f.values.remove(&id).map(|v| (id, v))) + .collect(); + Correction { + name, + sync_id, + amount, + active, + local, + masks, + extra: f, + } + } + + /// The adjustment `name` (`"LocalExposure2012"`), stored unit. + pub fn local_value(&self, name: &str) -> Option<&Value> { + self.local.get(&crate::registry::lookup(C, name)?) + } +} + +impl MaskComponent { + /// Builds the typed view of a component's fields. `path` is the + /// component's own path (`MaskGroupBasedCorrections[2].CorrectionMasks[0]`). + pub(crate) fn from_fields( + mut f: Fields, + path: &str, + warnings: &mut Vec, + ) -> MaskComponent { + let name = f.take_if(M, "MaskName", string); + let sync_id = f.take_if(M, "MaskSyncID", hex); + let active = f.take_if(M, "MaskActive", Value::as_bool); + let combine = take_combine(&mut f, path, warnings); + let tool = take_tool(&mut f); + MaskComponent { + tool, + combine, + name, + sync_id, + active, + extra: f, + } + } +} + +fn take_combine(f: &mut Fields, path: &str, warnings: &mut Vec) -> Combine { + let mode = f.get(M, "MaskBlendMode").and_then(Value::as_int); + let value = f.get(M, "MaskValue").and_then(Value::as_finite); + let inverted = f.get(M, "MaskInverted").and_then(Value::as_bool); + if let (Some(mode), Some(value), Some(inverted)) = (mode, value, inverted) { + if let Some(c) = Combine::decode(mode, value, inverted) { + for key in ["MaskBlendMode", "MaskValue", "MaskInverted"] { + f.take(M, key); + } + return c; + } + } + warnings.push(ParseWarning { + path: format!("{path}.MaskBlendMode"), + key: "MaskBlendMode".into(), + raw: Some(format!( + "MaskBlendMode={mode:?} MaskValue={:?} MaskInverted={inverted:?}", + value.map(Finite::get) + )), + kind: WarningKind::UnrecognisedMaskCombine, + }); + Combine::Unrecognised +} + +fn take_tool(f: &mut Fields) -> MaskTool { + let what = f.get(M, "What").and_then(Value::as_str).map(str::to_owned); + let typed = match what.as_deref() { + Some("Mask/Image") => semantic(f).map(MaskTool::Semantic), + Some("Mask/Gradient") => linear(f).map(MaskTool::Linear), + Some("Mask/CircularGradient") => radial(f).map(MaskTool::Radial), + Some("Mask/RangeMask") => luminance_range(f).map(MaskTool::LuminanceRange), + _ => None, + }; + if what.is_some() { + f.take(M, "What"); + } + typed.unwrap_or(MaskTool::Opaque { what }) +} + +fn int(f: &Fields, name: &str) -> Option { + f.get(M, name).and_then(Value::as_int) +} + +fn real(f: &Fields, name: &str) -> Option { + f.get(M, name).and_then(Value::as_finite) +} + +fn remove(f: &mut Fields, names: &[&str]) { + for n in names { + f.take(M, n); + } +} + +fn semantic(f: &mut Fields) -> Option { + if f.get(M, "Gesture").is_some() { + return None; // Select Object: photo-specific strokes/polygons + } + let sub = int(f, "MaskSubType")?; + let cat = int(f, "MaskSubCategoryID"); + let s = match (sub, cat) { + (1, None) => Semantic::Subject, + (2, None) => Semantic::Sky, + (0, Some(BACKGROUND_CATEGORY)) => Semantic::Background, + (3, Some(p)) if PEOPLE_PARTS.contains(&p) => Semantic::PeoplePart(p), + (0, Some(c)) if LANDSCAPE_CLASSES.contains(&c) => Semantic::Landscape(c), + (0, Some(p)) if PEOPLE_PARTS.contains(&p) => { + let point = f + .get(M, "ReferencePoint") + .and_then(Value::as_str) + .and_then(SensorPoint::parse_pair)?; + f.take(M, "ReferencePoint"); + Semantic::PersonPartAt { part: p, point } + } + _ => return None, + }; + remove(f, &["MaskSubType", "MaskSubCategoryID"]); + Some(s) +} + +fn linear(f: &mut Fields) -> Option { + let g = LinearGradient { + zero: SensorPoint { + x: real(f, "ZeroX")?, + y: real(f, "ZeroY")?, + }, + full: SensorPoint { + x: real(f, "FullX")?, + y: real(f, "FullY")?, + }, + }; + remove(f, &["ZeroX", "ZeroY", "FullX", "FullY"]); + Some(g) +} + +fn radial(f: &mut Fields) -> Option { + // Only Angle 0 until experiment E6 settles the rotation convention. + if real(f, "Angle")? != Finite::ZERO { + return None; + } + let g = RadialGradient { + top: real(f, "Top")?, + left: real(f, "Left")?, + bottom: real(f, "Bottom")?, + right: real(f, "Right")?, + feather: int(f, "Feather")?, + midpoint: int(f, "Midpoint")?, + roundness: int(f, "Roundness")?, + flipped: f.get(M, "Flipped").and_then(Value::as_bool)?, + }; + remove( + f, + &[ + "Angle", + "Top", + "Left", + "Bottom", + "Right", + "Feather", + "Midpoint", + "Roundness", + "Flipped", + ], + ); + Some(g) +} + +fn luminance_range(f: &mut Fields) -> Option { + let Some(Value::Struct(crm)) = f.get(M, "CorrectionRangeMask") else { + return None; + }; + let level = Level::Struct(StructKind::CorrectionRangeMask); + if crm.get("Type").and_then(Value::as_int) != Some(2) { + return None; + } + let invert = crm.get("Invert").and_then(Value::as_bool)?; + let range = parse_lum_range(crm.get("LumRange").and_then(Value::as_str)?)?; + let Some(Value::Struct(mut rest)) = f.take(M, "CorrectionRangeMask") else { + unreachable!("checked above"); + }; + for n in ["Type", "Invert", "LumRange"] { + rest.fields.take(level, n); + } + Some(LuminanceRange { + range, + invert, + rest, + }) +} + +/// Parses `LumRange` (`"0.000000 0.200000 0.800000 1.000000"`). +pub fn parse_lum_range(s: &str) -> Option<[Finite; 4]> { + let v: Vec = s + .split_whitespace() + .map(|t| t.parse::().ok().and_then(|x| Finite::new(x).ok())) + .collect::>()?; + v.try_into().ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn f(x: f64) -> Finite { + Finite::new(x).unwrap() + } + + #[test] + fn combine_table_round_trips() { + for c in [ + Combine::Add { inverted: false }, + Combine::Add { inverted: true }, + Combine::Subtract, + Combine::Intersect, + ] { + let (m, v, i) = c.encode().unwrap(); + assert_eq!(Combine::decode(m, v, i), Some(c)); + } + assert_eq!(Combine::decode(1, f(1.0), false), None); + assert_eq!(Combine::decode(0, f(0.0), false), None); + assert_eq!(Combine::Unrecognised.encode(), None); + } + + #[test] + fn reference_point_needs_exactly_two_numbers() { + assert_eq!( + SensorPoint::parse_pair("0.25 0.75"), + Some(SensorPoint { + x: f(0.25), + y: f(0.75) + }) + ); + assert_eq!(SensorPoint::parse_pair("0.25"), None); + assert_eq!(SensorPoint::parse_pair("0.25 0.75 1"), None); + assert_eq!(SensorPoint::parse_pair("a b"), None); + } + + #[test] + fn lum_range_needs_four_numbers() { + assert_eq!( + parse_lum_range("0 0.2 0.8 1"), + Some([f(0.0), f(0.2), f(0.8), f(1.0)]) + ); + assert_eq!(parse_lum_range("0 0.2 0.8"), None); + } + + #[test] + fn semantic_encodings_follow_the_plan_table() { + assert_eq!(Semantic::Subject.encoding(), (1, None)); + assert_eq!(Semantic::Sky.encoding(), (2, None)); + assert_eq!(Semantic::Background.encoding(), (0, Some(22))); + assert_eq!(Semantic::PeoplePart(5).encoding(), (3, Some(5))); + assert_eq!(Semantic::Landscape(50001).encoding(), (0, Some(50001))); + } + + #[test] + fn local_adjustments_exclude_bookkeeping() { + let spec = |n| crate::registry::lookup(C, n).unwrap().spec(); + assert!(is_local_adjustment(spec("LocalExposure2012"))); + assert!(is_local_adjustment(spec("MainCurve"))); + assert!(!is_local_adjustment(spec("CorrectionAmount"))); + assert!(!is_local_adjustment(spec("What"))); + assert!(!is_local_adjustment(spec("CorrectionID"))); + assert!(!is_local_adjustment(spec("LocalExposure"))); + } +} diff --git a/server-rs/crates/lrg-develop/src/model/mod.rs b/server-rs/crates/lrg-develop/src/model/mod.rs new file mode 100644 index 00000000..aa15777b --- /dev/null +++ b/server-rs/crates/lrg-develop/src/model/mod.rs @@ -0,0 +1,301 @@ +//! The develop model: values, settings and corrections. +//! +//! [`DevelopSettings`] holds one photo's (or preset's) develop settings in the +//! **stored** unit, keyed by registry [`KeyId`] so the order is the same in +//! every build. Only what needs logic has a typed view: corrections and their +//! masks ([`correction`]), the profile ([`Look`]), white balance +//! ([`whitebalance`]) and the process version. Everything the registry does +//! not know is kept verbatim ([`OpaqueEntry`]), never dropped. + +pub mod correction; +pub mod policy; +pub mod value; +pub mod whitebalance; +pub mod xmp_node; + +use std::collections::BTreeMap; + +pub use correction::{ + Combine, Correction, LinearGradient, LuminanceRange, MaskComponent, MaskTool, RadialGradient, + Semantic, SensorPoint, +}; +pub use policy::{FrameSplit, SkipReason, Skipped, Target, PORTABLE_PROFILES, WB_NUMBERS}; +pub use value::{Fields, Finite, Hex32, Opaque, OpaqueEntry, Struct, Value}; +pub use whitebalance::{NamedWb, WbFamily, WbMode, WbSetting}; +pub use xmp_node::{XmpArrayKind, XmpField, XmpNode, XmpValue}; + +use crate::registry::{self, KeyId, Level, ProcessVersion, StructKind}; + +/// Raw or not: decides the white-balance family and several defaults. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum FileKind { + /// Raw files and raw DNGs. + Raw, + /// JPEG, TIFF, PSD, HEIC and DNGs converted from them. + NonRaw, +} + +/// What the caller believes the file kind is (for example the plugin's +/// `is_raw`). The readers trust the settings' own white-balance keys over it +/// and warn when they disagree. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)] +pub enum FileKindHint { + /// Nothing known. + #[default] + Unknown, + /// The caller says raw. + Raw, + /// The caller says non-raw. + NonRaw, +} + +impl FileKindHint { + /// The hinted kind, if any. + pub fn kind(self) -> Option { + match self { + FileKindHint::Unknown => None, + FileKindHint::Raw => Some(FileKind::Raw), + FileKindHint::NonRaw => Some(FileKind::NonRaw), + } + } +} + +impl From> for FileKindHint { + /// From an `is_raw` flag. + fn from(is_raw: Option) -> FileKindHint { + match is_raw { + None => FileKindHint::Unknown, + Some(true) => FileKindHint::Raw, + Some(false) => FileKindHint::NonRaw, + } + } +} + +/// The lowest process version whose settings are learned (PV2012, `"6.7"`): +/// it introduced the `*2012` keys every later version shares. +pub const MIN_SUPPORTED_PV: ProcessVersion = ProcessVersion::PV2012; + +/// The creative profile, taken whole from its source: never rebuilt from a +/// name, and `Parameters` (the profile definition) stays opaque in +/// [`Look::rest`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Look { + /// `Name`. + pub name: Option, + /// `UUID`. + pub uuid: Option, + /// `Amount` (1 = 100 %). + pub amount: Option, + /// `CameraModelRestriction`: the profile only exists for this camera. + pub camera_restriction: Option, + /// Every other field, `Parameters` included, verbatim. + pub rest: Struct, +} + +impl Look { + /// Splits the typed fields off a `Look` structure (`s.kind` must be + /// [`StructKind::Look`]). A field whose value does not fit its typed + /// field, an empty `CameraModelRestriction` included, stays in + /// [`Look::rest`] as it was. + pub(crate) fn from_struct(mut s: Struct) -> Look { + debug_assert_eq!(s.kind, StructKind::Look, "not a Look structure"); + let level = Level::Struct(StructKind::Look); + let string = |v: &Value| v.as_str().map(str::to_owned); + Look { + name: s.fields.take_if(level, "Name", string), + uuid: s + .fields + .take_if(level, "UUID", |v| v.as_str().and_then(Hex32::parse)), + amount: s.fields.take_if(level, "Amount", Value::as_finite), + // "" (the form Adobe's own preset headers use) means "no + // restriction"; it stays in `rest` so a round trip keeps it. + camera_restriction: s.fields.take_if(level, "CameraModelRestriction", |v| { + v.as_str().filter(|r| !r.is_empty()).map(str::to_owned) + }), + rest: s, + } + } +} + +/// A key cannot be stored in [`DevelopSettings`]' value map. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum InsertError { + /// Only global keys live in the map; the others belong to corrections, + /// mask components or structures. + #[error("{key} is a {level} key, not a global one")] + NotGlobal { + /// Key name. + key: &'static str, + /// Its level. + level: Level, + }, + /// The key has a typed field of its own. + #[error("{key} is held in its typed field, not in the value map")] + TypedField { + /// Key name. + key: &'static str, + }, +} + +/// Global keys modelled as typed fields of [`DevelopSettings`]. +const TYPED_GLOBALS: &[&str] = &["MaskGroupBasedCorrections", "Look"]; + +/// One set of develop settings. +/// +/// Invariant (enforced by [`DevelopSettings::insert`], the value map is +/// private): the map holds only [`Level::Global`] keys, and never +/// `MaskGroupBasedCorrections` or `Look`, which are [`Self::corrections`] and +/// [`Self::look`]. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct DevelopSettings { + values: BTreeMap, + /// `MaskGroupBasedCorrections`, in panel order (empty = none). + pub corrections: Vec, + /// The creative profile. + pub look: Option, + /// Keys the registry does not know, pattern-family keys (`Table_…`, + /// `UprightTransform_N`) and wrongly typed values. The Lua reader emits + /// them sorted by key name (a Lua table has no order, and `serde_json`'s + /// map order depends on its features); an XMP reader keeps document + /// order. + pub opaque: Vec, + /// Raw or non-raw; `None` when unknown. + pub file_kind: Option, +} + +impl DevelopSettings { + /// Empty settings. + pub fn new() -> DevelopSettings { + DevelopSettings::default() + } + + /// Stores a global value, returning the previous one. + pub fn insert(&mut self, id: KeyId, value: Value) -> Result, InsertError> { + let spec = id.spec(); + if spec.level != Level::Global { + return Err(InsertError::NotGlobal { + key: spec.name, + level: spec.level, + }); + } + if TYPED_GLOBALS.contains(&spec.name) { + return Err(InsertError::TypedField { key: spec.name }); + } + Ok(self.values.insert(id, value)) + } + + /// The value of a global key. + pub fn get(&self, id: KeyId) -> Option<&Value> { + self.values.get(&id) + } + + /// The value of the global key `name`. + pub fn get_by_name(&self, name: &str) -> Option<&Value> { + self.get(registry::lookup(Level::Global, name)?) + } + + /// Removes a global value. + pub fn remove(&mut self, id: KeyId) -> Option { + self.values.remove(&id) + } + + /// Global values in registry order. + pub fn values(&self) -> impl Iterator { + self.values.iter().map(|(id, v)| (*id, v)) + } + + /// Number of global values (corrections, the Look and opaque entries + /// not counted; see [`Self::is_empty`] for "nothing at all"). + pub fn value_count(&self) -> usize { + self.values.len() + } + + /// True when nothing at all is set (values, corrections, look, opaque). + pub fn is_empty(&self) -> bool { + self.values.is_empty() + && self.corrections.is_empty() + && self.look.is_none() + && self.opaque.is_empty() + } + + /// Typed view of `ProcessVersion`; `None` when absent or malformed. + pub fn process_version(&self) -> Option { + ProcessVersion::parse(self.get_by_name("ProcessVersion")?.as_str()?) + } + + /// Typed view of the white balance ([`WbSetting::from_settings`]). + pub fn wb(&self) -> Option { + WbSetting::from_settings(self) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn id(level: Level, name: &str) -> KeyId { + registry::lookup(level, name).unwrap() + } + + #[test] + fn insert_takes_global_keys() { + let mut s = DevelopSettings::new(); + let exposure = id(Level::Global, "Exposure2012"); + assert_eq!(s.insert(exposure, Value::Real(Finite::ZERO)), Ok(None)); + assert_eq!( + s.get_by_name("Exposure2012"), + Some(&Value::Real(Finite::ZERO)) + ); + assert_eq!(s.value_count(), 1); + } + + #[test] + fn insert_refuses_non_global_keys() { + let mut s = DevelopSettings::new(); + for (level, name) in [ + (Level::Correction, "LocalExposure2012"), + (Level::MaskTool, "MaskName"), + (Level::Struct(StructKind::Look), "Name"), + ] { + assert!(matches!( + s.insert(id(level, name), Value::Int(0)), + Err(InsertError::NotGlobal { .. }) + )); + } + assert!(s.is_empty()); + } + + #[test] + fn insert_refuses_the_typed_globals() { + let mut s = DevelopSettings::new(); + for name in TYPED_GLOBALS { + assert_eq!( + s.insert(id(Level::Global, name), Value::Int(0)), + Err(InsertError::TypedField { key: name }) + ); + } + } + + #[test] + fn process_version_view() { + let mut s = DevelopSettings::new(); + assert_eq!(s.process_version(), None); + s.insert( + id(Level::Global, "ProcessVersion"), + Value::Str("15.4".into()), + ) + .unwrap(); + assert_eq!(s.process_version(), Some(ProcessVersion::V6)); + assert!(s.process_version().unwrap() >= MIN_SUPPORTED_PV); + } + + #[test] + fn hint_from_is_raw() { + assert_eq!(FileKindHint::from(Some(true)).kind(), Some(FileKind::Raw)); + assert_eq!( + FileKindHint::from(Some(false)).kind(), + Some(FileKind::NonRaw) + ); + assert_eq!(FileKindHint::from(None).kind(), None); + } +} diff --git a/server-rs/crates/lrg-develop/src/model/policy.rs b/server-rs/crates/lrg-develop/src/model/policy.rs new file mode 100644 index 00000000..d6a81cc8 --- /dev/null +++ b/server-rs/crates/lrg-develop/src/model/policy.rs @@ -0,0 +1,1458 @@ +//! What may leave the model for a given target. +//! +//! [`DevelopSettings::filtered`] applies the registry's policy classes to one +//! target (a shared preset, one photo, or a test round trip) and reports +//! every removed value that differs from its default as [`Skipped`], so a +//! caller can say what did not transfer instead of dropping it silently. +//! [`DevelopSettings::split_by_frame_scope`] sorts values by whether they can +//! be shared across the frames of a series. +//! +//! | Class | Preset | Apply to a photo | Test round trip | +//! |---|---|---|---| +//! | LEARN | yes | yes | yes | +//! | LEARN† | if the gate holds for every target | if the gate holds for the photo | yes | +//! | META, global (`ProcessVersion`, `Enable*`, ...) | no: set by the serializer | no: set by the serializer | yes | +//! | META, nested (correction, mask and structure bookkeeping) | yes | yes | yes | +//! | PHOTO | no | yes | yes | +//! | COMPUTED, NEVER, UNKNOWN, opaque | no | no (the `Look`'s profile record apart) | yes | +//! +//! The verdict is per key at every depth: a kept structure (`LensBlur`, a +//! mask's `CorrectionRangeMask`, a `Gesture` item, ...) is rebuilt from the +//! fields that pass at their own registry rows, so a shareable `LensBlur` +//! loses its sampled area and a luminance range its eyedropper point. A +//! registry value the reader kept whole ([`Value::Opaque`]: an `rdf:Bag`, +//! qualifiers, a Lua table of an unexpected shape) reaches no target but the +//! round trip, and is always reported. +//! +//! The [`Look`] goes to one photo whole (its profile record as the source +//! has it) and to a preset by reference: `Name`, `UUID`, `Amount` and the +//! META `Stubbed`, as Adobe's own presets refer to a profile. Its COMPUTED +//! fields (`Parameters`, the profile definition, and the profile's `Group`, +//! `Cluster`, `Copyright`, `Supports*`, ...) are copies Lightroom takes from +//! its profile library; they are dropped from a preset without a report. +//! +//! In a preset, white-balance numbers go only with a custom white balance: +//! next to any other `WhiteBalance` mode (`As Shot`, `Auto`, `Daylight`, ...) +//! they are what that mode resolved to for the source photo, and are dropped +//! and reported as [`SkipReason::WhiteBalanceMode`]. +//! +//! Global META values are dropped without a report: the source's +//! `ProcessVersion`, `Version` stamps and panel switches describe the source, +//! not the settings, and a writer sets them from its own context (copying an +//! example's `ProcessVersion` would silently change the target photo's +//! process version). Nested META stays (a range mask's `Version`, sync ids); +//! the XMP writer completes Adobe's fixed preset form itself (see +//! `xmp::write`). + +use super::correction::{Combine, Correction, LuminanceRange, MaskComponent, MaskTool, Semantic}; +use super::value::{Fields, Struct, Value}; +use super::whitebalance::WbFamily; +use super::{DevelopSettings, FileKind, Look}; +use crate::registry::{ + self, Def, FileScope, FrameScope, Gate, KeyId, KeySpec, Level, Policy, ProcessVersion, +}; + +/// `CameraProfile` values every camera has: Adobe's standard profile and the +/// symbolic defaults Lightroom resolves per camera. Any other name (`Camera +/// Standard`, `Adobe Standard v2`, which only newer cameras have, ...) is +/// camera-specific. +pub const PORTABLE_PROFILES: &[&str] = &[ + "Adobe Standard", + "Default Color", + "Default Monochrome", + "Default Profile", +]; + +/// Where filtered settings are going. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Target { + /// A shared preset. `mixed_file_kinds`: it may be applied to raw and + /// non-raw files alike. + Preset { + /// The preset may reach both raw and non-raw files. + mixed_file_kinds: bool, + }, + /// Applying to one photo with its own context. + /// + /// A camera-specific `CameraProfile` (outside [`PORTABLE_PROFILES`]) + /// passes: the model knows neither the source's camera nor the photo's + /// make, so the caller must only apply settings from the same make until + /// this target carries the make. + ApplyPhoto { + /// The photo's file kind, if known. + file_kind: Option, + /// The photo's process version, if known. + process_version: Option, + /// The photo's camera model, if known (for camera-restricted + /// profiles). + camera: Option, + }, + /// Test round trip: everything, opaque content included. + TestRoundTrip, +} + +/// Why a value was filtered out. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SkipReason { + /// The key's policy class does not reach this target. + Policy(Policy), + /// The key's gate does not hold for this target. + Gate(Gate), + /// The key needs a newer process version than the photo has. + ProcessVersion { + /// The key's minimum. + min: ProcessVersion, + /// The photo's version. + target: ProcessVersion, + }, + /// The key exists only for the other file kind. + FileKind(FileScope), + /// A profile restricted to a camera the target does not have (or may + /// not have). + CameraRestricted { + /// `CameraModelRestriction`. + restriction: String, + }, + /// Content the model keeps only for the round trip. + Opaque, + /// A white-balance number (`Temperature`, `Tint`, + /// `IncrementalTemperature`, `IncrementalTint`) next to a `WhiteBalance` + /// mode other than `Custom` (the mode, e.g. `"As Shot"`, `"Auto"`, + /// `"Daylight"`): the number is what that mode resolved to for the + /// source photo, so a preset would pin one photo's white balance on + /// every photo as a custom one. + WhiteBalanceMode(String), +} + +/// The white-balance numbers [`SkipReason::WhiteBalanceMode`] applies to. +pub const WB_NUMBERS: &[&str] = &[ + "Temperature", + "Tint", + "IncrementalTemperature", + "IncrementalTint", +]; + +/// One value [`DevelopSettings::filtered`] removed. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Skipped { + /// Where it was (`"Temperature"`, `"LensBlur.SampledArea"`, + /// `"MaskGroupBasedCorrections[1]"`, + /// `"MaskGroupBasedCorrections[0].CorrectionID"`). + pub path: String, + /// Why. + pub reason: SkipReason, +} + +impl Skipped { + /// The report as one sentence for a photographer (and for a model that + /// asked for the value): what was left out, and why in Lightroom's terms. + pub fn sentence(&self) -> String { + let why = match &self.reason { + SkipReason::Policy(_) => "it is not a setting an edit carries".to_string(), + SkipReason::Gate(_) => "a setting it depends on is off".to_string(), + SkipReason::ProcessVersion { min, .. } => format!( + "it needs process version {} or later, and the photo is older; update it \ + in Lightroom's Calibration panel to use it", + min.label().unwrap_or("a newer") + ), + SkipReason::FileKind(FileScope::RawOnly) => "it exists for raw files only".to_string(), + SkipReason::FileKind(FileScope::NonRawOnly) => { + "it exists for non-raw files only".to_string() + } + SkipReason::FileKind(FileScope::Both) => "it does not apply to this file".to_string(), + SkipReason::CameraRestricted { restriction } => { + format!("the profile is made for the {restriction} only") + } + SkipReason::Opaque => "Lightroom's own form of it cannot be rewritten".to_string(), + SkipReason::WhiteBalanceMode(mode) => { + format!("it would pin the white balance that \"{mode}\" resolved to") + } + }; + format!("{} was not applied: {why}.", self.path) + } +} + +/// [`DevelopSettings::split_by_frame_scope`]'s result: one settings value per +/// [`FrameScope`], each with the source's file kind. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct FrameSplit { + /// Frame-independent (tone, HSL, colour grading, detail, profile, + /// semantic and luminance masks). + pub shareable: DevelopSettings, + /// Shared but adjusted per frame (exposure, white balance, ...). + pub per_frame_adjusted: DevelopSettings, + /// Belongs to one frame (crop, geometry, lens identity, retouch, + /// geometric and hand-drawn masks). + pub per_frame_only: DevelopSettings, + /// Not a transferable setting (computed, meta, unknown, opaque). + pub not_applicable: DevelopSettings, +} + +impl FrameSplit { + fn part(&mut self, scope: FrameScope) -> &mut DevelopSettings { + match scope { + FrameScope::Shareable => &mut self.shareable, + FrameScope::PerFrameAdjusted => &mut self.per_frame_adjusted, + FrameScope::PerFrameOnly => &mut self.per_frame_only, + FrameScope::NotApplicable => &mut self.not_applicable, + } + } +} + +/// How restrictive a class is when components are combined: the most +/// restrictive component decides for the whole correction. +fn rank(p: Policy) -> u8 { + match p { + Policy::Learn | Policy::LearnGated(_) | Policy::Meta => 0, + Policy::Photo => 1, + Policy::Unknown | Policy::Computed | Policy::Never => 2, + } +} + +impl MaskComponent { + /// The component's policy class, from its tool; UNKNOWN when its + /// combination is not one the model recognises (the fields stay for the + /// round trip, but what the component does to the mask is not known). + pub fn policy(&self) -> Policy { + if self.combine == Combine::Unrecognised { + return Policy::Unknown; + } + match &self.tool { + MaskTool::Semantic(Semantic::PersonPartAt { .. }) => Policy::Photo, + MaskTool::Semantic(_) | MaskTool::LuminanceRange(_) => Policy::Learn, + MaskTool::Linear(_) | MaskTool::Radial(_) => Policy::Photo, + MaskTool::Opaque { what } => match what.as_deref() { + Some("Mask/Aggregate" | "Mask/Paint" | "Mask/Brush") => Policy::Never, + // Select Object (a gesture on an AI mask), colour/depth + // ranges and rotated radial gradients: tied to one frame. + Some("Mask/Image") if self.extra.get(Level::MaskTool, "Gesture").is_some() => { + Policy::Photo + } + Some("Mask/RangeMask" | "Mask/Gradient" | "Mask/CircularGradient") => Policy::Photo, + _ => Policy::Unknown, + }, + } + } + + /// The component's frame scope: shareable for semantic and luminance + /// masks, per frame for everything tied to image positions. + pub fn frame_scope(&self) -> FrameScope { + match self.policy() { + Policy::Learn | Policy::LearnGated(_) | Policy::Meta => FrameScope::Shareable, + Policy::Photo | Policy::Never => FrameScope::PerFrameOnly, + Policy::Computed | Policy::Unknown => FrameScope::NotApplicable, + } + } +} + +impl Correction { + /// The most restrictive policy of its components (UNKNOWN without any). + pub fn policy(&self) -> Policy { + self.masks + .iter() + .map(MaskComponent::policy) + .max_by_key(|p| rank(*p)) + .unwrap_or(Policy::Unknown) + } + + /// The least shareable frame scope of its components. + pub fn frame_scope(&self) -> FrameScope { + let order = |s: FrameScope| match s { + FrameScope::Shareable => 0, + FrameScope::PerFrameAdjusted => 1, + FrameScope::PerFrameOnly => 2, + FrameScope::NotApplicable => 3, + }; + self.masks + .iter() + .map(MaskComponent::frame_scope) + .max_by_key(|s| order(*s)) + .unwrap_or(FrameScope::NotApplicable) + } +} + +/// Whether a policy class reaches the target, before gates. +fn class_verdict(policy: Policy, target: &Target) -> Result<(), SkipReason> { + match (policy, target) { + (_, Target::TestRoundTrip) => Ok(()), + (Policy::Learn | Policy::LearnGated(_) | Policy::Meta, _) => Ok(()), + (Policy::Photo, Target::ApplyPhoto { .. }) => Ok(()), + (p, _) => Err(SkipReason::Policy(p)), + } +} + +fn gate_verdict(gate: Gate, value: Option<&Value>, target: &Target) -> Result<(), SkipReason> { + let fails = match (gate, target) { + (_, Target::TestRoundTrip) => false, + (Gate::RawOnly | Gate::FileKindDefault, Target::Preset { mixed_file_kinds }) => { + *mixed_file_kinds + } + (Gate::NonRawOnly, Target::Preset { mixed_file_kinds }) => *mixed_file_kinds, + (Gate::RawOnly, Target::ApplyPhoto { file_kind, .. }) => *file_kind != Some(FileKind::Raw), + (Gate::NonRawOnly, Target::ApplyPhoto { file_kind, .. }) => { + *file_kind != Some(FileKind::NonRaw) + } + (Gate::FileKindDefault, Target::ApplyPhoto { .. }) => false, + (Gate::OnlyValues(allowed), _) => { + !matches!(value, Some(Value::Str(s)) if allowed.contains(&s.as_str())) + } + // Adobe Standard and the symbolic defaults exist for every camera. + (Gate::CameraRestricted, _) if matches!(value, Some(Value::Str(s)) if PORTABLE_PROFILES.contains(&s.as_str())) => { + false + } + // Any other profile name is camera-specific, and a preset may reach + // any camera. + (Gate::CameraRestricted, Target::Preset { .. }) => true, + // One photo: the model has no make to compare, so the same-make rule + // is the caller's (see `Target::ApplyPhoto`). The Look's own + // restriction is checked in `Filter::look`, where the camera is known. + (Gate::CameraRestricted, Target::ApplyPhoto { .. }) => false, + // Learning-side gates: they shape how values are blended, not + // whether a value may be written. + ( + Gate::Categorical + | Gate::CircularHue(_) + | Gate::ByMaskType + | Gate::NeedsAiUpdate + | Gate::DependsOn(_), + _, + ) => false, + }; + if fails { + Err(SkipReason::Gate(gate)) + } else { + Ok(()) + } +} + +/// Whether a registry key with this value reaches the target. +fn key_verdict(spec: &KeySpec, value: Option<&Value>, target: &Target) -> Result<(), SkipReason> { + if let Target::TestRoundTrip = target { + return Ok(()); + } + class_verdict(spec.policy, target)?; + if let Some(gate) = spec.policy.gate() { + gate_verdict(gate, value, target)?; + } + if let Target::ApplyPhoto { + file_kind, + process_version, + .. + } = target + { + let wrong_kind = matches!( + (spec.file_kind, file_kind), + (FileScope::RawOnly, Some(FileKind::NonRaw)) + | (FileScope::NonRawOnly, Some(FileKind::Raw)) + ); + if wrong_kind { + return Err(SkipReason::FileKind(spec.file_kind)); + } + if let (Some(min), Some(pv)) = (spec.min_pv, *process_version) { + if min > pv { + return Err(SkipReason::ProcessVersion { min, target: pv }); + } + } + } + Ok(()) +} + +/// Whether `value` is the key's default for `kind` (so removing it loses +/// nothing and needs no report). +fn is_default(spec: &KeySpec, value: &Value, kind: Option) -> bool { + let def = match kind { + Some(FileKind::NonRaw) => spec.default.non_raw, + _ => spec.default.raw, + }; + matches!(def, Def::Value(lit) if value.equals_lit(&lit)) +} + +fn join(path: &str, key: &str) -> String { + if path.is_empty() { + key.to_owned() + } else { + format!("{path}.{key}") + } +} + +struct Filter<'a> { + target: &'a Target, + kind: Option, + /// Raw and non-raw white-balance keys together while the file kind is + /// unknown (the readers warn `ConflictingFileKind`). + wb_conflict: bool, + skipped: Vec, +} + +/// A single-kind preset (`Preset { mixed_file_kinds: false }`) is for photos +/// of the settings' own file kind, so a key of the other kind cannot go, and +/// with both white-balance families and no known kind neither can: either +/// would make the preset contradict itself. +fn single_kind_preset_verdict( + spec: &KeySpec, + kind: Option, + wb_conflict: bool, + target: &Target, +) -> Result<(), SkipReason> { + let single_kind = matches!( + target, + Target::Preset { + mixed_file_kinds: false + } + ); + let fails = single_kind + && match (spec.file_kind, kind) { + (FileScope::Both, _) => false, + (FileScope::RawOnly, Some(k)) => k != FileKind::Raw, + (FileScope::NonRawOnly, Some(k)) => k != FileKind::NonRaw, + (_, None) => wb_conflict, + }; + if fails { + Err(SkipReason::FileKind(spec.file_kind)) + } else { + Ok(()) + } +} + +impl Filter<'_> { + fn skip(&mut self, path: String, reason: SkipReason) { + self.skipped.push(Skipped { path, reason }); + } + + /// One registry key's value at `path` (the key's own path): its + /// verdict first, then, when it stays, what is inside it. + fn keyed(&mut self, id: KeyId, v: &Value, path: &str) -> Option { + let spec = id.spec(); + // A registry value the reader kept whole (`rdf:Bag`, qualifiers, a + // Lua table of an unexpected shape, `Look.Parameters`): its meaning + // is not established, so it reaches no target but the round trip, + // whatever its key's class. Always reported: even a default-looking + // key held something the model could not type. + if matches!(v, Value::Opaque(_)) { + self.skip(path.to_owned(), SkipReason::Opaque); + return None; + } + let verdict = key_verdict(spec, Some(v), self.target).and_then(|()| { + single_kind_preset_verdict(spec, self.kind, self.wb_conflict, self.target) + }); + match verdict { + Ok(()) => self.nested(v, path), + Err(reason) => { + if !is_default(spec, v, self.kind) { + self.skip(path.to_owned(), reason); + } + None + } + } + } + + /// The part of a kept value that may go: structures field by field at + /// their own registry rows, corrections by their components. A structure + /// or list left with nothing in it is dropped; every other value is kept + /// as it is. + fn nested(&mut self, v: &Value, path: &str) -> Option { + match v { + Value::Struct(s) => self.structure(s, path).map(Value::Struct), + Value::StructList(items) => { + let kept: Vec = items + .iter() + .enumerate() + .filter_map(|(i, s)| self.structure(s, &format!("{path}[{i}]"))) + .collect(); + (!kept.is_empty()).then_some(Value::StructList(kept)) + } + Value::Tools(items) => { + let kept: Vec = items + .iter() + .enumerate() + .map(|(i, f)| self.fields(f, &format!("{path}[{i}]"))) + .filter(|f| !f.is_empty()) + .collect(); + (!kept.is_empty()).then_some(Value::Tools(kept)) + } + Value::Corrections(items) => { + let kept: Vec = items + .iter() + .enumerate() + .filter_map(|(i, c)| self.correction(c, &format!("{path}[{i}]"))) + .collect(); + (!kept.is_empty()).then_some(Value::Corrections(kept)) + } + other => Some(other.clone()), + } + } + + fn structure(&mut self, s: &Struct, path: &str) -> Option { + let fields = self.fields(&s.fields, path); + (!fields.is_empty()).then_some(Struct { + kind: s.kind, + fields, + }) + } + + fn fields(&mut self, f: &Fields, path: &str) -> Fields { + let mut out = Fields::default(); + for (id, v) in &f.values { + if let Some(kept) = self.keyed(*id, v, &join(path, id.spec().name)) { + out.values.insert(*id, kept); + } + } + for o in &f.opaque { + self.skip(join(path, &o.name), SkipReason::Opaque); + } + out + } + + fn look(&mut self, look: &Look) -> Option { + let spec = registry::lookup(Level::Global, "Look") + .expect("registry row") + .spec(); + let mut verdict = class_verdict(spec.policy, self.target); + if verdict.is_ok() { + if let Some(restriction) = &look.camera_restriction { + let fits = match self.target { + Target::ApplyPhoto { camera, .. } => camera.as_deref() == Some(restriction), + Target::Preset { .. } => false, + Target::TestRoundTrip => true, + }; + if !fits { + verdict = Err(SkipReason::CameraRestricted { + restriction: restriction.clone(), + }); + } + } + } + if let Err(reason) = verdict { + self.skip("Look".into(), reason); + return None; + } + if !matches!(self.target, Target::Preset { .. }) { + // One photo: the whole profile record, as the source has it + // (experiments E1/E11 apply it this way). + return Some(look.clone()); + } + // A preset refers to the profile by `Name`, `UUID` and `Amount`, as + // Adobe's own presets do; LrC resolves it from its profile library. + let mut rest = Fields::default(); + for (id, v) in &look.rest.fields.values { + if id.spec().policy == Policy::Computed { + // Not reported on purpose: `Parameters` (the profile + // definition, whose `Table_*` blobs a preset does not carry), + // `Group`, `Cluster`, `Copyright`, `SortName`, `Supports*`, + // `isAdobeAdaptive` are copies LrC takes from its profile + // library. None of Adobe's bundled presets carries + // `Parameters`; nothing is lost that the user could act on. + continue; + } + if let Some(kept) = self.keyed(*id, v, &join("Look", id.spec().name)) { + rest.values.insert(*id, kept); + } + } + for o in &look.rest.fields.opaque { + self.skip(join("Look", &o.name), SkipReason::Opaque); + } + Some(Look { + rest: Struct { + kind: look.rest.kind, + fields: rest, + }, + ..look.clone() + }) + } + + fn correction(&mut self, c: &Correction, path: &str) -> Option { + let policy = c.policy(); + if let Err(reason) = class_verdict(policy, self.target) { + self.skip(path.to_owned(), reason); + return None; + } + let mut local = std::collections::BTreeMap::new(); + for (id, v) in &c.local { + if let Some(kept) = self.keyed(*id, v, &join(path, id.spec().name)) { + local.insert(*id, kept); + } + } + let masks = c + .masks + .iter() + .enumerate() + .map(|(i, m)| { + let mask_path = format!("{path}.CorrectionMasks[{i}]"); + let tool = match &m.tool { + // The typed range stays; the rest of the range mask + // (eyedropper sample, sample type, ...) is checked field + // by field like any other structure. + MaskTool::LuminanceRange(lr) => MaskTool::LuminanceRange(LuminanceRange { + rest: Struct { + kind: lr.rest.kind, + fields: self.fields( + &lr.rest.fields, + &format!("{mask_path}.CorrectionRangeMask"), + ), + }, + ..lr.clone() + }), + other => other.clone(), + }; + MaskComponent { + tool, + extra: self.fields(&m.extra, &mask_path), + ..m.clone() + } + }) + .collect(); + Some(Correction { + local, + masks, + extra: self.fields(&c.extra, path), + ..c.clone() + }) + } +} + +impl DevelopSettings { + /// The settings that may go to `target`, and what was removed. + /// + /// Every removed value that differs from its default (for this + /// settings' file kind) is reported; removing a default loses nothing. + /// The exception is global META (see the module docs), which is dropped + /// without a report because a writer sets it from its own context. + /// + /// A correction goes or stays whole, decided by its most restrictive + /// component ([`Correction::policy`]); inside a kept correction, + /// bookkeeping fields (runtime ids, digests) are removed one by one. + /// Structures are filtered field by field at their own registry rows, + /// at any depth. The `Look` goes whole to a photo and by reference + /// (without its COMPUTED fields, unreported) to a preset; see the module + /// docs, which also cover white-balance numbers in a preset. + pub fn filtered(&self, target: &Target) -> (DevelopSettings, Vec) { + if let Target::TestRoundTrip = target { + return (self.clone(), Vec::new()); + } + let (raw_wb, non_raw_wb) = WbFamily::present_in(self); + let mut f = Filter { + target, + kind: self.file_kind, + wb_conflict: self.file_kind.is_none() && raw_wb && non_raw_wb, + skipped: Vec::new(), + }; + let mut out = DevelopSettings { + file_kind: self.file_kind, + ..DevelopSettings::default() + }; + // A preset carries white-balance numbers only as a custom white + // balance: next to any other mode they are one photo's resolved + // values (`WhiteBalance` itself is PHOTO and never goes). + let resolved_wb = match target { + Target::Preset { .. } => self + .get_by_name("WhiteBalance") + .and_then(Value::as_str) + .filter(|mode| *mode != "Custom") + .map(str::to_owned), + _ => None, + }; + for (id, v) in self.values() { + let spec = id.spec(); + if spec.policy == Policy::Meta { + // Not reported on purpose: these describe the source + // (`ProcessVersion`, `Version`, `Enable*`, `HasCrop`, ...) and + // the writer sets them from the target's context. Nothing is + // lost that the user could act on. + continue; + } + if let Some(kept) = f.keyed(id, v, spec.name) { + if let Some(mode) = resolved_wb + .as_ref() + .filter(|_| WB_NUMBERS.contains(&spec.name)) + { + f.skip( + spec.name.to_owned(), + SkipReason::WhiteBalanceMode(mode.clone()), + ); + continue; + } + out.values.insert(id, kept); + } + } + if let Some(look) = &self.look { + out.look = f.look(look); + } + for (i, c) in self.corrections.iter().enumerate() { + let path = format!("MaskGroupBasedCorrections[{i}]"); + if let Some(kept) = f.correction(c, &path) { + out.corrections.push(kept); + } + } + for o in &self.opaque { + f.skip(o.name.clone(), SkipReason::Opaque); + } + (out, f.skipped) + } + + /// Sorts the settings by frame scope. Global values follow their + /// registry row, corrections their least shareable component, the Look + /// its registry row, opaque entries go to `not_applicable`. + pub fn split_by_frame_scope(&self) -> FrameSplit { + let mut split = FrameSplit::default(); + for part in [ + &mut split.shareable, + &mut split.per_frame_adjusted, + &mut split.per_frame_only, + &mut split.not_applicable, + ] { + part.file_kind = self.file_kind; + } + for (id, v) in self.values() { + split.part(id.spec().frame).values.insert(id, v.clone()); + } + if let Some(look) = &self.look { + let frame = registry::lookup(Level::Global, "Look") + .expect("registry row") + .spec() + .frame; + split.part(frame).look = Some(look.clone()); + } + for c in &self.corrections { + split.part(c.frame_scope()).corrections.push(c.clone()); + } + split.not_applicable.opaque = self.opaque.clone(); + split + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::correction::{Combine, LinearGradient, SensorPoint}; + use crate::model::value::{Finite, Opaque, OpaqueEntry, Struct}; + use crate::registry::{lookup, StructKind}; + + fn set(s: &mut DevelopSettings, name: &str, v: Value) { + s.insert(lookup(Level::Global, name).unwrap(), v).unwrap(); + } + + fn component(tool: MaskTool) -> MaskComponent { + MaskComponent { + tool, + combine: Combine::Add { inverted: false }, + name: None, + sync_id: None, + active: Some(true), + extra: Fields::default(), + } + } + + fn correction(masks: Vec) -> Correction { + let mut local = std::collections::BTreeMap::new(); + local.insert( + lookup(Level::Correction, "LocalExposure2012").unwrap(), + Value::Real(Finite::new_const(0.25)), + ); + let mut extra = Fields::default(); + extra.values.insert( + lookup(Level::Correction, "CorrectionID").unwrap(), + Value::Str("00000000-0000-4000-8000-000000000001".into()), + ); + Correction { + name: Some("Correction 1".into()), + sync_id: None, + amount: Some(Finite::new_const(1.0)), + active: Some(true), + local, + masks, + extra, + } + } + + fn linear() -> MaskTool { + let p = SensorPoint { + x: Finite::ZERO, + y: Finite::ZERO, + }; + MaskTool::Linear(LinearGradient { zero: p, full: p }) + } + + fn sample() -> DevelopSettings { + let mut s = DevelopSettings::new(); + s.file_kind = Some(FileKind::Raw); + set(&mut s, "Exposure2012", Value::Real(Finite::new_const(0.5))); + set(&mut s, "Temperature", Value::Int(5600)); + set(&mut s, "CropTop", Value::Real(Finite::new_const(0.1))); + set(&mut s, "ProcessVersion", Value::Str("15.4".into())); + set( + &mut s, + "FilterList", + Value::Struct(Struct::new(StructKind::FilterList)), + ); + set( + &mut s, + "RetouchAreas", + Value::StructList(vec![Struct::new(StructKind::RetouchArea)]), + ); + let mut subject = component(MaskTool::Semantic(Semantic::Subject)); + subject.extra.values.insert( + lookup(Level::MaskTool, "MaskDigest").unwrap(), + Value::Str("00000000000000000000000000000001".into()), + ); + s.corrections.push(correction(vec![subject])); + s.corrections.push(correction(vec![component(linear())])); + s.corrections + .push(correction(vec![component(MaskTool::Opaque { + what: Some("Mask/Aggregate".into()), + })])); + s.opaque.push(OpaqueEntry { + ns: None, + name: "SomethingNew".into(), + value: Opaque::Json(serde_json::json!(1)), + }); + s + } + + fn paths(skipped: &[Skipped]) -> Vec<&str> { + skipped.iter().map(|s| s.path.as_str()).collect() + } + + #[test] + fn test_round_trip_keeps_everything() { + let s = sample(); + let (out, skipped) = s.filtered(&Target::TestRoundTrip); + assert_eq!(out, s); + assert!(skipped.is_empty()); + } + + #[test] + fn a_mixed_preset_drops_photo_computed_raw_only_and_hand_drawn_content() { + let s = sample(); + let (out, skipped) = s.filtered(&Target::Preset { + mixed_file_kinds: true, + }); + assert!(out.get_by_name("Exposure2012").is_some()); + assert!( + out.get_by_name("ProcessVersion").is_none(), + "the writer sets the process version, never the example" + ); + assert!(!paths(&skipped).contains(&"ProcessVersion"), "not reported"); + for gone in ["Temperature", "CropTop", "FilterList", "RetouchAreas"] { + assert!(out.get_by_name(gone).is_none(), "{gone}"); + } + assert_eq!( + out.corrections.len(), + 1, + "only the subject mask is shareable" + ); + assert!(matches!( + out.corrections[0].masks[0].tool, + MaskTool::Semantic(Semantic::Subject) + )); + assert!( + out.corrections[0].extra.values.is_empty(), + "CorrectionID removed" + ); + assert!( + out.corrections[0].masks[0].extra.values.is_empty(), + "MaskDigest removed" + ); + assert!(out.opaque.is_empty()); + let p = paths(&skipped); + for want in [ + "Temperature", + "CropTop", + "FilterList", + "MaskGroupBasedCorrections[0].CorrectionID", + "MaskGroupBasedCorrections[0].CorrectionMasks[0].MaskDigest", + "RetouchAreas", + "MaskGroupBasedCorrections[1]", + "MaskGroupBasedCorrections[2]", + "SomethingNew", + ] { + assert!(p.contains(&want), "{want} not reported in {p:?}"); + } + let temp = skipped.iter().find(|s| s.path == "Temperature").unwrap(); + assert_eq!(temp.reason, SkipReason::Gate(Gate::RawOnly)); + let brush = skipped + .iter() + .find(|s| s.path == "MaskGroupBasedCorrections[2]") + .unwrap(); + assert_eq!(brush.reason, SkipReason::Policy(Policy::Never)); + } + + #[test] + fn a_single_kind_preset_keeps_raw_only_keys() { + let (out, _) = sample().filtered(&Target::Preset { + mixed_file_kinds: false, + }); + assert!(out.get_by_name("Temperature").is_some()); + } + + #[test] + fn a_component_with_an_unrecognised_combination_keeps_its_correction_out() { + let mut odd = component(MaskTool::Semantic(Semantic::Sky)); + odd.combine = Combine::Unrecognised; + assert_eq!(odd.policy(), Policy::Unknown); + let mut s = DevelopSettings::new(); + s.corrections = vec![correction(vec![odd])]; + for target in [ + Target::Preset { + mixed_file_kinds: true, + }, + Target::ApplyPhoto { + file_kind: None, + process_version: None, + camera: None, + }, + ] { + let (out, skipped) = s.filtered(&target); + assert!(out.corrections.is_empty()); + assert_eq!(skipped[0].reason, SkipReason::Policy(Policy::Unknown)); + } + let (out, _) = s.filtered(&Target::TestRoundTrip); + assert_eq!(out, s); + } + + #[test] + fn a_single_kind_preset_never_carries_both_white_balance_families() { + let mut s = DevelopSettings::new(); + set(&mut s, "Temperature", Value::Int(5000)); + set(&mut s, "IncrementalTint", Value::Int(10)); + let single = Target::Preset { + mixed_file_kinds: false, + }; + // Both families, kind unknown (what the readers make of it): neither. + let (out, skipped) = s.filtered(&single); + assert!(out.get_by_name("Temperature").is_none()); + assert!(out.get_by_name("IncrementalTint").is_none()); + let reasons: Vec<_> = skipped.iter().map(|k| (&*k.path, &k.reason)).collect(); + assert_eq!( + reasons, + [ + ("Temperature", &SkipReason::FileKind(FileScope::RawOnly)), + ( + "IncrementalTint", + &SkipReason::FileKind(FileScope::NonRawOnly) + ), + ] + ); + // A known kind keeps its own family and drops the other. + s.file_kind = Some(FileKind::Raw); + let (out, skipped) = s.filtered(&single); + assert!(out.get_by_name("Temperature").is_some()); + assert!(out.get_by_name("IncrementalTint").is_none()); + assert_eq!(skipped.len(), 1); + } + + #[test] + fn applying_to_a_photo_keeps_photo_content_but_checks_its_context() { + let s = sample(); + let (out, skipped) = s.filtered(&Target::ApplyPhoto { + file_kind: Some(FileKind::NonRaw), + process_version: Some(ProcessVersion::V6), + camera: None, + }); + assert!(out.get_by_name("CropTop").is_some()); + assert!(out.get_by_name("Temperature").is_none()); + assert!( + out.get_by_name("ProcessVersion").is_none(), + "the example's 15.4 must not reach a photo of another version" + ); + assert_eq!( + out.corrections.len(), + 2, + "subject and gradient, not the brush" + ); + let temp = skipped.iter().find(|s| s.path == "Temperature").unwrap(); + assert_eq!(temp.reason, SkipReason::Gate(Gate::RawOnly)); + } + + #[test] + fn keys_newer_than_the_photo_process_version_are_skipped() { + let s = sample(); + let (out, skipped) = s.filtered(&Target::ApplyPhoto { + file_kind: Some(FileKind::Raw), + process_version: Some(ProcessVersion::PV2010), + camera: None, + }); + assert!(out.get_by_name("Exposure2012").is_none()); + let e = skipped.iter().find(|s| s.path == "Exposure2012").unwrap(); + assert_eq!( + e.reason, + SkipReason::ProcessVersion { + min: ProcessVersion::PV2012, + target: ProcessVersion::PV2010 + } + ); + // Without a known file kind the PV check still applies. + let (out, _) = s.filtered(&Target::ApplyPhoto { + file_kind: None, + process_version: Some(ProcessVersion::PV2010), + camera: None, + }); + assert!(out.get_by_name("Exposure2012").is_none()); + } + + #[test] + fn defaults_are_removed_without_a_report() { + let mut s = DevelopSettings::new(); + s.file_kind = Some(FileKind::Raw); + set(&mut s, "CropTop", Value::Real(Finite::ZERO)); + let (out, skipped) = s.filtered(&Target::Preset { + mixed_file_kinds: true, + }); + assert!(out.get_by_name("CropTop").is_none()); + assert!(skipped.is_empty(), "{skipped:?}"); + } + + /// The non-raw default column (E13's readback) suppresses the report for + /// a non-raw source's removed defaults; a raw or unknown source falls + /// back to the raw column. + #[test] + fn non_raw_defaults_are_removed_without_a_report() { + let mixed = Target::Preset { + mixed_file_kinds: true, + }; + // A mixed preset: the incremental 0s reach `is_default` instead of + // the single-kind preset's white-balance rule. + let mut s = DevelopSettings::new(); + s.file_kind = Some(FileKind::NonRaw); + set(&mut s, "Sharpness", Value::Int(0)); + set(&mut s, "ColorNoiseReduction", Value::Int(0)); + set(&mut s, "CameraProfile", Value::Str("Embedded".into())); + set(&mut s, "IncrementalTemperature", Value::Int(0)); + set(&mut s, "IncrementalTint", Value::Int(0)); + set(&mut s, "WhiteBalance", Value::Str("As Shot".into())); + set(&mut s, "CropTop", Value::Real(Finite::ZERO)); + let (out, skipped) = s.filtered(&mixed); + for k in [ + "Sharpness", + "ColorNoiseReduction", + "CameraProfile", + "IncrementalTemperature", + "IncrementalTint", + "WhiteBalance", + "CropTop", + ] { + assert!(out.get_by_name(k).is_none(), "{k} kept"); + } + assert!(skipped.is_empty(), "{skipped:?}"); + + // Not the default: reported. + let mut s = DevelopSettings::new(); + s.file_kind = Some(FileKind::NonRaw); + set(&mut s, "Sharpness", Value::Int(40)); + let (_, skipped) = s.filtered(&mixed); + assert_eq!( + skipped, + [Skipped { + path: "Sharpness".into(), + reason: SkipReason::Gate(Gate::FileKindDefault), + }] + ); + + // 0 is not the raw default (40), and an unknown kind reads the raw + // column. + for kind in [Some(FileKind::Raw), None] { + let mut s = DevelopSettings::new(); + s.file_kind = kind; + set(&mut s, "Sharpness", Value::Int(0)); + let (_, skipped) = s.filtered(&mixed); + assert_eq!( + skipped, + [Skipped { + path: "Sharpness".into(), + reason: SkipReason::Gate(Gate::FileKindDefault), + }], + "{kind:?}" + ); + } + } + + fn look(restriction: Option<&str>) -> Look { + Look { + name: Some("Synthetic Profile".into()), + uuid: None, + amount: Some(Finite::new_const(1.0)), + camera_restriction: restriction.map(str::to_owned), + rest: Struct::new(StructKind::Look), + } + } + + #[test] + fn camera_restricted_looks_only_go_to_that_camera() { + let mut s = DevelopSettings::new(); + s.look = Some(look(Some("Synthetic Camera"))); + let apply = |camera: Option<&str>| { + s.filtered(&Target::ApplyPhoto { + file_kind: None, + process_version: None, + camera: camera.map(str::to_owned), + }) + }; + assert!(apply(Some("Synthetic Camera")).0.look.is_some()); + let (out, skipped) = apply(Some("Other Camera")); + assert!(out.look.is_none()); + assert!(matches!( + skipped[0].reason, + SkipReason::CameraRestricted { .. } + )); + assert!(apply(None).0.look.is_none()); + let (out, _) = s.filtered(&Target::Preset { + mixed_file_kinds: false, + }); + assert!(out.look.is_none()); + + let mut open = DevelopSettings::new(); + open.look = Some(look(None)); + let (out, skipped) = open.filtered(&Target::Preset { + mixed_file_kinds: true, + }); + assert_eq!(out.look, open.look, "taken whole"); + assert!(skipped.is_empty()); + } + + #[test] + fn a_preset_refers_to_the_look_and_drops_its_computed_profile_record() { + let lk = |name: &str| lookup(Level::Struct(StructKind::Look), name).unwrap(); + let mut s = DevelopSettings::new(); + let mut look = look(None); + let parameters = crate::model::xmp_node::XmpNode::text("profile definition"); + for (id, v) in [ + (lk("Parameters"), Value::Opaque(Opaque::Xmp(parameters))), + (lk("Group"), Value::Alt("Profiles".into())), + (lk("Copyright"), Value::Str("(c)".into())), + (lk("SupportsMonochrome"), Value::Bool(false)), + (lk("Stubbed"), Value::Bool(true)), + ] { + look.rest.fields.values.insert(id, v); + } + s.look = Some(look.clone()); + let (out, skipped) = s.filtered(&Target::Preset { + mixed_file_kinds: true, + }); + let kept = out.look.expect("the reference stays"); + assert_eq!((&kept.name, kept.amount), (&look.name, look.amount)); + let names: Vec<&str> = kept + .rest + .fields + .values + .keys() + .map(|id| id.spec().name) + .collect(); + assert_eq!(names, ["Stubbed"], "only the META stub flag stays"); + assert!(skipped.is_empty(), "copies of the profile: {skipped:?}"); + + // One photo gets the whole record. + let (out, _) = s.filtered(&Target::ApplyPhoto { + file_kind: None, + process_version: None, + camera: None, + }); + assert_eq!(out.look, Some(look)); + } + + #[test] + fn values_kept_whole_reach_no_target_and_are_always_reported() { + let mut s = DevelopSettings::new(); + let node = crate::model::xmp_node::XmpNode::text("5"); + set(&mut s, "Texture", Value::Opaque(Opaque::Xmp(node))); + let mut c = correction(vec![component(MaskTool::Semantic(Semantic::Sky))]); + c.local.insert( + lookup(Level::Correction, "LocalExposure2012").unwrap(), + Value::Opaque(Opaque::Json(serde_json::json!([0]))), + ); + s.corrections.push(c); + for target in [ + Target::Preset { + mixed_file_kinds: true, + }, + Target::ApplyPhoto { + file_kind: None, + process_version: None, + camera: None, + }, + ] { + let (out, skipped) = s.filtered(&target); + assert!(out.get_by_name("Texture").is_none()); + assert!(out.corrections[0].local.is_empty()); + let opaque: Vec = skipped + .into_iter() + .filter(|k| k.reason == SkipReason::Opaque) + .collect(); + assert_eq!( + opaque, + ["Texture", "MaskGroupBasedCorrections[0].LocalExposure2012"] + .map(|p| Skipped { + path: p.into(), + reason: SkipReason::Opaque + }) + .to_vec() + ); + } + let (out, _) = s.filtered(&Target::TestRoundTrip); + assert_eq!(out, s); + } + + #[test] + fn white_balance_numbers_reach_a_preset_only_as_a_custom_white_balance() { + let wb = |mode: Option<&str>, kind: FileKind| { + let mut s = DevelopSettings::new(); + s.file_kind = Some(kind); + if let Some(m) = mode { + set(&mut s, "WhiteBalance", Value::Str(m.into())); + } + match kind { + FileKind::Raw => { + set(&mut s, "Temperature", Value::Int(5150)); + set(&mut s, "Tint", Value::Int(8)); + } + FileKind::NonRaw => { + set(&mut s, "IncrementalTemperature", Value::Int(-4)); + set(&mut s, "IncrementalTint", Value::Int(3)); + } + } + s.filtered(&Target::Preset { + mixed_file_kinds: false, + }) + }; + for mode in ["As Shot", "Auto", "Daylight"] { + for kind in [FileKind::Raw, FileKind::NonRaw] { + let (out, skipped) = wb(Some(mode), kind); + assert_eq!(out.values().count(), 0, "{mode} {kind:?}"); + let reasons: Vec<_> = skipped + .iter() + .filter(|k| k.path != "WhiteBalance") + .map(|k| k.reason.clone()) + .collect(); + assert_eq!( + reasons, + vec![SkipReason::WhiteBalanceMode(mode.into()); 2], + "{mode} {kind:?}" + ); + } + } + // "As Shot" is the default, so the mode itself is not reported. + assert_eq!( + paths(&wb(Some("As Shot"), FileKind::Raw).1), + ["Temperature", "Tint"] + ); + assert_eq!( + paths(&wb(Some("As Shot"), FileKind::NonRaw).1), + ["IncrementalTemperature", "IncrementalTint"] + ); + for mode in [Some("Custom"), None] { + let (out, _) = wb(mode, FileKind::Raw); + assert_eq!(out.get_by_name("Temperature"), Some(&Value::Int(5150))); + assert_eq!(out.get_by_name("Tint"), Some(&Value::Int(8))); + } + // One photo takes the mode itself (PHOTO) and its numbers. + let mut s = DevelopSettings::new(); + set(&mut s, "WhiteBalance", Value::Str("As Shot".into())); + set(&mut s, "Temperature", Value::Int(5150)); + let (out, _) = s.filtered(&Target::ApplyPhoto { + file_kind: Some(FileKind::Raw), + process_version: None, + camera: None, + }); + assert!(out.get_by_name("Temperature").is_some()); + } + + #[test] + fn only_values_gate_keeps_listed_values() { + let mut s = DevelopSettings::new(); + set(&mut s, "LensProfileSetup", Value::Str("Custom".into())); + let (out, skipped) = s.filtered(&Target::Preset { + mixed_file_kinds: false, + }); + assert!(out.get_by_name("LensProfileSetup").is_none()); + assert_eq!(skipped.len(), 1); + set(&mut s, "LensProfileSetup", Value::Str("Auto".into())); + let (out, _) = s.filtered(&Target::Preset { + mixed_file_kinds: false, + }); + assert!(out.get_by_name("LensProfileSetup").is_some()); + } + + #[test] + fn split_by_frame_scope_sorts_values_and_corrections() { + let s = sample(); + let split = s.split_by_frame_scope(); + assert!(split + .per_frame_adjusted + .get_by_name("Exposure2012") + .is_some()); + assert!(split + .per_frame_adjusted + .get_by_name("Temperature") + .is_some()); + assert!(split.per_frame_only.get_by_name("CropTop").is_some()); + assert!(split.not_applicable.get_by_name("ProcessVersion").is_some()); + assert!(split.not_applicable.get_by_name("FilterList").is_some()); + assert_eq!(split.shareable.corrections.len(), 1); + assert_eq!(split.per_frame_only.corrections.len(), 2); + assert_eq!(split.not_applicable.opaque.len(), 1); + let total = split.shareable.value_count() + + split.per_frame_adjusted.value_count() + + split.per_frame_only.value_count() + + split.not_applicable.value_count(); + assert_eq!( + total, + s.value_count(), + "every value lands in exactly one part" + ); + assert_eq!(split.shareable.file_kind, Some(FileKind::Raw)); + } + + fn field(kind: StructKind, name: &str) -> KeyId { + lookup(Level::Struct(kind), name).unwrap() + } + + #[test] + fn nested_fields_are_filtered_at_their_own_rows() { + let mut blur = Struct::new(StructKind::LensBlur); + let lb = |n| field(StructKind::LensBlur, n); + blur.fields.values.insert(lb("Active"), Value::Bool(true)); + blur.fields.values.insert(lb("BlurAmount"), Value::Int(50)); + blur.fields + .values + .insert(lb("SampledArea"), Value::Str("0 0 1 1".into())); + blur.fields + .values + .insert(lb("FocalRange"), Value::Str("0 0 100 100".into())); + blur.fields.opaque.push(OpaqueEntry { + ns: None, + name: "Mystery".into(), + value: Opaque::Json(serde_json::json!(5)), + }); + let mut s = DevelopSettings::new(); + set(&mut s, "LensBlur", Value::Struct(blur)); + let (out, skipped) = s.filtered(&Target::Preset { + mixed_file_kinds: true, + }); + let Some(Value::Struct(kept)) = out.get_by_name("LensBlur") else { + panic!("LensBlur dropped: {out:?}"); + }; + let names: Vec<&str> = kept.fields.values.keys().map(|id| id.spec().name).collect(); + assert_eq!(names, ["Active", "BlurAmount"]); + assert!(kept.fields.opaque.is_empty()); + let mut p = paths(&skipped); + p.sort_unstable(); + assert_eq!( + p, + [ + "LensBlur.FocalRange", + "LensBlur.Mystery", + "LensBlur.SampledArea" + ] + ); + let sampled = skipped + .iter() + .find(|k| k.path == "LensBlur.SampledArea") + .unwrap(); + assert_eq!(sampled.reason, SkipReason::Policy(Policy::Computed)); + let mystery = skipped + .iter() + .find(|k| k.path == "LensBlur.Mystery") + .unwrap(); + assert_eq!(mystery.reason, SkipReason::Opaque); + // The round trip keeps all of it. + assert_eq!(s.filtered(&Target::TestRoundTrip).0, s); + } + + #[test] + fn a_luminance_range_loses_its_eyedropper_sample_in_a_preset() { + let crm = |n| field(StructKind::CorrectionRangeMask, n); + let mut rest = Struct::new(StructKind::CorrectionRangeMask); + rest.fields.values.insert(crm("Version"), Value::Int(3)); + rest.fields.values.insert( + crm("LuminanceDepthSampleInfo"), + Value::Str("0 0.5 0.5".into()), + ); + let range = MaskTool::LuminanceRange(LuminanceRange { + range: [0.0, 0.2, 0.8, 1.0].map(Finite::new_const), + invert: false, + rest, + }); + let mut s = DevelopSettings::new(); + s.corrections.push(correction(vec![component(range)])); + let (out, skipped) = s.filtered(&Target::Preset { + mixed_file_kinds: true, + }); + let MaskTool::LuminanceRange(lr) = &out.corrections[0].masks[0].tool else { + panic!("range mask lost"); + }; + let names: Vec<&str> = lr + .rest + .fields + .values + .keys() + .map(|id| id.spec().name) + .collect(); + assert_eq!(names, ["Version"], "nested META stays"); + assert!(paths(&skipped).contains( + &"MaskGroupBasedCorrections[0].CorrectionMasks[0].CorrectionRangeMask.LuminanceDepthSampleInfo" + )); + } + + #[test] + fn global_meta_is_left_to_the_writer() { + let mut s = DevelopSettings::new(); + set(&mut s, "ProcessVersion", Value::Str("11.0".into())); + set(&mut s, "EnableDetail", Value::Bool(false)); + set(&mut s, "Exposure2012", Value::Real(Finite::new_const(0.5))); + for target in [ + Target::Preset { + mixed_file_kinds: false, + }, + Target::ApplyPhoto { + file_kind: Some(FileKind::Raw), + process_version: Some(ProcessVersion::V6), + camera: None, + }, + ] { + let (out, skipped) = s.filtered(&target); + assert!(out.get_by_name("ProcessVersion").is_none(), "{target:?}"); + assert!(out.get_by_name("EnableDetail").is_none(), "{target:?}"); + assert!(out.get_by_name("Exposure2012").is_some()); + assert!(skipped.is_empty(), "{target:?}: {skipped:?}"); + } + let (all, _) = s.filtered(&Target::TestRoundTrip); + assert!(all.get_by_name("ProcessVersion").is_some()); + } + + #[test] + fn portable_camera_profiles_reach_presets_and_camera_specific_ones_do_not() { + let preset = Target::Preset { + mixed_file_kinds: false, + }; + for name in PORTABLE_PROFILES { + let mut s = DevelopSettings::new(); + set(&mut s, "CameraProfile", Value::Str((*name).into())); + let (out, skipped) = s.filtered(&preset); + assert_eq!( + out.get_by_name("CameraProfile"), + Some(&Value::Str((*name).into())) + ); + assert!(skipped.is_empty()); + } + let mut s = DevelopSettings::new(); + set( + &mut s, + "CameraProfile", + Value::Str("Camera Standard".into()), + ); + let (out, skipped) = s.filtered(&preset); + assert!(out.get_by_name("CameraProfile").is_none()); + assert_eq!( + skipped, + [Skipped { + path: "CameraProfile".into(), + reason: SkipReason::Gate(Gate::CameraRestricted) + }] + ); + // One photo: passed on; the caller keeps to the same make. + let (out, _) = s.filtered(&Target::ApplyPhoto { + file_kind: None, + process_version: None, + camera: None, + }); + assert!(out.get_by_name("CameraProfile").is_some()); + } +} diff --git a/server-rs/crates/lrg-develop/src/model/value.rs b/server-rs/crates/lrg-develop/src/model/value.rs new file mode 100644 index 00000000..78ed1cc2 --- /dev/null +++ b/server-rs/crates/lrg-develop/src/model/value.rs @@ -0,0 +1,482 @@ +//! Values of the develop model. +//! +//! [`Finite`] is the float type for everything that can reach a writer: +//! setting values, amounts, curve points and the registry's ranges. JSON.lua +//! and `serde_json` both turn NaN and infinities into `null`, so a non-finite +//! number must be impossible to construct rather than checked at the end. + +use std::cmp::Ordering; +use std::collections::BTreeMap; +use std::fmt; +use std::hash::{Hash, Hasher}; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +use super::correction::Correction; +use crate::registry::{KeyId, KeySpec, Level, Lit, NumFmt, Scalar, StructKind}; + +/// A finite `f64`: never NaN, never ±∞, and `-0.0` is stored as `0.0`. +/// +/// Equality, ordering and hashing go through the bit pattern (after the +/// `-0.0` normalisation), so `Finite` can be `Eq`, `Ord` and a map key, and a +/// value that compares equal also serializes identically. +#[derive(Clone, Copy)] +pub struct Finite(f64); + +/// The value handed to [`Finite::new`] was NaN or infinite. +#[derive(Debug, Clone, Copy, PartialEq, thiserror::Error)] +#[error("not a finite number: {0}")] +pub struct NotFinite(pub f64); + +impl Finite { + /// `0.0`. + pub const ZERO: Finite = Finite(0.0); + + /// Checks `x`; `-0.0` becomes `0.0`. + pub fn new(x: f64) -> Result { + if x.is_finite() { + Ok(Finite(if x == 0.0 { 0.0 } else { x })) + } else { + Err(NotFinite(x)) + } + } + + /// Compile-time constructor for tables such as the key registry. + /// + /// # Panics + /// + /// Panics when `x` is not finite. In a `const`/`static` initializer that + /// panic is a compile error, which is the point. + pub const fn new_const(x: f64) -> Finite { + assert!(x.is_finite(), "Finite::new_const: not a finite number"); + Finite(if x == 0.0 { 0.0 } else { x }) + } + + /// Builds a `Finite` from an integer (exact for |i| < 2^53). + pub fn from_i64(i: i64) -> Finite { + // Every i64 is finite as an f64; `new_const` only normalises -0.0. + Finite::new_const(i as f64) + } + + /// The wrapped value. + pub const fn get(self) -> f64 { + self.0 + } +} + +impl TryFrom for Finite { + type Error = NotFinite; + fn try_from(x: f64) -> Result { + Finite::new(x) + } +} + +impl From for f64 { + fn from(f: Finite) -> f64 { + f.0 + } +} + +impl PartialEq for Finite { + fn eq(&self, other: &Self) -> bool { + self.0.to_bits() == other.0.to_bits() + } +} + +impl Eq for Finite {} + +impl Hash for Finite { + fn hash(&self, state: &mut H) { + self.0.to_bits().hash(state); + } +} + +impl PartialOrd for Finite { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Finite { + fn cmp(&self, other: &Self) -> Ordering { + self.0.total_cmp(&other.0) + } +} + +impl fmt::Debug for Finite { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Debug::fmt(&self.0, f) + } +} + +impl fmt::Display for Finite { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(&self.0, f) + } +} + +impl Serialize for Finite { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_f64(self.0) + } +} + +impl<'de> Deserialize<'de> for Finite { + fn deserialize>(deserializer: D) -> Result { + let x = f64::deserialize(deserializer)?; + Finite::new(x).map_err(serde::de::Error::custom) + } +} + +/// 32 hex characters: a sync id, UUID or digest. +/// +/// Kept exactly as read (case included); the XMP writer (PR 1b) upper-cases. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)] +pub struct Hex32(String); + +impl Hex32 { + /// `Some` for exactly 32 ASCII hex digits. + pub fn parse(s: &str) -> Option { + (s.len() == 32 && s.bytes().all(|b| b.is_ascii_hexdigit())).then(|| Hex32(s.to_owned())) + } + + /// The digits as read. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for Hex32 { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +pub use super::xmp_node::{XmpArrayKind, XmpField, XmpNode, XmpValue}; + +/// Lossless pass-through of something the model does not interpret. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Opaque { + /// From a Lua/JSON table: `null` in an array, mixed arrays, unknown + /// objects, and the keys the registry keeps opaque on purpose + /// (`ValueKind::Any`, `ValueKind::Settings`). + Json(serde_json::Value), + /// From an XMP file: unknown keys, `rdf:Bag`, language alternatives + /// with more than `x-default`, qualifiers, `Look.Parameters` and + /// `Preset.Parameters`. + Xmp(XmpNode), +} + +/// A key the model keeps without a registry row, or a registry key whose +/// value did not have the registry's type: its name, and the value verbatim. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct OpaqueEntry { + /// XMP namespace URI; `None` for Lua tables and the `crs:` namespace. + /// Shared with the XMP fields of the same file (see [`XmpField::ns`]). + pub ns: Option>, + /// Key name as it appeared. + pub name: String, + /// The value, verbatim. + pub value: Opaque, +} + +/// The fields of one level: registry keys by [`KeyId`] (registry order, the +/// same in every build) plus what the registry does not know (sorted by name +/// from a Lua table, in document order from XMP). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Fields { + /// Registry keys and their values. + pub values: BTreeMap, + /// Unknown keys, pattern-family keys and wrongly typed values (see + /// [`DevelopSettings::opaque`](super::DevelopSettings::opaque) for the + /// order). + pub opaque: Vec, +} + +impl Fields { + /// True when there are no values and no opaque entries. + pub fn is_empty(&self) -> bool { + self.values.is_empty() && self.opaque.is_empty() + } + + /// The value of `name` at `level`, if the registry knows the key and it is set. + pub fn get(&self, level: Level, name: &str) -> Option<&Value> { + self.values.get(&crate::registry::lookup(level, name)?) + } + + /// Removes and returns the value of `name` at `level`. + pub fn take(&mut self, level: Level, name: &str) -> Option { + self.values.remove(&crate::registry::lookup(level, name)?) + } + + /// Removes `name` at `level` when `convert` accepts its value; a value + /// `convert` rejects stays where it was. + pub fn take_if( + &mut self, + level: Level, + name: &str, + convert: impl FnOnce(&Value) -> Option, + ) -> Option { + let id = crate::registry::lookup(level, name)?; + let t = convert(self.values.get(&id)?)?; + self.values.remove(&id); + Some(t) + } +} + +/// A nested structure (`LensBlur`, `CorrectionRangeMask`, a `PointColors` +/// item, ...): its kind and its fields at [`Level::Struct`] of that kind. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Struct { + /// Which structure. + pub kind: StructKind, + /// Its fields. + pub fields: Fields, +} + +impl Struct { + /// An empty structure of `kind`. + pub fn new(kind: StructKind) -> Struct { + Struct { + kind, + fields: Fields::default(), + } + } + + /// The value of field `name`. + pub fn get(&self, name: &str) -> Option<&Value> { + self.fields.get(Level::Struct(self.kind), name) + } +} + +/// One value of the model, in the **stored** unit. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Value { + /// `Int`, `IntFlag` (0/1), `EnumInt` and `VersionU32` keys. + Int(i64), + /// `Real` keys. + Real(Finite), + /// `Bool` keys. + Bool(bool), + /// `Str`, `Enum`, `Hex32` and `VersionStr` keys. + Str(String), + /// A tone curve as (x, y) points, global or local form alike. + Curve(Vec<(Finite, Finite)>), + /// A sequence of strings. + StrList(Vec), + /// A language alternative with only an `x-default` item. + Alt(String), + /// One nested structure. + Struct(Struct), + /// A sequence of nested structures. + StructList(Vec), + /// Nested mask items kept uninterpreted at [`Level::MaskTool`]: the brush + /// strokes of a `Mask/Aggregate`, the heal shapes of a retouch spot. + Tools(Vec), + /// Corrections of a legacy `*BasedCorrections` key + /// (`MaskGroupBasedCorrections` itself is [`DevelopSettings::corrections`](super::DevelopSettings::corrections)). + Corrections(Vec), + /// A registry key the model keeps verbatim on purpose (`ValueKind::Any`, + /// `Look.Parameters`, a language alternative with several languages). + Opaque(Opaque), +} + +impl From for Value { + /// A coerced number ([`KeySpec::coerce`]) as the value of its key's kind. + fn from(s: Scalar) -> Value { + match s { + Scalar::Int(i) => Value::Int(i), + Scalar::Real(r) => Value::Real(r), + Scalar::Bool(b) => Value::Bool(b), + } + } +} + +/// Rounds to `decimals` places the way a `%.Nf` writer would. +fn round_to(x: Finite, decimals: u8) -> Finite { + format!("{:.*}", usize::from(decimals), x.get()) + .parse::() + .ok() + .and_then(|y| Finite::new(y).ok()) + .unwrap_or(x) +} + +impl Value { + /// The value rounded to the precision the XMP writer uses for this key + /// ([`NumFmt`]), for comparing a Lua value with one that went through + /// XMP. Non-numeric values are returned unchanged. + pub fn quantized(&self, spec: &KeySpec) -> Value { + let decimals = match spec.fmt { + NumFmt::Int => 0, + NumFmt::Fixed(n) => n, + NumFmt::Trim6 | NumFmt::CompoundFixed6 => 6, + NumFmt::Text => return self.clone(), + }; + match self { + Value::Real(x) => Value::Real(round_to(*x, decimals)), + Value::Curve(points) => Value::Curve( + points + .iter() + .map(|&(x, y)| (round_to(x, decimals), round_to(y, decimals))) + .collect(), + ), + v => v.clone(), + } + } + + /// Whether this value equals a registry default literal. Integers and + /// reals compare numerically; a curve compares with a flat integer list. + pub fn equals_lit(&self, lit: &Lit) -> bool { + match (self, lit) { + (Value::Int(a), Lit::Int(b)) => a == b, + (Value::Int(a), Lit::Real(b)) => (*a as f64) == b.get(), + (Value::Real(a), Lit::Real(b)) => a == b, + (Value::Real(a), Lit::Int(b)) => a.get() == *b as f64, + (Value::Bool(a), Lit::Bool(b)) => a == b, + (Value::Str(a) | Value::Alt(a), Lit::Str(b)) => a == b, + (Value::Curve(points), Lit::IntList(flat)) => { + points.len() * 2 == flat.len() + && points + .iter() + .zip(flat.as_chunks::<2>().0) + .all(|(&(x, y), xy)| x.get() == xy[0] as f64 && y.get() == xy[1] as f64) + } + _ => false, + } + } + + /// The number of an `Int` or `Real` value. + pub fn as_finite(&self) -> Option { + match *self { + Value::Int(i) => Some(Finite::from_i64(i)), + Value::Real(r) => Some(r), + _ => None, + } + } + + /// The integer of an `Int` value. + pub fn as_int(&self) -> Option { + match *self { + Value::Int(i) => Some(i), + _ => None, + } + } + + /// The flag of a `Bool` value. + pub fn as_bool(&self) -> Option { + match *self { + Value::Bool(b) => Some(b), + _ => None, + } + } + + /// The text of a `Str` value. + pub fn as_str(&self) -> Option<&str> { + match self { + Value::Str(s) => Some(s), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + #[test] + fn rejects_nan_and_infinities() { + assert!(Finite::new(f64::NAN).is_err()); + assert!(Finite::new(f64::INFINITY).is_err()); + assert!(Finite::new(f64::NEG_INFINITY).is_err()); + assert_eq!(Finite::new(1.5).unwrap().get(), 1.5); + } + + #[test] + fn negative_zero_equals_zero_and_hashes_the_same() { + let neg = Finite::new(-0.0).unwrap(); + assert_eq!(neg, Finite::ZERO); + assert!(neg.get().is_sign_positive()); + let set: HashSet = [neg, Finite::ZERO].into_iter().collect(); + assert_eq!(set.len(), 1); + assert_eq!(Finite::new_const(-0.0), Finite::ZERO); + } + + #[test] + fn orders_totally() { + let mut v = [2.0, -1.0, 0.5].map(Finite::new_const); + v.sort(); + assert_eq!(v.map(Finite::get), [-1.0, 0.5, 2.0]); + } + + #[test] + fn serde_round_trips_and_never_yields_null() { + let f = Finite::new(0.0625).unwrap(); + let json = serde_json::to_string(&f).unwrap(); + assert_eq!(json, "0.0625"); + assert_eq!(serde_json::from_str::(&json).unwrap(), f); + assert!(serde_json::from_str::("null").is_err()); + } + + #[test] + fn hex32_needs_exactly_32_hex_digits() { + assert!(Hex32::parse("0000000000000000000000000000000A").is_some()); + assert!(Hex32::parse("0000000000000000000000000000000a").is_some()); + assert!(Hex32::parse("000000000000000000000000000000A").is_none()); + assert!(Hex32::parse("0000000000000000000000000000000G").is_none()); + } + + fn spec(level: Level, name: &str) -> &'static KeySpec { + crate::registry::lookup(level, name).unwrap().spec() + } + + #[test] + fn quantized_rounds_to_the_writer_precision() { + let exposure = spec(Level::Global, "Exposure2012"); + assert_eq!( + Value::Real(Finite::new_const(0.456)).quantized(exposure), + Value::Real(Finite::new_const(0.46)) + ); + let local = spec(Level::Correction, "LocalExposure2012"); + assert_eq!( + Value::Real(Finite::new_const(0.30000000000000004)).quantized(local), + Value::Real(Finite::new_const(0.3)) + ); + assert_eq!( + Value::Str("x".into()).quantized(local), + Value::Str("x".into()) + ); + } + + #[test] + fn equals_lit_compares_numbers_across_int_and_real() { + assert!(Value::Int(0).equals_lit(&Lit::Real(Finite::ZERO))); + assert!(Value::Real(Finite::new_const(2.0)).equals_lit(&Lit::Int(2))); + assert!(!Value::Bool(true).equals_lit(&Lit::Int(1))); + let curve = Value::Curve(vec![ + (Finite::ZERO, Finite::ZERO), + (Finite::new_const(255.0), Finite::new_const(255.0)), + ]); + assert!(curve.equals_lit(&Lit::IntList(&[0, 0, 255, 255]))); + assert!(!curve.equals_lit(&Lit::IntList(&[0, 0]))); + } + + #[test] + fn take_if_leaves_a_rejected_value_in_place() { + let mut fields = Fields::default(); + let id = crate::registry::lookup(Level::Correction, "CorrectionName").unwrap(); + fields.values.insert(id, Value::Int(3)); + assert_eq!( + fields.take_if(Level::Correction, "CorrectionName", |v| v + .as_str() + .map(str::to_owned)), + None + ); + assert!(fields.values.contains_key(&id)); + assert_eq!( + fields.take(Level::Correction, "CorrectionName"), + Some(Value::Int(3)) + ); + assert!(fields.is_empty()); + } +} diff --git a/server-rs/crates/lrg-develop/src/model/whitebalance.rs b/server-rs/crates/lrg-develop/src/model/whitebalance.rs new file mode 100644 index 00000000..0dec78b2 --- /dev/null +++ b/server-rs/crates/lrg-develop/src/model/whitebalance.rs @@ -0,0 +1,277 @@ +//! Typed view of white balance. +//! +//! Raw and non-raw files use different key families: `Temperature`/`Tint` +//! (Kelvin and tint, raw only) against `IncrementalTemperature`/ +//! `IncrementalTint` (offsets, non-raw only). Which family a file uses is +//! read from the keys it carries ([`WbFamily::of_key`] reads the registry's +//! [`FileScope`]), never from its extension: a DNG converted from a JPEG uses +//! the non-raw family. +//! +//! The learning policy (majority vote over the candidate pool, when Kelvin +//! transfers) lives in `lrg-analysis::style_engine`; this module only states +//! facts. + +use std::fmt; + +use super::value::Finite; +use super::{DevelopSettings, FileKind}; +use crate::registry::{FileScope, KeySpec}; + +/// The named white-balance presets. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum NamedWb { + /// `Daylight`. + Daylight, + /// `Cloudy`. + Cloudy, + /// `Shade`. + Shade, + /// `Tungsten`. + Tungsten, + /// `Fluorescent`. + Fluorescent, + /// `Flash`. + Flash, +} + +/// `WhiteBalance`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum WbMode { + /// `As Shot`: the camera's value. + AsShot, + /// `Auto`: resolved per photo by Lightroom. + Auto, + /// A named preset (raw files; Lightroom offers fewer for non-raw). + Named(NamedWb), + /// `Custom`: explicit temperature and tint. + Custom, +} + +impl WbMode { + /// Parses the `WhiteBalance` value. + pub fn parse(s: &str) -> Option { + Some(match s { + "As Shot" => WbMode::AsShot, + "Auto" => WbMode::Auto, + "Custom" => WbMode::Custom, + "Daylight" => WbMode::Named(NamedWb::Daylight), + "Cloudy" => WbMode::Named(NamedWb::Cloudy), + "Shade" => WbMode::Named(NamedWb::Shade), + "Tungsten" => WbMode::Named(NamedWb::Tungsten), + "Fluorescent" => WbMode::Named(NamedWb::Fluorescent), + "Flash" => WbMode::Named(NamedWb::Flash), + _ => return None, + }) + } + + /// The `WhiteBalance` value. + pub fn as_str(self) -> &'static str { + match self { + WbMode::AsShot => "As Shot", + WbMode::Auto => "Auto", + WbMode::Custom => "Custom", + WbMode::Named(NamedWb::Daylight) => "Daylight", + WbMode::Named(NamedWb::Cloudy) => "Cloudy", + WbMode::Named(NamedWb::Shade) => "Shade", + WbMode::Named(NamedWb::Tungsten) => "Tungsten", + WbMode::Named(NamedWb::Fluorescent) => "Fluorescent", + WbMode::Named(NamedWb::Flash) => "Flash", + } + } +} + +impl fmt::Display for WbMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Which white-balance key family a file uses. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum WbFamily { + /// `Temperature` (Kelvin) / `Tint`. + Raw, + /// `IncrementalTemperature` / `IncrementalTint` (offsets, -100..100). + NonRaw, +} + +impl WbFamily { + /// The family a key belongs to, from the registry's file scope: raw-only + /// keys are the raw family, non-raw-only keys the non-raw family. + pub fn of_key(spec: &KeySpec) -> Option { + match spec.file_kind { + FileScope::RawOnly => Some(WbFamily::Raw), + FileScope::NonRawOnly => Some(WbFamily::NonRaw), + FileScope::Both => None, + } + } + + /// The families present in `settings`, as (raw, non-raw). + pub fn present_in(settings: &DevelopSettings) -> (bool, bool) { + let keys = WbFamily::keys_in(settings); + ( + keys.iter().any(|(_, f)| *f == WbFamily::Raw), + keys.iter().any(|(_, f)| *f == WbFamily::NonRaw), + ) + } + + /// The white-balance family keys `settings` carries, with their family, + /// in registry order. + pub fn keys_in(settings: &DevelopSettings) -> Vec<(&'static str, WbFamily)> { + settings + .values() + .filter_map(|(id, _)| WbFamily::of_key(id.spec()).map(|f| (id.spec().name, f))) + .collect() + } + + /// The temperature key of this family. + pub fn temperature_key(self) -> &'static str { + match self { + WbFamily::Raw => "Temperature", + WbFamily::NonRaw => "IncrementalTemperature", + } + } + + /// The tint key of this family. + pub fn tint_key(self) -> &'static str { + match self { + WbFamily::Raw => "Tint", + WbFamily::NonRaw => "IncrementalTint", + } + } +} + +impl From for WbFamily { + fn from(k: FileKind) -> WbFamily { + match k { + FileKind::Raw => WbFamily::Raw, + FileKind::NonRaw => WbFamily::NonRaw, + } + } +} + +impl From for FileKind { + fn from(f: WbFamily) -> FileKind { + match f { + WbFamily::Raw => FileKind::Raw, + WbFamily::NonRaw => FileKind::NonRaw, + } + } +} + +/// A photo's white balance: mode, family and the family's two numbers. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct WbSetting { + /// `WhiteBalance`. + pub mode: WbMode, + /// Key family. + pub family: WbFamily, + /// `Temperature` (Kelvin) or `IncrementalTemperature` (offset). + pub temperature: Option, + /// `Tint` or `IncrementalTint`. + pub tint: Option, +} + +impl WbSetting { + /// Reads the white balance of `settings`: `None` without a (known) + /// `WhiteBalance` mode, or when neither the keys nor + /// [`DevelopSettings::file_kind`] tell the family. + pub fn from_settings(settings: &DevelopSettings) -> Option { + let mode = WbMode::parse(settings.get_by_name("WhiteBalance")?.as_str()?)?; + let family = match WbFamily::present_in(settings) { + (true, false) => WbFamily::Raw, + (false, true) => WbFamily::NonRaw, + _ => settings.file_kind?.into(), + }; + let number = |name| settings.get_by_name(name).and_then(|v| v.as_finite()); + Some(WbSetting { + mode, + family, + temperature: number(family.temperature_key()), + tint: number(family.tint_key()), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::value::Value; + use crate::registry::{lookup, Level}; + + fn set(s: &mut DevelopSettings, name: &str, v: Value) { + s.insert(lookup(Level::Global, name).unwrap(), v).unwrap(); + } + + #[test] + fn modes_round_trip_through_their_names() { + for name in [ + "As Shot", + "Auto", + "Custom", + "Daylight", + "Cloudy", + "Shade", + "Tungsten", + "Fluorescent", + "Flash", + ] { + assert_eq!(WbMode::parse(name).unwrap().as_str(), name); + } + assert_eq!(WbMode::parse("Candlelight"), None); + assert_eq!(WbMode::parse("auto"), None, "names are case-sensitive"); + } + + #[test] + fn family_follows_the_registry_file_scope() { + let fam = |n| WbFamily::of_key(lookup(Level::Global, n).unwrap().spec()); + assert_eq!(fam("Temperature"), Some(WbFamily::Raw)); + assert_eq!(fam("Tint"), Some(WbFamily::Raw)); + assert_eq!(fam("IncrementalTemperature"), Some(WbFamily::NonRaw)); + assert_eq!(fam("IncrementalTint"), Some(WbFamily::NonRaw)); + assert_eq!(fam("WhiteBalance"), None); + assert_eq!(fam("Exposure2012"), None); + } + + #[test] + fn only_the_four_white_balance_keys_carry_a_family() { + let with_family: Vec<&str> = crate::registry::iter() + .filter(|(_, s)| WbFamily::of_key(s).is_some()) + .map(|(_, s)| s.name) + .collect(); + assert_eq!( + with_family, + [ + "Temperature", + "Tint", + "IncrementalTemperature", + "IncrementalTint" + ] + ); + } + + #[test] + fn reads_the_family_from_the_keys() { + let mut s = DevelopSettings::default(); + set(&mut s, "WhiteBalance", Value::Str("Custom".into())); + set(&mut s, "IncrementalTemperature", Value::Int(12)); + set(&mut s, "IncrementalTint", Value::Int(-3)); + s.file_kind = Some(FileKind::Raw); // the keys win + let wb = s.wb().unwrap(); + assert_eq!(wb.mode, WbMode::Custom); + assert_eq!(wb.family, WbFamily::NonRaw); + assert_eq!(wb.temperature, Some(Finite::new_const(12.0))); + assert_eq!(wb.tint, Some(Finite::new_const(-3.0))); + } + + #[test] + fn falls_back_to_the_file_kind_without_numbers() { + let mut s = DevelopSettings::default(); + set(&mut s, "WhiteBalance", Value::Str("Auto".into())); + assert_eq!(s.wb(), None); + s.file_kind = Some(FileKind::Raw); + let wb = s.wb().unwrap(); + assert_eq!((wb.mode, wb.family), (WbMode::Auto, WbFamily::Raw)); + assert_eq!((wb.temperature, wb.tint), (None, None)); + } +} diff --git a/server-rs/crates/lrg-develop/src/model/xmp_node.rs b/server-rs/crates/lrg-develop/src/model/xmp_node.rs new file mode 100644 index 00000000..87295953 --- /dev/null +++ b/server-rs/crates/lrg-develop/src/model/xmp_node.rs @@ -0,0 +1,187 @@ +//! XMP subtrees the model keeps verbatim ([`Opaque::Xmp`](super::value::Opaque::Xmp)). +//! +//! An [`XmpNode`] is one value in the XMP data model, independent of how the +//! file spelled it: attribute or element form, a nested `rdf:Description`, +//! `rdf:parseType="Resource"` and attributes on the property element all +//! become the same [`XmpValue::Struct`]. What the model does not interpret +//! (unknown keys, an `rdf:Bag`, an `rdf:Alt` with more than the `x-default` +//! language, qualifiers, `Look.Parameters`, `Preset.Parameters`, a property +//! element mixing forms RDF does not allow) is kept as such a node, so +//! nothing is lost except text next to elements, which the reader reports; +//! the syntax form is not kept, since the writer chooses its own. + +use std::sync::Arc; + +/// One XMP value with its `xml:lang` and qualifiers. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct XmpNode { + /// The value. + pub value: XmpValue, + /// `xml:lang` (the language of an `rdf:Alt` item). + pub lang: Option, + /// Qualifiers other than `xml:lang` (the siblings of an `rdf:value`). + pub qualifiers: Vec, +} + +/// The value of an [`XmpNode`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum XmpValue { + /// A simple value, as text (entities decoded). + Text(String), + /// A structure: its fields in document order. + Struct(Vec), + /// An array. + Array { + /// `rdf:Seq`, `rdf:Bag` or `rdf:Alt`. + kind: XmpArrayKind, + /// The `rdf:li` items in document order. + items: Vec, + }, +} + +/// The three RDF containers. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum XmpArrayKind { + /// `rdf:Seq`: ordered. + Seq, + /// `rdf:Bag`: unordered. + Bag, + /// `rdf:Alt`: alternatives (language alternatives when the items carry + /// `xml:lang`). + Alt, +} + +impl XmpArrayKind { + /// `"Seq"`, `"Bag"` or `"Alt"`. + pub fn name(self) -> &'static str { + match self { + XmpArrayKind::Seq => "Seq", + XmpArrayKind::Bag => "Bag", + XmpArrayKind::Alt => "Alt", + } + } +} + +/// A named field of a structure (or a qualifier): namespace URI, local name +/// and value. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct XmpField { + /// Namespace URI (`""` for an attribute without a prefix). Shared: the + /// reader keeps each URI once per file, so a long URI costs its length + /// once, not once per field. + pub ns: Arc, + /// Local name. + pub name: String, + /// The value. + pub node: XmpNode, +} + +impl XmpNode { + /// A plain text node. + pub fn text(s: impl Into) -> XmpNode { + XmpNode { + value: XmpValue::Text(s.into()), + lang: None, + qualifiers: Vec::new(), + } + } + + /// True when the node has neither a language nor qualifiers. + pub fn is_plain(&self) -> bool { + self.lang.is_none() && self.qualifiers.is_empty() + } + + /// The text of a plain text node. + pub fn as_text(&self) -> Option<&str> { + match &self.value { + XmpValue::Text(s) if self.is_plain() => Some(s), + _ => None, + } + } + + /// The `x-default` item of an `rdf:Alt` (also when it has further + /// languages). + pub fn alt_default(&self) -> Option<&str> { + let XmpValue::Array { + kind: XmpArrayKind::Alt, + items, + } = &self.value + else { + return None; + }; + items.iter().find_map(|item| match &item.value { + XmpValue::Text(s) if item.lang.as_deref() == Some("x-default") => Some(s.as_str()), + _ => None, + }) + } + + /// A short description for a warning: the text (cut to 80 characters), + /// or the shape of a structure or array. + pub fn summary(&self) -> String { + const MAX: usize = 80; + match &self.value { + XmpValue::Text(s) if s.chars().count() <= MAX => s.clone(), + XmpValue::Text(s) => format!("{}…", s.chars().take(MAX).collect::()), + XmpValue::Struct(fields) => format!("a structure with {} field(s)", fields.len()), + XmpValue::Array { kind, items } => { + format!("an rdf:{} with {} item(s)", kind.name(), items.len()) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn alt(items: &[(&str, &str)]) -> XmpNode { + XmpNode { + value: XmpValue::Array { + kind: XmpArrayKind::Alt, + items: items + .iter() + .map(|(lang, s)| XmpNode { + lang: Some((*lang).to_owned()), + ..XmpNode::text(*s) + }) + .collect(), + }, + lang: None, + qualifiers: Vec::new(), + } + } + + #[test] + fn alt_default_finds_the_default_among_languages() { + assert_eq!( + alt(&[("de-DE", "Himmel"), ("x-default", "Sky")]).alt_default(), + Some("Sky") + ); + assert_eq!(alt(&[("de-DE", "Himmel")]).alt_default(), None); + assert_eq!(XmpNode::text("x").alt_default(), None); + } + + #[test] + fn as_text_refuses_qualified_nodes() { + assert_eq!(XmpNode::text("5").as_text(), Some("5")); + let qualified = XmpNode { + qualifiers: vec![XmpField { + ns: "urn:q".into(), + name: "q".into(), + node: XmpNode::text("1"), + }], + ..XmpNode::text("5") + }; + assert_eq!(qualified.as_text(), None); + } + + #[test] + fn summary_is_short() { + assert_eq!(XmpNode::text("+0.50").summary(), "+0.50"); + assert_eq!(XmpNode::text("x".repeat(200)).summary().chars().count(), 81); + assert_eq!( + alt(&[("x-default", "")]).summary(), + "an rdf:Alt with 1 item(s)" + ); + } +} diff --git a/server-rs/crates/lrg-develop/src/parse.rs b/server-rs/crates/lrg-develop/src/parse.rs new file mode 100644 index 00000000..fe7467c2 --- /dev/null +++ b/server-rs/crates/lrg-develop/src/parse.rs @@ -0,0 +1,171 @@ +//! Errors and warnings of the readers (the Lua/JSON table form and XMP). +//! +//! A [`ParseError`] means the input as a whole is unusable (not JSON or not +//! well-formed XML, not UTF-8, too large, not a settings table, no +//! `rdf:RDF`). Everything else is a [`ParseWarning`], +//! collected in a list (never a single slot): the value is kept (verbatim if +//! it had to be) and the caller decides how to report it. An unknown key is +//! never an error. + +use std::fmt; + +use crate::model::FileKind; +use crate::registry::ProcessVersion; + +/// The input cannot be read at all. +#[derive(Debug, thiserror::Error)] +pub enum ParseError { + /// Not valid JSON. + #[error("develop settings are not valid JSON: {0}")] + Json(#[from] serde_json::Error), + /// Larger than the reader accepts. + #[error("the input is {size} bytes, more than the {max}-byte limit")] + TooLarge { + /// Input size. + size: usize, + /// The limit. + max: usize, + }, + /// The top level is not a settings table (an object, or `[]` for the + /// empty table). + #[error("develop settings must be a JSON object (or [] for none), not {found}")] + NotATable { + /// What was found instead (`"a string"`, `"a non-empty array"`, ...). + found: &'static str, + }, + /// An XMP file that is not UTF-8 (after an optional byte-order mark). + #[error("the XMP file is not UTF-8: {0}")] + Encoding(#[from] std::str::Utf8Error), + /// An XMP file that is not well-formed XML. + #[error("the XMP file is not well-formed XML: {0}")] + Xml(#[from] roxmltree::Error), + /// An XMP file without an `rdf:RDF` element. + #[error("the XMP file has no rdf:RDF element")] + NoRdf, + /// An XMP file nested deeper than any develop setting goes. + #[error("the XMP file nests elements more than {max} levels deep")] + TooDeep { + /// The limit. + max: usize, + }, + /// An XMP file beyond a structural limit no real file comes near (too + /// many attributes on one element, namespace declarations, or too long + /// a namespace URI); refused before XML parsing. + #[error("the XMP file has more than {max} {what}")] + Limit { + /// What is counted (`"attributes on one element"`, ...). + what: &'static str, + /// The limit. + max: usize, + }, +} + +/// What is wrong with one value. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum WarningKind { + /// The registry does not know the key; the value is kept verbatim. + #[error("unknown key; kept verbatim, never written")] + UnknownKey, + /// A registry key with a value of the wrong type (`"abc"` for + /// `Exposure2012`, `null` inside a curve); kept verbatim, never written. + #[error("expected {expected}; kept verbatim, never written")] + WrongType { + /// The registry's type for the key. + expected: String, + }, + /// A non-integer on an integer key; the model holds it rounded + /// half-to-even. + #[error("not an integer; rounded to {rounded}")] + NonIntegerForIntKey { + /// The rounded value now in the model. + rounded: i64, + }, + /// A value outside the key's closed set; kept as it is. + #[error("not one of the key's known values; kept")] + ValueNotInSet, + /// An id or digest that is not 32 hex digits; kept as text. + #[error("not 32 hex digits; kept as text")] + NotHex32, + /// `ProcessVersion` is not of the form `"15.4"`. + #[error("not a process version")] + InvalidProcessVersion, + /// Settings older than PV2012 (`"6.7"`): readable, but not learned. + #[error("process version {found} is older than PV2012 (6.7); these settings are not learned")] + UnsupportedProcessVersion { + /// The version found. + found: ProcessVersion, + }, + /// The white-balance keys name a different file kind than the caller; + /// the keys win. + #[error("the white-balance keys belong to a {family:?} file, but the caller said {hint:?}; the keys win")] + FileKindMismatch { + /// What the caller said. + hint: FileKind, + /// What the keys say. + family: FileKind, + }, + /// Both raw and non-raw white-balance keys are present. + #[error("both raw (Temperature/Tint) and non-raw (IncrementalTemperature/IncrementalTint) keys are present")] + ConflictingFileKind, + /// `MaskBlendMode`/`MaskValue`/`MaskInverted` are missing or form none of + /// add, subtract, intersect; kept as they are. + #[error("MaskBlendMode/MaskValue/MaskInverted form no known combination; kept as they are")] + UnrecognisedMaskCombine, + /// The key appears more than once at one level (twice in one + /// `rdf:Description`, or in two merged descriptions); the first + /// occurrence is used (typed, or kept verbatim if it did not type), the + /// later ones are kept verbatim. + #[error( + "the key appears more than once; the first value is used, the later one kept verbatim" + )] + DuplicateKey, + /// XMP text that RDF does not allow where it stands (text next to + /// elements or a container); the text is dropped, the rest is read. + #[error("text next to elements is not valid RDF; the text is dropped")] + MalformedRdf, +} + +impl WarningKind { + /// Stable name for counting and tests (`"UnknownKey"`, ...). + pub fn name(&self) -> &'static str { + match self { + WarningKind::UnknownKey => "UnknownKey", + WarningKind::WrongType { .. } => "WrongType", + WarningKind::NonIntegerForIntKey { .. } => "NonIntegerForIntKey", + WarningKind::ValueNotInSet => "ValueNotInSet", + WarningKind::NotHex32 => "NotHex32", + WarningKind::InvalidProcessVersion => "InvalidProcessVersion", + WarningKind::UnsupportedProcessVersion { .. } => "UnsupportedProcessVersion", + WarningKind::FileKindMismatch { .. } => "FileKindMismatch", + WarningKind::ConflictingFileKind => "ConflictingFileKind", + WarningKind::UnrecognisedMaskCombine => "UnrecognisedMaskCombine", + WarningKind::DuplicateKey => "DuplicateKey", + WarningKind::MalformedRdf => "MalformedRdf", + } + } +} + +/// One problem with one value; the reading went on. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ParseWarning { + /// Where, e.g. `"MaskGroupBasedCorrections[2].CorrectionMasks[0].MaskSubType"`. + pub path: String, + /// The key name (last path element). + pub key: String, + /// The raw value, shortened, when it helps. + pub raw: Option, + /// What is wrong. + pub kind: WarningKind, +} + +impl fmt::Display for ParseWarning { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}: {}", self.path, self.kind)?; + if let Some(raw) = &self.raw { + write!(f, " (value: {raw})")?; + } + Ok(()) + } +} + +impl std::error::Error for ParseWarning {} diff --git a/server-rs/crates/lrg-develop/src/reader.rs b/server-rs/crates/lrg-develop/src/reader.rs new file mode 100644 index 00000000..fc1bd08a --- /dev/null +++ b/server-rs/crates/lrg-develop/src/reader.rs @@ -0,0 +1,270 @@ +//! Rules the Lua and XMP readers share, so both land in the same model. +//! +//! Each reader walks its own syntax (a decoded JSON.lua table, an XMP tree) +//! and resolves keys through the registry. From there on the decisions are +//! made here, once: numbers and flags go through [`KeySpec::coerce`] (with +//! the same warnings), closed sets and ids are checked the same way, curve +//! points parse the same way, and [`finish`] turns the global fields into +//! [`DevelopSettings`]: typed corrections and `Look`, the file kind from the +//! white-balance family, the process-version check. Correction and mask +//! classification lives in [`crate::model::correction`] and is shared the +//! same way. + +use crate::model::value::{Fields, Finite, Hex32, Opaque, OpaqueEntry, Value}; +use crate::model::{DevelopSettings, FileKind, FileKindHint, Look, WbFamily, MIN_SUPPORTED_PV}; +use crate::parse::{ParseWarning, WarningKind}; +use crate::registry::{CoerceError, KeySpec, Level, ProcessVersion, Scalar, ValueKind}; + +/// Appends one warning. +pub(crate) fn warn( + warnings: &mut Vec, + path: &str, + key: &str, + raw: Option, + kind: WarningKind, +) { + warnings.push(ParseWarning { + path: path.to_owned(), + key: key.to_owned(), + raw, + kind, + }); +} + +/// `path.key`, or `key` at the top. +pub(crate) fn child(path: &str, key: &str) -> String { + if path.is_empty() { + key.to_owned() + } else { + format!("{path}.{key}") + } +} + +/// A scalar as the input spelled it, before the registry types it. +#[derive(Clone, Copy, Debug)] +pub(crate) enum ScalarIn { + /// `true`/`false` (JSON), `True`/`true`/`False`/`false` (XMP). + Bool(bool), + /// A finite number. + Num(Finite), +} + +/// Types a scalar for `spec` (a numeric or boolean kind) through +/// [`KeySpec::coerce`]: a rounded integer is kept with a +/// [`WarningKind::NonIntegerForIntKey`], an integer outside its enum set is +/// kept with a [`WarningKind::ValueNotInSet`]; `None` when the value cannot +/// take the key's kind at all (the caller keeps it verbatim). +pub(crate) fn scalar( + spec: &KeySpec, + input: ScalarIn, + path: &str, + key: &str, + raw: impl Fn() -> String, + warnings: &mut Vec, +) -> Option { + let coerced = match input { + ScalarIn::Bool(b) => spec.coerce_bool(b), + ScalarIn::Num(x) => spec.coerce(x), + }; + match coerced { + Ok(c) => { + if let (Scalar::Int(i), true) = (c.value, c.rounded) { + warn( + warnings, + path, + key, + Some(raw()), + WarningKind::NonIntegerForIntKey { rounded: i }, + ); + } + Some(Value::from(c.value)) + } + // Observed truth outside the known set: keep it, but say so. + Err(CoerceError::NotInEnum { value, .. }) => { + warn(warnings, path, key, Some(raw()), WarningKind::ValueNotInSet); + Some(Value::Int(value)) + } + Err(_) => None, + } +} + +/// Types a text value for a text kind (`Enum`, `Str`, `VersionStr`, +/// `Hex32`): a label outside the enum set or an id that is not 32 hex digits +/// is kept as text with a warning. `None` for any other kind. +pub(crate) fn text( + kind: ValueKind, + s: &str, + path: &str, + key: &str, + raw: impl Fn() -> String, + warnings: &mut Vec, +) -> Option { + match kind { + ValueKind::Enum(set) => { + if !set.contains(&s) { + warn(warnings, path, key, Some(raw()), WarningKind::ValueNotInSet); + } + } + ValueKind::Hex32 => { + if Hex32::parse(s).is_none() { + warn(warnings, path, key, Some(raw()), WarningKind::NotHex32); + } + } + ValueKind::Str | ValueKind::VersionStr => {} + _ => return None, + } + Some(Value::Str(s.to_owned())) +} + +/// One curve point, `"x, y"` (global curves in XMP) or `"x,y"` (local +/// curves, and every Lua curve string). +pub(crate) fn parse_point(s: &str) -> Option<(Finite, Finite)> { + let (x, y) = s.split_once(',')?; + let num = |t: &str| { + t.trim() + .parse::() + .ok() + .and_then(|v| Finite::new(v).ok()) + }; + Some((num(x)?, num(y)?)) +} + +/// Builds the settings from the global fields a reader produced: the typed +/// `MaskGroupBasedCorrections` and `Look`, the file kind (the white-balance +/// family wins over `hint`, with a warning when they disagree) and the +/// process-version check. +pub(crate) fn finish( + mut fields: Fields, + hint: FileKindHint, + warnings: &mut Vec, +) -> DevelopSettings { + let mut settings = DevelopSettings::default(); + let mut kept_opaque = Vec::new(); + match fields.take(Level::Global, "MaskGroupBasedCorrections") { + Some(Value::Corrections(corrections)) => settings.corrections = corrections, + // A form the model keeps whole (an XMP `rdf:Bag`, qualifiers). + Some(Value::Opaque(o)) => kept_opaque.push(opaque_entry("MaskGroupBasedCorrections", o)), + Some(_) => unreachable!("the readers only build corrections for a CorrectionSeq key"), + None => {} + } + match fields.take(Level::Global, "Look") { + Some(Value::Struct(s)) => settings.look = Some(Look::from_struct(s)), + Some(Value::Opaque(o)) => kept_opaque.push(opaque_entry("Look", o)), + Some(_) => unreachable!("the readers only build a struct for a Struct key"), + None => {} + } + for (id, v) in fields.values { + settings + .insert(id, v) + .expect("global keys without the typed ones"); + } + settings.opaque = fields.opaque; + settings.opaque.extend(kept_opaque); + file_kind(&mut settings, hint, warnings); + process_version(&settings, warnings); + settings +} + +fn opaque_entry(name: &str, value: Opaque) -> OpaqueEntry { + OpaqueEntry { + ns: None, + name: name.to_owned(), + value, + } +} + +fn file_kind(settings: &mut DevelopSettings, hint: FileKindHint, warnings: &mut Vec) { + let (raw, non_raw) = WbFamily::present_in(settings); + let family = match (raw, non_raw) { + (true, false) => Some(FileKind::Raw), + (false, true) => Some(FileKind::NonRaw), + (true, true) => { + // Name the keys that clash, one of each family + // ("Temperature/IncrementalTint"). + let first = |family| { + WbFamily::keys_in(settings) + .into_iter() + .find(|(_, f)| *f == family) + .map_or("", |(k, _)| k) + }; + let keys = format!("{}/{}", first(WbFamily::Raw), first(WbFamily::NonRaw)); + warn( + warnings, + &keys, + &keys, + None, + WarningKind::ConflictingFileKind, + ); + None + } + (false, false) => None, + }; + if let (Some(family), Some(hinted)) = (family, hint.kind()) { + if family != hinted { + let key = WbFamily::from(family).temperature_key(); + warn( + warnings, + key, + key, + None, + WarningKind::FileKindMismatch { + hint: hinted, + family, + }, + ); + } + } + settings.file_kind = family.or(hint.kind()); +} + +fn process_version(settings: &DevelopSettings, warnings: &mut Vec) { + const KEY: &str = "ProcessVersion"; + let Some(Value::Str(text)) = settings.get_by_name(KEY) else { + return; + }; + match ProcessVersion::parse(text) { + None => warn( + warnings, + KEY, + KEY, + Some(text.clone()), + WarningKind::InvalidProcessVersion, + ), + Some(pv) if pv < MIN_SUPPORTED_PV => warn( + warnings, + KEY, + KEY, + Some(text.clone()), + WarningKind::UnsupportedProcessVersion { found: pv }, + ), + Some(_) => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn curve_points_parse_with_and_without_a_space() { + let p = |x: f64, y: f64| Some((Finite::new_const(x), Finite::new_const(y))); + assert_eq!(parse_point("0, 0"), p(0.0, 0.0)); + assert_eq!(parse_point("64,58"), p(64.0, 58.0)); + assert_eq!(parse_point("0.25,0.5"), p(0.25, 0.5)); + assert_eq!(parse_point("1"), None); + assert_eq!(parse_point("a, b"), None); + } + + #[test] + fn text_checks_sets_and_ids_but_keeps_the_value() { + let mut w = Vec::new(); + let raw = || "x".to_owned(); + let v = text(ValueKind::Enum(&["A"]), "B", "k", "k", raw, &mut w); + assert_eq!(v, Some(Value::Str("B".into()))); + let v = text(ValueKind::Hex32, "12", "k", "k", raw, &mut w); + assert_eq!(v, Some(Value::Str("12".into()))); + let kinds: Vec<_> = w.iter().map(|w| w.kind.name()).collect(); + assert_eq!(kinds, ["ValueNotInSet", "NotHex32"]); + assert_eq!(text(ValueKind::Int, "1", "k", "k", raw, &mut w), None); + } +} diff --git a/server-rs/crates/lrg-develop/src/registry/coerce.rs b/server-rs/crates/lrg-develop/src/registry/coerce.rs new file mode 100644 index 00000000..9eaaf26d --- /dev/null +++ b/server-rs/crates/lrg-develop/src/registry/coerce.rs @@ -0,0 +1,258 @@ +//! The one place where numbers become the registry's value kinds. +//! +//! Builders, [`from_ui`](super::from_ui) and blend results round integer keys +//! half-to-even here, and the readers report a rounding as a warning (the +//! value stays in the model, rounded). Nothing else may cast a float to an +//! integer key. + +use super::{Finite, KeySpec, ValueKind}; + +/// A number (or 0/1 flag) converted to the kind its key expects. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Scalar { + /// For `Int`, `IntFlag`, `EnumInt` and `VersionU32` keys. + Int(i64), + /// For `Real` keys. + Real(Finite), + /// For `Bool` keys. + Bool(bool), +} + +/// Result of [`KeySpec::coerce`]. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct Coerced { + /// The converted value. + pub value: Scalar, + /// True when a non-integer was rounded to fit an integer key. A reader + /// turns this into a warning; the rounded value is kept. + pub rounded: bool, +} + +/// A value that cannot take the key's kind at all. +#[derive(Clone, Debug, PartialEq, thiserror::Error)] +pub enum CoerceError { + /// The key does not hold a number or a flag. + #[error("{key}: expects {kind:?}, not a number")] + NotNumeric { + /// Key name. + key: &'static str, + /// The key's kind. + kind: ValueKind, + }, + /// Rounded value outside `i64`. + #[error("{key}: {value} does not fit an integer")] + OutOfIntRange { + /// Key name. + key: &'static str, + /// The value given. + value: Finite, + }, + /// A 0/1 flag got another value. + #[error("{key}: {value} is not 0 or 1")] + NotAFlag { + /// Key name. + key: &'static str, + /// The value given. + value: Finite, + }, + /// An enum integer outside its set. + #[error("{key}: {value} is not one of {allowed:?}")] + NotInEnum { + /// Key name. + key: &'static str, + /// The value given. + value: i64, + /// The allowed values. + allowed: &'static [i64], + }, +} + +/// Rounds half to even (`0.5 → 0`, `1.5 → 2`, `-2.5 → -2`); `None` outside +/// the `i64` range. +pub fn round_half_even(x: Finite) -> Option { + let r = x.get().round_ties_even(); + // i64::MIN is exactly representable; i64::MAX is not (it rounds up to 2^63). + if r >= -(2f64.powi(63)) && r < 2f64.powi(63) { + Some(r as i64) + } else { + None + } +} + +impl KeySpec { + /// Converts a number to this key's kind. + /// + /// Integer kinds round half-to-even and flag it in [`Coerced::rounded`]; + /// `IntFlag` then must be 0/1 and `EnumInt` one of its values. `Bool` + /// keys take exactly 0 and 1 (the 0/1 some Lua tables use). Real keys + /// take any finite number, integers included. Ranges are not checked + /// here; see [`from_ui`](super::from_ui). + pub fn coerce(&self, x: Finite) -> Result { + let key = self.name; + match self.kind { + ValueKind::Real => Ok(Coerced { + value: Scalar::Real(x), + rounded: false, + }), + ValueKind::Int | ValueKind::VersionU32 | ValueKind::IntFlag | ValueKind::EnumInt(_) => { + let i = round_half_even(x).ok_or(CoerceError::OutOfIntRange { key, value: x })?; + let rounded = x.get() != i as f64; + match self.kind { + ValueKind::IntFlag if !(0..=1).contains(&i) => { + return Err(CoerceError::NotAFlag { key, value: x }) + } + ValueKind::EnumInt(allowed) if !allowed.contains(&i) => { + return Err(CoerceError::NotInEnum { + key, + value: i, + allowed, + }) + } + _ => {} + } + Ok(Coerced { + value: Scalar::Int(i), + rounded, + }) + } + ValueKind::Bool(_) => { + let b = if x == Finite::ZERO { + false + } else if x == Finite::new_const(1.0) { + true + } else { + return Err(CoerceError::NotAFlag { key, value: x }); + }; + Ok(Coerced { + value: Scalar::Bool(b), + rounded: false, + }) + } + kind => Err(CoerceError::NotNumeric { key, kind }), + } + } + + /// Converts a boolean to this key's kind: a `Bool` key keeps it, an + /// `IntFlag` key gets 0/1. + pub fn coerce_bool(&self, b: bool) -> Result { + let value = match self.kind { + ValueKind::Bool(_) => Scalar::Bool(b), + ValueKind::IntFlag => Scalar::Int(i64::from(b)), + kind => { + return Err(CoerceError::NotNumeric { + key: self.name, + kind, + }) + } + }; + Ok(Coerced { + value, + rounded: false, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::registry::{lookup, Level}; + + fn spec(name: &str) -> &'static KeySpec { + lookup(Level::Global, name) + .unwrap_or_else(|| panic!("{name} missing")) + .spec() + } + + fn f(x: f64) -> Finite { + Finite::new(x).unwrap() + } + + #[test] + fn rounds_half_to_even() { + let cases = [ + (0.5, 0), + (1.5, 2), + (2.5, 2), + (-0.5, 0), + (-1.5, -2), + (-2.5, -2), + (2.4, 2), + (2.6, 3), + ]; + for (x, want) in cases { + assert_eq!(round_half_even(f(x)), Some(want), "{x}"); + } + assert_eq!(round_half_even(f(1e300)), None); + assert_eq!(round_half_even(f(-9.3e18)), None); + } + + #[test] + fn int_key_rounds_and_flags_the_rounding() { + let contrast = spec("Contrast2012"); + assert_eq!( + contrast.coerce(f(12.5)).unwrap(), + Coerced { + value: Scalar::Int(12), + rounded: true + } + ); + assert_eq!( + contrast.coerce(f(-7.0)).unwrap(), + Coerced { + value: Scalar::Int(-7), + rounded: false + } + ); + } + + #[test] + fn real_key_takes_integers_silently() { + let exposure = spec("Exposure2012"); + assert_eq!( + exposure.coerce(f(1.0)).unwrap(), + Coerced { + value: Scalar::Real(f(1.0)), + rounded: false + } + ); + } + + #[test] + fn flags_take_zero_and_one_only() { + let lens = spec("LensProfileEnable"); + assert_eq!(lens.coerce(f(1.0)).unwrap().value, Scalar::Int(1)); + assert!(matches!( + lens.coerce(f(2.0)), + Err(CoerceError::NotAFlag { .. }) + )); + assert_eq!(lens.coerce_bool(true).unwrap().value, Scalar::Int(1)); + } + + #[test] + fn bool_keys_accept_zero_and_one() { + let gray = spec("ConvertToGrayscale"); + assert_eq!(gray.coerce(f(0.0)).unwrap().value, Scalar::Bool(false)); + assert_eq!(gray.coerce(f(1.0)).unwrap().value, Scalar::Bool(true)); + assert!(gray.coerce(f(0.5)).is_err()); + assert_eq!(gray.coerce_bool(true).unwrap().value, Scalar::Bool(true)); + } + + #[test] + fn enum_ints_stay_in_their_set() { + let style = spec("PostCropVignetteStyle"); + assert_eq!(style.coerce(f(2.0)).unwrap().value, Scalar::Int(2)); + assert!(matches!( + style.coerce(f(7.0)), + Err(CoerceError::NotInEnum { value: 7, .. }) + )); + } + + #[test] + fn non_numeric_keys_refuse_numbers() { + assert!(matches!( + spec("WhiteBalance").coerce(f(1.0)), + Err(CoerceError::NotNumeric { .. }) + )); + assert!(spec("WhiteBalance").coerce_bool(true).is_err()); + } +} diff --git a/server-rs/crates/lrg-develop/src/registry/mod.rs b/server-rs/crates/lrg-develop/src/registry/mod.rs new file mode 100644 index 00000000..f6546707 --- /dev/null +++ b/server-rs/crates/lrg-develop/src/registry/mod.rs @@ -0,0 +1,644 @@ +//! The registry of Lightroom develop keys (`crs:` in XMP, the plain key names +//! in `getDevelopSettings()` tables). +//! +//! Every key the backend knows is one [`KeySpec`] row in the static table +//! ([`table()`]): its level, value kind, range in the **stored** +//! unit, UI scaling, number format, defaults, policy class, frame scope, file +//! kind, where it occurs (Lua, XMP or both), minimum process version and +//! recipe alias. Families that are never modelled one key at a time +//! (`Table_` blobs, `pm_*` patch state, the FilterList payload, the +//! numbered Upright matrices) are [`patterns`](PatternSpec) instead. +//! +//! The table is the single source of truth. It was seeded once from +//! `server-rs/scripts/develop_registry/spec.py` by `gen_table_rs.py`; edit +//! `table.rs` by hand from now on and regenerate the review snapshot with +//! `LRG_BLESS=1 cargo test -p lrg-develop --test registry_snapshot`. +//! +//! Two rules keep conversions in one place each: UI ↔ stored scaling lives +//! only in [`to_ui`]/[`from_ui`], and integer rounding only in +//! [`KeySpec::coerce`]. + +mod coerce; +mod patterns; +mod table; +mod ui; + +use std::collections::HashMap; +use std::fmt; +use std::sync::LazyLock; + +pub use crate::model::value::Finite; +pub use coerce::{round_half_even, CoerceError, Coerced, Scalar}; +pub use patterns::{match_pattern, Matcher, PatternSpec, PATTERNS}; +pub use ui::{from_ui, from_ui_clamped, from_ui_value, to_ui, UiError}; + +/// Index of a row in the registry table. Ordering follows the table, which is +/// the natural registry order (panel order, then levels). +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)] +pub struct KeyId(u16); + +impl KeyId { + /// Position in [`table()`]. + pub fn index(self) -> usize { + usize::from(self.0) + } + + /// The row this id stands for. + pub fn spec(self) -> &'static KeySpec { + &table::TABLE[self.index()] + } +} + +/// Where a key lives in the develop description. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub enum Level { + /// Top level of the develop settings (also the keys of `Look.Parameters` + /// and `Preset.Parameters`, see [`ValueKind::Settings`]). + Global, + /// Preset/profile file header (XMP only). + Header, + /// One `MaskGroupBasedCorrections` item. + Correction, + /// One mask component: a `CorrectionMasks` item, and every nested `Masks` + /// item (brush strokes of a `Mask/Aggregate`, heal shapes of a retouch + /// spot). + MaskTool, + /// A field of a nested structure. + Struct(StructKind), +} + +impl Level { + /// True for every level whose fields are written inside an XMP struct + /// (lower-case `true`/`false`, never a `+` sign). + pub fn is_inside_struct(self) -> bool { + !matches!(self, Level::Global | Level::Header) + } +} + +impl fmt::Display for Level { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Level::Global => f.write_str("global"), + Level::Header => f.write_str("header"), + Level::Correction => f.write_str("correction"), + Level::MaskTool => f.write_str("mask-tool"), + Level::Struct(k) => write!(f, "struct:{}", k.name()), + } + } +} + +/// The nested structures the registry knows fields of. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub enum StructKind { + /// `Look`: the creative profile reference. + Look, + /// `LensBlur`. + LensBlur, + /// `AILook`: computed state of an Adaptive profile. + AiLook, + /// `CorrectionRangeMask` inside a `Mask/RangeMask` component. + CorrectionRangeMask, + /// `CorrectionRangeMask.AreaModels` items (colour range, area form). + AreaModel, + /// `Gesture` items of a "Select Object" mask. + Gesture, + /// `Gesture.Points` items. + GesturePoint, + /// `PointColors` items in the Lua table form. + PointColor, + /// `HueRange`/`SatRange`/`LumRange` inside a `PointColors` item. + PointColorRange, + /// `RetouchAreas` and `RemoveAreas` items. + RetouchArea, + /// `RetouchInfo` items in the Lua table form. + RetouchInfo, + /// `RangeMaskMapInfo` (and its inner wrapper of the same name). + RangeMaskMapInfo, + /// `DepthMapInfo`. + DepthMapInfo, + /// `ISODependent` items of Adobe's ISO-adaptive presets. + IsoDependent, + /// `Preset`: the record of the last applied preset in a sidecar. + Preset, + /// `FilterList`: Denoise/Raw Details/Super Resolution/generative payloads. + /// Its fields are covered by a pattern and kept opaque. + FilterList, + /// An object the registry has no fields for. + Unknown, +} + +impl StructKind { + /// Every kind, in declaration order. + pub const ALL: &'static [StructKind] = &[ + StructKind::Look, + StructKind::LensBlur, + StructKind::AiLook, + StructKind::CorrectionRangeMask, + StructKind::AreaModel, + StructKind::Gesture, + StructKind::GesturePoint, + StructKind::PointColor, + StructKind::PointColorRange, + StructKind::RetouchArea, + StructKind::RetouchInfo, + StructKind::RangeMaskMapInfo, + StructKind::DepthMapInfo, + StructKind::IsoDependent, + StructKind::Preset, + StructKind::FilterList, + StructKind::Unknown, + ]; + + /// Stable name used in [`Level`]'s `Display` and the registry snapshot. + pub fn name(self) -> &'static str { + match self { + StructKind::Look => "Look", + StructKind::LensBlur => "LensBlur", + StructKind::AiLook => "AILook", + StructKind::CorrectionRangeMask => "CorrectionRangeMask", + StructKind::AreaModel => "AreaModel", + StructKind::Gesture => "Gesture", + StructKind::GesturePoint => "GesturePoint", + StructKind::PointColor => "PointColor", + StructKind::PointColorRange => "PointColorRange", + StructKind::RetouchArea => "RetouchArea", + StructKind::RetouchInfo => "RetouchInfo", + StructKind::RangeMaskMapInfo => "RangeMaskMapInfo", + StructKind::DepthMapInfo => "DepthMapInfo", + StructKind::IsoDependent => "ISODependent", + StructKind::Preset => "Preset", + StructKind::FilterList => "FilterList", + StructKind::Unknown => "Unknown", + } + } +} + +/// How a boolean is spelled in XMP (Lua always uses real booleans). +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub enum BoolStyle { + /// `True`/`False`: global develop keys and the preset header. + TitleCase, + /// `true`/`false`: every field inside a struct. + Lower, +} + +/// The two tone-curve shapes. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub enum CurveKind { + /// `ToneCurvePV2012*`: XMP items `"x, y"`, Lua a flat number array. + Global, + /// `MainCurve`/`RedCurve`/...: XMP items `"x,y"`, Lua a list of `"x,y"` + /// strings. + Local, +} + +/// The type of a key's value. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum ValueKind { + /// Integer slider or count. + Int, + /// Real number. + Real, + /// Boolean. + Bool(BoolStyle), + /// A 0/1 integer that means on/off (`LensProfileEnable`, `AutoLateralCA`, + /// `CropConstrainToWarp`, `HDREditMode`). + IntFlag, + /// A string from a closed set. + Enum(&'static [&'static str]), + /// An integer from a closed set. + EnumInt(&'static [i64]), + /// Free text. + Str, + /// 32 upper-case hex characters (ids and digests). + Hex32, + /// Packed engine version `major<<24 | minor<<16 | ...`. + VersionU32, + /// Version string such as `"15.4"` or `"18.5.1"`. + VersionStr, + /// Tone curve. + Curve(CurveKind), + /// Sequence of strings. + StrSeq, + /// XMP `rdf:Alt` with an `x-default` item (Lua: `{ ["x-default"] = ... }`). + LangAlt, + /// One nested structure. + Struct(StructKind), + /// Sequence of nested structures. + StructSeq(StructKind), + /// Sequence of corrections ([`Level::Correction`] items). + CorrectionSeq, + /// Sequence of mask components ([`Level::MaskTool`] items). + ComponentSeq, + /// A nested develop-settings table whose keys are [`Level::Global`] keys + /// (`Look.Parameters`, `Preset.Parameters`). Kept opaque by the readers. + Settings, + /// The Lua table and the XMP file use different shapes for this key. + PerFormat { + /// Shape in a `getDevelopSettings()` table. + lua: &'static ValueKind, + /// Shape in XMP. + xmp: &'static ValueKind, + }, + /// Type not established (key known by name only); kept as it comes. + Any, +} + +impl ValueKind { + /// The shape in a `getDevelopSettings()` table. + pub fn lua_form(self) -> ValueKind { + match self { + ValueKind::PerFormat { lua, .. } => *lua, + k => k, + } + } + + /// The shape in an XMP file. + pub fn xmp_form(self) -> ValueKind { + match self { + ValueKind::PerFormat { xmp, .. } => *xmp, + k => k, + } + } + + /// True for the kinds whose value is a single number. + pub fn is_numeric(self) -> bool { + matches!( + self, + ValueKind::Int + | ValueKind::Real + | ValueKind::IntFlag + | ValueKind::EnumInt(_) + | ValueKind::VersionU32 + ) + } + + /// True for the numeric kinds that only hold integers. + pub fn is_integer(self) -> bool { + self.is_numeric() && self != ValueKind::Real + } +} + +/// Stored ↔ UI scaling (see [`to_ui`]/[`from_ui`]). +#[derive(Clone, Copy, PartialEq, Debug)] +pub enum UiScale { + /// Stored value = UI value. + Identity, + /// Stored value = UI value / divisor (`LocalExposure2012`: 4, other + /// signed `Local*` and `CorrectionAmount`: 100). + Div(f64), + /// The scale is not verified; such keys cannot be converted, so no + /// builder can write them. + Unknown, +} + +/// XMP number format (rule 9 of the registry's serialization notes). +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub enum NumFmt { + /// Integer, no decimal point. + Int, + /// Fixed number of decimals (`Exposure2012`: 2, `SharpenRadius`: 1). + Fixed(u8), + /// Up to 6 decimals, trailing zeros trimmed. + Trim6, + /// A string of `%.6f` numbers (`ReferencePoint`, `LumRange`, ...). + CompoundFixed6, + /// Not a number in XMP, or not written to XMP at all (Lua-only keys). + Text, +} + +/// A literal default value. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Lit { + /// Integer. + Int(i64), + /// Real. + Real(Finite), + /// Boolean. + Bool(bool), + /// String or enum label. + Str(&'static str), + /// Flat integer list (a global tone curve). + IntList(&'static [i64]), +} + +/// A default for one file kind. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Def { + /// The value Lightroom uses when nothing was changed. + Value(Lit), + /// The key does not exist for this file kind (`Temperature` on a JPEG). + Absent, + /// Not established yet. Non-raw: every key the E13 readback of a non-raw + /// photo (`testdata/develop/defaults/`) did not cover, and the ones the + /// generator holds back as possibly per image (`HDRMaxValue`). + Unverified, + /// There is no fixed default: Lightroom fills the value per photo + /// (as-shot white balance, lens identity) or only writes the key when a + /// feature is used (digests, retouch, version stamps). + NoDefault, +} + +/// Defaults per file kind. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct DefaultSpec { + /// Raw files (and raw DNGs). + pub raw: Def, + /// JPEG/TIFF/PSD and DNGs converted from them. + pub non_raw: Def, +} + +/// Why a learnable key needs extra care (the `†` in the registry). +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub enum Gate { + /// Exists for raw files only. + RawOnly, + /// Exists for non-raw files only. + NonRawOnly, + /// Categorical: weighted majority vote, never a mean. + Categorical, + /// An angle (0..360): circular mean, weighted by the named saturation + /// key of the same level and zone. A hue whose saturation is 0 has no + /// visible effect, so it must not pull the mean. + CircularHue(&'static str), + /// Only to the same camera (or make) as the example. + CameraRestricted, + /// Per mask component: the mask type decides. + ByMaskType, + /// Needs an AI update in Lightroom after applying. + NeedsAiUpdate, + /// Only meaningful while the named key has its enabling value. + DependsOn(&'static str), + /// The default differs between raw and non-raw files: learn per file kind. + FileKindDefault, + /// Only these values transfer; any other value is photo-specific. + OnlyValues(&'static [&'static str]), +} + +/// Policy class (see the registry's policy table). +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub enum Policy { + /// Learn, blend, share in presets, apply. + Learn, + /// [`Policy::Learn`] behind a [`Gate`]. + LearnGated(Gate), + /// Apply to one photo with its own context only; never averaged or shared. + Photo, + /// Owned by Lightroom (digests, caches, runtime ids); never written. + Computed, + /// User-authored per-photo content (retouch, brush strokes); never written. + Never, + /// Set by our serializer from context (version stamps, header, names). + Meta, + /// Not enough evidence; round-trip only. + Unknown, +} + +impl Policy { + /// True for [`Policy::Learn`] and [`Policy::LearnGated`]. + pub fn is_learnable(self) -> bool { + matches!(self, Policy::Learn | Policy::LearnGated(_)) + } + + /// The gate of a [`Policy::LearnGated`] key. + pub fn gate(self) -> Option { + match self { + Policy::LearnGated(g) => Some(g), + _ => None, + } + } + + /// Stable name (`LEARN`, `LEARN†`, `PHOTO`, ...), as in the registry. + pub fn class_name(self) -> &'static str { + match self { + Policy::Learn => "LEARN", + Policy::LearnGated(_) => "LEARN†", + Policy::Photo => "PHOTO", + Policy::Computed => "COMPUTED", + Policy::Never => "NEVER", + Policy::Meta => "META", + Policy::Unknown => "UNKNOWN", + } + } +} + +/// Whether a value can be shared across the frames of a series. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub enum FrameScope { + /// Frame-independent (tone, HSL, colour grading, detail, profile, + /// semantic masks). + Shareable, + /// Shared, but adjusted per frame (exposure, white balance, the + /// guardrail keys). + PerFrameAdjusted, + /// Belongs to one frame (crop, geometry, lens identity, retouch, + /// geometric masks). + PerFrameOnly, + /// Not a transferable setting (computed, meta, unknown). + NotApplicable, +} + +/// File kinds a key exists for. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub enum FileScope { + /// Raw and non-raw. + Both, + /// Raw only (`Temperature`, `Tint`). + RawOnly, + /// Non-raw only (`IncrementalTemperature`, `IncrementalTint`). + NonRawOnly, +} + +/// Where a key occurs. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub enum Presence { + /// `getDevelopSettings()` tables and XMP. + Both, + /// Lua tables only (panel switches, runtime ids). + LuaOnly, + /// XMP only (preset header, `HasCrop`, `HasSettings`, ...). + XmpOnly, + /// A name from Camera Raw's key tables that Lightroom Classic was never + /// observed writing; no value semantics. + Unobserved, +} + +/// A Lightroom process version (`crs:ProcessVersion`, `"15.4"`). +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)] +pub struct ProcessVersion { + major: u16, + minor: u16, +} + +impl ProcessVersion { + /// PV2003, `"5.0"`. + pub const PV2003: ProcessVersion = ProcessVersion::new(5, 0); + /// PV2010, `"5.7"`. + pub const PV2010: ProcessVersion = ProcessVersion::new(5, 7); + /// PV2012, `"6.7"` (the `*2012` keys). + pub const PV2012: ProcessVersion = ProcessVersion::new(6, 7); + /// Version 4, `"10.0"`. + pub const V4: ProcessVersion = ProcessVersion::new(10, 0); + /// Version 5, `"11.0"`. + pub const V5: ProcessVersion = ProcessVersion::new(11, 0); + /// Version 6, `"15.4"`. + pub const V6: ProcessVersion = ProcessVersion::new(15, 4); + + /// Builds a version from its two parts. + pub const fn new(major: u16, minor: u16) -> ProcessVersion { + ProcessVersion { major, minor } + } + + /// Parses `"15.4"`; `None` for anything else. + pub fn parse(s: &str) -> Option { + let (major, minor) = s.trim().split_once('.')?; + let digits = |p: &str| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()); + if !digits(major) || !digits(minor) { + return None; + } + Some(ProcessVersion::new( + major.parse().ok()?, + minor.parse().ok()?, + )) + } + + /// Major part. + pub fn major(self) -> u16 { + self.major + } + + /// Minor part. + pub fn minor(self) -> u16 { + self.minor + } + + /// Adobe's name for the known versions (`"PV2012"`, `"Version 6"`). + pub fn label(self) -> Option<&'static str> { + Some(match self { + ProcessVersion::PV2003 => "PV2003", + ProcessVersion::PV2010 => "PV2010", + ProcessVersion::PV2012 => "PV2012", + ProcessVersion::V4 => "Version 4", + ProcessVersion::V5 => "Version 5", + ProcessVersion::V6 => "Version 6", + _ => return None, + }) + } +} + +impl fmt::Display for ProcessVersion { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}.{}", self.major, self.minor) + } +} + +/// One registry row. +#[derive(Clone, Copy, Debug)] +pub struct KeySpec { + /// Key name without the `crs:` prefix (`"LocalExposure2012"`). + pub name: &'static str, + /// Where the key lives. + pub level: Level, + /// Value type. + pub kind: ValueKind, + /// Allowed range in the **stored** unit (`LocalExposure2012`: -1..1), + /// for numeric keys whose range is established. `None` also for values + /// that may legitimately leave any range (mask coordinates). + pub range: Option<(Finite, Finite)>, + /// Stored ↔ UI scaling. + pub ui: UiScale, + /// XMP number format. + pub fmt: NumFmt, + /// Positive values get a `+` in XMP (global level only). + pub plus_sign: bool, + /// Defaults per file kind. + pub default: DefaultSpec, + /// Policy class. + pub policy: Policy, + /// Frame scope. + pub frame: FrameScope, + /// File kinds the key exists for. + pub file_kind: FileScope, + /// Lua, XMP or both. + pub presence: Presence, + /// Lowest process version the key applies to. + pub min_pv: Option, + /// Path of the matching field in the edit recipe (`"global.exposure"`). + pub recipe_alias: Option<&'static str>, + /// Maintainer's note (why a policy, what is unverified). + pub note: &'static str, +} + +impl KeySpec { + /// Whether this is a mask enum: every closed integer set below the + /// global level (`MaskSubType`, `MaskBlendMode`, `ErrorReason`, + /// `CorrectionRangeMask.Type`/`SampleType`, ...), and + /// `MaskSubCategoryID` (an open integer set). The Lua writer spells them + /// as numbers or numeric strings (`lua::LuaOptions::mask_enum_as`), and + /// the Lua reader accepts both. + pub fn is_mask_enum(&self) -> bool { + (self.level != Level::Global && matches!(self.kind, ValueKind::EnumInt(_))) + || (self.level == Level::MaskTool && self.name == "MaskSubCategoryID") + } +} + +/// How a `(level, name)` pair resolves. +#[derive(Clone, Copy, Debug)] +pub enum Resolved { + /// A registry row. + Key(KeyId), + /// A key of a pattern family. + Pattern(&'static PatternSpec), + /// Not in the registry. + Unknown, +} + +static INDEX: LazyLock> = LazyLock::new(|| { + let mut map = HashMap::with_capacity(table::TABLE.len()); + for (i, spec) in table::TABLE.iter().enumerate() { + let id = KeyId(u16::try_from(i).expect("registry table exceeds u16::MAX rows")); + let previous = map.insert((spec.level, spec.name), id); + debug_assert!( + previous.is_none(), + "duplicate registry row {}/{}", + spec.level, + spec.name + ); + } + map +}); + +/// All registry rows, in registry order. +pub fn table() -> &'static [KeySpec] { + table::TABLE +} + +// Every row index fits a `KeyId`, so `iter` may cast. +const _: () = assert!(table::TABLE.len() <= u16::MAX as usize); + +/// All registry rows with their ids. +pub fn iter() -> impl Iterator { + table::TABLE + .iter() + .enumerate() + .map(|(i, spec)| (KeyId(i as u16), spec)) +} + +/// The row for `name` at `level`, if the registry has one. +pub fn lookup(level: Level, name: &str) -> Option { + INDEX.get(&(level, name)).copied() +} + +/// Resolves `name` at `level`: a registry row first, then a pattern family. +pub fn resolve(level: Level, name: &str) -> Resolved { + if let Some(id) = lookup(level, name) { + Resolved::Key(id) + } else if let Some(p) = match_pattern(level, name) { + Resolved::Pattern(p) + } else { + Resolved::Unknown + } +} + +#[cfg(test)] +mod tests; diff --git a/server-rs/crates/lrg-develop/src/registry/patterns.rs b/server-rs/crates/lrg-develop/src/registry/patterns.rs new file mode 100644 index 00000000..86519cd4 --- /dev/null +++ b/server-rs/crates/lrg-develop/src/registry/patterns.rs @@ -0,0 +1,203 @@ +//! Key families the registry covers by name pattern instead of one row per key. +//! +//! Every family here is COMPUTED or NEVER: owned by Lightroom or authored per +//! photo, never learned and never written outside a test round trip. Their +//! values stay opaque ([`ValueKind::Any`]), so the readers keep them verbatim +//! and do not descend into them. + +use super::{FrameScope, Level, Policy, Presence, StructKind, ValueKind}; + +/// How a pattern matches a key name. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Matcher { + /// The name starts with this prefix (and is longer than it). + Prefix(&'static str), + /// The prefix followed by exactly 32 hex characters (`Table_`). + PrefixHex32(&'static str), + /// The prefix followed by a decimal index (`UprightTransform_0`). + PrefixIndex(&'static str), + /// Any name (every field of an opaque structure). + AnyName, +} + +impl Matcher { + /// Whether `name` belongs to this family. + pub fn matches(self, name: &str) -> bool { + match self { + Matcher::Prefix(p) => name.len() > p.len() && name.starts_with(p), + Matcher::PrefixHex32(p) => name.strip_prefix(p).is_some_and(|rest| { + rest.len() == 32 && rest.bytes().all(|b| b.is_ascii_hexdigit()) + }), + Matcher::PrefixIndex(p) => name + .strip_prefix(p) + .is_some_and(|rest| !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit())), + Matcher::AnyName => true, + } + } +} + +/// One key family. +#[derive(Clone, Copy, Debug)] +pub struct PatternSpec { + /// Stable identifier, used in the registry snapshot and in warnings. + pub id: &'static str, + /// Levels the family occurs at; empty means every level. + pub levels: &'static [Level], + /// Name matcher. + pub matcher: Matcher, + /// Value type (always [`ValueKind::Any`] or a plain scalar). + pub kind: ValueKind, + /// Policy class (COMPUTED or NEVER). + pub policy: Policy, + /// Frame scope. + pub frame: FrameScope, + /// Lua, XMP or both. + pub presence: Presence, + /// What the family is. + pub note: &'static str, +} + +impl PatternSpec { + /// Whether `name` at `level` belongs to this family. + pub fn matches(&self, level: Level, name: &str) -> bool { + (self.levels.is_empty() || self.levels.contains(&level)) && self.matcher.matches(name) + } +} + +/// The pattern families, checked in this order after the table. +pub static PATTERNS: &[PatternSpec] = &[ + PatternSpec { + id: "table_blob", + levels: &[], + matcher: Matcher::PrefixHex32("Table_"), + kind: ValueKind::Any, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + presence: Presence::Both, + note: "Base85 + zlib payloads named after their MD5: profile lookup tables, legacy \ + inline mask bitmaps, Denoise/Enhance results. Never written.", + }, + PatternSpec { + id: "brush_table_blob", + levels: &[Level::MaskTool], + matcher: Matcher::PrefixHex32("BrushTable_"), + kind: ValueKind::Any, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + presence: Presence::LuaOnly, + note: "Brush-stroke payload of a Mask/Aggregate in getDevelopSettings(), next to \ + MaskBrushTable. Never written.", + }, + PatternSpec { + id: "patch_state", + levels: &[Level::Struct(StructKind::RetouchArea)], + matcher: Matcher::Prefix("pm_"), + kind: ValueKind::Any, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + presence: Presence::Both, + note: "Content-aware / generative patch state of a retouch or remove spot (pm_patch, \ + pm_patch_variations, pm_remap_info_*, pm_search_*, pm_target_*, ...).", + }, + PatternSpec { + id: "upright_transform", + levels: &[Level::Global], + matcher: Matcher::PrefixIndex("UprightTransform_"), + kind: ValueKind::Str, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + presence: Presence::Both, + note: "Upright solver matrices (9 floats each), recomputed by Lightroom when \ + PerspectiveUpright is set. Never written.", + }, + PatternSpec { + id: "upright_guides", + levels: &[Level::Global], + matcher: Matcher::PrefixIndex("UprightFourSegments_"), + kind: ValueKind::Str, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + presence: Presence::Both, + note: "Guided Upright guide lines drawn by the user (4 floats each).", + }, + PatternSpec { + id: "filter_list_payload", + levels: &[Level::Struct(StructKind::FilterList)], + matcher: Matcher::AnyName, + kind: ValueKind::Any, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + presence: Presence::Both, + note: "Everything below FilterList (Filters, Images, ImageGroup, Alpha, \ + ColorVariations, BlackLevels, Linearization, GenAIInfo, ...): Denoise, Raw \ + Details, Super Resolution and generative-remove payloads. Kept opaque, never \ + written; the generative provenance inside is NEVER as well.", + }, +]; + +/// The first pattern family `name` at `level` belongs to. +pub fn match_pattern(level: Level, name: &str) -> Option<&'static PatternSpec> { + PATTERNS.iter().find(|p| p.matches(level, name)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn table_blob_needs_exactly_32_hex_digits() { + let hex = "0123456789ABCDEF0123456789abcdef"; + assert!(Matcher::PrefixHex32("Table_").matches(&format!("Table_{hex}"))); + assert!(!Matcher::PrefixHex32("Table_").matches("Table_0123")); + assert!(!Matcher::PrefixHex32("Table_").matches(&format!("Table_{hex}0"))); + assert!(!Matcher::PrefixHex32("Table_").matches(&format!("Table_{}G", &hex[..31]))); + } + + #[test] + fn indexed_prefix_needs_digits() { + let m = Matcher::PrefixIndex("UprightTransform_"); + assert!(m.matches("UprightTransform_0")); + assert!(m.matches("UprightTransform_12")); + assert!(!m.matches("UprightTransform_")); + assert!(!m.matches("UprightTransformCount")); + assert!(!m.matches("UprightTransform_x")); + } + + #[test] + fn prefix_needs_a_suffix() { + assert!(Matcher::Prefix("pm_").matches("pm_patch")); + assert!(!Matcher::Prefix("pm_").matches("pm_")); + assert!(!Matcher::Prefix("pm_").matches("xpm_patch")); + } + + #[test] + fn patterns_are_scoped_to_their_levels() { + let retouch = Level::Struct(StructKind::RetouchArea); + assert_eq!( + match_pattern(retouch, "pm_version").map(|p| p.id), + Some("patch_state") + ); + assert!(match_pattern(Level::Global, "pm_version").is_none()); + assert_eq!( + match_pattern(Level::Struct(StructKind::FilterList), "Filters").map(|p| p.id), + Some("filter_list_payload") + ); + assert!(match_pattern(Level::Global, "Filters").is_none()); + let blob = "Table_00000000000000000000000000000001"; + assert_eq!( + match_pattern(Level::MaskTool, blob).map(|p| p.id), + Some("table_blob") + ); + } + + #[test] + fn families_are_never_learned_or_written() { + for p in PATTERNS { + assert!( + matches!(p.policy, Policy::Computed | Policy::Never), + "{} must be COMPUTED or NEVER", + p.id + ); + } + } +} diff --git a/server-rs/crates/lrg-develop/src/registry/table.rs b/server-rs/crates/lrg-develop/src/registry/table.rs new file mode 100644 index 00000000..1b2b8bc4 --- /dev/null +++ b/server-rs/crates/lrg-develop/src/registry/table.rs @@ -0,0 +1,9593 @@ +//! The key registry table: one [`KeySpec`] per develop key. +//! +//! Seeded from `server-rs/scripts/develop_registry/spec.py` by +//! `gen_table_rs.py` in the same folder, and the source of truth since: +//! edit this file by hand, then refresh the review snapshot with +//! `LRG_BLESS=1 cargo test -p lrg-develop --test registry_snapshot`. +//! Re-running the generator overwrites hand edits. +//! +//! Order is registry order (the sections of the registry: global keys by +//! panel, header, correction, mask tool, nested structures); [`KeyId`]s +//! are indices into this slice. +//! +//! [`KeyId`]: super::KeyId + +use super::{ + BoolStyle, CurveKind, Def, DefaultSpec, FileScope, Finite, FrameScope, Gate, KeySpec, Level, + Lit, NumFmt, Policy, Presence, ProcessVersion, StructKind, UiScale, ValueKind, +}; + +/// The 563 registry rows. +pub(crate) static TABLE: &[KeySpec] = &[ + KeySpec { + name: "WhiteBalance", + level: Level::Global, + kind: ValueKind::Enum(&["As Shot", "Auto", "Custom", "Daylight", "Cloudy", "Shade", "Tungsten", "Fluorescent", "Flash"]), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Str("As Shot")), non_raw: Def::Value(Lit::Str("As Shot")) }, + policy: Policy::Photo, + frame: FrameScope::PerFrameAdjusted, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Never average. The preset writer derives `Custom` whenever it writes Temperature/Tint, which a preset carries only when this is `Custom` or absent: next to any other mode they are that photo's resolved values (SkipReason::WhiteBalanceMode). `Auto` is resolved per photo by LrC (see AutoWhiteVersion). Raw and non-raw have different preset lists, so a mixed batch must not share one value. [all PV]", + }, + KeySpec { + name: "Temperature", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(2000.0), Finite::new_const(50000.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::Absent }, + policy: Policy::LearnGated(Gate::RawOnly), + frame: FrameScope::PerFrameAdjusted, + file_kind: FileScope::RawOnly, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Raw-only key. Gate: drop from presets that can hit non-raw targets; learn relative to the example's as-shot value, not as absolute Kelvin (lighting differs per scene). Bug (f), fixed in PR 1d: the style engine read `Temp`; it now reads the white balance typed (`WbSetting`). [all PV; raw only]", + }, + KeySpec { + name: "Tint", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-150.0), Finite::new_const(150.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::Absent }, + policy: Policy::LearnGated(Gate::RawOnly), + frame: FrameScope::PerFrameAdjusted, + file_kind: FileScope::RawOnly, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Raw-only, same gate as Temperature. Bug (f), fixed in PR 1d: non-raw must use IncrementalTint; the style engine blended `Tint` for both. [all PV; raw only]", + }, + KeySpec { + name: "IncrementalTemperature", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Absent, non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::LearnGated(Gate::NonRawOnly), + frame: FrameScope::PerFrameAdjusted, + file_kind: FileScope::NonRawOnly, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Non-raw counterpart of Temperature. Not in this raw-only corpus; seen in 5 profile Look.Parameters and (research) in every non-raw catalog row checked. [all PV; non-raw only]", + }, + KeySpec { + name: "IncrementalTint", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Absent, non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::LearnGated(Gate::NonRawOnly), + frame: FrameScope::PerFrameAdjusted, + file_kind: FileScope::NonRawOnly, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Non-raw counterpart of Tint. [all PV; non-raw only]", + }, + KeySpec { + name: "AutoWhiteVersion", + level: Level::Global, + kind: ValueKind::VersionU32, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Written by LrC when WhiteBalance=Auto was resolved.", + }, + KeySpec { + name: "AsShotTemperature", + level: Level::Global, + kind: ValueKind::Int, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Not a photo setting in XMP. Meaning inside a preset record unknown (research §8 open point). [seen only inside applied-Preset/Parameters (a few sidecars)]", + }, + KeySpec { + name: "AsShotTint", + level: Level::Global, + kind: ValueKind::Int, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Not a photo setting in XMP. Meaning inside a preset record unknown (research §8 open point). [seen only inside applied-Preset/Parameters (a few sidecars)]", + }, + KeySpec { + name: "Exposure2012", + level: Level::Global, + kind: ValueKind::Real, + range: Some((Finite::new_const(-5.0), Finite::new_const(5.0))), + ui: UiScale::Identity, + fmt: NumFmt::Fixed(2), + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Real(Finite::new_const(0.0))), non_raw: Def::Value(Lit::Real(Finite::new_const(0.0))) }, + policy: Policy::Learn, + frame: FrameScope::PerFrameAdjusted, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: Some(ProcessVersion::PV2012), + recipe_alias: Some("global.exposure"), + note: "[PV2012+ (PV ≥ 2012; legacy `Exposure` for PV2003/2010)]", + }, + KeySpec { + name: "Contrast2012", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::PerFrameAdjusted, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: Some(ProcessVersion::PV2012), + recipe_alias: Some("global.contrast"), + note: "[PV2012+]", + }, + KeySpec { + name: "Highlights2012", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: Some(ProcessVersion::PV2012), + recipe_alias: Some("global.highlights"), + note: "[PV2012+]", + }, + KeySpec { + name: "Shadows2012", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::PerFrameAdjusted, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: Some(ProcessVersion::PV2012), + recipe_alias: Some("global.shadows"), + note: "[PV2012+]", + }, + KeySpec { + name: "Whites2012", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::PerFrameAdjusted, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: Some(ProcessVersion::PV2012), + recipe_alias: Some("global.whites"), + note: "[PV2012+]", + }, + KeySpec { + name: "Blacks2012", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: Some(ProcessVersion::PV2012), + recipe_alias: Some("global.blacks"), + note: "[PV2012+]", + }, + KeySpec { + name: "Texture", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.texture"), + note: "[LrC 8.3+]", + }, + KeySpec { + name: "Clarity2012", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::PerFrameAdjusted, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: Some(ProcessVersion::PV2012), + recipe_alias: Some("global.clarity"), + note: "[PV2012+]", + }, + KeySpec { + name: "Dehaze", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::PerFrameAdjusted, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: Some(ProcessVersion::PV2012), + recipe_alias: Some("global.dehaze"), + note: "[PV2012+]", + }, + KeySpec { + name: "Vibrance", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.vibrance"), + note: "[all PV]", + }, + KeySpec { + name: "Saturation", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.saturation"), + note: "[all PV]", + }, + KeySpec { + name: "ToneMapStrength", + level: Level::Global, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "Not in LrC 15.5.1 UI as far as observed; do not write. [known from Camera Raw; never observed in LrC output]", + }, + KeySpec { + name: "Exposure", + level: Level::Global, + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Superseded by the *2012 keys. Read for fidelity, never learned or written; a PV2012+ photo ignores them. [PV2003/PV2010; a few bundled presets and every getDevelopSettings() table (as defaults)]", + }, + KeySpec { + name: "Contrast", + level: Level::Global, + kind: ValueKind::Int, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Superseded by the *2012 keys. Read for fidelity, never learned or written; a PV2012+ photo ignores them. [PV2003/PV2010; a few bundled presets and every getDevelopSettings() table (as defaults)]", + }, + KeySpec { + name: "Brightness", + level: Level::Global, + kind: ValueKind::Int, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Superseded by the *2012 keys. Read for fidelity, never learned or written; a PV2012+ photo ignores them. [PV2003/PV2010; a few bundled presets and every getDevelopSettings() table (as defaults)]", + }, + KeySpec { + name: "Shadows", + level: Level::Global, + kind: ValueKind::Int, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Superseded by the *2012 keys. Read for fidelity, never learned or written; a PV2012+ photo ignores them. [PV2003/PV2010; a few bundled presets and every getDevelopSettings() table (as defaults)]", + }, + KeySpec { + name: "FillLight", + level: Level::Global, + kind: ValueKind::Int, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Superseded by the *2012 keys. Read for fidelity, never learned or written; a PV2012+ photo ignores them. [PV2003/PV2010; a few bundled presets and every getDevelopSettings() table (as defaults)]", + }, + KeySpec { + name: "HighlightRecovery", + level: Level::Global, + kind: ValueKind::Int, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Superseded by the *2012 keys. Read for fidelity, never learned or written; a PV2012+ photo ignores them. [PV2003/PV2010; a few bundled presets and every getDevelopSettings() table (as defaults)]", + }, + KeySpec { + name: "Clarity", + level: Level::Global, + kind: ValueKind::Int, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Superseded by the *2012 keys. Read for fidelity, never learned or written; a PV2012+ photo ignores them. [PV2003/PV2010; a few bundled presets and every getDevelopSettings() table (as defaults)]", + }, + KeySpec { + name: "ToneCurveName2012", + level: Level::Global, + kind: ValueKind::Str, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Str("Linear")), non_raw: Def::Value(Lit::Str("Linear")) }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: Some(ProcessVersion::PV2012), + recipe_alias: None, + note: "Display label, sometimes localised. Serializer derives: `Linear` for the identity curve, else `Custom`. Never learn from it. [PV2012+]", + }, + KeySpec { + name: "ToneCurvePV2012", + level: Level::Global, + kind: ValueKind::Curve(CurveKind::Global), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::IntList(&[0, 0, 255, 255])), non_raw: Def::Value(Lit::IntList(&[0, 0, 255, 255])) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: Some(ProcessVersion::PV2012), + recipe_alias: None, + note: "Learn by resampling to a fixed x grid; write back as a small point set. [PV2012+]", + }, + KeySpec { + name: "ToneCurvePV2012Red", + level: Level::Global, + kind: ValueKind::Curve(CurveKind::Global), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::IntList(&[0, 0, 255, 255])), non_raw: Def::Value(Lit::IntList(&[0, 0, 255, 255])) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: Some(ProcessVersion::PV2012), + recipe_alias: None, + note: "[PV2012+]", + }, + KeySpec { + name: "ToneCurvePV2012Green", + level: Level::Global, + kind: ValueKind::Curve(CurveKind::Global), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::IntList(&[0, 0, 255, 255])), non_raw: Def::Value(Lit::IntList(&[0, 0, 255, 255])) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: Some(ProcessVersion::PV2012), + recipe_alias: None, + note: "[PV2012+]", + }, + KeySpec { + name: "ToneCurvePV2012Blue", + level: Level::Global, + kind: ValueKind::Curve(CurveKind::Global), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::IntList(&[0, 0, 255, 255])), non_raw: Def::Value(Lit::IntList(&[0, 0, 255, 255])) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: Some(ProcessVersion::PV2012), + recipe_alias: None, + note: "[PV2012+]", + }, + KeySpec { + name: "ParametricShadows", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::PerFrameAdjusted, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.tone_curve.shadows"), + note: "", + }, + KeySpec { + name: "ParametricDarks", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::PerFrameAdjusted, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.tone_curve.darks"), + note: "", + }, + KeySpec { + name: "ParametricLights", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::PerFrameAdjusted, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.tone_curve.lights"), + note: "", + }, + KeySpec { + name: "ParametricHighlights", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::PerFrameAdjusted, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.tone_curve.highlights"), + note: "", + }, + KeySpec { + name: "ParametricShadowSplit", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(0.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(25)), non_raw: Def::Value(Lit::Int(25)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.tone_curve.shadow_split"), + note: "Keep the ordering Shadow < Midtone < Highlight when averaging.", + }, + KeySpec { + name: "ParametricMidtoneSplit", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(0.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(50)), non_raw: Def::Value(Lit::Int(50)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.tone_curve.midtone_split"), + note: "", + }, + KeySpec { + name: "ParametricHighlightSplit", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(0.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(75)), non_raw: Def::Value(Lit::Int(75)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.tone_curve.highlight_split"), + note: "", + }, + KeySpec { + name: "CurveRefineSaturation", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(0.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(100)), non_raw: Def::Value(Lit::Int(100)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Low priority. [written by recent LrC only (about a fifth of the sampled sidecars)]", + }, + KeySpec { + name: "ExtendedToneCurvePV2012", + level: Level::Global, + kind: ValueKind::Curve(CurveKind::Global), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Probably HDR-extended curves. Research: no XMP counterpart found. [in a few getDevelopSettings() tables (3 of 1,294 training rows); never observed in XMP]", + }, + KeySpec { + name: "ExtendedToneCurvePV2012Red", + level: Level::Global, + kind: ValueKind::Curve(CurveKind::Global), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Probably HDR-extended curves. Research: no XMP counterpart found. [in a few getDevelopSettings() tables (3 of 1,294 training rows); never observed in XMP]", + }, + KeySpec { + name: "ExtendedToneCurvePV2012Green", + level: Level::Global, + kind: ValueKind::Curve(CurveKind::Global), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Probably HDR-extended curves. Research: no XMP counterpart found. [in a few getDevelopSettings() tables (3 of 1,294 training rows); never observed in XMP]", + }, + KeySpec { + name: "ExtendedToneCurvePV2012Blue", + level: Level::Global, + kind: ValueKind::Curve(CurveKind::Global), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Probably HDR-extended curves. Research: no XMP counterpart found. [in a few getDevelopSettings() tables (3 of 1,294 training rows); never observed in XMP]", + }, + KeySpec { + name: "ExtendedToneCurveName2012", + level: Level::Global, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "Name for the extended curves above. [known from Camera Raw; never observed in LrC output]", + }, + KeySpec { + name: "ToneCurve", + level: Level::Global, + kind: ValueKind::Curve(CurveKind::Global), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Legacy. Never write; the parser keeps them for round-trip only. [legacy PV2003/2010 (11 Classic presets)]", + }, + KeySpec { + name: "ToneCurveRed", + level: Level::Global, + kind: ValueKind::Curve(CurveKind::Global), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Legacy. Never write; the parser keeps them for round-trip only. [legacy PV2003/2010 (11 Classic presets)]", + }, + KeySpec { + name: "ToneCurveGreen", + level: Level::Global, + kind: ValueKind::Curve(CurveKind::Global), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Legacy. Never write; the parser keeps them for round-trip only. [legacy PV2003/2010 (11 Classic presets)]", + }, + KeySpec { + name: "ToneCurveBlue", + level: Level::Global, + kind: ValueKind::Curve(CurveKind::Global), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Legacy. Never write; the parser keeps them for round-trip only. [legacy PV2003/2010 (11 Classic presets)]", + }, + KeySpec { + name: "ToneCurveName", + level: Level::Global, + kind: ValueKind::Str, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Legacy. Never write; the parser keeps them for round-trip only. [legacy PV2003/2010 (11 Classic presets)]", + }, + KeySpec { + name: "HueAdjustmentRed", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.hsl.red.hue"), + note: "8 keys: HueAdjustmentRed, HueAdjustmentOrange, HueAdjustmentYellow, HueAdjustmentGreen, HueAdjustmentAqua, HueAdjustmentBlue, HueAdjustmentPurple, HueAdjustmentMagenta", + }, + KeySpec { + name: "HueAdjustmentOrange", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.hsl.orange.hue"), + note: "8 keys: HueAdjustmentRed, HueAdjustmentOrange, HueAdjustmentYellow, HueAdjustmentGreen, HueAdjustmentAqua, HueAdjustmentBlue, HueAdjustmentPurple, HueAdjustmentMagenta", + }, + KeySpec { + name: "HueAdjustmentYellow", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.hsl.yellow.hue"), + note: "8 keys: HueAdjustmentRed, HueAdjustmentOrange, HueAdjustmentYellow, HueAdjustmentGreen, HueAdjustmentAqua, HueAdjustmentBlue, HueAdjustmentPurple, HueAdjustmentMagenta", + }, + KeySpec { + name: "HueAdjustmentGreen", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.hsl.green.hue"), + note: "8 keys: HueAdjustmentRed, HueAdjustmentOrange, HueAdjustmentYellow, HueAdjustmentGreen, HueAdjustmentAqua, HueAdjustmentBlue, HueAdjustmentPurple, HueAdjustmentMagenta", + }, + KeySpec { + name: "HueAdjustmentAqua", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.hsl.aqua.hue"), + note: "8 keys: HueAdjustmentRed, HueAdjustmentOrange, HueAdjustmentYellow, HueAdjustmentGreen, HueAdjustmentAqua, HueAdjustmentBlue, HueAdjustmentPurple, HueAdjustmentMagenta", + }, + KeySpec { + name: "HueAdjustmentBlue", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.hsl.blue.hue"), + note: "8 keys: HueAdjustmentRed, HueAdjustmentOrange, HueAdjustmentYellow, HueAdjustmentGreen, HueAdjustmentAqua, HueAdjustmentBlue, HueAdjustmentPurple, HueAdjustmentMagenta", + }, + KeySpec { + name: "HueAdjustmentPurple", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.hsl.purple.hue"), + note: "8 keys: HueAdjustmentRed, HueAdjustmentOrange, HueAdjustmentYellow, HueAdjustmentGreen, HueAdjustmentAqua, HueAdjustmentBlue, HueAdjustmentPurple, HueAdjustmentMagenta", + }, + KeySpec { + name: "HueAdjustmentMagenta", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.hsl.magenta.hue"), + note: "8 keys: HueAdjustmentRed, HueAdjustmentOrange, HueAdjustmentYellow, HueAdjustmentGreen, HueAdjustmentAqua, HueAdjustmentBlue, HueAdjustmentPurple, HueAdjustmentMagenta", + }, + KeySpec { + name: "SaturationAdjustmentRed", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.hsl.red.saturation"), + note: "8 keys.", + }, + KeySpec { + name: "SaturationAdjustmentOrange", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.hsl.orange.saturation"), + note: "8 keys.", + }, + KeySpec { + name: "SaturationAdjustmentYellow", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.hsl.yellow.saturation"), + note: "8 keys.", + }, + KeySpec { + name: "SaturationAdjustmentGreen", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.hsl.green.saturation"), + note: "8 keys.", + }, + KeySpec { + name: "SaturationAdjustmentAqua", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.hsl.aqua.saturation"), + note: "8 keys.", + }, + KeySpec { + name: "SaturationAdjustmentBlue", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.hsl.blue.saturation"), + note: "8 keys.", + }, + KeySpec { + name: "SaturationAdjustmentPurple", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.hsl.purple.saturation"), + note: "8 keys.", + }, + KeySpec { + name: "SaturationAdjustmentMagenta", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.hsl.magenta.saturation"), + note: "8 keys.", + }, + KeySpec { + name: "LuminanceAdjustmentRed", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.hsl.red.luminance"), + note: "8 keys.", + }, + KeySpec { + name: "LuminanceAdjustmentOrange", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.hsl.orange.luminance"), + note: "8 keys.", + }, + KeySpec { + name: "LuminanceAdjustmentYellow", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.hsl.yellow.luminance"), + note: "8 keys.", + }, + KeySpec { + name: "LuminanceAdjustmentGreen", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.hsl.green.luminance"), + note: "8 keys.", + }, + KeySpec { + name: "LuminanceAdjustmentAqua", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.hsl.aqua.luminance"), + note: "8 keys.", + }, + KeySpec { + name: "LuminanceAdjustmentBlue", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.hsl.blue.luminance"), + note: "8 keys.", + }, + KeySpec { + name: "LuminanceAdjustmentPurple", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.hsl.purple.luminance"), + note: "8 keys.", + }, + KeySpec { + name: "LuminanceAdjustmentMagenta", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.hsl.magenta.luminance"), + note: "8 keys.", + }, + KeySpec { + name: "ConvertToGrayscale", + level: Level::Global, + kind: ValueKind::Bool(BoolStyle::TitleCase), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Bool(false)), non_raw: Def::Value(Lit::Bool(false)) }, + policy: Policy::LearnGated(Gate::Categorical), + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Categorical: weighted majority, never a mean. Gate: B&W and colour examples must not be averaged into one recipe (today they are, research §2).", + }, + KeySpec { + name: "GrayMixerRed", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::LearnGated(Gate::DependsOn("ConvertToGrayscale")), + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Only meaningful with ConvertToGrayscale=True. 8 keys.", + }, + KeySpec { + name: "GrayMixerOrange", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::LearnGated(Gate::DependsOn("ConvertToGrayscale")), + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Only meaningful with ConvertToGrayscale=True. 8 keys.", + }, + KeySpec { + name: "GrayMixerYellow", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::LearnGated(Gate::DependsOn("ConvertToGrayscale")), + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Only meaningful with ConvertToGrayscale=True. 8 keys.", + }, + KeySpec { + name: "GrayMixerGreen", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::LearnGated(Gate::DependsOn("ConvertToGrayscale")), + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Only meaningful with ConvertToGrayscale=True. 8 keys.", + }, + KeySpec { + name: "GrayMixerAqua", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::LearnGated(Gate::DependsOn("ConvertToGrayscale")), + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Only meaningful with ConvertToGrayscale=True. 8 keys.", + }, + KeySpec { + name: "GrayMixerBlue", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::LearnGated(Gate::DependsOn("ConvertToGrayscale")), + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Only meaningful with ConvertToGrayscale=True. 8 keys.", + }, + KeySpec { + name: "GrayMixerPurple", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::LearnGated(Gate::DependsOn("ConvertToGrayscale")), + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Only meaningful with ConvertToGrayscale=True. 8 keys.", + }, + KeySpec { + name: "GrayMixerMagenta", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::LearnGated(Gate::DependsOn("ConvertToGrayscale")), + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Only meaningful with ConvertToGrayscale=True. 8 keys.", + }, + KeySpec { + name: "AutoGrayscaleMix", + level: Level::Global, + kind: ValueKind::Bool(BoolStyle::TitleCase), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Preset instruction \"compute auto B&W mix on apply\". Behaviour through applyDevelopSettings unverified. [presets only (13)]", + }, + KeySpec { + name: "SplitToningShadowHue", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(0.0), Finite::new_const(360.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::LearnGated(Gate::CircularHue("SplitToningShadowSaturation")), + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.color_grading.shadows.hue"), + note: "Circular mean; weight by saturation. [LrC 10+ reuses the split-toning keys]", + }, + KeySpec { + name: "SplitToningShadowSaturation", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(0.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.color_grading.shadows.saturation"), + note: "", + }, + KeySpec { + name: "SplitToningHighlightHue", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(0.0), Finite::new_const(360.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::LearnGated(Gate::CircularHue("SplitToningHighlightSaturation")), + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.color_grading.highlights.hue"), + note: "Circular mean.", + }, + KeySpec { + name: "SplitToningHighlightSaturation", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(0.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.color_grading.highlights.saturation"), + note: "", + }, + KeySpec { + name: "SplitToningBalance", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.color_grading.balance"), + note: "", + }, + KeySpec { + name: "ColorGradeShadowLum", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Bug (f): plugin warns \"not supported by Lightroom\" and drops it. [LrC 10+]", + }, + KeySpec { + name: "ColorGradeHighlightLum", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Bug (f): same false warning. [LrC 10+]", + }, + KeySpec { + name: "ColorGradeMidtoneHue", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(0.0), Finite::new_const(360.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::LearnGated(Gate::CircularHue("ColorGradeMidtoneSat")), + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Circular mean. [LrC 10+]", + }, + KeySpec { + name: "ColorGradeMidtoneSat", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(0.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "[LrC 10+]", + }, + KeySpec { + name: "ColorGradeMidtoneLum", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "[LrC 10+]", + }, + KeySpec { + name: "ColorGradeGlobalHue", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(0.0), Finite::new_const(360.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::LearnGated(Gate::CircularHue("ColorGradeGlobalSat")), + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Circular mean. [LrC 10+]", + }, + KeySpec { + name: "ColorGradeGlobalSat", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(0.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "[LrC 10+]", + }, + KeySpec { + name: "ColorGradeGlobalLum", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Missing from the LLM schema today. [LrC 10+]", + }, + KeySpec { + name: "ColorGradeBlending", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(0.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(50)), non_raw: Def::Value(Lit::Int(50)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Non-zero default. [LrC 10+]", + }, + KeySpec { + name: "Sharpness", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(0.0), Finite::new_const(150.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(40)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::LearnGated(Gate::FileKindDefault), + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.sharpening"), + note: "Default differs raw/non-raw: learn per file type (or as delta to the type default).", + }, + KeySpec { + name: "SharpenRadius", + level: Level::Global, + kind: ValueKind::Real, + range: Some((Finite::new_const(0.5), Finite::new_const(3.0))), + ui: UiScale::Identity, + fmt: NumFmt::Fixed(1), + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Real(Finite::new_const(1.0))), non_raw: Def::Value(Lit::Real(Finite::new_const(1.0))) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.sharpen_radius"), + note: "", + }, + KeySpec { + name: "SharpenDetail", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(0.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(25)), non_raw: Def::Value(Lit::Int(25)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.sharpen_detail"), + note: "Non-zero default.", + }, + KeySpec { + name: "SharpenEdgeMasking", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(0.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.sharpen_masking"), + note: "", + }, + KeySpec { + name: "LuminanceSmoothing", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(0.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.noise_reduction"), + note: "", + }, + KeySpec { + name: "LuminanceNoiseReductionDetail", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(0.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(50)), non_raw: Def::Value(Lit::Int(50)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.noise_reduction_detail"), + note: "[written only when LuminanceSmoothing was touched]", + }, + KeySpec { + name: "LuminanceNoiseReductionContrast", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(0.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.noise_reduction_contrast"), + note: "[as above]", + }, + KeySpec { + name: "ColorNoiseReduction", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(0.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(25)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::LearnGated(Gate::FileKindDefault), + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.color_noise_reduction"), + note: "Default differs raw/non-raw.", + }, + KeySpec { + name: "ColorNoiseReductionDetail", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(0.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(50)), non_raw: Def::Value(Lit::Int(50)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.color_noise_reduction_detail"), + note: "", + }, + KeySpec { + name: "ColorNoiseReductionSmoothness", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(0.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(50)), non_raw: Def::Value(Lit::Int(50)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.color_noise_reduction_smoothness"), + note: "", + }, + KeySpec { + name: "ISODependent", + level: Level::Global, + kind: ValueKind::StructSeq(StructKind::IsoDependent), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Preset-only table. Whether applyDevelopSettings/plugin presets accept it is unverified. [Adobe ISO-adaptive presets only (8)]", + }, + KeySpec { + name: "LensProfileEnable", + level: Level::Global, + kind: ValueKind::IntFlag, + range: Some((Finite::new_const(0.0), Finite::new_const(1.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Bug (f): plugin writes `EnableLensCorrections` (panel switch) instead.", + }, + KeySpec { + name: "LensProfileSetup", + level: Level::Global, + kind: ValueKind::Enum(&["LensDefaults", "Auto", "Custom"]), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Str("LensDefaults")), non_raw: Def::Value(Lit::Str("LensDefaults")) }, + policy: Policy::LearnGated(Gate::OnlyValues(&["LensDefaults", "Auto"])), + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Gate: only `LensDefaults`/`Auto` are transferable; `Custom` pins a specific profile (PHOTO).", + }, + KeySpec { + name: "LensProfileName", + level: Level::Global, + kind: ValueKind::Str, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Lens identity. LrC fills it itself for LensDefaults/Auto.", + }, + KeySpec { + name: "LensProfileFilename", + level: Level::Global, + kind: ValueKind::Str, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Lens identity.", + }, + KeySpec { + name: "LensProfileDigest", + level: Level::Global, + kind: ValueKind::Hex32, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Lens identity digest.", + }, + KeySpec { + name: "LensProfileIsEmbedded", + level: Level::Global, + kind: ValueKind::Bool(BoolStyle::TitleCase), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "True for in-camera (embedded) profiles.", + }, + KeySpec { + name: "LensProfileDistortionScale", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(0.0), Finite::new_const(200.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(100)), non_raw: Def::Unverified }, + policy: Policy::LearnGated(Gate::DependsOn("LensProfileEnable")), + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Only meaningful with LensProfileEnable=1.", + }, + KeySpec { + name: "LensProfileVignettingScale", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(0.0), Finite::new_const(200.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(100)), non_raw: Def::Unverified }, + policy: Policy::LearnGated(Gate::DependsOn("LensProfileEnable")), + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Only meaningful with LensProfileEnable=1 (0 seen = profile vignetting off).", + }, + KeySpec { + name: "AutoLateralCA", + level: Level::Global, + kind: ValueKind::IntFlag, + range: Some((Finite::new_const(0.0), Finite::new_const(1.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "LensManualDistortionAmount", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Geometry: disables geometric masks (research §4.3).", + }, + KeySpec { + name: "VignetteAmount", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Low priority.", + }, + KeySpec { + name: "VignetteMidpoint", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(0.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(50)), non_raw: Def::Value(Lit::Int(50)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "[written only when VignetteAmount ≠ 0 (2 sidecars in full corpus)]", + }, + KeySpec { + name: "DefringePurpleAmount", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(0.0), Finite::new_const(20.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Low priority, lens-dependent.", + }, + KeySpec { + name: "DefringePurpleHueLo", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(0.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(30)), non_raw: Def::Value(Lit::Int(30)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "DefringePurpleHueHi", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(0.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(70)), non_raw: Def::Value(Lit::Int(70)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "DefringeGreenAmount", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(0.0), Finite::new_const(20.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "DefringeGreenHueLo", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(0.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(40)), non_raw: Def::Value(Lit::Int(40)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "DefringeGreenHueHi", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(0.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(60)), non_raw: Def::Value(Lit::Int(60)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "Defringe", + level: Level::Global, + kind: ValueKind::Int, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "Legacy CA controls. Never write. [legacy; in every getDevelopSettings() table (Lua) and the Zeroed template, never observed in XMP]", + }, + KeySpec { + name: "ChromaticAberrationR", + level: Level::Global, + kind: ValueKind::Int, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "Legacy CA controls. Never write. [legacy; in every getDevelopSettings() table (Lua) and the Zeroed template, never observed in XMP]", + }, + KeySpec { + name: "ChromaticAberrationB", + level: Level::Global, + kind: ValueKind::Int, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "Legacy CA controls. Never write. [legacy; in every getDevelopSettings() table (Lua) and the Zeroed template, never observed in XMP]", + }, + KeySpec { + name: "LensProfileChromaticAberrationScale", + level: Level::Global, + kind: ValueKind::Int, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "Legacy CA control. Never write. [legacy (Zeroed template only; never observed in XMP or getDevelopSettings())]", + }, + KeySpec { + name: "PerspectiveUpright", + level: Level::Global, + kind: ValueKind::EnumInt(&[0, 1, 2, 3, 4, 5]), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Write 1–4 only, never 5 and never the UprightTransform_* matrices; LrC recomputes. Disables geometric masks.", + }, + KeySpec { + name: "PerspectiveVertical", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Geometry.", + }, + KeySpec { + name: "PerspectiveHorizontal", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Geometry.", + }, + KeySpec { + name: "PerspectiveRotate", + level: Level::Global, + kind: ValueKind::Real, + range: Some((Finite::new_const(-10.0), Finite::new_const(10.0))), + ui: UiScale::Identity, + fmt: NumFmt::Fixed(1), + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Real(Finite::new_const(0.0))), non_raw: Def::Value(Lit::Real(Finite::new_const(0.0))) }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Geometry.", + }, + KeySpec { + name: "PerspectiveScale", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(50.0), Finite::new_const(150.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(100)), non_raw: Def::Value(Lit::Int(100)) }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Geometry.", + }, + KeySpec { + name: "PerspectiveAspect", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Geometry.", + }, + KeySpec { + name: "PerspectiveX", + level: Level::Global, + kind: ValueKind::Real, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Fixed(2), + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Real(Finite::new_const(0.0))), non_raw: Def::Value(Lit::Real(Finite::new_const(0.0))) }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Geometry.", + }, + KeySpec { + name: "PerspectiveY", + level: Level::Global, + kind: ValueKind::Real, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Fixed(2), + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Real(Finite::new_const(0.0))), non_raw: Def::Value(Lit::Real(Finite::new_const(0.0))) }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Geometry.", + }, + KeySpec { + name: "UprightVersion", + level: Level::Global, + kind: ValueKind::VersionU32, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "UprightVersion, UprightTransformCount, UprightTransform_0..5 (9-float matrices), UprightCenterMode, UprightCenterNormX/Y, UprightFocalMode, UprightFocalLength35mm, UprightPreview, UprightDependentDigest, UprightGuidedDependentDigest, UprightFourSegmentsCount. [written when PerspectiveUpright ≠ 0]", + }, + KeySpec { + name: "UprightTransformCount", + level: Level::Global, + kind: ValueKind::Int, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "UprightVersion, UprightTransformCount, UprightTransform_0..5 (9-float matrices), UprightCenterMode, UprightCenterNormX/Y, UprightFocalMode, UprightFocalLength35mm, UprightPreview, UprightDependentDigest, UprightGuidedDependentDigest, UprightFourSegmentsCount. [written when PerspectiveUpright ≠ 0]", + }, + KeySpec { + name: "UprightCenterMode", + level: Level::Global, + kind: ValueKind::Int, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "UprightVersion, UprightTransformCount, UprightTransform_0..5 (9-float matrices), UprightCenterMode, UprightCenterNormX/Y, UprightFocalMode, UprightFocalLength35mm, UprightPreview, UprightDependentDigest, UprightGuidedDependentDigest, UprightFourSegmentsCount. [written when PerspectiveUpright ≠ 0]", + }, + KeySpec { + name: "UprightCenterNormX", + level: Level::Global, + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "UprightVersion, UprightTransformCount, UprightTransform_0..5 (9-float matrices), UprightCenterMode, UprightCenterNormX/Y, UprightFocalMode, UprightFocalLength35mm, UprightPreview, UprightDependentDigest, UprightGuidedDependentDigest, UprightFourSegmentsCount. [written when PerspectiveUpright ≠ 0]", + }, + KeySpec { + name: "UprightCenterNormY", + level: Level::Global, + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "UprightVersion, UprightTransformCount, UprightTransform_0..5 (9-float matrices), UprightCenterMode, UprightCenterNormX/Y, UprightFocalMode, UprightFocalLength35mm, UprightPreview, UprightDependentDigest, UprightGuidedDependentDigest, UprightFourSegmentsCount. [written when PerspectiveUpright ≠ 0]", + }, + KeySpec { + name: "UprightFocalMode", + level: Level::Global, + kind: ValueKind::Int, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "UprightVersion, UprightTransformCount, UprightTransform_0..5 (9-float matrices), UprightCenterMode, UprightCenterNormX/Y, UprightFocalMode, UprightFocalLength35mm, UprightPreview, UprightDependentDigest, UprightGuidedDependentDigest, UprightFourSegmentsCount. [written when PerspectiveUpright ≠ 0]", + }, + KeySpec { + name: "UprightFocalLength35mm", + level: Level::Global, + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "UprightVersion, UprightTransformCount, UprightTransform_0..5 (9-float matrices), UprightCenterMode, UprightCenterNormX/Y, UprightFocalMode, UprightFocalLength35mm, UprightPreview, UprightDependentDigest, UprightGuidedDependentDigest, UprightFourSegmentsCount. [written when PerspectiveUpright ≠ 0]", + }, + KeySpec { + name: "UprightPreview", + level: Level::Global, + kind: ValueKind::Bool(BoolStyle::TitleCase), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "UprightVersion, UprightTransformCount, UprightTransform_0..5 (9-float matrices), UprightCenterMode, UprightCenterNormX/Y, UprightFocalMode, UprightFocalLength35mm, UprightPreview, UprightDependentDigest, UprightGuidedDependentDigest, UprightFourSegmentsCount. [written when PerspectiveUpright ≠ 0]", + }, + KeySpec { + name: "UprightDependentDigest", + level: Level::Global, + kind: ValueKind::Hex32, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "UprightVersion, UprightTransformCount, UprightTransform_0..5 (9-float matrices), UprightCenterMode, UprightCenterNormX/Y, UprightFocalMode, UprightFocalLength35mm, UprightPreview, UprightDependentDigest, UprightGuidedDependentDigest, UprightFourSegmentsCount. [written when PerspectiveUpright ≠ 0]", + }, + KeySpec { + name: "UprightGuidedDependentDigest", + level: Level::Global, + kind: ValueKind::Hex32, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "UprightVersion, UprightTransformCount, UprightTransform_0..5 (9-float matrices), UprightCenterMode, UprightCenterNormX/Y, UprightFocalMode, UprightFocalLength35mm, UprightPreview, UprightDependentDigest, UprightGuidedDependentDigest, UprightFourSegmentsCount. [written when PerspectiveUpright ≠ 0]", + }, + KeySpec { + name: "UprightFourSegmentsCount", + level: Level::Global, + kind: ValueKind::Int, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "UprightVersion, UprightTransformCount, UprightTransform_0..5 (9-float matrices), UprightCenterMode, UprightCenterNormX/Y, UprightFocalMode, UprightFocalLength35mm, UprightPreview, UprightDependentDigest, UprightGuidedDependentDigest, UprightFourSegmentsCount. [written when PerspectiveUpright ≠ 0]", + }, + KeySpec { + name: "CropTop", + level: Level::Global, + kind: ValueKind::Real, + range: Some((Finite::new_const(0.0), Finite::new_const(1.0))), + ui: UiScale::Identity, + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Real(Finite::new_const(0.0))), non_raw: Def::Value(Lit::Real(Finite::new_const(0.0))) }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Crop corners are opposite corners of a rectangle rotated by CropAngle (research §4).", + }, + KeySpec { + name: "CropLeft", + level: Level::Global, + kind: ValueKind::Real, + range: Some((Finite::new_const(0.0), Finite::new_const(1.0))), + ui: UiScale::Identity, + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Real(Finite::new_const(0.0))), non_raw: Def::Value(Lit::Real(Finite::new_const(0.0))) }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "CropBottom", + level: Level::Global, + kind: ValueKind::Real, + range: Some((Finite::new_const(0.0), Finite::new_const(1.0))), + ui: UiScale::Identity, + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Real(Finite::new_const(1.0))), non_raw: Def::Value(Lit::Real(Finite::new_const(1.0))) }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "CropRight", + level: Level::Global, + kind: ValueKind::Real, + range: Some((Finite::new_const(0.0), Finite::new_const(1.0))), + ui: UiScale::Identity, + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Real(Finite::new_const(1.0))), non_raw: Def::Value(Lit::Real(Finite::new_const(1.0))) }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "CropAngle", + level: Level::Global, + kind: ValueKind::Real, + range: Some((Finite::new_const(-45.0), Finite::new_const(45.0))), + ui: UiScale::Identity, + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Real(Finite::new_const(0.0))), non_raw: Def::Value(Lit::Real(Finite::new_const(0.0))) }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "CropConstrainToWarp", + level: Level::Global, + kind: ValueKind::IntFlag, + range: Some((Finite::new_const(0.0), Finite::new_const(1.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "CropConstrainToUnitSquare", + level: Level::Global, + kind: ValueKind::Int, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(1)), non_raw: Def::Value(Lit::Int(1)) }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Always 1 when written.", + }, + KeySpec { + name: "HasCrop", + level: Level::Global, + kind: ValueKind::Bool(BoolStyle::TitleCase), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::XmpOnly, + min_pv: None, + recipe_alias: None, + note: "Derived: written iff the crop differs from the full frame. [XMP only (not in the Lua table)]", + }, + KeySpec { + name: "CropConstrainAspectRatio", + level: Level::Global, + kind: ValueKind::Bool(BoolStyle::TitleCase), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "Aspect-ratio lock of the crop tool: per-photo UI state. [Lua only (getDevelopSettings(), 66 of 1,294 training rows); never observed in XMP]", + }, + KeySpec { + name: "CropWidth", + level: Level::Global, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "ACR crop-to-size settings; not an LrC feature as far as observed. [known from Camera Raw; never observed in LrC output]", + }, + KeySpec { + name: "CropHeight", + level: Level::Global, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "ACR crop-to-size settings; not an LrC feature as far as observed. [known from Camera Raw; never observed in LrC output]", + }, + KeySpec { + name: "CropUnit", + level: Level::Global, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "ACR crop-to-size settings; not an LrC feature as far as observed. [known from Camera Raw; never observed in LrC output]", + }, + KeySpec { + name: "CropUnits", + level: Level::Global, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "ACR crop-to-size settings; not an LrC feature as far as observed. [known from Camera Raw; never observed in LrC output]", + }, + KeySpec { + name: "PostCropVignetteAmount", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.vignette"), + note: "", + }, + KeySpec { + name: "PostCropVignetteMidpoint", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(0.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(50)), non_raw: Def::Value(Lit::Int(50)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.vignette_midpoint"), + note: "", + }, + KeySpec { + name: "PostCropVignetteFeather", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(0.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(50)), non_raw: Def::Value(Lit::Int(50)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.vignette_feather"), + note: "", + }, + KeySpec { + name: "PostCropVignetteRoundness", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.vignette_roundness"), + note: "", + }, + KeySpec { + name: "PostCropVignetteStyle", + level: Level::Global, + kind: ValueKind::EnumInt(&[1, 2, 3]), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(1)), non_raw: Def::Value(Lit::Int(1)) }, + policy: Policy::LearnGated(Gate::Categorical), + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Categorical (majority).", + }, + KeySpec { + name: "PostCropVignetteHighlightContrast", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(0.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.vignette_highlights"), + note: "", + }, + KeySpec { + name: "OverrideLookVignette", + level: Level::Global, + kind: ValueKind::Bool(BoolStyle::TitleCase), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Bool(false)), non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Set by LrC when the user vignette overrides a vignette built into the Look; whether we must set it when writing PostCropVignette* is unverified.", + }, + KeySpec { + name: "GrainAmount", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(0.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.grain"), + note: "", + }, + KeySpec { + name: "GrainSize", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(0.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(25)), non_raw: Def::Value(Lit::Int(25)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.grain_size"), + note: "Zeroed template uses 50; the Adobe default and the corpus mode are 25.", + }, + KeySpec { + name: "GrainFrequency", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(0.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(50)), non_raw: Def::Value(Lit::Int(50)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: Some("global.grain_roughness"), + note: "", + }, + KeySpec { + name: "GrainSeed", + level: Level::Global, + kind: ValueKind::Int, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Grain pattern seed chosen by LrC. [recent LrC]", + }, + KeySpec { + name: "Glow", + level: Level::Global, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Newer engine feature (the Glow panel, with GlowRange/GlowWarmth/GlowSpread/GlowStyle); type, range and default not established. [one sidecar of the local corpus, Camera Raw 18.7 engine (October 2026); never observed in LrC 15.5.1 output]", + }, + KeySpec { + name: "GlowRange", + level: Level::Global, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "Newer engine features not seen in LrC 15.5.1 output. [known from Camera Raw 18.6; never observed in LrC output]", + }, + KeySpec { + name: "GlowWarmth", + level: Level::Global, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "Newer engine features not seen in LrC 15.5.1 output. [known from Camera Raw 18.6; never observed in LrC output]", + }, + KeySpec { + name: "GlowSpread", + level: Level::Global, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "Newer engine features not seen in LrC 15.5.1 output. [known from Camera Raw 18.6; never observed in LrC output]", + }, + KeySpec { + name: "GlowStyle", + level: Level::Global, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "Newer engine features not seen in LrC 15.5.1 output. [known from Camera Raw 18.6; never observed in LrC output]", + }, + KeySpec { + name: "ReshapeAmount", + level: Level::Global, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "Newer engine features not seen in LrC 15.5.1 output. [known from Camera Raw 18.6; never observed in LrC output]", + }, + KeySpec { + name: "LensBlur", + level: Level::Global, + kind: ValueKind::Struct(StructKind::LensBlur), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::LearnGated(Gate::NeedsAiUpdate), + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Gate: needs a depth map, i.e. an AI update after applying; FocalRange semantics are UNKNOWN, so v1 writes only Active + the look sliders. [LrC 13+ (Adobe ships lens-blur-only presets)]", + }, + KeySpec { + name: "ShadowTint", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "RedHue", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "RedSaturation", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "GreenHue", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "GreenSaturation", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "BlueHue", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "BlueSaturation", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "`BluePrimarySaturation` does not exist (silently dropped by LrC, research §7).", + }, + KeySpec { + name: "CameraProfile", + level: Level::Global, + kind: ValueKind::Str, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Str("Adobe Standard")), non_raw: Def::Value(Lit::Str("Embedded")) }, + policy: Policy::LearnGated(Gate::CameraRestricted), + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Gate: transfer only Adobe Standard and the symbolic Default* values; camera-specific names only to the same make. The creative profile is the `Look`. Bug (f): dead `profile` branch writes `CameraProfile=\"Adobe Color\"`, which is a Look name and is silently dropped.", + }, + KeySpec { + name: "CameraProfileDigest", + level: Level::Global, + kind: ValueKind::Hex32, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "Look", + level: Level::Global, + kind: ValueKind::Struct(StructKind::Look), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::LearnGated(Gate::CameraRestricted), + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Identity = Look.Name + Look.UUID, strength = Look.Amount. Gate: camera-restricted Looks only to the same camera; Adaptive looks need an AI update (AILook). [LrC 7.3+]", + }, + KeySpec { + name: "LookTable", + level: Level::Global, + kind: ValueKind::Hex32, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Profile lookup-table references.", + }, + KeySpec { + name: "RGBTable", + level: Level::Global, + kind: ValueKind::Hex32, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Profile lookup-table references.", + }, + KeySpec { + name: "LookTableAmount", + level: Level::Global, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Part of a profile definition, not a photo setting. [profile-definition fields (Look.Parameters) / known from Camera Raw]", + }, + KeySpec { + name: "RGBTableAmount", + level: Level::Global, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Part of a profile definition, not a photo setting. [profile-definition fields (Look.Parameters) / known from Camera Raw]", + }, + KeySpec { + name: "RGBTables", + level: Level::Global, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Part of a profile definition, not a photo setting. [profile-definition fields (Look.Parameters) / known from Camera Raw]", + }, + KeySpec { + name: "ProfileGainTableMap", + level: Level::Global, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Part of a profile definition, not a photo setting. [profile-definition fields (Look.Parameters) / known from Camera Raw]", + }, + KeySpec { + name: "ProfileGainTableMap2", + level: Level::Global, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Part of a profile definition, not a photo setting. [profile-definition fields (Look.Parameters) / known from Camera Raw]", + }, + KeySpec { + name: "ProfileToneCurve", + level: Level::Global, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Part of a profile definition, not a photo setting. [profile-definition fields (Look.Parameters) / known from Camera Raw]", + }, + KeySpec { + name: "MissingCameraProfile", + level: Level::Global, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Part of a profile definition, not a photo setting. [profile-definition fields (Look.Parameters) / known from Camera Raw]", + }, + KeySpec { + name: "MissingCameraProfileDigest", + level: Level::Global, + kind: ValueKind::Hex32, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Part of a profile definition, not a photo setting. [profile-definition fields (Look.Parameters) / known from Camera Raw]", + }, + KeySpec { + name: "AILook", + level: Level::Global, + kind: ValueKind::Struct(StructKind::AiLook), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Computed state of an adaptive profile. [Adaptive Color profile]", + }, + KeySpec { + name: "PointColors", + level: Level::Global, + kind: ValueKind::PerFormat { lua: &ValueKind::StructSeq(StructKind::PointColor), xmp: &ValueKind::StrSeq }, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::CompoundFixed6, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Layout only partly decoded; values are sampled from one photo. Round-trip only. [LrC 13+]", + }, + KeySpec { + name: "ColorVariance", + level: Level::Global, + kind: ValueKind::StrSeq, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::CompoundFixed6, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Meaning of -50 unknown (research §8). [LrC 13+]", + }, + KeySpec { + name: "HDREditMode", + level: Level::Global, + kind: ValueKind::IntFlag, + range: Some((Finite::new_const(0.0), Finite::new_const(1.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Never learn: the 8-bit JPEG export cannot show HDR. Keep the photo's value. [LrC 12+]", + }, + KeySpec { + name: "HDRMaxValue", + level: Level::Global, + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Fixed(2), + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Real(Finite::new_const(4.0))), non_raw: Def::Unverified }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Non-raw default not established: the one non-raw readback (E13, a virtual copy of a TIFF, LrC 15.6) has 2.3, which may be per image, so the generator holds it back (NON_RAW_READBACK_NOT_CONSTANT). [LrC 12+]", + }, + KeySpec { + name: "SDRBrightness", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "[LrC 12+, only with HDREditMode=1]", + }, + KeySpec { + name: "SDRContrast", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "[LrC 12+, only with HDREditMode=1]", + }, + KeySpec { + name: "SDRClarity", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "[LrC 12+, only with HDREditMode=1]", + }, + KeySpec { + name: "SDRHighlights", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "[LrC 12+, only with HDREditMode=1]", + }, + KeySpec { + name: "SDRShadows", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "[LrC 12+, only with HDREditMode=1]", + }, + KeySpec { + name: "SDRWhites", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "[LrC 12+, only with HDREditMode=1]", + }, + KeySpec { + name: "SDRBlend", + level: Level::Global, + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: true, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Value(Lit::Int(0)) }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "[LrC 12+, only with HDREditMode=1]", + }, + KeySpec { + name: "AutoTone", + level: Level::Global, + kind: ValueKind::Bool(BoolStyle::TitleCase), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Preset instruction \"run Auto Tone on apply\"; collides with explicit tone values; applyDevelopSettings has its own `flattenAutoNow` flag. Not for v1. [presets (23) + 1 sidecar]", + }, + KeySpec { + name: "AutoToneDigest", + level: Level::Global, + kind: ValueKind::Hex32, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Cache keys of the last auto-tone evaluation.", + }, + KeySpec { + name: "AutoToneDigestNoSat", + level: Level::Global, + kind: ValueKind::Hex32, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Cache keys of the last auto-tone evaluation.", + }, + KeySpec { + name: "AutoToneDigestPV2", + level: Level::Global, + kind: ValueKind::Hex32, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Cache keys of the last auto-tone evaluation.", + }, + KeySpec { + name: "SourceAutoExposure2012", + level: Level::Global, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "SourceAutoExposure2012 ... SourceAutoVibrance. [known from Camera Raw; never observed in LrC output]", + }, + KeySpec { + name: "SourceAutoContrast2012", + level: Level::Global, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "SourceAutoExposure2012 ... SourceAutoVibrance. [known from Camera Raw; never observed in LrC output]", + }, + KeySpec { + name: "SourceAutoHighlights2012", + level: Level::Global, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "SourceAutoExposure2012 ... SourceAutoVibrance. [known from Camera Raw; never observed in LrC output]", + }, + KeySpec { + name: "SourceAutoShadows2012", + level: Level::Global, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "SourceAutoExposure2012 ... SourceAutoVibrance. [known from Camera Raw; never observed in LrC output]", + }, + KeySpec { + name: "SourceAutoWhites2012", + level: Level::Global, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "SourceAutoExposure2012 ... SourceAutoVibrance. [known from Camera Raw; never observed in LrC output]", + }, + KeySpec { + name: "SourceAutoBlacks2012", + level: Level::Global, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "SourceAutoExposure2012 ... SourceAutoVibrance. [known from Camera Raw; never observed in LrC output]", + }, + KeySpec { + name: "SourceAutoSaturation", + level: Level::Global, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "SourceAutoExposure2012 ... SourceAutoVibrance. [known from Camera Raw; never observed in LrC output]", + }, + KeySpec { + name: "SourceAutoVibrance", + level: Level::Global, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "SourceAutoExposure2012 ... SourceAutoVibrance. [known from Camera Raw; never observed in LrC output]", + }, + KeySpec { + name: "AutoExposure", + level: Level::Global, + kind: ValueKind::Bool(BoolStyle::TitleCase), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Bool(false)), non_raw: Def::Value(Lit::Bool(false)) }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Legacy auto flags. [legacy PV2003/2010]", + }, + KeySpec { + name: "AutoShadows", + level: Level::Global, + kind: ValueKind::Bool(BoolStyle::TitleCase), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Bool(false)), non_raw: Def::Value(Lit::Bool(false)) }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Legacy auto flags. [legacy PV2003/2010]", + }, + KeySpec { + name: "AutoBrightness", + level: Level::Global, + kind: ValueKind::Bool(BoolStyle::TitleCase), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Bool(false)), non_raw: Def::Value(Lit::Bool(false)) }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Legacy auto flags. [legacy PV2003/2010]", + }, + KeySpec { + name: "AutoContrast", + level: Level::Global, + kind: ValueKind::Bool(BoolStyle::TitleCase), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Bool(false)), non_raw: Def::Value(Lit::Bool(false)) }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Legacy auto flags. [legacy PV2003/2010]", + }, + KeySpec { + name: "ToggleStyleDigest", + level: Level::Global, + kind: ValueKind::Hex32, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Probably identifies an applied \"style\"/preset for toggling; meaning unverified. [recent LrC (a few sampled sidecars)]", + }, + KeySpec { + name: "ToggleStyleAmount", + level: Level::Global, + kind: ValueKind::Real, + range: None, + ui: UiScale::Unknown, + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Observed 0.67..1. [as above]", + }, + KeySpec { + name: "RetouchAreas", + level: Level::Global, + kind: ValueKind::StructSeq(StructKind::RetouchArea), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Heal/clone/content-aware spots.", + }, + KeySpec { + name: "RemoveAreas", + level: Level::Global, + kind: ValueKind::StructSeq(StructKind::RetouchArea), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Remove tool / generative remove. [LrC 13+]", + }, + KeySpec { + name: "RetouchInfo", + level: Level::Global, + kind: ValueKind::PerFormat { lua: &ValueKind::StructSeq(StructKind::RetouchInfo), xmp: &ValueKind::StrSeq }, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "[legacy spot removal]", + }, + KeySpec { + name: "RedEyeInfo", + level: Level::Global, + kind: ValueKind::StrSeq, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Present (empty) in the Zeroed Lua template. [legacy]", + }, + KeySpec { + name: "FilterList", + level: Level::Global, + kind: ValueKind::Struct(StructKind::FilterList), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Enhance/AI filter payloads; FilterIDs 3/4/6 are remove/heal/distraction removal (NEVER). [LrC 12.3+ (Denoise, Raw Details, Super Resolution)]", + }, + KeySpec { + name: "AllowFilters", + level: Level::Global, + kind: ValueKind::Int, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "[with FilterList]", + }, + KeySpec { + name: "DepthMapInfo", + level: Level::Global, + kind: ValueKind::Struct(StructKind::DepthMapInfo), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "[lens blur / depth]", + }, + KeySpec { + name: "RangeMaskMapInfo", + level: Level::Global, + kind: ValueKind::Struct(StructKind::RangeMaskMapInfo), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Per-photo image statistics for range masks. [range masks]", + }, + KeySpec { + name: "DepthBasedCorrections", + level: Level::Global, + kind: ValueKind::CorrectionSeq, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "Old depth-based local corrections. [known from Camera Raw; never observed in LrC output]", + }, + KeySpec { + name: "MaskGroupBasedCorrections", + level: Level::Global, + kind: ValueKind::CorrectionSeq, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::LearnGated(Gate::ByMaskType), + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Per-item policy follows the mask components (sub-tables below): semantic AI components are LEARN, geometry PHOTO, brush NEVER. [crs:Version ≥ 14 (LrC 11+)]", + }, + KeySpec { + name: "GradientBasedCorrections", + level: Level::Global, + kind: ValueKind::CorrectionSeq, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Never write (no AI masks, no add/subtract). A parser may migrate them read-only. [legacy; 0 in corpus]", + }, + KeySpec { + name: "CircularGradientBasedCorrections", + level: Level::Global, + kind: ValueKind::CorrectionSeq, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Never write (no AI masks, no add/subtract). A parser may migrate them read-only. [legacy; 0 in corpus]", + }, + KeySpec { + name: "PaintBasedCorrections", + level: Level::Global, + kind: ValueKind::CorrectionSeq, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Never write (no AI masks, no add/subtract). A parser may migrate them read-only. [legacy; 0 in corpus]", + }, + KeySpec { + name: "ProcessVersion", + level: Level::Global, + kind: ValueKind::VersionStr, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Fixed(1), + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Write only when switching PV on purpose; omitting it keeps the photo's PV. Learning must refuse to mix PV2003/2010 examples.", + }, + KeySpec { + name: "Version", + level: Level::Global, + kind: ValueKind::VersionStr, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Must be ≤ the engine of the target LrC (LrC 15.5.1 = 18.5.1).", + }, + KeySpec { + name: "CompatibleVersion", + level: Level::Global, + kind: ValueKind::VersionU32, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Feature → version mapping unknown; omit unless copying Adobe's value for the same feature.", + }, + KeySpec { + name: "HasSettings", + level: Level::Global, + kind: ValueKind::Bool(BoolStyle::TitleCase), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Bool(true)), non_raw: Def::Unverified }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::XmpOnly, + min_pv: None, + recipe_alias: None, + note: "Always True. [XMP only]", + }, + KeySpec { + name: "AlreadyApplied", + level: Level::Global, + kind: ValueKind::Bool(BoolStyle::TitleCase), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Bool(false)), non_raw: Def::Unverified }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::XmpOnly, + min_pv: None, + recipe_alias: None, + note: "[sidecars only]", + }, + KeySpec { + name: "RawFileName", + level: Level::Global, + kind: ValueKind::Str, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::XmpOnly, + min_pv: None, + recipe_alias: None, + note: "Photo identity; set from the target file. [sidecars only]", + }, + KeySpec { + name: "RenderVersion", + level: Level::Global, + kind: ValueKind::VersionU32, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "[known from Camera Raw; never observed in LrC output]", + }, + KeySpec { + name: "Preset", + level: Level::Global, + kind: ValueKind::Struct(StructKind::Preset), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::XmpOnly, + min_pv: None, + recipe_alias: None, + note: "Written by LrC; its Parameters subtree is a preset, not the photo's settings. [sidecars only (about a tenth of them)]", + }, + KeySpec { + name: "EnableCalibration", + level: Level::Global, + kind: ValueKind::Bool(BoolStyle::TitleCase), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Bool(true)), non_raw: Def::Value(Lit::Bool(true)) }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "EnableCalibration, EnableColorAdjustments, EnableDetail, EnableEffects, EnableGrayscaleMix, EnableLensCorrections, EnableRedEye, EnableRetouch, EnableSplitToning, EnableToneCurve, EnableTransform, EnableMaskGroupBasedCorrections, EnableGradientBasedCorrections, EnableDistractionRemoval. Never write `false`; write `true` only for panels we set (panel switches, not features). [in Zeroed.lrtemplate and the SDK table; 0 occurrences in the sidecar corpus or the bundled preset/profile files]", + }, + KeySpec { + name: "EnableColorAdjustments", + level: Level::Global, + kind: ValueKind::Bool(BoolStyle::TitleCase), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Bool(true)), non_raw: Def::Value(Lit::Bool(true)) }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "EnableCalibration, EnableColorAdjustments, EnableDetail, EnableEffects, EnableGrayscaleMix, EnableLensCorrections, EnableRedEye, EnableRetouch, EnableSplitToning, EnableToneCurve, EnableTransform, EnableMaskGroupBasedCorrections, EnableGradientBasedCorrections, EnableDistractionRemoval. Never write `false`; write `true` only for panels we set (panel switches, not features). [in Zeroed.lrtemplate and the SDK table; 0 occurrences in the sidecar corpus or the bundled preset/profile files]", + }, + KeySpec { + name: "EnableDetail", + level: Level::Global, + kind: ValueKind::Bool(BoolStyle::TitleCase), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Bool(true)), non_raw: Def::Value(Lit::Bool(true)) }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "EnableCalibration, EnableColorAdjustments, EnableDetail, EnableEffects, EnableGrayscaleMix, EnableLensCorrections, EnableRedEye, EnableRetouch, EnableSplitToning, EnableToneCurve, EnableTransform, EnableMaskGroupBasedCorrections, EnableGradientBasedCorrections, EnableDistractionRemoval. Never write `false`; write `true` only for panels we set (panel switches, not features). [in Zeroed.lrtemplate and the SDK table; 0 occurrences in the sidecar corpus or the bundled preset/profile files]", + }, + KeySpec { + name: "EnableEffects", + level: Level::Global, + kind: ValueKind::Bool(BoolStyle::TitleCase), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Bool(true)), non_raw: Def::Value(Lit::Bool(true)) }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "EnableCalibration, EnableColorAdjustments, EnableDetail, EnableEffects, EnableGrayscaleMix, EnableLensCorrections, EnableRedEye, EnableRetouch, EnableSplitToning, EnableToneCurve, EnableTransform, EnableMaskGroupBasedCorrections, EnableGradientBasedCorrections, EnableDistractionRemoval. Never write `false`; write `true` only for panels we set (panel switches, not features). [in Zeroed.lrtemplate and the SDK table; 0 occurrences in the sidecar corpus or the bundled preset/profile files]", + }, + KeySpec { + name: "EnableGrayscaleMix", + level: Level::Global, + kind: ValueKind::Bool(BoolStyle::TitleCase), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Bool(true)), non_raw: Def::Value(Lit::Bool(true)) }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "EnableCalibration, EnableColorAdjustments, EnableDetail, EnableEffects, EnableGrayscaleMix, EnableLensCorrections, EnableRedEye, EnableRetouch, EnableSplitToning, EnableToneCurve, EnableTransform, EnableMaskGroupBasedCorrections, EnableGradientBasedCorrections, EnableDistractionRemoval. Never write `false`; write `true` only for panels we set (panel switches, not features). [in Zeroed.lrtemplate and the SDK table; 0 occurrences in the sidecar corpus or the bundled preset/profile files]", + }, + KeySpec { + name: "EnableLensCorrections", + level: Level::Global, + kind: ValueKind::Bool(BoolStyle::TitleCase), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Bool(true)), non_raw: Def::Value(Lit::Bool(true)) }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "EnableCalibration, EnableColorAdjustments, EnableDetail, EnableEffects, EnableGrayscaleMix, EnableLensCorrections, EnableRedEye, EnableRetouch, EnableSplitToning, EnableToneCurve, EnableTransform, EnableMaskGroupBasedCorrections, EnableGradientBasedCorrections, EnableDistractionRemoval. Never write `false`; write `true` only for panels we set (panel switches, not features). [in Zeroed.lrtemplate and the SDK table; 0 occurrences in the sidecar corpus or the bundled preset/profile files]", + }, + KeySpec { + name: "EnableRedEye", + level: Level::Global, + kind: ValueKind::Bool(BoolStyle::TitleCase), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Bool(true)), non_raw: Def::Value(Lit::Bool(true)) }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "EnableCalibration, EnableColorAdjustments, EnableDetail, EnableEffects, EnableGrayscaleMix, EnableLensCorrections, EnableRedEye, EnableRetouch, EnableSplitToning, EnableToneCurve, EnableTransform, EnableMaskGroupBasedCorrections, EnableGradientBasedCorrections, EnableDistractionRemoval. Never write `false`; write `true` only for panels we set (panel switches, not features). [in Zeroed.lrtemplate and the SDK table; 0 occurrences in the sidecar corpus or the bundled preset/profile files]", + }, + KeySpec { + name: "EnableRetouch", + level: Level::Global, + kind: ValueKind::Bool(BoolStyle::TitleCase), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Bool(true)), non_raw: Def::Value(Lit::Bool(true)) }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "EnableCalibration, EnableColorAdjustments, EnableDetail, EnableEffects, EnableGrayscaleMix, EnableLensCorrections, EnableRedEye, EnableRetouch, EnableSplitToning, EnableToneCurve, EnableTransform, EnableMaskGroupBasedCorrections, EnableGradientBasedCorrections, EnableDistractionRemoval. Never write `false`; write `true` only for panels we set (panel switches, not features). [in Zeroed.lrtemplate and the SDK table; 0 occurrences in the sidecar corpus or the bundled preset/profile files]", + }, + KeySpec { + name: "EnableSplitToning", + level: Level::Global, + kind: ValueKind::Bool(BoolStyle::TitleCase), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Bool(true)), non_raw: Def::Value(Lit::Bool(true)) }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "EnableCalibration, EnableColorAdjustments, EnableDetail, EnableEffects, EnableGrayscaleMix, EnableLensCorrections, EnableRedEye, EnableRetouch, EnableSplitToning, EnableToneCurve, EnableTransform, EnableMaskGroupBasedCorrections, EnableGradientBasedCorrections, EnableDistractionRemoval. Never write `false`; write `true` only for panels we set (panel switches, not features). [in Zeroed.lrtemplate and the SDK table; 0 occurrences in the sidecar corpus or the bundled preset/profile files]", + }, + KeySpec { + name: "EnableToneCurve", + level: Level::Global, + kind: ValueKind::Bool(BoolStyle::TitleCase), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Bool(true)), non_raw: Def::Value(Lit::Bool(true)) }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "EnableCalibration, EnableColorAdjustments, EnableDetail, EnableEffects, EnableGrayscaleMix, EnableLensCorrections, EnableRedEye, EnableRetouch, EnableSplitToning, EnableToneCurve, EnableTransform, EnableMaskGroupBasedCorrections, EnableGradientBasedCorrections, EnableDistractionRemoval. Never write `false`; write `true` only for panels we set (panel switches, not features). [in Zeroed.lrtemplate and the SDK table; 0 occurrences in the sidecar corpus or the bundled preset/profile files]", + }, + KeySpec { + name: "EnableTransform", + level: Level::Global, + kind: ValueKind::Bool(BoolStyle::TitleCase), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Bool(true)), non_raw: Def::Value(Lit::Bool(true)) }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "EnableCalibration, EnableColorAdjustments, EnableDetail, EnableEffects, EnableGrayscaleMix, EnableLensCorrections, EnableRedEye, EnableRetouch, EnableSplitToning, EnableToneCurve, EnableTransform, EnableMaskGroupBasedCorrections, EnableGradientBasedCorrections, EnableDistractionRemoval. Never write `false`; write `true` only for panels we set (panel switches, not features). [in Zeroed.lrtemplate and the SDK table; 0 occurrences in the sidecar corpus or the bundled preset/profile files]", + }, + KeySpec { + name: "EnableMaskGroupBasedCorrections", + level: Level::Global, + kind: ValueKind::Bool(BoolStyle::TitleCase), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Bool(true)), non_raw: Def::Value(Lit::Bool(true)) }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "EnableCalibration, EnableColorAdjustments, EnableDetail, EnableEffects, EnableGrayscaleMix, EnableLensCorrections, EnableRedEye, EnableRetouch, EnableSplitToning, EnableToneCurve, EnableTransform, EnableMaskGroupBasedCorrections, EnableGradientBasedCorrections, EnableDistractionRemoval. Never write `false`; write `true` only for panels we set (panel switches, not features). [in Zeroed.lrtemplate and the SDK table; 0 occurrences in the sidecar corpus or the bundled preset/profile files]", + }, + KeySpec { + name: "EnableGradientBasedCorrections", + level: Level::Global, + kind: ValueKind::Bool(BoolStyle::TitleCase), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Bool(true)), non_raw: Def::Unverified }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "EnableCalibration, EnableColorAdjustments, EnableDetail, EnableEffects, EnableGrayscaleMix, EnableLensCorrections, EnableRedEye, EnableRetouch, EnableSplitToning, EnableToneCurve, EnableTransform, EnableMaskGroupBasedCorrections, EnableGradientBasedCorrections, EnableDistractionRemoval. Never write `false`; write `true` only for panels we set (panel switches, not features). [in Zeroed.lrtemplate and the SDK table; 0 occurrences in the sidecar corpus or the bundled preset/profile files]", + }, + KeySpec { + name: "EnableDistractionRemoval", + level: Level::Global, + kind: ValueKind::Bool(BoolStyle::TitleCase), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Bool(true)), non_raw: Def::Value(Lit::Bool(true)) }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "EnableCalibration, EnableColorAdjustments, EnableDetail, EnableEffects, EnableGrayscaleMix, EnableLensCorrections, EnableRedEye, EnableRetouch, EnableSplitToning, EnableToneCurve, EnableTransform, EnableMaskGroupBasedCorrections, EnableGradientBasedCorrections, EnableDistractionRemoval. Never write `false`; write `true` only for panels we set (panel switches, not features). [in Zeroed.lrtemplate and the SDK table; 0 occurrences in the sidecar corpus or the bundled preset/profile files]", + }, + KeySpec { + name: "orientation", + level: Level::Global, + kind: ValueKind::Str, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "Read-only input for the coordinate transform (E13 decides the runtime format). Never write. [catalog / Lua only]", + }, + KeySpec { + name: "PresetType", + level: Level::Header, + kind: ValueKind::Enum(&["Normal", "Look"]), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::XmpOnly, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "UUID", + level: Level::Header, + kind: ValueKind::Hex32, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::XmpOnly, + min_pv: None, + recipe_alias: None, + note: "Stable per generated preset; LrDevelopPreset:getUuid() differs from it (research §5).", + }, + KeySpec { + name: "Cluster", + level: Level::Header, + kind: ValueKind::Str, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::XmpOnly, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "Name", + level: Level::Header, + kind: ValueKind::LangAlt, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::XmpOnly, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "ShortName", + level: Level::Header, + kind: ValueKind::LangAlt, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::XmpOnly, + min_pv: None, + recipe_alias: None, + note: "Empty Alt allowed.", + }, + KeySpec { + name: "SortName", + level: Level::Header, + kind: ValueKind::LangAlt, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::XmpOnly, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "Group", + level: Level::Header, + kind: ValueKind::LangAlt, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::XmpOnly, + min_pv: None, + recipe_alias: None, + note: "Use \"LrGeniusAI\".", + }, + KeySpec { + name: "Description", + level: Level::Header, + kind: ValueKind::LangAlt, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::XmpOnly, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "Copyright", + level: Level::Header, + kind: ValueKind::Str, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::XmpOnly, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "ContactInfo", + level: Level::Header, + kind: ValueKind::Str, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::XmpOnly, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "CameraModelRestriction", + level: Level::Header, + kind: ValueKind::Str, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Str("")), non_raw: Def::Unverified }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::XmpOnly, + min_pv: None, + recipe_alias: None, + note: "Set to the model when a camera-restricted Look is included.", + }, + KeySpec { + name: "SupportsAmount", + level: Level::Header, + kind: ValueKind::Bool(BoolStyle::TitleCase), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::XmpOnly, + min_pv: None, + recipe_alias: None, + note: "Enables the amount slider (0..200 %).", + }, + KeySpec { + name: "SupportsAmount2", + level: Level::Header, + kind: ValueKind::Bool(BoolStyle::TitleCase), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::XmpOnly, + min_pv: None, + recipe_alias: None, + note: "Meaning vs SupportsAmount unverified (E4 compares). [newer presets (330)]", + }, + KeySpec { + name: "SupportsColor", + level: Level::Header, + kind: ValueKind::Bool(BoolStyle::TitleCase), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::XmpOnly, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "SupportsMonochrome", + level: Level::Header, + kind: ValueKind::Bool(BoolStyle::TitleCase), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::XmpOnly, + min_pv: None, + recipe_alias: None, + note: "False when the preset forces colour/B&W semantics.", + }, + KeySpec { + name: "SupportsHighDynamicRange", + level: Level::Header, + kind: ValueKind::Bool(BoolStyle::TitleCase), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::XmpOnly, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "SupportsNormalDynamicRange", + level: Level::Header, + kind: ValueKind::Bool(BoolStyle::TitleCase), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::XmpOnly, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "SupportsSceneReferred", + level: Level::Header, + kind: ValueKind::Bool(BoolStyle::TitleCase), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::XmpOnly, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "SupportsOutputReferred", + level: Level::Header, + kind: ValueKind::Bool(BoolStyle::TitleCase), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::XmpOnly, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "RequiresRGBTables", + level: Level::Header, + kind: ValueKind::Bool(BoolStyle::TitleCase), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::XmpOnly, + min_pv: None, + recipe_alias: None, + note: "[newer presets]", + }, + KeySpec { + name: "ShowInPresets", + level: Level::Header, + kind: ValueKind::Bool(BoolStyle::TitleCase), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::XmpOnly, + min_pv: None, + recipe_alias: None, + note: "[few presets]", + }, + KeySpec { + name: "ShowInQuickActions", + level: Level::Header, + kind: ValueKind::Bool(BoolStyle::TitleCase), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::XmpOnly, + min_pv: None, + recipe_alias: None, + note: "[few presets]", + }, + KeySpec { + name: "Baseline", + level: Level::Header, + kind: ValueKind::Enum(&["Adobe Default", "Camera Settings"]), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::XmpOnly, + min_pv: None, + recipe_alias: None, + note: "Probably the reference state for the amount slider (amount 0 % = baseline). Unverified. [presets (20) and applied-Preset records]", + }, + KeySpec { + name: "What", + level: Level::Correction, + kind: ValueKind::Enum(&["Correction"]), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "CorrectionAmount", + level: Level::Correction, + kind: ValueKind::Real, + range: Some((Finite::new_const(0.0), Finite::new_const(2.0))), + ui: UiScale::Div(100.0), + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Real(Finite::new_const(1.0))), non_raw: Def::Unverified }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Research: observed 0.21–2.", + }, + KeySpec { + name: "CorrectionActive", + level: Level::Correction, + kind: ValueKind::Bool(BoolStyle::Lower), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Bool(true)), non_raw: Def::Unverified }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Always true when we write.", + }, + KeySpec { + name: "CorrectionName", + level: Level::Correction, + kind: ValueKind::Str, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "We write \"LrGenius · \". Never infer semantics from names.", + }, + KeySpec { + name: "CorrectionSyncID", + level: Level::Correction, + kind: ValueKind::Hex32, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Stable, role-derived id; whether an equal id replaces or duplicates is E5.", + }, + KeySpec { + name: "CorrectionMasks", + level: Level::Correction, + kind: ValueKind::ComponentSeq, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::LearnGated(Gate::ByMaskType), + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Policy per component (mask-tool table).", + }, + KeySpec { + name: "LocalExposure2012", + level: Level::Correction, + kind: ValueKind::Real, + range: Some((Finite::new_const(-1.0), Finite::new_const(1.0))), + ui: UiScale::Div(4.0), + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Real(Finite::new_const(0.0))), non_raw: Def::Unverified }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: Some(ProcessVersion::PV2012), + recipe_alias: None, + note: "Dodge 0.0625 = +0.25 EV (Adobe local presets). [PV2012+]", + }, + KeySpec { + name: "LocalContrast2012", + level: Level::Correction, + kind: ValueKind::Real, + range: Some((Finite::new_const(-1.0), Finite::new_const(1.0))), + ui: UiScale::Div(100.0), + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Real(Finite::new_const(0.0))), non_raw: Def::Unverified }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: Some(ProcessVersion::PV2012), + recipe_alias: None, + note: "[PV2012+]", + }, + KeySpec { + name: "LocalHighlights2012", + level: Level::Correction, + kind: ValueKind::Real, + range: Some((Finite::new_const(-1.0), Finite::new_const(1.0))), + ui: UiScale::Div(100.0), + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Real(Finite::new_const(0.0))), non_raw: Def::Unverified }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: Some(ProcessVersion::PV2012), + recipe_alias: None, + note: "[PV2012+]", + }, + KeySpec { + name: "LocalShadows2012", + level: Level::Correction, + kind: ValueKind::Real, + range: Some((Finite::new_const(-1.0), Finite::new_const(1.0))), + ui: UiScale::Div(100.0), + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Real(Finite::new_const(0.0))), non_raw: Def::Unverified }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: Some(ProcessVersion::PV2012), + recipe_alias: None, + note: "[PV2012+]", + }, + KeySpec { + name: "LocalWhites2012", + level: Level::Correction, + kind: ValueKind::Real, + range: Some((Finite::new_const(-1.0), Finite::new_const(1.0))), + ui: UiScale::Div(100.0), + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Real(Finite::new_const(0.0))), non_raw: Def::Unverified }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: Some(ProcessVersion::PV2012), + recipe_alias: None, + note: "[PV2012+]", + }, + KeySpec { + name: "LocalBlacks2012", + level: Level::Correction, + kind: ValueKind::Real, + range: Some((Finite::new_const(-1.0), Finite::new_const(1.0))), + ui: UiScale::Div(100.0), + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Real(Finite::new_const(0.0))), non_raw: Def::Unverified }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: Some(ProcessVersion::PV2012), + recipe_alias: None, + note: "[PV2012+]", + }, + KeySpec { + name: "LocalClarity2012", + level: Level::Correction, + kind: ValueKind::Real, + range: Some((Finite::new_const(-1.0), Finite::new_const(1.0))), + ui: UiScale::Div(100.0), + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Real(Finite::new_const(0.0))), non_raw: Def::Unverified }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: Some(ProcessVersion::PV2012), + recipe_alias: None, + note: "[PV2012+]", + }, + KeySpec { + name: "LocalDehaze", + level: Level::Correction, + kind: ValueKind::Real, + range: Some((Finite::new_const(-1.0), Finite::new_const(1.0))), + ui: UiScale::Div(100.0), + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Real(Finite::new_const(0.0))), non_raw: Def::Unverified }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "[no \"2012\" suffix]", + }, + KeySpec { + name: "LocalTexture", + level: Level::Correction, + kind: ValueKind::Real, + range: Some((Finite::new_const(-1.0), Finite::new_const(1.0))), + ui: UiScale::Div(100.0), + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Real(Finite::new_const(0.0))), non_raw: Def::Unverified }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "[no suffix]", + }, + KeySpec { + name: "LocalSaturation", + level: Level::Correction, + kind: ValueKind::Real, + range: Some((Finite::new_const(-1.0), Finite::new_const(1.0))), + ui: UiScale::Div(100.0), + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Real(Finite::new_const(0.0))), non_raw: Def::Unverified }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "[no suffix]", + }, + KeySpec { + name: "LocalSharpness", + level: Level::Correction, + kind: ValueKind::Real, + range: Some((Finite::new_const(-1.0), Finite::new_const(1.0))), + ui: UiScale::Div(100.0), + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Real(Finite::new_const(0.0))), non_raw: Def::Unverified }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "LocalTemperature", + level: Level::Correction, + kind: ValueKind::Real, + range: Some((Finite::new_const(-1.0), Finite::new_const(1.0))), + ui: UiScale::Div(100.0), + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Real(Finite::new_const(0.0))), non_raw: Def::Unverified }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "The LLM schema range is right in UI units; the serializer must divide by 100. [no suffix]", + }, + KeySpec { + name: "LocalTint", + level: Level::Correction, + kind: ValueKind::Real, + range: Some((Finite::new_const(-1.0), Finite::new_const(1.0))), + ui: UiScale::Div(100.0), + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Real(Finite::new_const(0.0))), non_raw: Def::Unverified }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "[no suffix]", + }, + KeySpec { + name: "LocalLuminanceNoise", + level: Level::Correction, + kind: ValueKind::Real, + range: Some((Finite::new_const(-1.0), Finite::new_const(1.0))), + ui: UiScale::Div(100.0), + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Real(Finite::new_const(0.0))), non_raw: Def::Unverified }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "UI range from knowledge; only 0..1 observed. The plugin's `local_Noise` must be `local_LuminanceNoise`.", + }, + KeySpec { + name: "LocalMoire", + level: Level::Correction, + kind: ValueKind::Real, + range: Some((Finite::new_const(-1.0), Finite::new_const(1.0))), + ui: UiScale::Div(100.0), + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Real(Finite::new_const(0.0))), non_raw: Def::Unverified }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "LocalDefringe", + level: Level::Correction, + kind: ValueKind::Real, + range: Some((Finite::new_const(-1.0), Finite::new_const(1.0))), + ui: UiScale::Div(100.0), + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Real(Finite::new_const(0.0))), non_raw: Def::Unverified }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "LocalHue", + level: Level::Correction, + kind: ValueKind::Real, + range: None, + ui: UiScale::Unknown, + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Real(Finite::new_const(0.0))), non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Scale unverified; 2 non-zero values in the sample.", + }, + KeySpec { + name: "LocalToningHue", + level: Level::Correction, + kind: ValueKind::Real, + range: Some((Finite::new_const(0.0), Finite::new_const(360.0))), + ui: UiScale::Identity, + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Real(Finite::new_const(0.0))), non_raw: Def::Unverified }, + policy: Policy::LearnGated(Gate::CircularHue("LocalToningSaturation")), + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Circular; ignore when LocalToningSaturation = 0.", + }, + KeySpec { + name: "LocalToningSaturation", + level: Level::Correction, + kind: ValueKind::Real, + range: Some((Finite::new_const(0.0), Finite::new_const(1.0))), + ui: UiScale::Div(100.0), + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Real(Finite::new_const(0.0))), non_raw: Def::Unverified }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "LocalCurveRefineSaturation", + level: Level::Correction, + kind: ValueKind::Real, + range: Some((Finite::new_const(0.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Real(Finite::new_const(100.0))), non_raw: Def::Unverified }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Only 100 and 30 observed. [LrC 13+]", + }, + KeySpec { + name: "LocalGrain", + level: Level::Correction, + kind: ValueKind::Real, + range: None, + ui: UiScale::Unknown, + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Real(Finite::new_const(0.0))), non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Only 0 and 1 observed; scale unverified. [LrC 13+]", + }, + KeySpec { + name: "MainCurve", + level: Level::Correction, + kind: ValueKind::Curve(CurveKind::Local), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Low priority. [LrC 13+]", + }, + KeySpec { + name: "RedCurve", + level: Level::Correction, + kind: ValueKind::Curve(CurveKind::Local), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Low priority. [LrC 13+]", + }, + KeySpec { + name: "GreenCurve", + level: Level::Correction, + kind: ValueKind::Curve(CurveKind::Local), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Low priority. [LrC 13+]", + }, + KeySpec { + name: "BlueCurve", + level: Level::Correction, + kind: ValueKind::Curve(CurveKind::Local), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Low priority. [LrC 13+]", + }, + KeySpec { + name: "LocalPointColors", + level: Level::Correction, + kind: ValueKind::StrSeq, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::CompoundFixed6, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "As global PointColors. [LrC 13+]", + }, + KeySpec { + name: "LocalColorVariance", + level: Level::Correction, + kind: ValueKind::StrSeq, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::CompoundFixed6, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "[LrC 13+]", + }, + KeySpec { + name: "LocalExposure", + level: Level::Correction, + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Real(Finite::new_const(0.0))), non_raw: Def::Unverified }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Adobe presets write them as 0; the serializer may emit 0 for fidelity, never other values. (LocalClarity 0.655 appears only in legacy local presets.) [legacy PV2010 local keys]", + }, + KeySpec { + name: "LocalContrast", + level: Level::Correction, + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Real(Finite::new_const(0.0))), non_raw: Def::Unverified }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Adobe presets write them as 0; the serializer may emit 0 for fidelity, never other values. (LocalClarity 0.655 appears only in legacy local presets.) [legacy PV2010 local keys]", + }, + KeySpec { + name: "LocalClarity", + level: Level::Correction, + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Real(Finite::new_const(0.0))), non_raw: Def::Unverified }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Adobe presets write them as 0; the serializer may emit 0 for fidelity, never other values. (LocalClarity 0.655 appears only in legacy local presets.) [legacy PV2010 local keys]", + }, + KeySpec { + name: "LocalBrightness", + level: Level::Correction, + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Real(Finite::new_const(0.0))), non_raw: Def::Unverified }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Adobe presets write them as 0; the serializer may emit 0 for fidelity, never other values. (LocalClarity 0.655 appears only in legacy local presets.) [legacy PV2010 local keys]", + }, + KeySpec { + name: "LocalCorrectedDepth", + level: Level::Correction, + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Real(Finite::new_const(0.0))), non_raw: Def::Unverified }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Always 0. [depth corrections]", + }, + KeySpec { + name: "LocalColorGradeShadowHue", + level: Level::Correction, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "Do not use (research §3.4). [known from Camera Raw 18.6; never observed in LrC output]", + }, + KeySpec { + name: "LocalColorGradeShadowSat", + level: Level::Correction, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "Do not use (research §3.4). [known from Camera Raw 18.6; never observed in LrC output]", + }, + KeySpec { + name: "LocalColorGradeShadowLum", + level: Level::Correction, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "Do not use (research §3.4). [known from Camera Raw 18.6; never observed in LrC output]", + }, + KeySpec { + name: "LocalColorGradeHighlightHue", + level: Level::Correction, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "Do not use (research §3.4). [known from Camera Raw 18.6; never observed in LrC output]", + }, + KeySpec { + name: "LocalColorGradeHighlightSat", + level: Level::Correction, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "Do not use (research §3.4). [known from Camera Raw 18.6; never observed in LrC output]", + }, + KeySpec { + name: "LocalColorGradeHighlightLum", + level: Level::Correction, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "Do not use (research §3.4). [known from Camera Raw 18.6; never observed in LrC output]", + }, + KeySpec { + name: "LocalColorGradeMidtoneHue", + level: Level::Correction, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "Do not use (research §3.4). [known from Camera Raw 18.6; never observed in LrC output]", + }, + KeySpec { + name: "LocalColorGradeMidtoneSat", + level: Level::Correction, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "Do not use (research §3.4). [known from Camera Raw 18.6; never observed in LrC output]", + }, + KeySpec { + name: "LocalColorGradeMidtoneLum", + level: Level::Correction, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "Do not use (research §3.4). [known from Camera Raw 18.6; never observed in LrC output]", + }, + KeySpec { + name: "LocalColorGradeGlobalHue", + level: Level::Correction, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "Do not use (research §3.4). [known from Camera Raw 18.6; never observed in LrC output]", + }, + KeySpec { + name: "LocalColorGradeGlobalSat", + level: Level::Correction, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "Do not use (research §3.4). [known from Camera Raw 18.6; never observed in LrC output]", + }, + KeySpec { + name: "LocalColorGradeGlobalLum", + level: Level::Correction, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "Do not use (research §3.4). [known from Camera Raw 18.6; never observed in LrC output]", + }, + KeySpec { + name: "LocalColorGradeBalance", + level: Level::Correction, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "Do not use (research §3.4). [known from Camera Raw 18.6; never observed in LrC output]", + }, + KeySpec { + name: "LocalColorGradeBlending", + level: Level::Correction, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "Do not use (research §3.4). [known from Camera Raw 18.6; never observed in LrC output]", + }, + KeySpec { + name: "LocalToningLuminance", + level: Level::Correction, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "Do not use (research §3.4). [known from Camera Raw 18.6; never observed in LrC output]", + }, + KeySpec { + name: "LocalGlow", + level: Level::Correction, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "Do not use (research §3.4). [known from Camera Raw 18.6; never observed in LrC output]", + }, + KeySpec { + name: "CorrectionColorTable", + level: Level::Correction, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "Do not use (research §3.4). [known from Camera Raw 18.6; never observed in LrC output]", + }, + KeySpec { + name: "CorrectionLUTName", + level: Level::Correction, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "Do not use (research §3.4). [known from Camera Raw 18.6; never observed in LrC output]", + }, + KeySpec { + name: "CorrectionLUTAmount", + level: Level::Correction, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "Do not use (research §3.4). [known from Camera Raw 18.6; never observed in LrC output]", + }, + KeySpec { + name: "CorrectionID", + level: Level::Correction, + kind: ValueKind::Str, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "Runtime ids; never write. [Lua/catalog only]", + }, + KeySpec { + name: "CorrectionReferenceX", + level: Level::Correction, + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "Runtime ids; never write. [Lua/catalog only]", + }, + KeySpec { + name: "CorrectionReferenceY", + level: Level::Correction, + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "Runtime ids; never write. [Lua/catalog only]", + }, + KeySpec { + name: "What", + level: Level::MaskTool, + kind: ValueKind::Enum(&["Mask/Image", "Mask/Gradient", "Mask/CircularGradient", "Mask/RangeMask", "Mask/Aggregate", "Mask/Paint", "Mask/Polygon", "Mask/Ellipse", "Mask/FlattenedGroup", "Mask/Clip", "Mask/Combine"]), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::LearnGated(Gate::ByMaskType), + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Policy follows the type: Image = LEARN, Gradient/CircularGradient/Polygon = PHOTO, Paint/Aggregate = NEVER.", + }, + KeySpec { + name: "MaskActive", + level: Level::MaskTool, + kind: ValueKind::Bool(BoolStyle::Lower), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Bool(true)), non_raw: Def::Unverified }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "MaskName", + level: Level::MaskTool, + kind: ValueKind::Str, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Never key on it.", + }, + KeySpec { + name: "MaskBlendMode", + level: Level::MaskTool, + kind: ValueKind::EnumInt(&[0, 1]), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Unverified }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Intersect = 1 + MaskValue 0 + MaskInverted true (105 corpus cases).", + }, + KeySpec { + name: "MaskInverted", + level: Level::MaskTool, + kind: ValueKind::Bool(BoolStyle::Lower), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Bool(false)), non_raw: Def::Unverified }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "MaskSyncID", + level: Level::MaskTool, + kind: ValueKind::Hex32, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "MaskValue", + level: Level::MaskTool, + kind: ValueKind::Real, + range: Some((Finite::new_const(0.0), Finite::new_const(1.0))), + ui: UiScale::Identity, + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Real(Finite::new_const(1.0))), non_raw: Def::Unverified }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "0 iff MaskBlendMode = 1 (235/235).", + }, + KeySpec { + name: "MaskID", + level: Level::MaskTool, + kind: ValueKind::Str, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "Runtime id; never write. [Lua/catalog only]", + }, + KeySpec { + name: "MaskVersion", + level: Level::MaskTool, + kind: ValueKind::Int, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(1)), non_raw: Def::Unverified }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "MaskSubType", + level: Level::MaskTool, + kind: ValueKind::EnumInt(&[0, 1, 2, 3]), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Subtype 3 = \"all people\" is unconfirmed; 0 + part id = one specific person. [LrC 11+]", + }, + KeySpec { + name: "MaskSubCategoryID", + level: Level::MaskTool, + kind: ValueKind::Int, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Full tables in Dev-AI-Edit-XMP-Findings.md. 20036 generic form unknown. [landscape classes LrC 13+]", + }, + KeySpec { + name: "ReferencePoint", + level: Level::MaskTool, + kind: ValueKind::Str, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::CompoundFixed6, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Write the constant 0.5 0.5 for preset-form semantic masks (META); a real point selects a person/object (PHOTO).", + }, + KeySpec { + name: "ErrorReason", + level: Level::MaskTool, + kind: ValueKind::EnumInt(&[0, 1]), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Unverified }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Adobe presets write 0; read back after an AI update and surface 1 as a warning.", + }, + KeySpec { + name: "Gesture", + level: Level::MaskTool, + kind: ValueKind::StructSeq(StructKind::Gesture), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Frame-dependent (research §4.2). Polygon form only; never paint strokes. [\"Select Object\"]", + }, + KeySpec { + name: "MaskDigest", + level: Level::MaskTool, + kind: ValueKind::Hex32, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Computed-mask bookkeeping (bitmap lives in .acr / catalog). FullMaskSize ~ \"2880,1920\"; WholeImageArea rationals.", + }, + KeySpec { + name: "InputDigest", + level: Level::MaskTool, + kind: ValueKind::Hex32, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Computed-mask bookkeeping (bitmap lives in .acr / catalog). FullMaskSize ~ \"2880,1920\"; WholeImageArea rationals.", + }, + KeySpec { + name: "InputDigestVersion", + level: Level::MaskTool, + kind: ValueKind::Int, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Computed-mask bookkeeping (bitmap lives in .acr / catalog). FullMaskSize ~ \"2880,1920\"; WholeImageArea rationals.", + }, + KeySpec { + name: "LocalInputDigest", + level: Level::MaskTool, + kind: ValueKind::Hex32, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Computed-mask bookkeeping (bitmap lives in .acr / catalog). FullMaskSize ~ \"2880,1920\"; WholeImageArea rationals.", + }, + KeySpec { + name: "LocalInputDigestVersion", + level: Level::MaskTool, + kind: ValueKind::Int, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Computed-mask bookkeeping (bitmap lives in .acr / catalog). FullMaskSize ~ \"2880,1920\"; WholeImageArea rationals.", + }, + KeySpec { + name: "ModelVersion", + level: Level::MaskTool, + kind: ValueKind::VersionU32, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Computed-mask bookkeeping (bitmap lives in .acr / catalog). FullMaskSize ~ \"2880,1920\"; WholeImageArea rationals.", + }, + KeySpec { + name: "WholeImageArea", + level: Level::MaskTool, + kind: ValueKind::Str, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Computed-mask bookkeeping (bitmap lives in .acr / catalog). FullMaskSize ~ \"2880,1920\"; WholeImageArea rationals.", + }, + KeySpec { + name: "Origin", + level: Level::MaskTool, + kind: ValueKind::Str, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Computed-mask bookkeeping (bitmap lives in .acr / catalog). FullMaskSize ~ \"2880,1920\"; WholeImageArea rationals.", + }, + KeySpec { + name: "FullMaskSize", + level: Level::MaskTool, + kind: ValueKind::Str, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Computed-mask bookkeeping (bitmap lives in .acr / catalog). FullMaskSize ~ \"2880,1920\"; WholeImageArea rationals.", + }, + KeySpec { + name: "DidOverrideInputDigestMismatch", + level: Level::MaskTool, + kind: ValueKind::Bool(BoolStyle::Lower), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Computed-mask bookkeeping (bitmap lives in .acr / catalog). FullMaskSize ~ \"2880,1920\"; WholeImageArea rationals.", + }, + KeySpec { + name: "TargetImageArea", + level: Level::MaskTool, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "[known from Camera Raw; never observed in LrC output]", + }, + KeySpec { + name: "EdgeShift", + level: Level::MaskTool, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "[known from Camera Raw; never observed in LrC output]", + }, + KeySpec { + name: "SemanticObjectPrompt", + level: Level::MaskTool, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "SemanticObjectPrompt suggests text-prompted object masks; relevant for step 4 (dodge & burn tier 2). [known from Camera Raw 18.6; never observed in LrC output]", + }, + KeySpec { + name: "ExtendedSemanticName", + level: Level::MaskTool, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "SemanticObjectPrompt suggests text-prompted object masks; relevant for step 4 (dodge & burn tier 2). [known from Camera Raw 18.6; never observed in LrC output]", + }, + KeySpec { + name: "ExtendedInstanceID", + level: Level::MaskTool, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "SemanticObjectPrompt suggests text-prompted object masks; relevant for step 4 (dodge & burn tier 2). [known from Camera Raw 18.6; never observed in LrC output]", + }, + KeySpec { + name: "InstanceIDs", + level: Level::MaskTool, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "SemanticObjectPrompt suggests text-prompted object masks; relevant for step 4 (dodge & burn tier 2). [known from Camera Raw 18.6; never observed in LrC output]", + }, + KeySpec { + name: "InstanceID", + level: Level::MaskTool, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "SemanticObjectPrompt suggests text-prompted object masks; relevant for step 4 (dodge & burn tier 2). [known from Camera Raw 18.6; never observed in LrC output]", + }, + KeySpec { + name: "InstanceBounds", + level: Level::MaskTool, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "SemanticObjectPrompt suggests text-prompted object masks; relevant for step 4 (dodge & burn tier 2). [known from Camera Raw 18.6; never observed in LrC output]", + }, + KeySpec { + name: "ZeroX", + level: Level::MaskTool, + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "0 % point.", + }, + KeySpec { + name: "ZeroY", + level: Level::MaskTool, + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "FullX", + level: Level::MaskTool, + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "100 % point.", + }, + KeySpec { + name: "FullY", + level: Level::MaskTool, + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Direction/orthogonality in pixel space is derived, not measured.", + }, + KeySpec { + name: "Bidirectional", + level: Level::MaskTool, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "Newer gradient variants. [known from Camera Raw; never observed in LrC output]", + }, + KeySpec { + name: "Zero2X", + level: Level::MaskTool, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "Newer gradient variants. [known from Camera Raw; never observed in LrC output]", + }, + KeySpec { + name: "Zero2Y", + level: Level::MaskTool, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "Newer gradient variants. [known from Camera Raw; never observed in LrC output]", + }, + KeySpec { + name: "ZeroFeather", + level: Level::MaskTool, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "Newer gradient variants. [known from Camera Raw; never observed in LrC output]", + }, + KeySpec { + name: "Zero2Feather", + level: Level::MaskTool, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "Newer gradient variants. [known from Camera Raw; never observed in LrC output]", + }, + KeySpec { + name: "FullPointDistance", + level: Level::MaskTool, + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "Newer gradient variants. [known from Camera Raw; never observed in LrC output]", + }, + KeySpec { + name: "Top", + level: Level::MaskTool, + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "Left", + level: Level::MaskTool, + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "Bottom", + level: Level::MaskTool, + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "Right", + level: Level::MaskTool, + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "Angle", + level: Level::MaskTool, + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Real(Finite::new_const(0.0))), non_raw: Def::Unverified }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Convention (sign, rotated corners) uncalibrated: write 0 only.", + }, + KeySpec { + name: "Midpoint", + level: Level::MaskTool, + kind: ValueKind::Int, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(50)), non_raw: Def::Unverified }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Always 50.", + }, + KeySpec { + name: "Roundness", + level: Level::MaskTool, + kind: ValueKind::Int, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Unverified }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "Feather", + level: Level::MaskTool, + kind: ValueKind::Int, + range: Some((Finite::new_const(0.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(50)), non_raw: Def::Unverified }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "Flipped", + level: Level::MaskTool, + kind: ValueKind::Bool(BoolStyle::Lower), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Always = !MaskInverted (276 + 186 corpus radials).", + }, + KeySpec { + name: "Version", + level: Level::MaskTool, + kind: ValueKind::Int, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(2)), non_raw: Def::Unverified }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "CorrectionRangeMask", + level: Level::MaskTool, + kind: ValueKind::Struct(StructKind::CorrectionRangeMask), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::LearnGated(Gate::ByMaskType), + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Luminance (Type 2) is LEARN, colour (Type 1) PHOTO.", + }, + KeySpec { + name: "Masks", + level: Level::MaskTool, + kind: ValueKind::ComponentSeq, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "Dabs", + level: Level::MaskTool, + kind: ValueKind::StrSeq, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::CompoundFixed6, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Up to 218 KB per sidecar.", + }, + KeySpec { + name: "Radius", + level: Level::MaskTool, + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "Flow", + level: Level::MaskTool, + kind: ValueKind::Real, + range: Some((Finite::new_const(0.0), Finite::new_const(1.0))), + ui: UiScale::Identity, + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "CenterWeight", + level: Level::MaskTool, + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "MaskBrushTable", + level: Level::MaskTool, + kind: ValueKind::Hex32, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "[strokes moved to .acr/catalog]", + }, + KeySpec { + name: "MaskBrushUncompressedBytes", + level: Level::MaskTool, + kind: ValueKind::Int, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "[strokes moved to .acr/catalog]", + }, + KeySpec { + name: "Name", + level: Level::Struct(StructKind::Look), + kind: ValueKind::Str, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "UUID", + level: Level::Struct(StructKind::Look), + kind: ValueKind::Hex32, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Must match an installed profile.", + }, + KeySpec { + name: "Amount", + level: Level::Struct(StructKind::Look), + kind: ValueKind::Real, + range: Some((Finite::new_const(0.0), Finite::new_const(2.0))), + ui: UiScale::Div(100.0), + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Real(Finite::new_const(1.0))), non_raw: Def::Unverified }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "Stubbed", + level: Level::Struct(StructKind::Look), + kind: ValueKind::Bool(BoolStyle::Lower), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Presets carry a stub (no Parameters); whether applyDevelopSettings accepts a stub is E11.", + }, + KeySpec { + name: "Parameters", + level: Level::Struct(StructKind::Look), + kind: ValueKind::Settings, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "LrC fills it from its profile library. [sidecars only]", + }, + KeySpec { + name: "Group", + level: Level::Struct(StructKind::Look), + kind: ValueKind::LangAlt, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Copied from the profile.", + }, + KeySpec { + name: "SortName", + level: Level::Struct(StructKind::Look), + kind: ValueKind::LangAlt, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "Cluster", + level: Level::Struct(StructKind::Look), + kind: ValueKind::Str, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "Copyright", + level: Level::Struct(StructKind::Look), + kind: ValueKind::Str, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "CameraModelRestriction", + level: Level::Struct(StructKind::Look), + kind: ValueKind::Str, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Also the learning gate: never transfer such a Look to another camera.", + }, + KeySpec { + name: "isAdobeAdaptive", + level: Level::Struct(StructKind::Look), + kind: ValueKind::Bool(BoolStyle::Lower), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "Marks an Adobe Adaptive profile (needs an AI update, see AILook). [Lua only (getDevelopSettings() of adaptive profiles); never observed in XMP]", + }, + KeySpec { + name: "SupportsAmount", + level: Level::Struct(StructKind::Look), + kind: ValueKind::Bool(BoolStyle::Lower), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "SupportsMonochrome", + level: Level::Struct(StructKind::Look), + kind: ValueKind::Bool(BoolStyle::Lower), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "SupportsOutputReferred", + level: Level::Struct(StructKind::Look), + kind: ValueKind::Bool(BoolStyle::Lower), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "Active", + level: Level::Struct(StructKind::LensBlur), + kind: ValueKind::Bool(BoolStyle::Lower), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Bool(false)), non_raw: Def::Unverified }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Needs a depth map (AI update). [LrC 13+]", + }, + KeySpec { + name: "BlurAmount", + level: Level::Struct(StructKind::LensBlur), + kind: ValueKind::Int, + range: Some((Finite::new_const(0.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(50)), non_raw: Def::Unverified }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "BokehShape", + level: Level::Struct(StructKind::LensBlur), + kind: ValueKind::Int, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Unverified }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "BokehShapeDetail", + level: Level::Struct(StructKind::LensBlur), + kind: ValueKind::Int, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Unverified }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "BokehAspect", + level: Level::Struct(StructKind::LensBlur), + kind: ValueKind::Int, + range: Some((Finite::new_const(-100.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Unverified }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "BokehRotation", + level: Level::Struct(StructKind::LensBlur), + kind: ValueKind::Int, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Unverified }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "CatEyeAmount", + level: Level::Struct(StructKind::LensBlur), + kind: ValueKind::Int, + range: Some((Finite::new_const(0.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Unverified }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "CatEyeScale", + level: Level::Struct(StructKind::LensBlur), + kind: ValueKind::Int, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(100)), non_raw: Def::Unverified }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "HighlightsBoost", + level: Level::Struct(StructKind::LensBlur), + kind: ValueKind::Int, + range: Some((Finite::new_const(0.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(50)), non_raw: Def::Unverified }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "HighlightsThreshold", + level: Level::Struct(StructKind::LensBlur), + kind: ValueKind::Int, + range: Some((Finite::new_const(0.0), Finite::new_const(100.0))), + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(50)), non_raw: Def::Unverified }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "SphericalAberration", + level: Level::Struct(StructKind::LensBlur), + kind: ValueKind::Int, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Unverified }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "Version", + level: Level::Struct(StructKind::LensBlur), + kind: ValueKind::Int, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(1)), non_raw: Def::Unverified }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "FocalRange", + level: Level::Struct(StructKind::LensBlur), + kind: ValueKind::Str, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Preset value vs per-photo focus range; whether LrC re-focuses on the subject when applied from a preset is unverified.", + }, + KeySpec { + name: "FocalRangeSource", + level: Level::Struct(StructKind::LensBlur), + kind: ValueKind::Int, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "SampledArea", + level: Level::Struct(StructKind::LensBlur), + kind: ValueKind::Str, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Per-photo focus sampling.", + }, + KeySpec { + name: "SampledRange", + level: Level::Struct(StructKind::LensBlur), + kind: ValueKind::Str, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Per-photo focus sampling.", + }, + KeySpec { + name: "SubjectRange", + level: Level::Struct(StructKind::LensBlur), + kind: ValueKind::Str, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Per-photo focus sampling.", + }, + KeySpec { + name: "ImageOrientation", + level: Level::Struct(StructKind::LensBlur), + kind: ValueKind::Int, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Per-photo focus sampling.", + }, + KeySpec { + name: "Version", + level: Level::Struct(StructKind::CorrectionRangeMask), + kind: ValueKind::Int, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(3)), non_raw: Def::Unverified }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "Type", + level: Level::Struct(StructKind::CorrectionRangeMask), + kind: ValueKind::EnumInt(&[1, 2, 3]), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::LearnGated(Gate::ByMaskType), + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Type 2 is LEARN; Type 1 is PHOTO (photo-specific samples).", + }, + KeySpec { + name: "Invert", + level: Level::Struct(StructKind::CorrectionRangeMask), + kind: ValueKind::Bool(BoolStyle::Lower), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Bool(false)), non_raw: Def::Unverified }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "SampleType", + level: Level::Struct(StructKind::CorrectionRangeMask), + kind: ValueKind::EnumInt(&[0, 1]), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Value(Lit::Int(0)), non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Meaning unverified.", + }, + KeySpec { + name: "LumRange", + level: Level::Struct(StructKind::CorrectionRangeMask), + kind: ValueKind::Str, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::CompoundFixed6, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Learn, + frame: FrameScope::Shareable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Threshold-only, frame-independent.", + }, + KeySpec { + name: "LuminanceDepthSampleInfo", + level: Level::Struct(StructKind::CorrectionRangeMask), + kind: ValueKind::Str, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::CompoundFixed6, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "ColorAmount", + level: Level::Struct(StructKind::CorrectionRangeMask), + kind: ValueKind::Real, + range: Some((Finite::new_const(0.0), Finite::new_const(1.0))), + ui: UiScale::Div(100.0), + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "[colour ranges]", + }, + KeySpec { + name: "PointModels", + level: Level::Struct(StructKind::CorrectionRangeMask), + kind: ValueKind::StrSeq, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::CompoundFixed6, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "[colour ranges]", + }, + KeySpec { + name: "AreaModels", + level: Level::Struct(StructKind::CorrectionRangeMask), + kind: ValueKind::StructSeq(StructKind::AreaModel), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "[colour ranges]", + }, + KeySpec { + name: "AreaComponents", + level: Level::Struct(StructKind::AreaModel), + kind: ValueKind::StrSeq, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "[inside AreaModels]", + }, + KeySpec { + name: "ColorRangeMaskAreaSampleInfo", + level: Level::Struct(StructKind::AreaModel), + kind: ValueKind::Str, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "[inside AreaModels]", + }, + KeySpec { + name: "LumMin", + level: Level::Struct(StructKind::CorrectionRangeMask), + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "[known from Camera Raw; never observed in LrC output (legacy LrC 7 range masks)]", + }, + KeySpec { + name: "LumMax", + level: Level::Struct(StructKind::CorrectionRangeMask), + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "[known from Camera Raw; never observed in LrC output (legacy LrC 7 range masks)]", + }, + KeySpec { + name: "LumFeather", + level: Level::Struct(StructKind::CorrectionRangeMask), + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "[known from Camera Raw; never observed in LrC output (legacy LrC 7 range masks)]", + }, + KeySpec { + name: "DepthMin", + level: Level::Struct(StructKind::CorrectionRangeMask), + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "[known from Camera Raw; never observed in LrC output (legacy LrC 7 range masks)]", + }, + KeySpec { + name: "DepthMax", + level: Level::Struct(StructKind::CorrectionRangeMask), + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "[known from Camera Raw; never observed in LrC output (legacy LrC 7 range masks)]", + }, + KeySpec { + name: "DepthFeather", + level: Level::Struct(StructKind::CorrectionRangeMask), + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "[known from Camera Raw; never observed in LrC output (legacy LrC 7 range masks)]", + }, + KeySpec { + name: "DepthRange", + level: Level::Struct(StructKind::CorrectionRangeMask), + kind: ValueKind::Any, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Unobserved, + min_pv: None, + recipe_alias: None, + note: "[known from Camera Raw; never observed in LrC output (legacy LrC 7 range masks)]", + }, + KeySpec { + name: "What", + level: Level::Struct(StructKind::Gesture), + kind: ValueKind::Enum(&["Mask/Polygon", "Mask/Paint"]), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "Points", + level: Level::Struct(StructKind::Gesture), + kind: ValueKind::StructSeq(StructKind::GesturePoint), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "The only gesture form we may write (a box as prompt).", + }, + KeySpec { + name: "X", + level: Level::Struct(StructKind::GesturePoint), + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "[inside Points]", + }, + KeySpec { + name: "Y", + level: Level::Struct(StructKind::GesturePoint), + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Photo, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "[inside Points]", + }, + KeySpec { + name: "Dabs", + level: Level::Struct(StructKind::Gesture), + kind: ValueKind::StrSeq, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::CompoundFixed6, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "[paint-stroke gestures]", + }, + KeySpec { + name: "Radius", + level: Level::Struct(StructKind::Gesture), + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "[paint-stroke gestures]", + }, + KeySpec { + name: "Flow", + level: Level::Struct(StructKind::Gesture), + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "[paint-stroke gestures]", + }, + KeySpec { + name: "CenterWeight", + level: Level::Struct(StructKind::Gesture), + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "[paint-stroke gestures]", + }, + KeySpec { + name: "BrushGestureInterpretation", + level: Level::Struct(StructKind::Gesture), + kind: ValueKind::Int, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "[paint-stroke gestures]", + }, + KeySpec { + name: "MaskActive", + level: Level::Struct(StructKind::Gesture), + kind: ValueKind::Bool(BoolStyle::Lower), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "MaskBlendMode", + level: Level::Struct(StructKind::Gesture), + kind: ValueKind::EnumInt(&[0, 1]), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "MaskInverted", + level: Level::Struct(StructKind::Gesture), + kind: ValueKind::Bool(BoolStyle::Lower), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "MaskSyncID", + level: Level::Struct(StructKind::Gesture), + kind: ValueKind::Hex32, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "MaskValue", + level: Level::Struct(StructKind::Gesture), + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Meta, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "", + }, + KeySpec { + name: "MaskID", + level: Level::Struct(StructKind::Gesture), + kind: ValueKind::Str, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "Runtime id; never write. [Lua/catalog only]", + }, + KeySpec { + name: "Active", + level: Level::Struct(StructKind::AiLook), + kind: ValueKind::Bool(BoolStyle::Lower), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Produced by the AI update.", + }, + KeySpec { + name: "AILookData", + level: Level::Struct(StructKind::AiLook), + kind: ValueKind::Str, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Produced by the AI update.", + }, + KeySpec { + name: "InputDigest", + level: Level::Struct(StructKind::AiLook), + kind: ValueKind::Hex32, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Produced by the AI update.", + }, + KeySpec { + name: "InputDigestVersion", + level: Level::Struct(StructKind::AiLook), + kind: ValueKind::Int, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Produced by the AI update.", + }, + KeySpec { + name: "ModelVersion", + level: Level::Struct(StructKind::AiLook), + kind: ValueKind::VersionU32, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Produced by the AI update.", + }, + KeySpec { + name: "Version", + level: Level::Struct(StructKind::AiLook), + kind: ValueKind::VersionU32, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Produced by the AI update.", + }, + KeySpec { + name: "RangeMaskMapInfo", + level: Level::Struct(StructKind::RangeMaskMapInfo), + kind: ValueKind::Struct(StructKind::RangeMaskMapInfo), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Written as rdf:parseType=\"Resource\" wrapper around an inner RangeMaskMapInfo struct.", + }, + KeySpec { + name: "RGBMin", + level: Level::Struct(StructKind::RangeMaskMapInfo), + kind: ValueKind::Str, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::CompoundFixed6, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Written as rdf:parseType=\"Resource\" wrapper around an inner RangeMaskMapInfo struct.", + }, + KeySpec { + name: "RGBMax", + level: Level::Struct(StructKind::RangeMaskMapInfo), + kind: ValueKind::Str, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::CompoundFixed6, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Written as rdf:parseType=\"Resource\" wrapper around an inner RangeMaskMapInfo struct.", + }, + KeySpec { + name: "LabMin", + level: Level::Struct(StructKind::RangeMaskMapInfo), + kind: ValueKind::Str, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::CompoundFixed6, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Written as rdf:parseType=\"Resource\" wrapper around an inner RangeMaskMapInfo struct.", + }, + KeySpec { + name: "LabMax", + level: Level::Struct(StructKind::RangeMaskMapInfo), + kind: ValueKind::Str, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::CompoundFixed6, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Written as rdf:parseType=\"Resource\" wrapper around an inner RangeMaskMapInfo struct.", + }, + KeySpec { + name: "LumEq", + level: Level::Struct(StructKind::RangeMaskMapInfo), + kind: ValueKind::StrSeq, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::CompoundFixed6, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Written as rdf:parseType=\"Resource\" wrapper around an inner RangeMaskMapInfo struct.", + }, + KeySpec { + name: "DepthSource", + level: Level::Struct(StructKind::DepthMapInfo), + kind: ValueKind::Int, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "10 keys.", + }, + KeySpec { + name: "BaseRawDepthTable", + level: Level::Struct(StructKind::DepthMapInfo), + kind: ValueKind::Hex32, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "10 keys.", + }, + KeySpec { + name: "BaseRawDepthInputDigest", + level: Level::Struct(StructKind::DepthMapInfo), + kind: ValueKind::Hex32, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "10 keys.", + }, + KeySpec { + name: "BaseRawDepthVersion", + level: Level::Struct(StructKind::DepthMapInfo), + kind: ValueKind::Int, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "10 keys.", + }, + KeySpec { + name: "BaseLayeredDepthTable", + level: Level::Struct(StructKind::DepthMapInfo), + kind: ValueKind::Hex32, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "10 keys.", + }, + KeySpec { + name: "BaseLayeredDepthInputDigest", + level: Level::Struct(StructKind::DepthMapInfo), + kind: ValueKind::Hex32, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "10 keys.", + }, + KeySpec { + name: "BaseLayeredDepthVersion", + level: Level::Struct(StructKind::DepthMapInfo), + kind: ValueKind::Int, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "10 keys.", + }, + KeySpec { + name: "BaseHighlightGuideTable", + level: Level::Struct(StructKind::DepthMapInfo), + kind: ValueKind::Hex32, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "10 keys.", + }, + KeySpec { + name: "BaseHighlightGuideInputDigest", + level: Level::Struct(StructKind::DepthMapInfo), + kind: ValueKind::Hex32, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "10 keys.", + }, + KeySpec { + name: "BaseHighlightGuideVersion", + level: Level::Struct(StructKind::DepthMapInfo), + kind: ValueKind::Int, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "10 keys.", + }, + KeySpec { + name: "ISO", + level: Level::Struct(StructKind::IsoDependent), + kind: ValueKind::Int, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Interpolated by ISO on apply (inferred). Not for v1. [presets only]", + }, + KeySpec { + name: "ColorNoiseReduction", + level: Level::Struct(StructKind::IsoDependent), + kind: ValueKind::Int, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Interpolated by ISO on apply (inferred). Not for v1. [presets only]", + }, + KeySpec { + name: "LuminanceSmoothing", + level: Level::Struct(StructKind::IsoDependent), + kind: ValueKind::Int, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Interpolated by ISO on apply (inferred). Not for v1. [presets only]", + }, + KeySpec { + name: "Parameters", + level: Level::Struct(StructKind::Preset), + kind: ValueKind::Settings, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Kept opaque; never merged into the photo's settings. [sidecars only]", + }, + KeySpec { + name: "Name", + level: Level::Struct(StructKind::Preset), + kind: ValueKind::Str, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Written by LrC. Useful as a learning signal (\"which preset did the user start from\"), never written by us. [about a tenth of the sidecars; Parameters subtree excluded from this inventory]", + }, + KeySpec { + name: "UUID", + level: Level::Struct(StructKind::Preset), + kind: ValueKind::Hex32, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Written by LrC. Useful as a learning signal (\"which preset did the user start from\"), never written by us. [about a tenth of the sidecars; Parameters subtree excluded from this inventory]", + }, + KeySpec { + name: "Amount", + level: Level::Struct(StructKind::Preset), + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Written by LrC. Useful as a learning signal (\"which preset did the user start from\"), never written by us. [about a tenth of the sidecars; Parameters subtree excluded from this inventory]", + }, + KeySpec { + name: "LookAmount", + level: Level::Struct(StructKind::Preset), + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Written by LrC. Useful as a learning signal (\"which preset did the user start from\"), never written by us. [about a tenth of the sidecars; Parameters subtree excluded from this inventory]", + }, + KeySpec { + name: "Group", + level: Level::Struct(StructKind::Preset), + kind: ValueKind::LangAlt, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Written by LrC. Useful as a learning signal (\"which preset did the user start from\"), never written by us. [about a tenth of the sidecars; Parameters subtree excluded from this inventory]", + }, + KeySpec { + name: "Cluster", + level: Level::Struct(StructKind::Preset), + kind: ValueKind::Str, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Written by LrC. Useful as a learning signal (\"which preset did the user start from\"), never written by us. [about a tenth of the sidecars; Parameters subtree excluded from this inventory]", + }, + KeySpec { + name: "Baseline", + level: Level::Struct(StructKind::Preset), + kind: ValueKind::Str, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Written by LrC. Useful as a learning signal (\"which preset did the user start from\"), never written by us. [about a tenth of the sidecars; Parameters subtree excluded from this inventory]", + }, + KeySpec { + name: "SupportsAmount", + level: Level::Struct(StructKind::Preset), + kind: ValueKind::Bool(BoolStyle::Lower), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Written by LrC. Useful as a learning signal (\"which preset did the user start from\"), never written by us. [about a tenth of the sidecars; Parameters subtree excluded from this inventory]", + }, + KeySpec { + name: "SupportsMonochrome", + level: Level::Struct(StructKind::Preset), + kind: ValueKind::Bool(BoolStyle::Lower), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Written by LrC. Useful as a learning signal (\"which preset did the user start from\"), never written by us. [about a tenth of the sidecars; Parameters subtree excluded from this inventory]", + }, + KeySpec { + name: "SupportsOutputReferred", + level: Level::Struct(StructKind::Preset), + kind: ValueKind::Bool(BoolStyle::Lower), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Computed, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Written by LrC. Useful as a learning signal (\"which preset did the user start from\"), never written by us. [about a tenth of the sidecars; Parameters subtree excluded from this inventory]", + }, + KeySpec { + name: "SpotType", + level: Level::Struct(StructKind::RetouchArea), + kind: ValueKind::Str, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Heal/clone/remove spots (Masks = Mask/Ellipse or Mask/Paint).", + }, + KeySpec { + name: "Method", + level: Level::Struct(StructKind::RetouchArea), + kind: ValueKind::Str, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Heal/clone/remove spots (Masks = Mask/Ellipse or Mask/Paint).", + }, + KeySpec { + name: "SourceState", + level: Level::Struct(StructKind::RetouchArea), + kind: ValueKind::Str, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Heal/clone/remove spots (Masks = Mask/Ellipse or Mask/Paint).", + }, + KeySpec { + name: "Opacity", + level: Level::Struct(StructKind::RetouchArea), + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Heal/clone/remove spots (Masks = Mask/Ellipse or Mask/Paint).", + }, + KeySpec { + name: "Feather", + level: Level::Struct(StructKind::RetouchArea), + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Heal/clone/remove spots (Masks = Mask/Ellipse or Mask/Paint).", + }, + KeySpec { + name: "Seed", + level: Level::Struct(StructKind::RetouchArea), + kind: ValueKind::Int, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Heal/clone/remove spots (Masks = Mask/Ellipse or Mask/Paint).", + }, + KeySpec { + name: "OffsetY", + level: Level::Struct(StructKind::RetouchArea), + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Heal/clone/remove spots (Masks = Mask/Ellipse or Mask/Paint).", + }, + KeySpec { + name: "SourceX", + level: Level::Struct(StructKind::RetouchArea), + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Heal/clone/remove spots (Masks = Mask/Ellipse or Mask/Paint).", + }, + KeySpec { + name: "HealVersion", + level: Level::Struct(StructKind::RetouchArea), + kind: ValueKind::Int, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Int, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Heal/clone/remove spots (Masks = Mask/Ellipse or Mask/Paint).", + }, + KeySpec { + name: "IngestInfo", + level: Level::Struct(StructKind::RetouchArea), + kind: ValueKind::Str, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Heal/clone/remove spots (Masks = Mask/Ellipse or Mask/Paint).", + }, + KeySpec { + name: "fill_method", + level: Level::Struct(StructKind::RetouchArea), + kind: ValueKind::Str, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Heal/clone/remove spots (Masks = Mask/Ellipse or Mask/Paint).", + }, + KeySpec { + name: "Masks", + level: Level::Struct(StructKind::RetouchArea), + kind: ValueKind::ComponentSeq, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "Heal/clone/remove spots (Masks = Mask/Ellipse or Mask/Paint).", + }, + KeySpec { + name: "centerX", + level: Level::Struct(StructKind::RetouchArea), + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "Same fields as a RetouchInfo item. [Lua only: legacy spot fields next to the new ones in a few getDevelopSettings() retouch areas]", + }, + KeySpec { + name: "centerY", + level: Level::Struct(StructKind::RetouchArea), + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "Same fields as a RetouchInfo item. [Lua only: legacy spot fields next to the new ones in a few getDevelopSettings() retouch areas]", + }, + KeySpec { + name: "opacity", + level: Level::Struct(StructKind::RetouchArea), + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "Same fields as a RetouchInfo item. [Lua only: legacy spot fields next to the new ones in a few getDevelopSettings() retouch areas]", + }, + KeySpec { + name: "radius", + level: Level::Struct(StructKind::RetouchArea), + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "Same fields as a RetouchInfo item. [Lua only: legacy spot fields next to the new ones in a few getDevelopSettings() retouch areas]", + }, + KeySpec { + name: "seed", + level: Level::Struct(StructKind::RetouchArea), + kind: ValueKind::Int, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "Same fields as a RetouchInfo item. [Lua only: legacy spot fields next to the new ones in a few getDevelopSettings() retouch areas]", + }, + KeySpec { + name: "sourceState", + level: Level::Struct(StructKind::RetouchArea), + kind: ValueKind::Str, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "Same fields as a RetouchInfo item. [Lua only: legacy spot fields next to the new ones in a few getDevelopSettings() retouch areas]", + }, + KeySpec { + name: "sourceX", + level: Level::Struct(StructKind::RetouchArea), + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "Same fields as a RetouchInfo item. [Lua only: legacy spot fields next to the new ones in a few getDevelopSettings() retouch areas]", + }, + KeySpec { + name: "sourceY", + level: Level::Struct(StructKind::RetouchArea), + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "Same fields as a RetouchInfo item. [Lua only: legacy spot fields next to the new ones in a few getDevelopSettings() retouch areas]", + }, + KeySpec { + name: "spotType", + level: Level::Struct(StructKind::RetouchArea), + kind: ValueKind::Str, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "Same fields as a RetouchInfo item. [Lua only: legacy spot fields next to the new ones in a few getDevelopSettings() retouch areas]", + }, + KeySpec { + name: "X", + level: Level::MaskTool, + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "[heal spot shape]", + }, + KeySpec { + name: "Y", + level: Level::MaskTool, + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "[heal spot shape]", + }, + KeySpec { + name: "SizeX", + level: Level::MaskTool, + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "[heal spot shape]", + }, + KeySpec { + name: "SizeY", + level: Level::MaskTool, + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "[heal spot shape]", + }, + KeySpec { + name: "Alpha", + level: Level::MaskTool, + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "[heal spot shape]", + }, + KeySpec { + name: "CenterValue", + level: Level::MaskTool, + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "[heal spot shape]", + }, + KeySpec { + name: "PerimeterValue", + level: Level::MaskTool, + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Trim6, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::Both, + min_pv: None, + recipe_alias: None, + note: "[heal spot shape]", + }, + KeySpec { + name: "Method", + level: Level::Struct(StructKind::RetouchInfo), + kind: ValueKind::Str, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "Legacy heal/clone spot. [Lua only (the XMP form is one string per spot)]", + }, + KeySpec { + name: "centerX", + level: Level::Struct(StructKind::RetouchInfo), + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "Legacy heal/clone spot. [Lua only (the XMP form is one string per spot)]", + }, + KeySpec { + name: "centerY", + level: Level::Struct(StructKind::RetouchInfo), + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "Legacy heal/clone spot. [Lua only (the XMP form is one string per spot)]", + }, + KeySpec { + name: "opacity", + level: Level::Struct(StructKind::RetouchInfo), + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "Legacy heal/clone spot. [Lua only (the XMP form is one string per spot)]", + }, + KeySpec { + name: "radius", + level: Level::Struct(StructKind::RetouchInfo), + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "Legacy heal/clone spot. [Lua only (the XMP form is one string per spot)]", + }, + KeySpec { + name: "sourceState", + level: Level::Struct(StructKind::RetouchInfo), + kind: ValueKind::Str, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "Legacy heal/clone spot. [Lua only (the XMP form is one string per spot)]", + }, + KeySpec { + name: "sourceX", + level: Level::Struct(StructKind::RetouchInfo), + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "Legacy heal/clone spot. [Lua only (the XMP form is one string per spot)]", + }, + KeySpec { + name: "sourceY", + level: Level::Struct(StructKind::RetouchInfo), + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "Legacy heal/clone spot. [Lua only (the XMP form is one string per spot)]", + }, + KeySpec { + name: "spotType", + level: Level::Struct(StructKind::RetouchInfo), + kind: ValueKind::Str, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::NoDefault, non_raw: Def::NoDefault }, + policy: Policy::Never, + frame: FrameScope::PerFrameOnly, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "Legacy heal/clone spot. [Lua only (the XMP form is one string per spot)]", + }, + KeySpec { + name: "SrcHue", + level: Level::Struct(StructKind::PointColor), + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "Sampled colour and its shift; values come from one photo. Round-trip only. [Lua only (the XMP form is one string of 19 floats per swatch)]", + }, + KeySpec { + name: "SrcSat", + level: Level::Struct(StructKind::PointColor), + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "Sampled colour and its shift; values come from one photo. Round-trip only. [Lua only (the XMP form is one string of 19 floats per swatch)]", + }, + KeySpec { + name: "SrcLum", + level: Level::Struct(StructKind::PointColor), + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "Sampled colour and its shift; values come from one photo. Round-trip only. [Lua only (the XMP form is one string of 19 floats per swatch)]", + }, + KeySpec { + name: "HueShift", + level: Level::Struct(StructKind::PointColor), + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "Sampled colour and its shift; values come from one photo. Round-trip only. [Lua only (the XMP form is one string of 19 floats per swatch)]", + }, + KeySpec { + name: "SatScale", + level: Level::Struct(StructKind::PointColor), + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "Sampled colour and its shift; values come from one photo. Round-trip only. [Lua only (the XMP form is one string of 19 floats per swatch)]", + }, + KeySpec { + name: "LumScale", + level: Level::Struct(StructKind::PointColor), + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "Sampled colour and its shift; values come from one photo. Round-trip only. [Lua only (the XMP form is one string of 19 floats per swatch)]", + }, + KeySpec { + name: "RangeAmount", + level: Level::Struct(StructKind::PointColor), + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "Sampled colour and its shift; values come from one photo. Round-trip only. [Lua only (the XMP form is one string of 19 floats per swatch)]", + }, + KeySpec { + name: "Variance", + level: Level::Struct(StructKind::PointColor), + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "Sampled colour and its shift; values come from one photo. Round-trip only. [Lua only (the XMP form is one string of 19 floats per swatch)]", + }, + KeySpec { + name: "HueRange", + level: Level::Struct(StructKind::PointColor), + kind: ValueKind::Struct(StructKind::PointColorRange), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "[Lua only]", + }, + KeySpec { + name: "SatRange", + level: Level::Struct(StructKind::PointColor), + kind: ValueKind::Struct(StructKind::PointColorRange), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "[Lua only]", + }, + KeySpec { + name: "LumRange", + level: Level::Struct(StructKind::PointColor), + kind: ValueKind::Struct(StructKind::PointColorRange), + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "[Lua only]", + }, + KeySpec { + name: "LowerNone", + level: Level::Struct(StructKind::PointColorRange), + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "Range with feathered edges. [Lua only]", + }, + KeySpec { + name: "LowerFull", + level: Level::Struct(StructKind::PointColorRange), + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "Range with feathered edges. [Lua only]", + }, + KeySpec { + name: "UpperFull", + level: Level::Struct(StructKind::PointColorRange), + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "Range with feathered edges. [Lua only]", + }, + KeySpec { + name: "UpperNone", + level: Level::Struct(StructKind::PointColorRange), + kind: ValueKind::Real, + range: None, + ui: UiScale::Identity, + fmt: NumFmt::Text, + plus_sign: false, + default: DefaultSpec { raw: Def::Unverified, non_raw: Def::Unverified }, + policy: Policy::Unknown, + frame: FrameScope::NotApplicable, + file_kind: FileScope::Both, + presence: Presence::LuaOnly, + min_pv: None, + recipe_alias: None, + note: "Range with feathered edges. [Lua only]", + }, +]; diff --git a/server-rs/crates/lrg-develop/src/registry/tests.rs b/server-rs/crates/lrg-develop/src/registry/tests.rs new file mode 100644 index 00000000..4c2da048 --- /dev/null +++ b/server-rs/crates/lrg-develop/src/registry/tests.rs @@ -0,0 +1,744 @@ +//! Registry invariants (see Dev-Develop-Model, "Maintaining the registry") +//! and spot checks. + +use std::collections::{HashMap, HashSet}; + +use super::*; + +fn row(level: Level, name: &str) -> &'static KeySpec { + lookup(level, name) + .unwrap_or_else(|| panic!("{level}/{name} missing")) + .spec() +} + +fn f(x: f64) -> Finite { + Finite::new(x).unwrap() +} + +/// Every numeric literal of a default, as a float. +fn numeric_default(d: Def) -> Option { + match d { + Def::Value(Lit::Int(i)) => Some(i as f64), + Def::Value(Lit::Real(r)) => Some(r.get()), + _ => None, + } +} + +#[test] +fn level_and_name_identify_a_row() { + let mut seen = HashSet::new(); + for (id, spec) in iter() { + assert!( + seen.insert((spec.level, spec.name)), + "duplicate row {}/{}", + spec.level, + spec.name + ); + assert_eq!(lookup(spec.level, spec.name), Some(id)); + assert_eq!(id.spec().name, spec.name); + } + assert_eq!(seen.len(), table().len()); + assert!(table().len() < usize::from(u16::MAX)); +} + +#[test] +fn the_same_name_can_mean_different_keys_on_different_levels() { + let global = row(Level::Global, "Version"); + let radial = row(Level::MaskTool, "Version"); + assert_eq!(global.kind, ValueKind::VersionStr); + assert_eq!(radial.kind, ValueKind::Int); + assert_ne!( + lookup(Level::Global, "Version"), + lookup(Level::MaskTool, "Version") + ); +} + +#[test] +fn ranges_are_ordered_and_integer_keys_have_integer_bounds() { + for (_, s) in iter() { + let Some((min, max)) = s.range else { continue }; + assert!( + s.kind.is_numeric(), + "{}: range on a non-numeric key", + s.name + ); + assert!(min < max, "{}: empty range", s.name); + if s.kind.is_integer() { + assert!( + min.get().fract() == 0.0 && max.get().fract() == 0.0, + "{}: fractional bound on an integer key", + s.name + ); + } + } +} + +#[test] +fn every_range_contains_its_default() { + for (_, s) in iter() { + let Some((min, max)) = s.range else { continue }; + for d in [s.default.raw, s.default.non_raw] { + if let Some(v) = numeric_default(d) { + assert!( + min.get() <= v && v <= max.get(), + "{}/{}: default {v} outside {min}..{max}", + s.level, + s.name + ); + } + } + } +} + +#[test] +fn defaults_match_the_value_kind() { + for (_, s) in iter() { + for d in [s.default.raw, s.default.non_raw] { + let Def::Value(lit) = d else { continue }; + let ok = match (s.kind, lit) { + (ValueKind::Int | ValueKind::VersionU32, Lit::Int(_)) => true, + (ValueKind::IntFlag, Lit::Int(i)) => i == 0 || i == 1, + (ValueKind::EnumInt(set), Lit::Int(i)) => set.contains(&i), + (ValueKind::Real, Lit::Real(_)) => true, + (ValueKind::Bool(_), Lit::Bool(_)) => true, + (ValueKind::Enum(set), Lit::Str(v)) => set.contains(&v), + (ValueKind::Str, Lit::Str(_)) => true, + (ValueKind::Curve(CurveKind::Global), Lit::IntList(v)) => { + v.len() >= 4 && v.len() % 2 == 0 + } + _ => false, + }; + assert!( + ok, + "{}/{}: default {lit:?} vs {:?}", + s.level, s.name, s.kind + ); + } + } +} + +#[test] +fn plus_sign_only_on_the_global_level() { + for (_, s) in iter() { + if s.plus_sign { + assert_eq!(s.level, Level::Global, "{}", s.name); + assert!( + matches!(s.kind, ValueKind::Int | ValueKind::Real), + "{}", + s.name + ); + } + } + // XMP rule 8: signed sliders, plus the two keys that always carry "+". + for name in [ + "Exposure2012", + "Tint", + "SharpenRadius", + "HDRMaxValue", + "VignetteAmount", + ] { + assert!(row(Level::Global, name).plus_sign, "{name}"); + } + for name in [ + "Temperature", + "CropAngle", + "CropTop", + "Sharpness", + "SplitToningShadowHue", + ] { + assert!(!row(Level::Global, name).plus_sign, "{name}"); + } + assert!(!row(Level::Correction, "LocalExposure2012").plus_sign); +} + +#[test] +fn bool_spelling_follows_the_level() { + fn check(level: Level, name: &str, kind: ValueKind) { + match kind { + ValueKind::Bool(BoolStyle::Lower) => { + assert!( + level.is_inside_struct(), + "{level}/{name}: lower-case bool outside a struct" + ) + } + ValueKind::Bool(BoolStyle::TitleCase) => assert!( + !level.is_inside_struct(), + "{level}/{name}: title-case bool inside a struct" + ), + _ => {} + } + } + for (_, s) in iter() { + check(s.level, s.name, s.kind.lua_form()); + check(s.level, s.name, s.kind.xmp_form()); + } +} + +#[test] +fn recipe_aliases_are_unique_and_only_on_learnable_global_keys() { + let mut seen = HashMap::new(); + for (_, s) in iter() { + let Some(alias) = s.recipe_alias else { + continue; + }; + assert!( + s.level == Level::Global && s.policy.is_learnable(), + "{}: alias on a non-learnable or non-global key", + s.name + ); + assert!( + alias.starts_with("global.") + && alias.split('.').all( + |p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_lowercase() || b == b'_') + ), + "{}: malformed alias {alias}", + s.name + ); + if let Some(other) = seen.insert(alias, s.name) { + panic!("alias {alias} on both {other} and {}", s.name); + } + } + // Every field the installed plugin applies from `global` (1f), minus + // white balance (1d): 36 sliders and parametric curve values, 24 + // HSL values, 5 colour-grading values. + assert_eq!(seen.len(), 65); + // A path must never end where another continues (`global.hsl` next to + // `global.hsl.red.hue`): the recipe builder nests objects along them. + for a in seen.keys() { + for b in seen.keys() { + assert!( + !b.starts_with(&format!("{a}.")), + "alias {a} is a prefix of {b}" + ); + } + } + assert_eq!( + row(Level::Global, "HueAdjustmentAqua").recipe_alias, + Some("global.hsl.aqua.hue") + ); + assert_eq!( + row(Level::Global, "SplitToningHighlightSaturation").recipe_alias, + Some("global.color_grading.highlights.saturation") + ); + assert_eq!( + row(Level::Global, "ParametricMidtoneSplit").recipe_alias, + Some("global.tone_curve.midtone_split") + ); + assert_eq!( + row(Level::Global, "GrainFrequency").recipe_alias, + Some("global.grain_roughness") + ); + // The installed plugin warns about and drops these (1f leaves them out). + for name in [ + "ColorGradeMidtoneHue", + "ColorGradeGlobalHue", + "ColorGradeShadowLum", + "ColorGradeBlending", + "ToneCurvePV2012", + ] { + assert_eq!(row(Level::Global, name).recipe_alias, None, "{name}"); + } + assert_eq!( + row(Level::Global, "Exposure2012").recipe_alias, + Some("global.exposure") + ); + assert_eq!( + row(Level::Global, "ParametricShadows").recipe_alias, + Some("global.tone_curve.shadows") + ); + assert_eq!(row(Level::Global, "Temperature").recipe_alias, None); + assert_eq!(row(Level::Global, "Tint").recipe_alias, None); +} + +#[test] +fn raw_only_keys_are_absent_for_non_raw_files_and_vice_versa() { + for (_, s) in iter() { + match s.policy.gate() { + Some(Gate::RawOnly) => { + assert_eq!(s.default.non_raw, Def::Absent, "{}", s.name); + assert_eq!(s.file_kind, FileScope::RawOnly, "{}", s.name); + } + Some(Gate::NonRawOnly) => { + assert_eq!(s.default.raw, Def::Absent, "{}", s.name); + assert_eq!(s.file_kind, FileScope::NonRawOnly, "{}", s.name); + } + _ => assert_eq!(s.file_kind, FileScope::Both, "{}", s.name), + } + } + for name in ["Temperature", "Tint"] { + assert_eq!( + row(Level::Global, name).policy, + Policy::LearnGated(Gate::RawOnly) + ); + } + for name in ["IncrementalTemperature", "IncrementalTint"] { + assert_eq!( + row(Level::Global, name).policy, + Policy::LearnGated(Gate::NonRawOnly) + ); + } +} + +/// Lightroom's non-raw defaults as read back by experiment E13 (one non-raw +/// photo without develop edits; provenance inside the file). The generator +/// (`scripts/develop_registry/gen_table_rs.py`) fills the non-raw column from +/// it; this file is what a claimed non-raw default must come from. +const NON_RAW_READBACK: &str = + include_str!("../../../../testdata/develop/defaults/non_raw_lrc15.6.json"); + +/// Readback keys held back from the non-raw defaults because the one photo +/// read may carry a per-image value; the same set as the generator's +/// `NON_RAW_READBACK_NOT_CONSTANT`. +const NON_RAW_READBACK_NOT_CONSTANT: &[&str] = &["HDRMaxValue"]; + +fn non_raw_readback() -> serde_json::Map { + let doc: serde_json::Value = serde_json::from_str(NON_RAW_READBACK).unwrap(); + for k in ["lightroom", "process_version", "file_kind"] { + assert!( + doc["provenance"][k].as_str().is_some_and(|s| !s.is_empty()), + "provenance.{k} missing" + ); + } + doc["settings"].as_object().unwrap().clone() +} + +fn lit_equals_json(lit: Lit, v: &serde_json::Value) -> bool { + match lit { + Lit::Int(i) => v.as_i64() == Some(i), + Lit::Real(r) => v.as_f64() == Some(r.get()), + Lit::Bool(b) => v.as_bool() == Some(b), + Lit::Str(s) => v.as_str() == Some(s), + Lit::IntList(l) => v.as_array().is_some_and(|a| { + a.len() == l.len() && a.iter().zip(l).all(|(x, y)| x.as_i64() == Some(*y)) + }), + } +} + +/// Every claimed non-raw default is a value Lightroom read back for a +/// non-raw photo, and every global row the readback covers that the text +/// left unverified got its value: nothing is invented for a key the +/// readback does not have (absence can mean "written only when used"). +#[test] +fn non_raw_defaults_come_from_the_readback() { + let readback = non_raw_readback(); + for (_, s) in iter() { + let read = (s.level == Level::Global) + .then(|| readback.get(s.name)) + .flatten(); + match (s.default.non_raw, read) { + (Def::Value(lit), Some(v)) => assert!( + lit_equals_json(lit, v), + "{}: non-raw default {lit:?}, read back {v}", + s.name + ), + (Def::Value(lit), None) => { + panic!("{}: non-raw default {lit:?} without a readback", s.name) + } + (Def::Unverified, Some(_)) if NON_RAW_READBACK_NOT_CONSTANT.contains(&s.name) => {} + (Def::Unverified, Some(v)) => { + panic!("{}: read back as {v} but still unverified", s.name) + } + (Def::Absent, Some(v)) => panic!("{}: absent for non-raw, read back as {v}", s.name), + _ => {} + } + } + for key in readback.keys() { + assert!(lookup(Level::Global, key).is_some(), "{key}: no global row"); + } + for key in NON_RAW_READBACK_NOT_CONSTANT { + assert!(readback.contains_key(*key), "{key}: held back but not read"); + assert_eq!(row(Level::Global, key).default.non_raw, Def::Unverified); + } +} + +/// What the readback changes against the raw column: the keys whose +/// defaults really differ by file kind. +#[test] +fn the_non_raw_readback_differs_from_raw_where_expected() { + let non_raw = |name| row(Level::Global, name).default.non_raw; + assert_eq!(non_raw("Sharpness"), Def::Value(Lit::Int(0))); + assert_eq!( + row(Level::Global, "Sharpness").default.raw, + Def::Value(Lit::Int(40)) + ); + assert_eq!(non_raw("ColorNoiseReduction"), Def::Value(Lit::Int(0))); + assert_eq!( + row(Level::Global, "ColorNoiseReduction").default.raw, + Def::Value(Lit::Int(25)) + ); + assert_eq!(non_raw("CameraProfile"), Def::Value(Lit::Str("Embedded"))); + assert_eq!(non_raw("WhiteBalance"), Def::Value(Lit::Str("As Shot"))); + assert_eq!(non_raw("IncrementalTemperature"), Def::Value(Lit::Int(0))); + assert_eq!(non_raw("IncrementalTint"), Def::Value(Lit::Int(0))); + // Raw-only keys stay absent: the readback has no `Temperature`. + assert_eq!(non_raw("Temperature"), Def::Absent); + assert_eq!(non_raw("Tint"), Def::Absent); + // Per-photo or written-when-used keys keep `NoDefault` whatever the + // readback shows (the Upright solver state). + assert_eq!(non_raw("UprightVersion"), Def::NoDefault); +} + +#[test] +fn lua_only_keys_carry_no_xmp_format_rules() { + for (_, s) in iter() { + if s.presence == Presence::LuaOnly { + assert!(!s.plus_sign, "{}", s.name); + assert_eq!(s.fmt, NumFmt::Text, "{}", s.name); + } + } + for name in [ + "EnableCalibration", + "orientation", + "CropConstrainAspectRatio", + ] { + assert_eq!( + row(Level::Global, name).presence, + Presence::LuaOnly, + "{name}" + ); + } + assert_eq!( + row(Level::Correction, "CorrectionID").presence, + Presence::LuaOnly + ); + assert_eq!(row(Level::MaskTool, "MaskID").presence, Presence::LuaOnly); +} + +#[test] +fn preset_header_and_derived_flags_are_xmp_only() { + for (_, s) in iter() { + if s.level == Level::Header { + assert_eq!(s.presence, Presence::XmpOnly, "{}", s.name); + // The header is ours to write; only `Baseline` is not understood. + assert!( + matches!(s.policy, Policy::Meta | Policy::Unknown), + "{}", + s.name + ); + } + } + for name in ["HasCrop", "HasSettings", "AlreadyApplied", "RawFileName"] { + assert_eq!( + row(Level::Global, name).presence, + Presence::XmpOnly, + "{name}" + ); + } +} + +#[test] +fn number_formats_fit_the_value_kind() { + for (_, s) in iter() { + let ok = match s.fmt { + NumFmt::Int => s.kind.is_integer() || matches!(s.kind, ValueKind::Curve(_)), + NumFmt::Fixed(_) => matches!(s.kind, ValueKind::Real | ValueKind::VersionStr), + NumFmt::Trim6 => s.kind == ValueKind::Real, + NumFmt::CompoundFixed6 => { + matches!(s.kind.xmp_form(), ValueKind::Str | ValueKind::StrSeq) + } + NumFmt::Text => !s.kind.is_numeric() || s.presence == Presence::LuaOnly, + }; + assert!(ok, "{}/{}: {:?} on {:?}", s.level, s.name, s.fmt, s.kind); + } + assert_eq!(row(Level::Global, "Exposure2012").fmt, NumFmt::Fixed(2)); + assert_eq!(row(Level::Global, "SharpenRadius").fmt, NumFmt::Fixed(1)); + assert_eq!(row(Level::Global, "ProcessVersion").fmt, NumFmt::Fixed(1)); + assert_eq!(row(Level::Global, "CropAngle").fmt, NumFmt::Trim6); + assert_eq!( + row(Level::MaskTool, "ReferencePoint").fmt, + NumFmt::CompoundFixed6 + ); +} + +#[test] +fn ui_scales_apply_to_numbers_only() { + for (_, s) in iter() { + match s.ui { + UiScale::Identity => {} + UiScale::Div(d) => { + assert!(d > 0.0 && d.is_finite(), "{}", s.name); + assert_eq!(s.kind, ValueKind::Real, "{}: scaled non-real key", s.name); + } + UiScale::Unknown => assert!(s.kind.is_numeric(), "{}", s.name), + } + } + let exp = row(Level::Correction, "LocalExposure2012"); + assert_eq!(exp.ui, UiScale::Div(4.0)); + assert_eq!(exp.range, Some((f(-1.0), f(1.0)))); + let amount = row(Level::Correction, "CorrectionAmount"); + assert_eq!(amount.ui, UiScale::Div(100.0)); + assert_eq!(amount.range, Some((f(0.0), f(2.0)))); + assert_eq!( + row(Level::Correction, "LocalToningHue").ui, + UiScale::Identity + ); + assert_eq!( + row(Level::Correction, "LocalCurveRefineSaturation").ui, + UiScale::Identity + ); + assert_eq!(row(Level::Correction, "LocalHue").ui, UiScale::Unknown); + assert_eq!(row(Level::Correction, "LocalGrain").ui, UiScale::Unknown); + assert_eq!(row(Level::Global, "Exposure2012").ui, UiScale::Identity); +} + +#[test] +fn frame_scope_follows_the_policy_class() { + for (_, s) in iter() { + let ok = match s.policy { + Policy::Computed | Policy::Meta | Policy::Unknown => { + s.frame == FrameScope::NotApplicable + } + Policy::Never => s.frame == FrameScope::PerFrameOnly, + Policy::Photo => matches!( + s.frame, + FrameScope::PerFrameOnly | FrameScope::PerFrameAdjusted + ), + Policy::Learn | Policy::LearnGated(_) => { + matches!( + s.frame, + FrameScope::Shareable | FrameScope::PerFrameAdjusted + ) + } + }; + assert!( + ok, + "{}/{}: {:?} with {:?}", + s.level, s.name, s.frame, s.policy + ); + } + assert_eq!( + row(Level::Global, "Exposure2012").frame, + FrameScope::PerFrameAdjusted + ); + assert_eq!( + row(Level::Global, "WhiteBalance").frame, + FrameScope::PerFrameAdjusted + ); + assert_eq!( + row(Level::Global, "CropTop").frame, + FrameScope::PerFrameOnly + ); + assert_eq!( + row(Level::Global, "HueAdjustmentRed").frame, + FrameScope::Shareable + ); +} + +#[test] +fn gates_are_consistent() { + for (_, s) in iter() { + match s.policy.gate() { + Some(Gate::DependsOn(k)) => { + assert!( + lookup(Level::Global, k).is_some(), + "{}: gate names unknown {k}", + s.name + ) + } + Some(Gate::OnlyValues(values)) => { + let ValueKind::Enum(all) = s.kind else { + panic!("{}: OnlyValues on a non-enum key", s.name) + }; + assert!(values.iter().all(|v| all.contains(v)), "{}", s.name); + } + Some(Gate::CircularHue(weight)) => { + let (_, max) = s + .range + .unwrap_or_else(|| panic!("{}: hue without range", s.name)); + assert!(max.get() >= 359.0, "{}: not an angle", s.name); + // The weight is a saturation of the same level, never negative. + let w = lookup(s.level, weight) + .unwrap_or_else(|| panic!("{}: weight {weight} unknown", s.name)) + .spec(); + assert!(w.name.contains("Sat"), "{}: weight {weight}", s.name); + assert!( + w.range.is_some_and(|(lo, _)| lo.get() >= 0.0), + "{}: weight {weight} can be negative", + s.name + ); + } + _ => {} + } + } + // Hue offsets of the colour mixer are linear, the grading hues circular. + assert_eq!(row(Level::Global, "HueAdjustmentRed").policy, Policy::Learn); + assert_eq!( + row(Level::Global, "SplitToningShadowHue").policy, + Policy::LearnGated(Gate::CircularHue("SplitToningShadowSaturation")) + ); + assert_eq!( + row(Level::Global, "ConvertToGrayscale").policy, + Policy::LearnGated(Gate::Categorical) + ); +} + +#[test] +fn unobserved_names_carry_no_semantics() { + for (_, s) in iter() { + if s.presence == Presence::Unobserved { + assert!( + matches!(s.policy, Policy::Unknown | Policy::Computed), + "{}: unobserved key with policy {:?}", + s.name, + s.policy + ); + assert!(s.recipe_alias.is_none() && !s.plus_sign, "{}", s.name); + } + } +} + +#[test] +fn every_structure_is_reachable_and_has_fields() { + let mut referenced = HashSet::new(); + let mut note = |k: ValueKind| { + if let ValueKind::Struct(sk) | ValueKind::StructSeq(sk) = k { + referenced.insert(sk); + } + }; + for (_, s) in iter() { + note(s.kind.lua_form()); + note(s.kind.xmp_form()); + } + for &kind in StructKind::ALL { + if kind == StructKind::Unknown { + continue; + } + assert!( + referenced.contains(&kind), + "{kind:?} is not referenced by any key" + ); + let level = Level::Struct(kind); + let has_rows = iter().any(|(_, s)| s.level == level); + let has_pattern = PATTERNS.iter().any(|p| p.levels.contains(&level)); + assert!(has_rows || has_pattern, "{kind:?} has no fields"); + } + for (_, s) in iter() { + if let Level::Struct(kind) = s.level { + assert!( + referenced.contains(&kind), + "{}: level {kind:?} unreachable", + s.name + ); + } + } +} + +#[test] +fn patterns_never_shadow_a_row() { + for (_, s) in iter() { + if let Some(p) = match_pattern(s.level, s.name) { + panic!("{}/{} also matches pattern {}", s.level, s.name, p.id); + } + } +} + +#[test] +fn resolve_prefers_rows_then_patterns() { + assert!(matches!( + resolve(Level::Global, "Exposure2012"), + Resolved::Key(_) + )); + assert!(matches!( + resolve(Level::Global, "UprightTransform_3"), + Resolved::Pattern(p) if p.id == "upright_transform" + )); + assert!(matches!( + resolve(Level::Global, "NoSuchKey"), + Resolved::Unknown + )); +} + +#[test] +fn the_plugin_temperature_bug_key_is_not_a_registry_key() { + // Built from pieces so no backend source holds the misspelt key as a + // literal, which a later grep check enforces. + let bug = concat!("Te", "mp"); + assert!(matches!(resolve(Level::Global, bug), Resolved::Unknown)); +} + +#[test] +fn per_format_shapes_are_limited_to_the_known_keys() { + let per_format: Vec<_> = iter() + .filter(|(_, s)| matches!(s.kind, ValueKind::PerFormat { .. })) + .map(|(_, s)| s.name) + .collect(); + assert_eq!(per_format, ["PointColors", "RetouchInfo"]); + let pc = row(Level::Global, "PointColors"); + assert_eq!(pc.kind.xmp_form(), ValueKind::StrSeq); + assert_eq!( + pc.kind.lua_form(), + ValueKind::StructSeq(StructKind::PointColor) + ); +} + +#[test] +fn process_versions_follow_adobe_numbering() { + // The registry draft had "6.7" as PV2010; Adobe's numbering is 5.7 = PV2010, + // 6.7 = PV2012. + assert_eq!(ProcessVersion::parse("5.7"), Some(ProcessVersion::PV2010)); + assert_eq!(ProcessVersion::parse("6.7"), Some(ProcessVersion::PV2012)); + assert_eq!(ProcessVersion::PV2012.label(), Some("PV2012")); + assert_eq!(ProcessVersion::parse("15.4"), Some(ProcessVersion::V6)); + assert_eq!(ProcessVersion::parse("11.0").unwrap().to_string(), "11.0"); + assert!(ProcessVersion::PV2010 < ProcessVersion::PV2012); + assert!(ProcessVersion::PV2012 < ProcessVersion::V4); + assert!(ProcessVersion::parse("15").is_none()); + assert!(ProcessVersion::parse("x.4").is_none()); + assert!(ProcessVersion::parse("").is_none()); + assert_eq!( + row(Level::Global, "ProcessVersion").kind, + ValueKind::VersionStr + ); +} + +#[test] +fn min_pv_marks_the_2012_keys() { + for (_, s) in iter() { + if let Some(pv) = s.min_pv { + assert_eq!(pv, ProcessVersion::PV2012, "{}", s.name); + } + } + for name in ["Exposure2012", "Contrast2012", "ToneCurvePV2012"] { + assert_eq!( + row(Level::Global, name).min_pv, + Some(ProcessVersion::PV2012), + "{name}" + ); + } + assert_eq!( + row(Level::Correction, "LocalExposure2012").min_pv, + Some(ProcessVersion::PV2012) + ); + assert_eq!(row(Level::Global, "Vibrance").min_pv, None); +} + +#[test] +fn group_rows_are_expanded() { + for color in [ + "Red", "Orange", "Yellow", "Green", "Aqua", "Blue", "Purple", "Magenta", + ] { + for prefix in [ + "HueAdjustment", + "SaturationAdjustment", + "LuminanceAdjustment", + "GrayMixer", + ] { + let name = format!("{prefix}{color}"); + assert!(lookup(Level::Global, &name).is_some(), "{name}"); + } + } + assert!(lookup(Level::Global, "SDRWhites").is_some()); + assert!(lookup(Level::Correction, "BlueCurve").is_some()); +} + +#[test] +fn level_names_are_stable() { + assert_eq!(Level::Global.to_string(), "global"); + assert_eq!(Level::MaskTool.to_string(), "mask-tool"); + assert_eq!( + Level::Struct(StructKind::AiLook).to_string(), + "struct:AILook" + ); + assert!(!Level::Header.is_inside_struct()); + assert!(Level::Correction.is_inside_struct()); +} diff --git a/server-rs/crates/lrg-develop/src/registry/ui.rs b/server-rs/crates/lrg-develop/src/registry/ui.rs new file mode 100644 index 00000000..7b00cba9 --- /dev/null +++ b/server-rs/crates/lrg-develop/src/registry/ui.rs @@ -0,0 +1,279 @@ +//! The one place where stored values and UI values are converted. +//! +//! Lua tables and XMP files both carry the **stored** unit +//! (`LocalExposure2012 = EV / 4`, other signed `Local*` = UI / 100). UI units +//! exist only at the recipe/LLM/display boundary, and they go through here. +//! +//! [`from_ui_value`] is what a builder or blend uses: it returns the model +//! [`Value`] of the key's kind (`Value::Int` for `Contrast2012`), so the +//! integer rounding happens here and in [`KeySpec::coerce`] only. +//! [`from_ui`]/[`from_ui_clamped`] give the same number as a [`Finite`], for +//! display and comparison. + +use super::{CoerceError, Finite, KeySpec, Scalar, UiScale}; +use crate::model::Value; + +/// Why a UI value could not be converted. +#[derive(Clone, Debug, PartialEq, thiserror::Error)] +pub enum UiError { + /// The key is not a number. + #[error("{key}: not a numeric key")] + NotNumeric { + /// Key name. + key: &'static str, + }, + /// The key's UI scale is unverified (`LocalHue`, `LocalGrain`), so no + /// value may be converted for it. + #[error("{key}: the UI scale is not verified; the value cannot be converted")] + UnknownScale { + /// Key name. + key: &'static str, + }, + /// The stored value falls outside the key's range. + #[error("{key}: {value} (stored unit) is outside {min}..{max}")] + OutOfRange { + /// Key name. + key: &'static str, + /// The stored value before clamping. + value: Finite, + /// Lower bound (stored unit). + min: Finite, + /// Upper bound (stored unit). + max: Finite, + }, + /// The value does not fit the key's kind (a flag or enum value). + #[error(transparent)] + Coerce(#[from] CoerceError), + /// The conversion left the range of `f64` (a huge stored value times the + /// key's divisor). + #[error("{key}: the converted value is not a finite number")] + NotFinite { + /// Key name. + key: &'static str, + }, +} + +fn divisor(spec: &KeySpec) -> Result { + if !spec.kind.is_numeric() { + return Err(UiError::NotNumeric { key: spec.name }); + } + match spec.ui { + UiScale::Identity => Ok(1.0), + UiScale::Div(d) => Ok(d), + UiScale::Unknown => Err(UiError::UnknownScale { key: spec.name }), + } +} + +fn finite(spec: &KeySpec, x: f64) -> Result { + // A finite stored value times the divisor can still overflow (1e307 × + // 100); dividing by a divisor ≥ 1 cannot. + Finite::new(x).map_err(|_| UiError::NotFinite { key: spec.name }) +} + +fn as_finite(spec: &KeySpec, s: Scalar) -> Result { + match s { + Scalar::Int(i) => Ok(Finite::from_i64(i)), + Scalar::Real(r) => Ok(r), + Scalar::Bool(_) => Err(UiError::NotNumeric { key: spec.name }), + } +} + +/// Stored value → UI value (`LocalExposure2012` 0.0625 → 0.25 EV). +pub fn to_ui(spec: &KeySpec, stored: Finite) -> Result { + let d = divisor(spec)?; + finite(spec, stored.get() * d) +} + +/// UI value → stored value, rounded for integer keys ([`KeySpec::coerce`]) +/// and checked against the key's range. Out of range is an error; use +/// [`from_ui_clamped`] to clamp instead. +pub fn from_ui(spec: &KeySpec, ui: Finite) -> Result { + match from_ui_clamped(spec, ui)? { + (v, None) => Ok(v), + (_, Some(err)) => Err(err), + } +} + +/// Like [`from_ui`], but clamps an out-of-range value to the key's range and +/// returns the [`UiError::OutOfRange`] alongside it, so the caller can report +/// the clamping (a builder as `Skipped`, a blend as a warning). +pub fn from_ui_clamped(spec: &KeySpec, ui: Finite) -> Result<(Finite, Option), UiError> { + let (s, err) = stored_scalar(spec, ui)?; + Ok((as_finite(spec, s)?, err)) +} + +/// [`from_ui_clamped`] as the model value of the key's kind: `Value::Int` +/// for integer keys (equal to what the readers produce), `Value::Real` for +/// real keys. This is the form a builder or blend stores. +pub fn from_ui_value(spec: &KeySpec, ui: Finite) -> Result<(Value, Option), UiError> { + let (s, err) = stored_scalar(spec, ui)?; + Ok((Value::from(s), err)) +} + +/// UI → stored, clamped to the range (with the error alongside), coerced to +/// the key's kind. +fn stored_scalar(spec: &KeySpec, ui: Finite) -> Result<(Scalar, Option), UiError> { + let d = divisor(spec)?; + let stored = finite(spec, ui.get() / d)?; + let (min, max) = match spec.range { + Some(r) => r, + None => return Ok((spec.coerce(stored)?.value, None)), + }; + if stored < min || stored > max { + let clamped = stored.clamp(min, max); + let err = UiError::OutOfRange { + key: spec.name, + value: stored, + min, + max, + }; + return Ok((spec.coerce(clamped)?.value, Some(err))); + } + Ok((spec.coerce(stored)?.value, None)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::registry::{iter, lookup, Level, Policy}; + + fn spec(level: Level, name: &str) -> &'static KeySpec { + lookup(level, name) + .unwrap_or_else(|| panic!("{level}/{name} missing")) + .spec() + } + + fn f(x: f64) -> Finite { + Finite::new(x).unwrap() + } + + #[test] + fn local_exposure_is_stored_as_a_quarter() { + let s = spec(Level::Correction, "LocalExposure2012"); + assert_eq!(from_ui(s, f(0.25)).unwrap(), f(0.0625)); + assert_eq!(to_ui(s, f(0.0625)).unwrap(), f(0.25)); + assert_eq!(from_ui(s, f(4.0)).unwrap(), f(1.0)); + assert!(matches!( + from_ui(s, f(5.0)), + Err(UiError::OutOfRange { .. }) + )); + } + + #[test] + fn signed_locals_and_amount_are_stored_per_hundred() { + let shadows = spec(Level::Correction, "LocalShadows2012"); + assert_eq!(from_ui(shadows, f(12.0)).unwrap(), f(0.12)); + let amount = spec(Level::Correction, "CorrectionAmount"); + assert_eq!(from_ui(amount, f(100.0)).unwrap(), f(1.0)); + assert_eq!(from_ui(amount, f(200.0)).unwrap(), f(2.0)); + } + + #[test] + fn identity_keys_stay_in_ui_units() { + let hue = spec(Level::Correction, "LocalToningHue"); + assert_eq!(from_ui(hue, f(240.0)).unwrap(), f(240.0)); + let refine = spec(Level::Correction, "LocalCurveRefineSaturation"); + assert_eq!(from_ui(refine, f(30.0)).unwrap(), f(30.0)); + let exposure = spec(Level::Global, "Exposure2012"); + assert_eq!(from_ui(exposure, f(0.5)).unwrap(), f(0.5)); + } + + #[test] + fn unverified_scales_cannot_be_converted() { + for name in ["LocalHue", "LocalGrain"] { + let s = spec(Level::Correction, name); + assert!(matches!( + from_ui(s, f(1.0)), + Err(UiError::UnknownScale { .. }) + )); + assert!(matches!( + to_ui(s, f(0.1)), + Err(UiError::UnknownScale { .. }) + )); + } + } + + #[test] + fn integer_keys_round_half_even_on_the_way_in() { + let contrast = spec(Level::Global, "Contrast2012"); + assert_eq!(from_ui(contrast, f(12.5)).unwrap(), f(12.0)); + assert_eq!(from_ui(contrast, f(13.5)).unwrap(), f(14.0)); + } + + #[test] + fn clamping_reports_the_original_value() { + let contrast = spec(Level::Global, "Contrast2012"); + let (v, err) = from_ui_clamped(contrast, f(250.0)).unwrap(); + assert_eq!(v, f(100.0)); + assert!(matches!( + err, + Some(UiError::OutOfRange { value, .. }) if value == f(250.0) + )); + let (v, err) = from_ui_clamped(contrast, f(-20.0)).unwrap(); + assert_eq!((v, err), (f(-20.0), None)); + } + + #[test] + fn from_ui_value_keeps_the_key_kind() { + let contrast = spec(Level::Global, "Contrast2012"); + assert_eq!( + from_ui_value(contrast, f(12.5)).unwrap(), + (Value::Int(12), None) + ); + let exposure = spec(Level::Global, "Exposure2012"); + assert_eq!( + from_ui_value(exposure, f(0.5)).unwrap(), + (Value::Real(f(0.5)), None) + ); + let local = spec(Level::Correction, "LocalExposure2012"); + let (v, err) = from_ui_value(local, f(5.0)).unwrap(); + assert_eq!(v, Value::Real(f(1.0))); + assert!(matches!(err, Some(UiError::OutOfRange { .. }))); + } + + #[test] + fn an_overflowing_conversion_is_not_finite() { + let shadows = spec(Level::Correction, "LocalShadows2012"); + assert_eq!( + to_ui(shadows, f(1e307)), + Err(UiError::NotFinite { + key: "LocalShadows2012" + }) + ); + } + + #[test] + fn non_numeric_keys_are_refused() { + let wb = spec(Level::Global, "WhiteBalance"); + assert!(matches!( + from_ui(wb, f(1.0)), + Err(UiError::NotNumeric { .. }) + )); + } + + /// Every learnable correction-level number converts both ways, and the + /// ends of its UI range land on the ends of its stored range. + #[test] + fn every_learnable_correction_key_round_trips_through_ui_units() { + let mut checked = 0; + for (_, s) in iter() { + if s.level != Level::Correction || !s.policy.is_learnable() || !s.kind.is_numeric() { + continue; + } + let (min, max) = s.range.unwrap_or_else(|| panic!("{} has no range", s.name)); + for stored in [min, max, Finite::ZERO.clamp(min, max)] { + let ui = to_ui(s, stored).unwrap(); + let back = from_ui(s, ui).unwrap(); + assert!( + (back.get() - stored.get()).abs() < 1e-12, + "{}: {stored} -> {ui} -> {back}", + s.name + ); + } + checked += 1; + } + assert!(checked >= 20, "only {checked} correction keys checked"); + // The policy filter above must not silently skip the scaled keys. + assert!(spec(Level::Correction, "LocalExposure2012").policy == Policy::Learn); + } +} diff --git a/server-rs/crates/lrg-develop/src/xmp/format.rs b/server-rs/crates/lrg-develop/src/xmp/format.rs new file mode 100644 index 00000000..4880566a --- /dev/null +++ b/server-rs/crates/lrg-develop/src/xmp/format.rs @@ -0,0 +1,431 @@ +//! How the XMP writer spells values: numbers per the registry's [`NumFmt`] +//! and `plus_sign`, booleans per [`BoolStyle`], curve points, compound number +//! strings, ids, packed versions and XML escaping. +//! +//! Every rule here is what Lightroom Classic and Camera Raw write in their own +//! presets and sidecars (the registry's serialization notes, checked against +//! the bundled presets): +//! +//! | Rule | Example | +//! |---|---| +//! | `NumFmt::Int`: no decimal point | `Temperature` 3569 → `3569` | +//! | `NumFmt::Fixed(n)`: exactly `n` decimals | `Exposure2012` 0.5 → `+0.50`, `SharpenRadius` 1 → `+1.0` | +//! | `NumFmt::Trim6`: up to 6 decimals, trailing zeros trimmed | `LocalExposure2012` 0.0625 → `0.0625`, `CorrectionAmount` 1 → `1` | +//! | `NumFmt::CompoundFixed6`: every number `%.6f`, one space apart | `ReferencePoint` → `0.500000 0.500000` | +//! | `+` only on `plus_sign` keys at the global level, only above 0 | `Tint` 6 → `+6`, 0 → `0`, -6 → `-6` | +//! | never `-0` | -0.001 at 2 decimals → `0.00` | +//! | booleans: `True`/`False` global and header, `true`/`false` in structures | `CorrectionActive` → `true` | +//! | global curve points `x, y`, local curve points `x,y` | `0, 0` / `62,53` | +//! | ids: 32 upper-case hex digits | `CorrectionSyncID` | +//! | packed versions: `major << 24 \| minor << 16` | 15.3 → `251854848` | +//! +//! **Lossless mode** (the writer's test round trip): a number the registry +//! format would change (an `Exposure2012` of 0.333 at `Fixed(2)`) is written +//! as the shortest text that reads back as the same `f64` instead, so the +//! round trip compares equal. Lightroom never writes such a number; the +//! preset writer quantizes as the table says. + +use crate::model::value::Finite; +use crate::registry::{round_half_even, BoolStyle, CurveKind, KeySpec, Level, NumFmt}; + +/// A string cannot be written into XML 1.0 at all (a control character other +/// than tab, line feed and carriage return; `U+FFFE`/`U+FFFF`). +#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)] +#[error("character U+{0:04X} cannot appear in an XML 1.0 document")] +pub struct InvalidXmlChar(pub u32); + +fn is_xml_char(c: char) -> bool { + matches!(c, '\t' | '\n' | '\r' | '\u{20}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{10000}'..) +} + +/// Escapes `s` for an attribute value in double quotes: the five XML +/// entities, and tab, line feed and carriage return as character references +/// (a parser normalises them to spaces in an attribute otherwise). +pub fn escape_attr(s: &str) -> Result { + escape(s, true) +} + +/// Escapes `s` for element text: the five XML entities, and carriage return +/// as a character reference (a parser turns a literal one into a line feed). +pub fn escape_text(s: &str) -> Result { + escape(s, false) +} + +fn escape(s: &str, attr: bool) -> Result { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + if !is_xml_char(c) { + return Err(InvalidXmlChar(u32::from(c))); + } + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + '\r' => out.push_str(" "), + '\t' if attr => out.push_str(" "), + '\n' if attr => out.push_str(" "), + c => out.push(c), + } + } + Ok(out) +} + +/// `True`/`False` or `true`/`false`. +pub fn format_bool(style: BoolStyle, b: bool) -> &'static str { + match (style, b) { + (BoolStyle::TitleCase, true) => "True", + (BoolStyle::TitleCase, false) => "False", + (BoolStyle::Lower, true) => "true", + (BoolStyle::Lower, false) => "false", + } +} + +/// Whether a positive value of `spec` gets a `+`: `plus_sign` keys at the +/// global level only (the registry allows the flag nowhere else, and a +/// structure never carries a sign). +fn signed(spec: &KeySpec) -> bool { + spec.plus_sign && spec.level == Level::Global +} + +/// The shortest text that parses back to exactly `x` (Rust's `Display` for +/// `f64`: round-trip exact and never in exponent notation). +fn shortest(x: Finite) -> String { + format!("{}", x.get()) +} + +/// `x` with `decimals` fixed decimals, never `-0…`. +fn fixed(x: Finite, decimals: u8) -> String { + let s = format!("{:.*}", usize::from(decimals), x.get()); + match s.strip_prefix('-') { + Some(rest) if rest.bytes().all(|b| b == b'0' || b == b'.') => rest.to_owned(), + _ => s, + } +} + +/// Up to 6 decimals, trailing zeros (and a trailing point) trimmed. +fn trim6(x: Finite) -> String { + let s = fixed(x, 6); + if s.contains('.') { + s.trim_end_matches('0').trim_end_matches('.').to_owned() + } else { + s + } +} + +/// Keeps `text` when it reads back as `x`; otherwise (lossless mode only) +/// the shortest exact text. +fn exact_or(text: String, x: Finite, lossless: bool) -> String { + if lossless && text.parse::().ok() != Some(x.get()) { + shortest(x) + } else { + text + } +} + +/// An integer as a number of `spec`, with its `+` rule. +pub fn format_int(spec: &KeySpec, i: i64) -> String { + if i > 0 && signed(spec) { + format!("+{i}") + } else { + i.to_string() + } +} + +/// A number of `spec` per its [`NumFmt`] and `+` rule. +/// +/// `NumFmt::Int` rounds half-to-even (a real value on an integer format only +/// reaches the writer through a curve point); `NumFmt::Text` (keys that are +/// no number in XMP, or not written to XMP at all) uses the shortest exact +/// text. With `lossless`, a format that would change the value falls back to +/// the shortest exact text. +pub fn format_real(spec: &KeySpec, x: Finite, lossless: bool) -> String { + let body = number_body(spec.fmt, x, lossless); + if x > Finite::ZERO && signed(spec) { + format!("+{body}") + } else { + body + } +} + +/// A number per `fmt`, without a sign rule. +fn number_body(fmt: NumFmt, x: Finite, lossless: bool) -> String { + let text = match fmt { + NumFmt::Int => match round_half_even(x) { + Some(i) => i.to_string(), + None => shortest(x), + }, + NumFmt::Fixed(n) => fixed(x, n), + NumFmt::Trim6 => trim6(x), + NumFmt::CompoundFixed6 => fixed(x, 6), + NumFmt::Text => shortest(x), + }; + exact_or(text, x, lossless) +} + +/// One curve point: `"x, y"` for a global curve, `"x,y"` for a local one; +/// each coordinate in the curve key's number format (integers). +pub fn format_curve_point( + kind: CurveKind, + fmt: NumFmt, + (x, y): (Finite, Finite), + lossless: bool, +) -> String { + let sep = match kind { + CurveKind::Global => ", ", + CurveKind::Local => ",", + }; + format!( + "{}{sep}{}", + number_body(fmt, x, lossless), + number_body(fmt, y, lossless) + ) +} + +/// Numbers as one compound string: each `%.6f`, one space apart +/// (`ReferencePoint`, `LumRange`). +pub fn format_compound(values: &[Finite], lossless: bool) -> String { + values + .iter() + .map(|x| exact_or(fixed(*x, 6), *x, lossless)) + .collect::>() + .join(" ") +} + +/// Re-spells a compound number string (`"0.5 0.5"` → `"0.500000 +/// 0.500000"`); `None` when a part is not a finite number or there is none. +pub fn reformat_compound(s: &str) -> Option { + let values = s + .split_whitespace() + .map(|t| t.parse::().ok().and_then(|x| Finite::new(x).ok())) + .collect::>>()?; + (!values.is_empty()).then(|| format_compound(&values, false)) +} + +/// 32 hex digits upper-cased; `None` for anything else. +pub fn hex32_upper(s: &str) -> Option { + (s.len() == 32 && s.bytes().all(|b| b.is_ascii_hexdigit())).then(|| s.to_ascii_uppercase()) +} + +/// A Camera Raw engine version (`crs:Version`, `crs:CompatibleVersion`). +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct EngineVersion { + /// Major part (`18` in 18.5). + pub major: u8, + /// Minor part (`5` in 18.5). + pub minor: u8, +} + +impl EngineVersion { + /// `major.minor`. + pub const fn new(major: u8, minor: u8) -> EngineVersion { + EngineVersion { major, minor } + } + + /// The packed form of `crs:CompatibleVersion`: `major << 24 | minor << 16` + /// (15.3 → 251854848). + pub const fn packed(self) -> i64 { + ((self.major as i64) << 24) | ((self.minor as i64) << 16) + } +} + +impl std::fmt::Display for EngineVersion { + /// `crs:Version`'s form, `"18.5"`. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}.{}", self.major, self.minor) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::registry::lookup; + + fn spec(level: Level, name: &str) -> &'static KeySpec { + lookup(level, name).unwrap().spec() + } + + fn g(name: &str) -> &'static KeySpec { + spec(Level::Global, name) + } + + fn f(x: f64) -> Finite { + Finite::new(x).unwrap() + } + + #[test] + fn plan_examples_are_spelled_as_lightroom_does() { + assert_eq!(format_real(g("Exposure2012"), f(0.5), false), "+0.50"); + assert_eq!(format_int(g("Tint"), 6), "+6"); + assert_eq!(format_int(g("Temperature"), 3569), "3569"); + assert_eq!( + format_real( + spec(Level::Correction, "LocalExposure2012"), + f(0.0625), + false + ), + "0.0625" + ); + assert_eq!(format_real(g("SharpenRadius"), f(1.0), false), "+1.0"); + } + + #[test] + fn fixed_formats_keep_their_decimals_and_sign_rule() { + let exposure = g("Exposure2012"); + assert_eq!(format_real(exposure, f(0.0), false), "0.00"); + assert_eq!(format_real(exposure, f(-0.5), false), "-0.50"); + assert_eq!(format_real(exposure, f(1.234), false), "+1.23"); + assert_eq!(format_real(exposure, f(-0.001), false), "0.00", "never -0"); + assert_eq!(format_real(g("SharpenRadius"), f(0.5), false), "+0.5"); + assert_eq!(format_real(g("PerspectiveRotate"), f(0.0), false), "0.0"); + assert_eq!(format_real(g("HDRMaxValue"), f(2.0), false), "+2.00"); + } + + #[test] + fn integers_are_signed_only_on_plus_sign_globals_above_zero() { + assert_eq!(format_int(g("Contrast2012"), 15), "+15"); + assert_eq!(format_int(g("Contrast2012"), 0), "0"); + assert_eq!(format_int(g("Contrast2012"), -50), "-50"); + assert_eq!(format_int(g("IncrementalTint"), 34), "+34"); + assert_eq!(format_int(g("Temperature"), 12500), "12500"); + // Inside a structure a key never gets a sign. + assert_eq!(format_int(spec(Level::MaskTool, "Feather"), 50), "50"); + assert_eq!(format_int(g("CompatibleVersion"), 251854848), "251854848"); + } + + #[test] + fn no_key_below_the_global_level_is_ever_signed() { + for (_, s) in crate::registry::iter().filter(|(_, s)| s.level != Level::Global) { + assert_eq!(format_int(s, 7), "7", "{}/{}", s.level, s.name); + assert!( + !format_real(s, f(0.5), false).starts_with('+'), + "{}", + s.name + ); + } + } + + #[test] + fn trim6_trims_trailing_zeros_and_rounds_at_six_decimals() { + let local = spec(Level::Correction, "LocalClarity2012"); + assert_eq!(format_real(local, f(0.153914), false), "0.153914"); + assert_eq!(format_real(local, f(1.0), false), "1"); + assert_eq!(format_real(local, f(0.0), false), "0"); + assert_eq!(format_real(local, f(-0.25), false), "-0.25"); + assert_eq!(format_real(local, f(0.1234567), false), "0.123457"); + assert_eq!(format_real(local, f(-0.0000001), false), "0", "never -0"); + assert_eq!(format_real(g("CropTop"), f(0.1), false), "0.1", "no sign"); + assert_eq!( + format_real(spec(Level::Correction, "CorrectionAmount"), f(1.0), false), + "1" + ); + } + + #[test] + fn lossless_mode_keeps_what_the_format_would_round() { + let exposure = g("Exposure2012"); + assert_eq!(format_real(exposure, f(0.333), false), "+0.33"); + assert_eq!(format_real(exposure, f(0.333), true), "+0.333"); + assert_eq!( + format_real(exposure, f(0.5), true), + "+0.50", + "an exact value keeps its format" + ); + let local = spec(Level::Correction, "LocalClarity2012"); + assert_eq!(format_real(local, f(0.1234567), true), "0.1234567"); + assert_eq!( + format_real(local, f(1e-7), true), + "0.0000001", + "no exponent" + ); + } + + #[test] + fn booleans_follow_the_level_style() { + assert_eq!(format_bool(BoolStyle::TitleCase, true), "True"); + assert_eq!(format_bool(BoolStyle::TitleCase, false), "False"); + assert_eq!(format_bool(BoolStyle::Lower, true), "true"); + assert_eq!(format_bool(BoolStyle::Lower, false), "false"); + } + + #[test] + fn curve_points_use_a_space_only_on_global_curves() { + let p = (f(0.0), f(0.0)); + assert_eq!( + format_curve_point(CurveKind::Global, NumFmt::Int, p, false), + "0, 0" + ); + assert_eq!( + format_curve_point(CurveKind::Local, NumFmt::Int, (f(62.0), f(53.0)), false), + "62,53" + ); + assert_eq!( + format_curve_point(CurveKind::Global, NumFmt::Int, (f(64.4), f(58.5)), false), + "64, 58", + "rounded half-to-even in a preset" + ); + assert_eq!( + format_curve_point(CurveKind::Local, NumFmt::Int, (f(64.4), f(58.0)), true), + "64.4,58" + ); + } + + #[test] + fn compound_strings_are_six_decimals_space_separated() { + assert_eq!( + format_compound(&[f(0.5), f(0.5)], false), + "0.500000 0.500000" + ); + assert_eq!( + format_compound(&[f(0.0), f(0.2), f(0.8), f(1.0)], false), + "0.000000 0.200000 0.800000 1.000000" + ); + assert_eq!( + reformat_compound("0.5 0.25").as_deref(), + Some("0.500000 0.250000") + ); + assert_eq!(reformat_compound("0.5 x"), None); + assert_eq!(reformat_compound(""), None); + assert_eq!(format_compound(&[f(0.1234567)], true), "0.1234567"); + } + + #[test] + fn ids_are_upper_case_hex32() { + assert_eq!( + hex32_upper("00000000000000000000000000000a0b").as_deref(), + Some("00000000000000000000000000000A0B") + ); + assert_eq!(hex32_upper("0000"), None); + assert_eq!(hex32_upper("0000000000000000000000000000000g"), None); + } + + #[test] + fn engine_versions_pack_like_compatible_version() { + assert_eq!(EngineVersion::new(15, 3).packed(), 251_854_848); + assert_eq!(EngineVersion::new(14, 0).packed(), 234_881_024); + assert_eq!(EngineVersion::new(17, 4).packed(), 285_474_816); + assert_eq!(EngineVersion::new(18, 5).to_string(), "18.5"); + } + + #[test] + fn escaping_covers_the_five_entities_and_whitespace_in_attributes() { + assert_eq!( + escape_attr(r#"a&bd"e'f"#).unwrap(), + "a&b<c>d"e'f" + ); + assert_eq!( + escape_text(r#"a&bd"e'f"#).unwrap(), + "a&b<c>d"e'f" + ); + assert_eq!(escape_attr("a\tb\nc\rd").unwrap(), "a b c d"); + assert_eq!(escape_text("a\tb\nc\rd").unwrap(), "a\tb\nc d"); + assert_eq!( + escape_text("Äpfel · 🍎").unwrap(), + "Äpfel · 🍎", + "UTF-8 unchanged" + ); + assert_eq!(escape_text("a\u{1}b"), Err(InvalidXmlChar(1))); + assert_eq!(escape_attr("\u{FFFE}"), Err(InvalidXmlChar(0xFFFE))); + } +} diff --git a/server-rs/crates/lrg-develop/src/xmp/mod.rs b/server-rs/crates/lrg-develop/src/xmp/mod.rs new file mode 100644 index 00000000..053434fb --- /dev/null +++ b/server-rs/crates/lrg-develop/src/xmp/mod.rs @@ -0,0 +1,17 @@ +//! The XMP form: sidecars (`.xmp`), develop presets and profiles as +//! Lightroom Classic and Camera Raw write them. [`read`] parses all three; +//! [`write`](mod@write) writes develop presets ([`format`](mod@format) spells the values). + +pub mod format; +pub mod read; +pub mod write; + +pub use format::EngineVersion; +pub use read::{ + parse, PresetHeader, SkippedCounts, XmpDocument, XmpKind, CRSS_NS, CRS_NS, MAX_XMP_BYTES, + MAX_XMP_DEPTH, +}; +pub use write::{ + compatible_version, write, PresetSpec, WriteError, WriteMode, Written, COMPATIBLE_VERSIONS, + TARGET_ENGINE, +}; diff --git a/server-rs/crates/lrg-develop/src/xmp/read.rs b/server-rs/crates/lrg-develop/src/xmp/read.rs new file mode 100644 index 00000000..993d1bdf --- /dev/null +++ b/server-rs/crates/lrg-develop/src/xmp/read.rs @@ -0,0 +1,1668 @@ +//! XMP → [`XmpDocument`]: a sidecar, a develop preset or a profile. +//! +//! Two passes. The first reads RDF into the XMP data model +//! ([`XmpNode`]), whatever syntax the file used: +//! +//! - attribute form (`crs:Exposure2012="+0.50"`) and element form +//! (`+0.50`), mixed freely; +//! - structures as a nested `rdf:Description`, as attributes on the property +//! element, or with `rdf:parseType="Resource"`; +//! - `rdf:Seq`, `rdf:Bag` and `rdf:Alt` (empty items included), `xml:lang`, +//! and qualifiers (`rdf:value`, as an element or an attribute, with +//! siblings); +//! - every `rdf:Description` under every outermost `rdf:RDF`, merged in +//! document order. +//! +//! A property element that mixes forms RDF does not allow (a container next +//! to other elements or field attributes, something other than `rdf:li` in +//! a container) is read as a plain structure of everything it holds, so the +//! second pass reports it (`WrongType` or `UnknownKey`) and keeps it whole. +//! Text next to elements cannot be kept in the data model; it is dropped +//! with a [`WarningKind::MalformedRdf`] (one per top-level property). +//! Top-level properties outside `crs:` are counted and never built. +//! +//! The second pass types the `crs:` properties (matched by namespace URI, +//! never by prefix) through the registry, into the same model the Lua reader +//! produces: the same key ids, value variants, correction and mask +//! classification, `Look` and white-balance family (the shared rules are in +//! `crate::reader` and [`crate::model::correction`]). Top-level names resolve +//! at the global level first, then as preset header keys. +//! +//! - `True`/`true`/`False`/`false` are both accepted; `"+15"` is 15, +//! `"1.000000"` is 1.0; a non-integer on an integer key is rounded +//! half-to-even with a warning; ids are checked for 32 hex digits; +//! - global curves (`"x, y"`) and local curves (`"x,y"`) are both read into +//! [`Value::Curve`]; +//! - an `rdf:Alt` with only an `x-default` item is [`Value::Alt`]; one with +//! further languages, an `rdf:Bag` or `rdf:Alt` where the registry expects +//! an `rdf:Seq`, and any value with qualifiers are kept whole as +//! [`Opaque::Xmp`] under their key, without a warning (valid XMP the model +//! does not interpret) but counted in [`SkippedCounts::kept_whole`], so a +//! caller or a sweep sees the lost typing; +//! - `crss:` (saved snapshots) is skipped entirely; `Look.Parameters` (a +//! profile definition) and `Preset.Parameters` (a copy of the last applied +//! preset) are kept as [`Opaque::Xmp`] and never interpreted; properties in +//! other namespaces (`dc:`, `xmp:`, `exif:`, ...) are not part of the +//! model and are dropped at the top level. [`SkippedCounts`] counts all of +//! these; +//! - an unknown `crs:` key is kept verbatim with a +//! [`WarningKind::UnknownKey`], a known key of the wrong shape with a +//! [`WarningKind::WrongType`]; neither is an error. +//! +//! [`ParseError`] only for input that is unusable as a whole: more than +//! [`MAX_XMP_BYTES`], not UTF-8 (after an optional byte-order mark), not +//! well-formed XML, no `rdf:RDF`, nesting deeper than [`MAX_XMP_DEPTH`], or +//! beyond one of the structural limits ([`MAX_XMP_ATTRIBUTES`], +//! [`MAX_XMP_NAMESPACES`], [`MAX_XMP_NAMESPACE_URI`]). +//! +//! The depth and structural limits are enforced by a linear, non-recursive +//! pre-scan of the text *before* roxmltree sees it. roxmltree's tokenizer +//! recurses once per element and has no depth limit, so a deeply nested +//! file would overflow the stack (an abort, not a panic) inside +//! `Document::parse`; and it has quadratic paths in its duplicate-attribute +//! check and namespace resolution. The pre-scan refuses such a file first. +//! Namespace URIs are shared ([`Arc`]) rather than copied per field, so +//! a long URI cannot multiply into gigabytes. +//! Everything else is a [`ParseWarning`] with a path such as +//! `MaskGroupBasedCorrections[2].CorrectionMasks[0].MaskSubType`. + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use roxmltree::{Document, Node, ParsingOptions}; + +use crate::model::correction::Correction; +use crate::model::value::{Fields, Finite, Hex32, Opaque, OpaqueEntry, Struct, Value}; +use crate::model::xmp_node::{XmpArrayKind, XmpField, XmpNode, XmpValue}; +use crate::model::{DevelopSettings, FileKindHint}; +use crate::parse::{ParseError, ParseWarning, WarningKind}; +use crate::reader::{self, child, parse_point, ScalarIn}; +use crate::registry::{self, CurveKind, KeySpec, Level, Resolved, StructKind, ValueKind}; + +/// Largest XMP file [`parse`] accepts (4 MiB, the same limit as the +/// sidecar reader in `lrg-imaging`). +pub const MAX_XMP_BYTES: usize = 4 * 1024 * 1024; + +/// Deepest element nesting below an `rdf:Description` that [`parse`] +/// accepts. Real files nest about 20 levels (range masks inside brush +/// aggregates inside corrections). A pre-scan checks the depth before the +/// XML parser runs (its tokenizer recurses once per element), allowing a +/// few more levels for the envelope (`x:xmpmeta`, `rdf:RDF`, +/// `rdf:Description`); the reader's own recursion checks it again. Low +/// enough that a debug build on a 2 MiB thread keeps a real margin. +pub const MAX_XMP_DEPTH: usize = 64; + +/// Most attributes one start tag may carry (real files: about 200). Bounds +/// the XML parser's duplicate-attribute check, which is quadratic per +/// element. +pub const MAX_XMP_ATTRIBUTES: usize = 1024; + +/// Most namespace declarations (`xmlns`, `xmlns:*`) in one file (real files: +/// about 20). Bounds the XML parser's namespace resolution, which is +/// quadratic in the namespaces in scope. +pub const MAX_XMP_NAMESPACES: usize = 256; + +/// Longest namespace URI, in bytes as written (real files: under 60). +pub const MAX_XMP_NAMESPACE_URI: usize = 1024; + +/// Elements the pre-scan allows above the first property. +const DEPTH_ENVELOPE: usize = 8; + +/// Most XML nodes the parser builds (real files: a few thousand elements). +const MAX_XMP_NODES: u32 = 1 << 18; + +/// The Camera Raw settings namespace (`crs:`). +pub const CRS_NS: &str = "http://ns.adobe.com/camera-raw-settings/1.0/"; +/// The Camera Raw saved-settings namespace (`crss:`, snapshots). +pub const CRSS_NS: &str = "http://ns.adobe.com/camera-raw-saved-settings/1.0/"; +const RDF_NS: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"; +const XML_NS: &str = "http://www.w3.org/XML/1998/namespace"; + +/// What an XMP file holds. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum XmpKind { + /// A photo's sidecar: `crs:ProcessVersion` without `crs:PresetType`. + Sidecar, + /// A develop preset: `crs:PresetType="Normal"`. + Preset, + /// A profile: `crs:PresetType="Look"`. Its develop settings are the + /// profile definition. + Profile, + /// Anything else (no develop settings, or an unknown preset type). Not + /// an error: the settings are read all the same. + NotDevelop, +} + +/// The header of a preset or profile file (the `Level::Header` keys). +/// +/// The fields Lightroom shows or the writer sets are typed; every other +/// header key, and a typed one whose value did not fit (a name with several +/// languages, an empty `CameraModelRestriction`), stays in [`Self::rest`]. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct PresetHeader { + /// `PresetType` (`"Normal"`, `"Look"`). + pub preset_type: Option, + /// `UUID`. + pub uuid: Option, + /// `Name` (`x-default`). + pub name: Option, + /// `ShortName` (`x-default`). + pub short_name: Option, + /// `SortName` (`x-default`). + pub sort_name: Option, + /// `Group` (`x-default`). + pub group: Option, + /// `Description` (`x-default`). + pub description: Option, + /// `Cluster`. + pub cluster: Option, + /// `CameraModelRestriction` (`None` also for the empty string, which + /// stays in [`Self::rest`]). + pub camera_model_restriction: Option, + /// `SupportsAmount`. + pub supports_amount: Option, + /// `SupportsAmount2`. + pub supports_amount2: Option, + /// `SupportsColor`. + pub supports_color: Option, + /// `SupportsMonochrome`. + pub supports_monochrome: Option, + /// Every other header key, and what did not fit a typed field. + pub rest: Fields, +} + +impl PresetHeader { + fn from_fields(mut f: Fields) -> PresetHeader { + const H: Level = Level::Header; + let string = |v: &Value| v.as_str().map(str::to_owned); + let alt = |v: &Value| match v { + Value::Alt(s) => Some(s.clone()), + _ => None, + }; + PresetHeader { + preset_type: f.take_if(H, "PresetType", string), + uuid: f.take_if(H, "UUID", |v| v.as_str().and_then(Hex32::parse)), + name: f.take_if(H, "Name", alt), + short_name: f.take_if(H, "ShortName", alt), + sort_name: f.take_if(H, "SortName", alt), + group: f.take_if(H, "Group", alt), + description: f.take_if(H, "Description", alt), + cluster: f.take_if(H, "Cluster", string), + camera_model_restriction: f.take_if(H, "CameraModelRestriction", |v| { + v.as_str().filter(|r| !r.is_empty()).map(str::to_owned) + }), + supports_amount: f.take_if(H, "SupportsAmount", Value::as_bool), + supports_amount2: f.take_if(H, "SupportsAmount2", Value::as_bool), + supports_color: f.take_if(H, "SupportsColor", Value::as_bool), + supports_monochrome: f.take_if(H, "SupportsMonochrome", Value::as_bool), + rest: f, + } + } +} + +/// Subtrees the parser skipped on purpose, counted. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct SkippedCounts { + /// `crss:` properties (snapshots). + pub crss: usize, + /// `Preset.Parameters` kept opaque (a copy of the last applied preset). + pub preset_parameters: usize, + /// `Look.Parameters` kept opaque (a profile definition). + pub look_parameters: usize, + /// Top-level properties in other namespaces (`dc:`, `xmp:`, `tiff:`, + /// ...), not part of the develop model. + pub other_namespaces: usize, + /// Registry keys whose value came in a form the model does not type (an + /// `rdf:Bag`, an `rdf:Alt` with more than `x-default`, qualifiers) and + /// was kept whole as [`Opaque::Xmp`]: valid XMP, so not a warning, but + /// typing was lost. Lightroom's own files never do this; the local + /// sweeps require 0. The by-design opaque kinds (`Look.Parameters`, + /// `Preset.Parameters`, keys the registry keeps opaque) are not counted. + pub kept_whole: usize, +} + +/// One parsed XMP file. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct XmpDocument { + /// Sidecar, preset, profile or none of them. + pub kind: XmpKind, + /// The preset/profile header; `None` when the file has no header key. + pub header: Option, + /// The develop settings (for a profile: its definition). + pub develop: DevelopSettings, + /// Everything that was odd, in document order. + pub warnings: Vec, + /// What was skipped on purpose. + pub skipped_subtrees: SkippedCounts, +} + +/// Parses an XMP file (see the module docs for the rules). +pub fn parse(bytes: &[u8]) -> Result { + if bytes.len() > MAX_XMP_BYTES { + return Err(ParseError::TooLarge { + size: bytes.len(), + max: MAX_XMP_BYTES, + }); + } + let bytes = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes); + let text = std::str::from_utf8(bytes)?; + // Before roxmltree: it recurses per element and has quadratic paths. + prescan(text)?; + // DTDs stay refused (XMP never has one); the pre-scan relies on it. + let options = ParsingOptions { + allow_dtd: false, + nodes_limit: MAX_XMP_NODES, + ..ParsingOptions::default() + }; + let doc = Document::parse_with_options(text, options)?; + let mut pass1 = Pass1::default(); + let properties = pass1.rdf_properties(&doc)?; + + let mut interp = Interp { + warnings: pass1.warnings, + skipped: pass1.skipped, + }; + let (global, header) = interp.top_level(properties); + let kind = document_kind(&global, &header); + let header = (!header.is_empty()).then(|| PresetHeader::from_fields(header)); + let develop = reader::finish(global, FileKindHint::Unknown, &mut interp.warnings); + Ok(XmpDocument { + kind, + header, + develop, + warnings: interp.warnings, + skipped_subtrees: interp.skipped, + }) +} + +fn document_kind(global: &Fields, header: &Fields) -> XmpKind { + const PRESET_TYPE: &str = "PresetType"; + match header.get(Level::Header, PRESET_TYPE) { + Some(Value::Str(t)) if t == "Normal" => return XmpKind::Preset, + Some(Value::Str(t)) if t == "Look" => return XmpKind::Profile, + Some(_) => return XmpKind::NotDevelop, + None if header.opaque.iter().any(|o| o.name == PRESET_TYPE) => return XmpKind::NotDevelop, + None => {} + } + let has_pv = global.get(Level::Global, "ProcessVersion").is_some() + || global.opaque.iter().any(|o| o.name == "ProcessVersion"); + if has_pv { + XmpKind::Sidecar + } else { + XmpKind::NotDevelop + } +} + +// ---- pre-scan: depth and structural limits, before roxmltree ------------- + +/// What the pre-scan saw (for tests). +#[derive(Debug, Default, PartialEq, Eq)] +struct Scan { + /// Deepest element nesting, counted from the document root. + max_depth: usize, + /// Namespace declarations. + namespaces: usize, +} + +fn is_xml_space(c: u8) -> bool { + matches!(c, b' ' | b'\t' | b'\r' | b'\n') +} + +/// The index of the first `pat` at or after `from`. +fn find(b: &[u8], from: usize, pat: &[u8]) -> Option { + b.get(from..)? + .windows(pat.len()) + .position(|w| w == pat) + .map(|p| from + p) +} + +/// One linear pass over the text, without recursion: element depth, +/// attributes per start tag, namespace declarations and their length. +/// +/// It only has to agree with roxmltree on well-formed input: roxmltree +/// stops at the first malformed token, so it never gets deeper than what +/// was counted up to there. Comments, CDATA sections and processing +/// instructions are skipped; `>` inside a quoted attribute value does not +/// end a tag. Any other ` Result { + let b = text.as_bytes(); + let max_depth = MAX_XMP_DEPTH + DEPTH_ENVELOPE; + let mut scan = Scan::default(); + let mut depth = 0usize; + let mut i = 0; + while let Some(off) = b[i..].iter().position(|&c| c == b'<') { + let lt = i + off; + let rest = &b[lt..]; + let skip_to = + |open: usize, close: &[u8]| find(b, lt + open, close).map(|e| e + close.len()); + let next = if rest.starts_with(b"") + } else if rest.starts_with(b"") + } else if rest.starts_with(b"") + } else if rest.starts_with(b"").map(|e| e + 1) + } else { + match start_tag(b, lt, &mut scan.namespaces)? { + Some((after, self_closing)) => { + if !self_closing { + depth += 1; + scan.max_depth = scan.max_depth.max(depth); + if depth > max_depth { + return Err(ParseError::TooDeep { max: MAX_XMP_DEPTH }); + } + } + Some(after) + } + None => None, + } + }; + match next { + Some(n) => i = n, + None => break, + } + } + Ok(scan) +} + +/// Scans the start tag whose `<` is at `lt`: the index after its `>` and +/// whether it closes itself, or `None` when the text ends first. Counts its +/// attributes and namespace declarations against the limits. +fn start_tag( + b: &[u8], + lt: usize, + namespaces: &mut usize, +) -> Result, ParseError> { + let len = b.len(); + let mut i = lt + 1; + while i < len && !is_xml_space(b[i]) && !matches!(b[i], b'>' | b'/') { + i += 1; + } + let mut attributes = 0usize; + loop { + while i < len && is_xml_space(b[i]) { + i += 1; + } + match b.get(i) { + None => return Ok(None), + Some(b'>') => return Ok(Some((i + 1, false))), + Some(b'/') if b.get(i + 1) == Some(&b'>') => return Ok(Some((i + 2, true))), + _ => {} + } + let name_start = i; + while i < len && !is_xml_space(b[i]) && !matches!(b[i], b'=' | b'>' | b'/' | b'"' | b'\'') { + i += 1; + } + let name = &b[name_start..i]; + while i < len && is_xml_space(b[i]) { + i += 1; + } + if b.get(i) != Some(&b'=') { + // Malformed (roxmltree rejects this tag); just make progress. + if i == name_start { + i += 1; + } + continue; + } + i += 1; + attributes += 1; + if attributes > MAX_XMP_ATTRIBUTES { + return Err(ParseError::Limit { + what: "attributes on one element", + max: MAX_XMP_ATTRIBUTES, + }); + } + while i < len && is_xml_space(b[i]) { + i += 1; + } + let Some("e @ (b'"' | b'\'')) = b.get(i) else { + continue; + }; + let Some(close) = b[i + 1..].iter().position(|&c| c == quote) else { + return Ok(None); + }; + let value_len = close; + i += close + 2; + if name == b"xmlns" || name.starts_with(b"xmlns:") { + *namespaces += 1; + if *namespaces > MAX_XMP_NAMESPACES { + return Err(ParseError::Limit { + what: "namespace declarations", + max: MAX_XMP_NAMESPACES, + }); + } + if value_len > MAX_XMP_NAMESPACE_URI { + return Err(ParseError::Limit { + what: "bytes in one namespace URI", + max: MAX_XMP_NAMESPACE_URI, + }); + } + } + } +} + +// ---- pass 1: RDF → XMP data model ----------------------------------------- + +fn is_rdf(n: &Node, local: &str) -> bool { + n.is_element() && n.tag_name().namespace() == Some(RDF_NS) && n.tag_name().name() == local +} + +/// Attributes other than `rdf:*`/`xml:*` count as fields, and `rdf:value` +/// (a qualified value in attribute form). +fn is_field_attribute(ns: Option<&str>, name: &str) -> bool { + match ns { + Some(RDF_NS) => name == "value", + Some(XML_NS) => false, + _ => true, + } +} + +fn has_field_attributes(el: Node) -> bool { + el.attributes() + .any(|a| is_field_attribute(a.namespace(), a.name())) +} + +fn container_kind(n: Node) -> Option { + if n.tag_name().namespace() != Some(RDF_NS) { + return None; + } + match n.tag_name().name() { + "Seq" => Some(XmpArrayKind::Seq), + "Bag" => Some(XmpArrayKind::Bag), + "Alt" => Some(XmpArrayKind::Alt), + _ => None, + } +} + +/// The only element child of `el`, if it has exactly one. +fn only_element<'a, 'input>(el: Node<'a, 'input>) -> Option> { + let mut elements = el.children().filter(Node::is_element); + match (elements.next(), elements.next()) { + (Some(c), None) => Some(c), + _ => None, + } +} + +/// One step of a pass-1 path (built only when a warning needs it, so a +/// deep file with long names costs nothing per element). +enum Seg<'a> { + Name(&'a str), + Index(usize), +} + +/// Text dropped below the current top-level property. +struct Dropped { + path: String, + key: String, + first: String, + more: usize, +} + +#[derive(Default)] +struct Pass1<'a> { + /// Every namespace URI once: fields share it instead of copying it. + uris: HashMap<&'a str, Arc>, + path: Vec>, + dropped: Option, + warnings: Vec, + skipped: SkippedCounts, +} + +impl<'a> Pass1<'a> { + fn ns(&mut self, uri: Option<&'a str>) -> Arc { + let uri = uri.unwrap_or(""); + self.uris + .entry(uri) + .or_insert_with(|| Arc::from(uri)) + .clone() + } + + /// The `crs:` properties of every `rdf:Description` under every + /// outermost `rdf:RDF`, in document order. Other top-level properties + /// are counted, never built. + fn rdf_properties<'input>( + &mut self, + doc: &'a Document<'input>, + ) -> Result, ParseError> { + let roots: Vec> = doc + .descendants() + .filter(|n| is_rdf(n, "RDF") && !n.ancestors().skip(1).any(|a| is_rdf(&a, "RDF"))) + .collect(); + if roots.is_empty() { + return Err(ParseError::NoRdf); + } + let mut out = Vec::new(); + for d in roots + .iter() + .flat_map(|r| r.children().filter(|n| is_rdf(n, "Description"))) + { + self.note_text(d); + self.flush_dropped(); + for a in d.attributes() { + if !is_field_attribute(a.namespace(), a.name()) { + continue; + } + if self.top_level_is_crs(a.namespace()) { + out.push(XmpField { + ns: self.ns(a.namespace()), + name: a.name().to_owned(), + node: XmpNode::text(a.value()), + }); + } + } + for c in d.children().filter(Node::is_element) { + if !self.top_level_is_crs(c.tag_name().namespace()) { + continue; + } + let name = c.tag_name().name(); + self.path.push(Seg::Name(name)); + let node = self.property_node(c, 1); + self.path.pop(); + self.flush_dropped(); + out.push(XmpField { + ns: self.ns(c.tag_name().namespace()), + name: name.to_owned(), + node: node?, + }); + } + } + Ok(out) + } + + /// True for a `crs:` property; counts `crss:` and other namespaces. + fn top_level_is_crs(&mut self, ns: Option<&str>) -> bool { + match ns { + Some(CRS_NS) => true, + Some(CRSS_NS) => { + self.skipped.crss += 1; + false + } + _ => { + self.skipped.other_namespaces += 1; + false + } + } + } + + fn path_string(&self) -> String { + let mut out = String::new(); + for seg in &self.path { + match seg { + Seg::Name(n) => out = child(&out, n), + Seg::Index(i) => out.push_str(&format!("[{i}]")), + } + } + out + } + + /// Records non-whitespace text directly in `el` that the value built + /// from `el` cannot hold (text next to elements or attributes). + fn note_text(&mut self, el: Node) { + let Some(text) = el + .children() + .filter(Node::is_text) + .filter_map(|n| n.text()) + .find(|t| !t.trim().is_empty()) + else { + return; + }; + match &mut self.dropped { + Some(d) => d.more += 1, + None => { + let path = self.path_string(); + let key = match self.path.iter().rev().find_map(|s| match s { + Seg::Name(n) => Some(*n), + Seg::Index(_) => None, + }) { + Some(n) => n.to_owned(), + None => "rdf:Description".to_owned(), + }; + self.dropped = Some(Dropped { + path: if path.is_empty() { key.clone() } else { path }, + key, + first: XmpNode::text(text.trim()).summary(), + more: 0, + }); + } + } + } + + /// One [`WarningKind::MalformedRdf`] for the text dropped under the + /// property just read (the first place, and how many more). + fn flush_dropped(&mut self) { + if let Some(d) = self.dropped.take() { + let raw = if d.more == 0 { + d.first + } else { + format!("{} (and {} more text run(s))", d.first, d.more) + }; + reader::warn( + &mut self.warnings, + &d.path, + &d.key, + Some(raw), + WarningKind::MalformedRdf, + ); + } + } + + /// The fields of a node that is a structure: its field attributes, then + /// its child elements. + fn fields_of<'input>( + &mut self, + node: Node<'a, 'input>, + depth: usize, + ) -> Result, ParseError> { + self.note_text(node); + let mut out = Vec::new(); + for a in node.attributes() { + if is_field_attribute(a.namespace(), a.name()) { + out.push(XmpField { + ns: self.ns(a.namespace()), + name: a.name().to_owned(), + node: XmpNode::text(a.value()), + }); + } + } + for c in node.children().filter(Node::is_element) { + let name = c.tag_name().name(); + self.path.push(Seg::Name(name)); + let value = self.property_node(c, depth + 1); + self.path.pop(); + out.push(XmpField { + ns: self.ns(c.tag_name().namespace()), + name: name.to_owned(), + node: value?, + }); + } + Ok(out) + } + + /// The value of a property element or an `rdf:li`. + /// + /// A container or a nested `rdf:Description` is taken as such only when + /// it is the element's only child element and the element has no field + /// attributes (and, for a container, holds nothing but `rdf:li`); + /// anything else is read as a plain structure of everything the element + /// holds, so nothing is dropped silently. + fn property_node<'input>( + &mut self, + el: Node<'a, 'input>, + depth: usize, + ) -> Result { + if depth > MAX_XMP_DEPTH { + return Err(ParseError::TooDeep { max: MAX_XMP_DEPTH }); + } + let lang = el.attribute((XML_NS, "lang")).map(str::to_owned); + let single = only_element(el).filter(|_| !has_field_attributes(el)); + let container = single.and_then(|c| { + let kind = container_kind(c)?; + c.children() + .filter(Node::is_element) + .all(|n| is_rdf(&n, "li")) + .then_some((kind, c)) + }); + let has_elements = el.children().any(|n| n.is_element()); + let value = if let Some((kind, c)) = container { + self.note_text(el); + self.note_text(c); + let mut items = Vec::new(); + for (i, li) in c.children().filter(Node::is_element).enumerate() { + self.path.push(Seg::Index(i)); + let item = self.property_node(li, depth + 2); + self.path.pop(); + items.push(item?); + } + XmpValue::Array { kind, items } + } else if let Some(desc) = single.filter(|c| is_rdf(c, "Description")) { + self.note_text(el); + XmpValue::Struct(self.fields_of(desc, depth + 1)?) + } else if has_elements + || has_field_attributes(el) + || el.attribute((RDF_NS, "parseType")) == Some("Resource") + { + XmpValue::Struct(self.fields_of(el, depth)?) + } else if let Some(uri) = el.attribute((RDF_NS, "resource")) { + self.note_text(el); + XmpValue::Text(uri.to_owned()) + } else { + XmpValue::Text( + el.children() + .filter(Node::is_text) + .filter_map(|n| n.text()) + .collect(), + ) + }; + Ok(with_qualifiers(value, lang)) + } +} + +/// A structure with an `rdf:value` field is a simple value whose other +/// fields are qualifiers. +fn with_qualifiers(value: XmpValue, lang: Option) -> XmpNode { + let XmpValue::Struct(mut fields) = value else { + return XmpNode { + value, + lang, + qualifiers: Vec::new(), + }; + }; + match fields + .iter() + .position(|f| &*f.ns == RDF_NS && f.name == "value") + { + Some(i) => { + let inner = fields.remove(i).node; + XmpNode { + value: inner.value, + lang: lang.or(inner.lang), + qualifiers: fields, + } + } + None => XmpNode { + value: XmpValue::Struct(fields), + lang, + qualifiers: Vec::new(), + }, + } +} + +// ---- pass 2: XMP data model → develop model ------------------------------- + +struct Interp { + warnings: Vec, + skipped: SkippedCounts, +} + +/// How an array fits the registry's `rdf:Seq` shape. +enum Shape { + /// An `rdf:Seq` whose items all fit. + Fits, + /// Valid XMP the model does not interpret (another container, items with + /// a language or qualifiers): kept whole. + KeepWhole, + /// Not the expected shape. + Wrong, +} + +fn seq_shape(node: &XmpNode, item_fits: impl Fn(&XmpNode) -> bool) -> Shape { + match &node.value { + XmpValue::Array { + kind: XmpArrayKind::Seq, + items, + } => { + if items.iter().any(|i| !i.is_plain()) { + Shape::KeepWhole + } else if items.iter().all(item_fits) { + Shape::Fits + } else { + Shape::Wrong + } + } + XmpValue::Array { .. } => Shape::KeepWhole, + _ => Shape::Wrong, + } +} + +fn is_text(n: &XmpNode) -> bool { + matches!(n.value, XmpValue::Text(_)) +} + +fn is_struct(n: &XmpNode) -> bool { + matches!(n.value, XmpValue::Struct(_)) +} + +/// The text items of a fitting `rdf:Seq`. +fn seq_texts(node: &XmpNode) -> Vec<&str> { + match &node.value { + XmpValue::Array { items, .. } => items.iter().filter_map(XmpNode::as_text).collect(), + _ => Vec::new(), + } +} + +/// The structure items of a fitting `rdf:Seq`, consumed. +fn seq_structs(node: XmpNode) -> Vec> { + match node.value { + XmpValue::Array { items, .. } => items + .into_iter() + .filter_map(|i| match i.value { + XmpValue::Struct(fields) => Some(fields), + _ => None, + }) + .collect(), + _ => Vec::new(), + } +} + +fn scalar_in(s: &str) -> Option { + match s.trim() { + "True" | "true" => Some(ScalarIn::Bool(true)), + "False" | "false" => Some(ScalarIn::Bool(false)), + t => t + .parse::() + .ok() + .and_then(|x| Finite::new(x).ok()) + .map(ScalarIn::Num), + } +} + +fn describe(kind: ValueKind) -> String { + match kind { + ValueKind::Int => "an integer".into(), + ValueKind::Real => "a number".into(), + ValueKind::Bool(_) => "True/False (or 0/1)".into(), + ValueKind::IntFlag => "0/1 (or True/False)".into(), + ValueKind::Enum(set) => format!("one of {set:?}"), + ValueKind::EnumInt(set) => format!("one of {set:?}"), + ValueKind::Str | ValueKind::VersionStr | ValueKind::Hex32 => "text".into(), + ValueKind::VersionU32 => "a packed version number".into(), + ValueKind::Curve(CurveKind::Global) => "an rdf:Seq of \"x, y\" points".into(), + ValueKind::Curve(CurveKind::Local) => "an rdf:Seq of \"x,y\" points".into(), + ValueKind::StrSeq => "an rdf:Seq of text items".into(), + ValueKind::LangAlt => "an rdf:Alt of language items".into(), + ValueKind::Struct(k) => format!("a {} structure", k.name()), + ValueKind::StructSeq(k) => format!("an rdf:Seq of {} structures", k.name()), + ValueKind::CorrectionSeq => "an rdf:Seq of corrections".into(), + ValueKind::ComponentSeq => "an rdf:Seq of mask components".into(), + ValueKind::Settings | ValueKind::Any | ValueKind::PerFormat { .. } => "any value".into(), + } +} + +fn opaque(name: String, node: XmpNode) -> OpaqueEntry { + OpaqueEntry { + ns: None, + name, + value: Opaque::Xmp(node), + } +} + +impl Interp { + fn warn(&mut self, path: &str, key: &str, node: &XmpNode, kind: WarningKind) { + reader::warn(&mut self.warnings, path, key, Some(node.summary()), kind); + } + + /// Splits the top-level `crs:` properties (pass 1 keeps no others) + /// into global and header fields: a name resolves at the global level + /// first, then as a header key. + fn top_level(&mut self, properties: Vec) -> (Fields, Fields) { + let mut global = Fields::default(); + let mut header = Fields::default(); + // One set for both: a name always lands on the same side. + let mut seen = HashSet::new(); + for f in properties { + debug_assert_eq!(&*f.ns, CRS_NS, "pass 1 keeps only crs: at the top"); + let is_header = matches!(registry::resolve(Level::Global, &f.name), Resolved::Unknown) + && registry::lookup(Level::Header, &f.name).is_some(); + if is_header { + self.field(&mut header, &mut seen, Level::Header, f, ""); + } else { + self.field(&mut global, &mut seen, Level::Global, f, ""); + } + } + (global, header) + } + + fn fields(&mut self, list: Vec, level: Level, path: &str) -> Fields { + let mut out = Fields::default(); + let mut seen = HashSet::new(); + for f in list { + self.field(&mut out, &mut seen, level, f, path); + } + out + } + + /// Types one field into `out`. `seen` holds the `crs:` names already + /// read at this level: a repeat is kept verbatim with a + /// [`WarningKind::DuplicateKey`], also when the first occurrence went + /// to `opaque` (wrong type, unknown), so the first value always wins. + fn field( + &mut self, + out: &mut Fields, + seen: &mut HashSet, + level: Level, + f: XmpField, + path: &str, + ) { + let XmpField { ns, name, node } = f; + if &*ns == CRSS_NS { + self.skipped.crss += 1; + return; + } + let p = child(path, &name); + if &*ns != CRS_NS { + // A foreign property inside a develop structure: kept, with its + // namespace, and reported. + self.warn(&p, &name, &node, WarningKind::UnknownKey); + out.opaque.push(OpaqueEntry { + ns: Some(ns), + name, + value: Opaque::Xmp(node), + }); + return; + } + if !seen.insert(name.clone()) { + self.warn(&p, &name, &node, WarningKind::DuplicateKey); + out.opaque.push(opaque(name, node)); + return; + } + match registry::resolve(level, &name) { + Resolved::Key(id) => match self.value(id.spec(), node, &p, &name) { + Ok(v) => { + out.values.insert(id, v); + } + Err(node) => out.opaque.push(opaque(name, node)), + }, + Resolved::Pattern(_) => out.opaque.push(opaque(name, node)), + Resolved::Unknown => { + self.warn(&p, &name, &node, WarningKind::UnknownKey); + out.opaque.push(opaque(name, node)); + } + } + } + + /// Types `node` as `spec`'s XMP kind; `Err(node)` (after a + /// [`WarningKind::WrongType`]) when it does not have that shape. + fn value( + &mut self, + spec: &KeySpec, + node: XmpNode, + path: &str, + key: &str, + ) -> Result { + let kind = spec.kind.xmp_form(); + let keep = |node| Ok(Value::Opaque(Opaque::Xmp(node))); + match kind { + ValueKind::Settings => { + match spec.level { + Level::Struct(StructKind::Look) => self.skipped.look_parameters += 1, + Level::Struct(StructKind::Preset) => self.skipped.preset_parameters += 1, + _ => {} + } + return keep(node); + } + ValueKind::Any | ValueKind::PerFormat { .. } => return keep(node), + _ => {} + } + if !node.is_plain() { + return self.keep_whole(node); + } + let typed = match kind { + ValueKind::Int + | ValueKind::Real + | ValueKind::IntFlag + | ValueKind::EnumInt(_) + | ValueKind::VersionU32 + | ValueKind::Bool(_) => node.as_text().and_then(scalar_in).and_then(|input| { + reader::scalar( + spec, + input, + path, + key, + || node.summary(), + &mut self.warnings, + ) + }), + ValueKind::Enum(_) | ValueKind::Str | ValueKind::VersionStr | ValueKind::Hex32 => { + node.as_text().and_then(|s| { + reader::text(kind, s, path, key, || node.summary(), &mut self.warnings) + }) + } + ValueKind::Curve(_) => match seq_shape(&node, is_text) { + Shape::Fits => seq_texts(&node) + .into_iter() + .map(parse_point) + .collect::>>() + .map(Value::Curve), + Shape::KeepWhole => return self.keep_whole(node), + Shape::Wrong => None, + }, + ValueKind::StrSeq => match seq_shape(&node, is_text) { + Shape::Fits => Some(Value::StrList( + seq_texts(&node).into_iter().map(str::to_owned).collect(), + )), + Shape::KeepWhole => return self.keep_whole(node), + Shape::Wrong => None, + }, + ValueKind::LangAlt => match &node.value { + XmpValue::Array { + kind: XmpArrayKind::Alt, + items, + } if items.iter().all(|i| is_text(i) && i.qualifiers.is_empty()) => { + match items.as_slice() { + [only] if only.lang.as_deref() == Some("x-default") => match &only.value { + XmpValue::Text(s) => Some(Value::Alt(s.clone())), + _ => None, + }, + // Further languages: valid, kept whole. + _ => return self.keep_whole(node), + } + } + XmpValue::Array { .. } => return self.keep_whole(node), + _ => None, + }, + ValueKind::Struct(k) => { + if let XmpValue::Struct(fields) = node.value { + let fields = self.fields(fields, Level::Struct(k), path); + return Ok(Value::Struct(Struct { kind: k, fields })); + } + None + } + ValueKind::StructSeq(_) | ValueKind::CorrectionSeq | ValueKind::ComponentSeq => { + match seq_shape(&node, is_struct) { + Shape::Fits => return Ok(self.struct_seq(kind, node, path)), + Shape::KeepWhole => return self.keep_whole(node), + Shape::Wrong => None, + } + } + ValueKind::Settings | ValueKind::Any | ValueKind::PerFormat { .. } => { + unreachable!("kept whole above") + } + }; + match typed { + Some(v) => Ok(v), + None => { + self.warn( + path, + key, + &node, + WarningKind::WrongType { + expected: describe(kind), + }, + ); + Err(node) + } + } + } + + /// A registry key kept whole because of its form (see + /// [`SkippedCounts::kept_whole`]): counted, since its typing is lost. + fn keep_whole(&mut self, node: XmpNode) -> Result { + self.skipped.kept_whole += 1; + Ok(Value::Opaque(Opaque::Xmp(node))) + } + + /// A fitting `rdf:Seq` of structures as `kind` (a structure list, + /// corrections or mask components). + fn struct_seq(&mut self, kind: ValueKind, node: XmpNode, path: &str) -> Value { + let items = seq_structs(node).into_iter().enumerate(); + match kind { + ValueKind::StructSeq(k) => Value::StructList( + items + .map(|(i, fields)| Struct { + kind: k, + fields: self.fields(fields, Level::Struct(k), &format!("{path}[{i}]")), + }) + .collect(), + ), + ValueKind::CorrectionSeq => Value::Corrections( + items + .map(|(i, fields)| { + let p = format!("{path}[{i}]"); + let fields = self.fields(fields, Level::Correction, &p); + Correction::from_fields(fields, &p, &mut self.warnings) + }) + .collect(), + ), + ValueKind::ComponentSeq => Value::Tools( + items + .map(|(i, fields)| { + self.fields(fields, Level::MaskTool, &format!("{path}[{i}]")) + }) + .collect(), + ), + _ => unreachable!("only called for sequences of structures"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{Combine, FileKind, MaskTool, Semantic}; + + const HEAD: &str = r#""#; + const TAIL: &str = ""; + + /// One description with the `crs:` namespace bound to `crs`. + fn doc(attrs: &str, body: &str) -> String { + format!( + r#"{HEAD}{body}{TAIL}"# + ) + } + + fn read(text: &str) -> XmpDocument { + parse(text.as_bytes()).unwrap_or_else(|e| panic!("{e}")) + } + + fn kinds(d: &XmpDocument) -> Vec<&'static str> { + d.warnings.iter().map(|w| w.kind.name()).collect() + } + + #[test] + fn hard_errors_are_only_for_unusable_input() { + assert!(matches!(parse(b""), Err(ParseError::Xml(_)))); + assert!(matches!( + parse(b"\xff\xfe"), + Err(ParseError::Encoding(_)) + )); + assert!(matches!(parse(b""), Err(ParseError::NoRdf))); + let big = vec![b' '; MAX_XMP_BYTES + 1]; + assert!(matches!(parse(&big), Err(ParseError::TooLarge { .. }))); + let dtd = format!("]>{}", doc("", "")); + assert!(matches!(parse(dtd.as_bytes()), Err(ParseError::Xml(_)))); + } + + #[test] + fn nesting_just_past_the_limit_is_refused() { + let depth = MAX_XMP_DEPTH + 5; + let body = format!( + "{}{}", + "".repeat(depth), + "".repeat(depth) + ); + assert!(matches!( + parse(doc("", &body).as_bytes()), + Err(ParseError::TooDeep { .. }) + )); + } + + /// Parses on a thread with a 2 MiB stack (the default for tokio + /// workers, `spawn_blocking` and `std::thread`). roxmltree recurses once + /// per element, so without the pre-scan these inputs abort the process + /// with a stack overflow, in debug and release builds alike. + fn parse_on_small_stack(text: String) -> Result { + std::thread::Builder::new() + .stack_size(2 << 20) + .spawn(move || parse(text.as_bytes())) + .expect("spawn") + .join() + .expect("no panic") + } + + #[test] + fn very_deep_nesting_is_refused_before_the_xml_parser_recurses() { + let n = 100_000; + let plain = format!("{}{}", "".repeat(n), "".repeat(n)); + assert!(plain.len() < MAX_XMP_BYTES); + assert!(matches!( + parse_on_small_stack(plain), + Err(ParseError::TooDeep { .. }) + )); + let n = 50_000; + let in_rdf = doc( + "", + &format!("{}{}", "".repeat(n), "".repeat(n)), + ); + assert!(in_rdf.len() < MAX_XMP_BYTES); + assert!(matches!( + parse_on_small_stack(in_rdf), + Err(ParseError::TooDeep { .. }) + )); + } + + #[test] + fn the_prescan_ignores_markup_in_comments_cdata_pis_and_attributes() { + let deep = "".repeat(500); + let text = format!( + r#""# + ); + let scan = prescan(&text).unwrap(); + assert_eq!(scan.max_depth, 1); + assert_eq!( + prescan("") + .unwrap() + .max_depth, + 3 + ); + let ns = prescan(r#""#).unwrap(); + assert_eq!(ns.namespaces, 2); + // A real file stays well inside the limits. + let d = doc(r#"crs:ProcessVersion="15.4""#, ""); + assert!(prescan(&d).unwrap().max_depth <= DEPTH_ENVELOPE); + } + + #[test] + fn structural_limits_refuse_quadratic_inputs() { + let attrs: String = (0..100_000).map(|i| format!(" a{i}=\"\"")).collect(); + let many_attrs = format!(""); + assert!(matches!( + parse(many_attrs.as_bytes()), + Err(ParseError::Limit { + max: MAX_XMP_ATTRIBUTES, + .. + }) + )); + let decls: String = (0..20_000) + .map(|i| format!(" xmlns:p{i}=\"urn:{i}\"")) + .collect(); + // Split over elements, so the per-element attribute cap is not what + // fires. + let per_element: Vec = decls + .split(" xmlns") + .filter(|s| !s.is_empty()) + .collect::>() + .chunks(100) + .map(|c| format!("", c.join(" xmlns"))) + .collect(); + let many_ns = format!("{}", per_element.concat()); + assert!(matches!( + parse(many_ns.as_bytes()), + Err(ParseError::Limit { + max: MAX_XMP_NAMESPACES, + .. + }) + )); + let long_uri = format!( + r#""#, + "x".repeat(MAX_XMP_NAMESPACE_URI) + ); + assert!(matches!( + parse(long_uri.as_bytes()), + Err(ParseError::Limit { + max: MAX_XMP_NAMESPACE_URI, + .. + }) + )); + let e = ParseError::Limit { + what: "attributes on one element", + max: MAX_XMP_ATTRIBUTES, + }; + assert_eq!( + e.to_string(), + "the XMP file has more than 1024 attributes on one element" + ); + } + + #[test] + fn namespace_uris_are_shared_not_copied_per_field() { + let uri = format!("urn:{}", "u".repeat(1000)); + let body = format!( + r#"{}"#, + "".repeat(10_000) + ); + let d = read(&doc("", &body)); + let Some(Value::Struct(blur)) = d.develop.get_by_name("LensBlur") else { + panic!("LensBlur"); + }; + let foreign = &blur.fields.opaque; + assert_eq!(foreign.len(), 10_000); + let first = foreign[0].ns.as_ref().expect("namespace kept"); + assert_eq!(&**first, uri); + assert!(foreign + .iter() + .all(|o| o.ns.as_ref().is_some_and(|ns| Arc::ptr_eq(ns, first)))); + } + + #[test] + fn top_level_foreign_properties_are_counted_not_built() { + let body = format!( + r#"{}{}"#, + "".repeat(100), + r#"t"# + ); + let d = read(&doc( + r#"crs:ProcessVersion="15.4" xmlns:q="urn:q" q:c="1""#, + &body, + )); + assert_eq!(d.skipped_subtrees.other_namespaces, 3); + assert!(d.develop.opaque.is_empty()); + assert!(d.warnings.is_empty(), "{:?}", d.warnings); + } + + #[test] + fn a_bom_is_stripped() { + let mut bytes = b"\xEF\xBB\xBF".to_vec(); + bytes.extend(doc(r#"crs:ProcessVersion="15.4""#, "").as_bytes()); + let d = parse(&bytes).unwrap(); + assert_eq!(d.kind, XmpKind::Sidecar); + } + + #[test] + fn the_namespace_is_matched_by_uri_not_prefix() { + let text = format!( + r#"{HEAD}{TAIL}"# + ); + let d = read(&text); + assert_eq!( + d.develop.get_by_name("Exposure2012"), + Some(&Value::Real(Finite::new_const(0.5))) + ); + assert_eq!(d.develop.get_by_name("Contrast2012"), None); + assert_eq!(d.skipped_subtrees.other_namespaces, 1); + assert!(d.warnings.is_empty()); + } + + #[test] + fn numbers_bools_and_signs_are_typed_by_the_registry() { + let d = read(&doc( + r#"crs:Exposure2012="+0.50" crs:Contrast2012="+15" crs:Shadows2012="-7" crs:Texture="0" crs:ConvertToGrayscale="True" crs:AutoLateralCA="1" crs:LensProfileEnable="1""#, + "", + )); + assert!(d.warnings.is_empty(), "{:?}", d.warnings); + let s = &d.develop; + assert_eq!( + s.get_by_name("Exposure2012"), + Some(&Value::Real(Finite::new_const(0.5))) + ); + assert_eq!(s.get_by_name("Contrast2012"), Some(&Value::Int(15))); + assert_eq!(s.get_by_name("Shadows2012"), Some(&Value::Int(-7))); + assert_eq!(s.get_by_name("Texture"), Some(&Value::Int(0))); + assert_eq!( + s.get_by_name("ConvertToGrayscale"), + Some(&Value::Bool(true)) + ); + assert_eq!(s.get_by_name("AutoLateralCA"), Some(&Value::Int(1))); + } + + #[test] + fn wrong_values_are_warned_about_and_kept() { + let d = read(&doc( + r#"crs:Exposure2012="abc" crs:Contrast2012="12.5" crs:WhiteBalance="Sunset" crs:Mystery="1""#, + "", + )); + assert_eq!( + kinds(&d), + [ + "WrongType", + "NonIntegerForIntKey", + "ValueNotInSet", + "UnknownKey" + ] + ); + assert_eq!(d.develop.get_by_name("Contrast2012"), Some(&Value::Int(12))); + assert_eq!( + d.develop.get_by_name("WhiteBalance"), + Some(&Value::Str("Sunset".into())) + ); + let names: Vec<&str> = d.develop.opaque.iter().map(|o| o.name.as_str()).collect(); + assert_eq!(names, ["Exposure2012", "Mystery"]); + assert_eq!(d.warnings[0].raw.as_deref(), Some("abc")); + } + + #[test] + fn duplicate_keys_keep_the_first_value() { + let text = format!( + r#"{HEAD}{TAIL}"# + ); + let d = read(&text); + assert_eq!(kinds(&d), ["DuplicateKey"]); + assert_eq!(d.develop.get_by_name("Contrast2012"), Some(&Value::Int(10))); + assert_eq!(d.develop.opaque.len(), 1); + } + + #[test] + fn a_wrongly_typed_first_value_still_wins_over_a_duplicate() { + let text = format!( + r#"{HEAD}{TAIL}"# + ); + let d = read(&text); + assert_eq!( + kinds(&d), + ["WrongType", "UnknownKey", "DuplicateKey", "DuplicateKey"] + ); + assert_eq!(d.develop.get_by_name("Exposure2012"), None); + let names: Vec<&str> = d.develop.opaque.iter().map(|o| o.name.as_str()).collect(); + assert_eq!( + names, + ["Exposure2012", "Mystery", "Exposure2012", "Mystery"] + ); + } + + #[test] + fn rdf_value_as_an_attribute_is_the_value() { + let d = read(&doc("", r#""#)); + assert!(d.warnings.is_empty(), "{:?}", d.warnings); + assert_eq!(d.develop.get_by_name("Texture"), Some(&Value::Int(5))); + assert_eq!(d.skipped_subtrees.kept_whole, 0); + + let d = read(&doc( + "", + r#""#, + )); + assert!(d.warnings.is_empty(), "{:?}", d.warnings); + let Some(Value::Opaque(Opaque::Xmp(tex))) = d.develop.get_by_name("Texture") else { + panic!("Texture kept whole"); + }; + assert_eq!(tex.value, XmpValue::Text("5".into())); + assert_eq!(tex.lang.as_deref(), Some("en")); + assert_eq!(tex.qualifiers.len(), 1); + assert_eq!(tex.qualifiers[0].name, "Note"); + assert_eq!(d.skipped_subtrees.kept_whole, 1); + } + + #[test] + fn a_container_mixed_with_other_content_is_reported_and_kept() { + let d = read(&doc( + "", + r#"0, 0xh1, 1"#, + )); + assert_eq!(kinds(&d), ["WrongType"]); + assert_eq!(d.warnings[0].path, "ToneCurvePV2012"); + assert_eq!(d.develop.get_by_name("ToneCurvePV2012"), None); + let [kept] = d.develop.opaque.as_slice() else { + panic!("{:?}", d.develop.opaque); + }; + let Opaque::Xmp(node) = &kept.value else { + panic!("xmp"); + }; + let XmpValue::Struct(fields) = &node.value else { + panic!("kept as a structure of everything"); + }; + let names: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect(); + assert_eq!(names, ["Extra", "Seq", "Hidden", "Seq"]); + + // A lone container with something other than rdf:li inside. + let d = read(&doc( + "", + r#"0, 0x"#, + )); + assert_eq!(kinds(&d), ["WrongType"]); + } + + #[test] + fn text_next_to_elements_is_reported_once_per_property() { + let d = read(&doc( + "", + r#"junktruemore + stray0, 0"#, + )); + assert_eq!(kinds(&d), ["MalformedRdf", "MalformedRdf"]); + assert_eq!(d.warnings[0].path, "LensBlur"); + assert_eq!(d.warnings[0].raw.as_deref(), Some("junk")); + assert_eq!(d.warnings[1].path, "ToneCurvePV2012"); + let Some(Value::Struct(blur)) = d.develop.get_by_name("LensBlur") else { + panic!("LensBlur still typed"); + }; + assert_eq!(blur.get("Active"), Some(&Value::Bool(true))); + assert_eq!( + d.develop.get_by_name("ToneCurvePV2012"), + Some(&Value::Curve(vec![( + Finite::new_const(0.0), + Finite::new_const(0.0) + )])) + ); + + // Deeper: the path names the first place, the rest are counted. + let d = read(&doc( + "", + r#"xCorrection + y + "#, + )); + assert_eq!(kinds(&d), ["MalformedRdf"]); + assert_eq!(d.warnings[0].path, "MaskGroupBasedCorrections[0]"); + assert_eq!( + d.warnings[0].raw.as_deref(), + Some("x (and 1 more text run(s))") + ); + } + + #[test] + fn every_outermost_rdf_root_is_read() { + let crs = r#"xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/""#; + let rdf = r#"xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#""#; + let text = format!( + r#""# + ); + let d = read(&text); + assert_eq!(d.kind, XmpKind::Sidecar); + } + + #[test] + fn kinds_follow_preset_type_and_process_version() { + let kind = |attrs: &str| read(&doc(attrs, "")).kind; + assert_eq!(kind(r#"crs:ProcessVersion="15.4""#), XmpKind::Sidecar); + assert_eq!( + kind(r#"crs:PresetType="Normal" crs:ProcessVersion="15.4""#), + XmpKind::Preset + ); + assert_eq!(kind(r#"crs:PresetType="Look""#), XmpKind::Profile); + assert_eq!(kind(r#"crs:PresetType="Other""#), XmpKind::NotDevelop); + assert_eq!(kind(r#"crs:Exposure2012="0""#), XmpKind::NotDevelop); + assert_eq!(kind(""), XmpKind::NotDevelop); + } + + #[test] + fn crss_is_skipped_entirely() { + let text = format!( + r#"{HEAD}Snapshot{TAIL}"# + ); + let d = read(&text); + assert!(d.warnings.is_empty()); + assert_eq!(d.skipped_subtrees.crss, 1); + assert!(d.develop.opaque.is_empty()); + } + + #[test] + fn element_form_and_attribute_form_read_the_same() { + let attrs = read(&doc( + r#"crs:Exposure2012="+0.50" crs:WhiteBalance="Custom" crs:Temperature="5500""#, + "", + )); + let elements = read(&doc( + "", + "+0.50Custom5500", + )); + assert_eq!(attrs.develop, elements.develop); + assert_eq!(attrs.develop.file_kind, Some(FileKind::Raw)); + } + + #[test] + fn struct_forms_read_the_same() { + let desc = read(&doc( + "", + r#""#, + )); + let attrs = read(&doc( + "", + r#""#, + )); + let resource = read(&doc( + "", + r#"true50"#, + )); + assert!(desc.warnings.is_empty(), "{:?}", desc.warnings); + assert_eq!(desc.develop, attrs.develop); + assert_eq!(desc.develop, resource.develop); + let Some(Value::Struct(blur)) = desc.develop.get_by_name("LensBlur") else { + panic!("LensBlur"); + }; + assert_eq!(blur.get("Active"), Some(&Value::Bool(true))); + } + + #[test] + fn alt_bag_and_qualifiers_are_kept_whole() { + let d = read(&doc( + r#"crs:PresetType="Normal""#, + r#"NameName DE + + 0, 0 + 5q"#, + )); + assert!(d.warnings.is_empty(), "{:?}", d.warnings); + // Name (a second language), ToneCurvePV2012 (a Bag), Texture + // (qualifiers): typing lost, counted. + assert_eq!(d.skipped_subtrees.kept_whole, 3); + let h = d.header.unwrap(); + assert_eq!(h.name, None); + assert_eq!(h.group.as_deref(), Some("")); + let Some(Value::Opaque(Opaque::Xmp(name))) = h.rest.get(Level::Header, "Name") else { + panic!("Name kept whole"); + }; + assert_eq!(name.alt_default(), Some("Name")); + let Some(Value::Opaque(Opaque::Xmp(tex))) = d.develop.get_by_name("Texture") else { + panic!("Texture kept whole"); + }; + assert_eq!(tex.value, XmpValue::Text("5".into())); + assert_eq!(tex.qualifiers.len(), 1); + assert!(matches!( + d.develop.get_by_name("ToneCurvePV2012"), + Some(Value::Opaque(Opaque::Xmp(_))) + )); + } + + #[test] + fn corrections_land_in_the_shared_model_with_paths() { + let d = read(&doc( + "", + r#" + + + + + + + "#, + )); + assert_eq!(kinds(&d), ["UnknownKey"]); + assert_eq!( + d.warnings[0].path, + "MaskGroupBasedCorrections[0].CorrectionMasks[1].Oddity" + ); + let c = &d.develop.corrections[0]; + assert_eq!(c.amount, Some(Finite::new_const(1.0))); + assert_eq!( + c.local_value("LocalExposure2012"), + Some(&Value::Real(Finite::new_const(0.0625))) + ); + assert_eq!(c.masks[0].tool, MaskTool::Semantic(Semantic::Sky)); + assert_eq!(c.masks[1].tool, MaskTool::Semantic(Semantic::Subject)); + assert_eq!(c.masks[1].combine, Combine::Intersect); + } + + #[test] + fn look_and_preset_parameters_stay_opaque() { + let d = read(&doc( + r#"crs:ProcessVersion="15.4""#, + r#" + + + + + "#, + )); + assert!(d.warnings.is_empty(), "{:?}", d.warnings); + assert_eq!(d.skipped_subtrees.look_parameters, 1); + assert_eq!(d.skipped_subtrees.preset_parameters, 1); + let look = d.develop.look.as_ref().unwrap(); + assert_eq!(look.amount, Some(Finite::new_const(1.0))); + assert!(matches!( + look.rest.get("Parameters"), + Some(Value::Opaque(Opaque::Xmp(_))) + )); + let Some(Value::Struct(preset)) = d.develop.get_by_name("Preset") else { + panic!("Preset"); + }; + assert!(matches!( + preset.get("Parameters"), + Some(Value::Opaque(Opaque::Xmp(_))) + )); + } +} diff --git a/server-rs/crates/lrg-develop/src/xmp/write.rs b/server-rs/crates/lrg-develop/src/xmp/write.rs new file mode 100644 index 00000000..240bc4c6 --- /dev/null +++ b/server-rs/crates/lrg-develop/src/xmp/write.rs @@ -0,0 +1,2217 @@ +//! [`DevelopSettings`] → XMP: a Lightroom Classic develop preset, or (tests +//! only) a lossless rewrite of what [`parse`](super::parse) read. +//! +//! Hand-written and deterministic: the same input gives the same bytes in +//! every build. Keys come in registry order (never a `serde_json` map, whose +//! order depends on the features the workspace unifies), unknown content in +//! the order it was read, namespace prefixes in the order they are first +//! needed. +//! +//! # Modes +//! +//! - [`WriteMode::Preset`]: what production writes. The settings go through +//! [`DevelopSettings::filtered`] with [`Target::Preset`] first, so only keys +//! whose policy reaches a shared preset are written, and every value that +//! did not make it is returned in [`Written::skipped`]. The writer then +//! sets what describes the file rather than the settings: the preset +//! header, `Version` ([`TARGET_ENGINE`]), `CompatibleVersion` (from +//! [`COMPATIBLE_VERSIONS`]), `ProcessVersion` only when +//! [`PresetSpec::process_version`] asks for it, `HasSettings`, +//! `WhiteBalance="Custom"` next to white-balance numbers, and +//! `ToneCurveName2012` next to a point curve. It also completes the +//! fixed form Adobe's adaptive presets use for what the policy filter +//! removes as per-photo (see [`write`](fn@write)). A last guard refuses the +//! never-write list (digests, runtime ids, brush and retouch payloads, +//! `Enable*` switches, anything Lua-only) whatever the policy says. +//! - `WriteMode::TestRoundTrip` (crate feature `test-roundtrip`, enabled +//! only through this crate's own dev-dependency): everything the model +//! holds, opaque, COMPUTED, NEVER and UNKNOWN content included, so +//! XMP → model → XMP → model compares equal. **Never write a photo's +//! sidecar with it**: the model drops every namespace but `crs:` (`dc:`, +//! `xmp:`, `exif:`, `tiff:`, `aux:`, `photoshop:`, `xmpMM:`), so a sidecar +//! rewritten this way loses the photo's metadata. Writing into an existing +//! sidecar needs a merge into that document, not a rewrite. +//! +//! # Document shape +//! +//! One `rdf:Description` in the `crs:` namespace, `x:xmptk` set to +//! [`xmptk`] (never an Adobe toolkit string). Simple values are attributes +//! of their description; `rdf:Seq`, `rdf:Alt` and structures are child +//! elements. A structure of simple values only is written as attributes on +//! its property element (or `rdf:li`), one with a complex field as a nested +//! `rdf:Description`; `rdf:parseType="Resource"` and `rdf:Bag` are never +//! written (an `rdf:Bag` the reader kept whole goes back as one in a test +//! round trip). Numbers, booleans, curves and ids are spelled by +//! [`super::format`]. + +use std::collections::{BTreeMap, HashSet}; +use std::sync::Arc; + +use super::format::{ + escape_attr, escape_text, format_bool, format_compound, format_curve_point, format_int, + format_real, hex32_upper, reformat_compound, EngineVersion, InvalidXmlChar, +}; +use super::read::{PresetHeader, CRS_NS}; +use crate::model::correction::{Correction, MaskComponent, MaskTool, Semantic}; +use crate::model::policy::{SkipReason, Skipped, Target}; +use crate::model::value::{Fields, Finite, Hex32, Opaque, OpaqueEntry, Struct, Value}; +use crate::model::xmp_node::{XmpArrayKind, XmpField, XmpNode, XmpValue}; +use crate::model::{DevelopSettings, Look}; +use crate::registry::{ + self, KeyId, KeySpec, Level, NumFmt, Presence, ProcessVersion, StructKind, ValueKind, +}; + +const RDF_NS: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"; + +/// The Camera Raw engine the writer targets: Lightroom Classic 15.5.1 ships +/// Camera Raw 18.5.1. Written as `crs:Version`, and the cap of +/// `crs:CompatibleVersion`. +pub const TARGET_ENGINE: EngineVersion = EngineVersion::new(18, 5); + +/// The preset group ([`PresetHeader::group`]) of [`PresetHeader::lrgenius`]. +pub const PRESET_GROUP: &str = "LrGeniusAI"; + +/// The `x:xmptk` toolkit string: `LrGeniusAI ` (the +/// release version baked into the build, `0.0.0-dev` otherwise). +pub fn xmptk() -> String { + format!( + "LrGeniusAI {}", + option_env!("LRG_BACKEND_VERSION").unwrap_or("0.0.0-dev") + ) +} + +/// A preset feature that needs a minimum Camera Raw engine to be read. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Feature { + /// Any `MaskGroupBasedCorrections` (the Masking panel). + MaskGroups, + /// A people part (`MaskSubType` 3, or a person part at a point). + PeopleParts, + /// "Select Background" (`MaskSubType` 0, category 22). + Background, + /// A landscape class (`MaskSubType` 0, categories 50001–50008). + Landscape, + /// A point curve inside a correction (`MainCurve`, `RedCurve`, ...). + LocalCurves, + /// `LensBlur`. + LensBlur, + /// The global `CurveRefineSaturation`. + CurveRefineSaturation, + /// `LocalPointColors` (point colour inside a mask). + LocalPointColors, +} + +/// The lowest `CompatibleVersion` Adobe's bundled presets (Lightroom Classic +/// 15.x) use for each feature; the writer takes the highest one a preset +/// needs, capped at [`TARGET_ENGINE`], and writes none when no row applies +/// (as most of Adobe's own presets without masks do). +/// +/// | Feature | Version | Evidence | +/// |---|---|---| +/// | mask groups, subject, sky | 14.0 | adaptive Subject/Sky presets | +/// | people parts | 15.0 | adaptive Portrait presets | +/// | background | 15.0 | not in the bundle; Select Background shipped with the people masks. Unverified: the hand test only shows that a newer Lightroom imports it, and an import into 15.x cannot check a minimum | +/// | landscape classes | 15.3 | adaptive Landscape presets | +/// | point curves in a correction | 15.3 | adaptive presets with `MainCurve`/`BlueCurve` | +/// | `LensBlur` | 16.0 | Blur Background presets | +/// | global `CurveRefineSaturation` | 17.0 | presets with the refine-saturation curve | +/// | `LocalPointColors` | 17.4 | adaptive presets with point colour in a mask | +/// +/// The preset writer computes this from the *filtered* settings, so two +/// rows never apply to a preset it writes: `LocalPointColors` (UNKNOWN) and +/// a person part at a point ([`Semantic::PersonPartAt`], PHOTO; the other +/// half of [`Feature::PeopleParts`]) never pass the policy filter. They stay +/// for [`compatible_version`] on unfiltered settings. +pub const COMPATIBLE_VERSIONS: &[(Feature, EngineVersion)] = &[ + (Feature::MaskGroups, EngineVersion::new(14, 0)), + (Feature::PeopleParts, EngineVersion::new(15, 0)), + (Feature::Background, EngineVersion::new(15, 0)), + (Feature::Landscape, EngineVersion::new(15, 3)), + (Feature::LocalCurves, EngineVersion::new(15, 3)), + (Feature::LensBlur, EngineVersion::new(16, 0)), + (Feature::CurveRefineSaturation, EngineVersion::new(17, 0)), + (Feature::LocalPointColors, EngineVersion::new(17, 4)), +]; + +/// The features of [`COMPATIBLE_VERSIONS`] `settings` uses. +pub fn features(settings: &DevelopSettings) -> Vec { + let masks = || settings.corrections.iter().flat_map(|c| c.masks.iter()); + let semantic = |pred: fn(&Semantic) -> bool| { + masks().any(|m| matches!(&m.tool, MaskTool::Semantic(s) if pred(s))) + }; + let local = |names: &[&str]| { + settings + .corrections + .iter() + .any(|c| names.iter().any(|n| c.local_value(n).is_some())) + }; + let mut out = Vec::new(); + for (feature, _) in COMPATIBLE_VERSIONS { + let used = match feature { + Feature::MaskGroups => !settings.corrections.is_empty(), + Feature::PeopleParts => { + semantic(|s| matches!(s, Semantic::PeoplePart(_) | Semantic::PersonPartAt { .. })) + } + Feature::Background => semantic(|s| matches!(s, Semantic::Background)), + Feature::Landscape => semantic(|s| matches!(s, Semantic::Landscape(_))), + Feature::LocalCurves => local(&["MainCurve", "RedCurve", "GreenCurve", "BlueCurve"]), + Feature::LensBlur => settings.get_by_name("LensBlur").is_some(), + Feature::CurveRefineSaturation => { + settings.get_by_name("CurveRefineSaturation").is_some() + } + Feature::LocalPointColors => local(&["LocalPointColors"]), + }; + if used { + out.push(*feature); + } + } + out +} + +/// The `CompatibleVersion` a preset of `settings` needs: the highest +/// [`COMPATIBLE_VERSIONS`] row among its [`features`], capped at +/// [`TARGET_ENGINE`]; `None` when no row applies. +pub fn compatible_version(settings: &DevelopSettings) -> Option { + let used = features(settings); + COMPATIBLE_VERSIONS + .iter() + .filter(|(f, _)| used.contains(f)) + .map(|(_, v)| *v) + .max() + .map(|v| v.min(TARGET_ENGINE)) +} + +impl PresetHeader { + /// A develop preset header in the [`PRESET_GROUP`] group, with Adobe's + /// usual flags: amount slider (both generations), colour and + /// monochrome photos. + pub fn lrgenius(uuid: Hex32, name: impl Into) -> PresetHeader { + PresetHeader { + preset_type: Some("Normal".into()), + uuid: Some(uuid), + name: Some(name.into()), + group: Some(PRESET_GROUP.into()), + supports_amount: Some(true), + supports_amount2: Some(true), + supports_color: Some(true), + supports_monochrome: Some(true), + ..PresetHeader::default() + } + } +} + +/// What a develop preset is written with. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PresetSpec { + /// The header. `uuid` and `name` are required; every other field has a + /// default (see [`write`](fn@write)). `rest` may carry further header flags + /// (`ShowInPresets`, ...); anything in it that is not a META header key + /// is skipped and reported. + pub header: PresetHeader, + /// The preset may reach raw and non-raw photos alike: no raw-only or + /// non-raw-only key (`Temperature`, `IncrementalTint`, ...), see + /// [`Target::Preset`]. + pub mixed_file_kinds: bool, + /// `ProcessVersion` to stamp into the preset; `None` writes none, so the + /// preset leaves each photo's process version as it is. The settings' own + /// `ProcessVersion` never reaches a preset (it describes the example). + pub process_version: Option, +} + +impl PresetSpec { + /// A preset for raw and non-raw photos, without a process version. + pub fn new(header: PresetHeader) -> PresetSpec { + PresetSpec { + header, + mixed_file_kinds: true, + process_version: None, + } + } +} + +/// How [`write`](fn@write) writes. +/// +/// `#[non_exhaustive]`: `TestRoundTrip` exists only with the crate feature +/// `test-roundtrip`, which any build of this crate's tests (`cargo test +/// --workspace`, `cargo clippy --all-targets`) unifies into the one +/// `lrg-develop` every crate links. A match outside this crate therefore +/// always needs a wildcard arm, and no other crate may name +/// `TestRoundTrip`: CI would not notice, only the release build would fail. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum WriteMode { + /// A shared develop preset: policy-filtered, header and stamps set by + /// the writer. + Preset(PresetSpec), + /// Tests only: everything the model and this header hold, verbatim. See + /// the module docs for why this must never write a sidecar. + #[cfg(feature = "test-roundtrip")] + TestRoundTrip(Option), +} + +/// A written XMP document. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Written { + /// The document (UTF-8, ends with a line feed). + pub xmp: String, + /// Every value that was not written although it differs from its + /// default: the policy filter's reports, then the writer's own (content + /// read from a Lua table or kept whole, header keys that are not META, + /// the never-write guard). What the preset form puts back without a loss + /// is not reported: an AI mask's `ReferencePoint` and `ErrorReason` + /// (Lightroom recomputes them per photo; a people part's point only at + /// the centre), a range mask's `SampleType` 0, `WhiteBalance="Custom"` + /// next to its numbers. Empty in a test round trip. + pub skipped: Vec, +} + +/// The settings cannot be written. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum WriteError { + /// A preset header without a required field. + #[error("a develop preset needs a {0}")] + MissingHeaderField(&'static str), + /// Only `PresetType="Normal"` (develop presets) can be written. + #[error("cannot write a preset of type {0:?}; only \"Normal\" develop presets")] + UnsupportedPresetType(String), + /// A value whose variant does not fit its registry key. + #[error("{path}: expected {expected}, found {found}")] + WrongValue { + /// Where (`"MaskGroupBasedCorrections[0].LocalExposure2012"`). + path: String, + /// The registry's kind. + expected: String, + /// The model value's variant. + found: &'static str, + }, + /// An id that is not 32 hex digits, in a preset. + #[error("{path}: {value:?} is not 32 hex digits")] + NotHex32 { + /// Where. + path: String, + /// The value. + value: String, + }, + /// A compound number string (`ReferencePoint`, `LumRange`) that is not + /// a list of numbers, in a preset. + #[error("{path}: {value:?} is not a list of numbers")] + NotCompound { + /// Where. + path: String, + /// The value. + value: String, + }, + /// Content kept from a Lua table (`Opaque::Json`) has no XMP form; a test + /// round trip cannot write it (a preset skips and reports it). + #[error("{path}: content read from a Lua table has no XMP form")] + NotXmp { + /// Where. + path: String, + }, + /// Text XML 1.0 cannot hold. + #[error("{path}: {source}")] + InvalidChar { + /// Where. + path: String, + /// The character. + source: InvalidXmlChar, + }, +} + +/// Writes `settings` as an XMP document. +/// +/// In [`WriteMode::Preset`] the header defaults to +/// `PresetType="Normal"`, an empty `Cluster`, `CameraModelRestriction`, +/// `Copyright` and `ContactInfo`, the four `Supports*` flags of +/// [`PresetHeader::lrgenius`] (all `True`), and the values Adobe's newer +/// (Camera Raw >= 14.4) presets carry: `SupportsHighDynamicRange`, +/// `SupportsNormalDynamicRange`, `SupportsSceneReferred`, +/// `SupportsOutputReferred` `True`, `RequiresRGBTables` `False`; +/// `ShortName`, `SortName`, `Group` and `Description` default to an empty +/// `x-default` item. `Name` must not be blank. +/// +/// A preset also gets the fixed form of Adobe's adaptive presets for what the +/// policy filter removes or a builder may leave out: a correction's `What`, +/// `CorrectionAmount` 1 and `CorrectionActive`; a component's `MaskActive`; +/// an AI mask's `MaskVersion` 1, `ReferencePoint` `0.500000 0.500000` and +/// `ErrorReason` 0; a luminance range's `CorrectionRangeMask` `Version` 3 +/// and `SampleType` 0. Values already present are kept. +pub fn write(settings: &DevelopSettings, mode: &WriteMode) -> Result { + match mode { + WriteMode::Preset(spec) => write_preset(settings, spec), + #[cfg(feature = "test-roundtrip")] + WriteMode::TestRoundTrip(header) => write_round_trip(settings, header.as_ref()), + } +} + +fn id(level: Level, name: &str) -> KeyId { + registry::lookup(level, name).unwrap_or_else(|| panic!("registry row {level}/{name}")) +} + +fn child(path: &str, key: &str) -> String { + if path.is_empty() { + key.to_owned() + } else { + format!("{path}.{key}") + } +} + +fn write_preset(settings: &DevelopSettings, spec: &PresetSpec) -> Result { + let h = &spec.header; + if let Some(t) = h.preset_type.as_deref().filter(|t| *t != "Normal") { + return Err(WriteError::UnsupportedPresetType(t.to_owned())); + } + let uuid = h + .uuid + .as_ref() + .ok_or(WriteError::MissingHeaderField("UUID"))?; + let name = h + .name + .as_ref() + .filter(|n| !n.trim().is_empty()) + .ok_or(WriteError::MissingHeaderField("Name"))?; + + let (filtered, skipped) = settings.filtered(&Target::Preset { + mixed_file_kinds: spec.mixed_file_kinds, + }); + let mut conv = Conv::new(true, skipped); + + // Header: defaults, then the META keys of `rest`, then the typed fields. + const HD: Level = Level::Header; + let mut header = Fields::default(); + let set = |f: &mut Fields, name: &str, v: Value| { + f.values.insert(id(HD, name), v); + }; + let text = |s: &str| Value::Str(s.to_owned()); + for (k, v) in [ + ("Cluster", text("")), + ("SupportsHighDynamicRange", Value::Bool(true)), + ("SupportsNormalDynamicRange", Value::Bool(true)), + ("SupportsSceneReferred", Value::Bool(true)), + ("SupportsOutputReferred", Value::Bool(true)), + // `False` is right because a preset never carries an RGB table: + // `RGBTables` is COMPUTED, the `Table_*` blobs are opaque, and the + // Look goes by reference without its `Parameters`. + ("RequiresRGBTables", Value::Bool(false)), + ("CameraModelRestriction", text("")), + ("Copyright", text("")), + ("ContactInfo", text("")), + ] { + set(&mut header, k, v); + } + for (kid, v) in &h.rest.values { + let s = kid.spec(); + if s.policy == registry::Policy::Meta { + header.values.insert(*kid, v.clone()); + } else { + conv.skip(s.name.to_owned(), SkipReason::Policy(s.policy)); + } + } + for o in &h.rest.opaque { + conv.skip(o.name.clone(), SkipReason::Opaque); + } + set(&mut header, "PresetType", text("Normal")); + set(&mut header, "UUID", Value::Str(uuid.as_str().to_owned())); + set(&mut header, "Name", Value::Alt(name.clone())); + let flags = [ + ("SupportsAmount", h.supports_amount), + ("SupportsAmount2", h.supports_amount2), + ("SupportsColor", h.supports_color), + ("SupportsMonochrome", h.supports_monochrome), + ]; + for (k, flag) in flags { + set(&mut header, k, Value::Bool(flag.unwrap_or(true))); + } + if let Some(c) = &h.cluster { + set(&mut header, "Cluster", text(c)); + } + if let Some(r) = &h.camera_model_restriction { + set(&mut header, "CameraModelRestriction", text(r)); + } + for (k, v) in [ + ("ShortName", &h.short_name), + ("SortName", &h.sort_name), + ("Group", &h.group), + ("Description", &h.description), + ] { + set(&mut header, k, Value::Alt(v.clone().unwrap_or_default())); + } + + // Global values, then what the writer sets from context. + let mut global = global_fields(&filtered); + let g = |name: &str| id(Level::Global, name); + let has = |f: &Fields, name: &str| f.values.contains_key(&g(name)); + let wb_numbers = [ + "Temperature", + "Tint", + "IncrementalTemperature", + "IncrementalTint", + ]; + if wb_numbers.iter().any(|k| has(&global, k)) && !has(&global, "WhiteBalance") { + global.values.insert(g("WhiteBalance"), text("Custom")); + } + if let Some(curve @ Value::Curve(_)) = global.values.get(&g("ToneCurvePV2012")) { + let linear = curve.equals_lit(®istry::Lit::IntList(&[0, 0, 255, 255])); + global.values.insert( + g("ToneCurveName2012"), + text(if linear { "Linear" } else { "Custom" }), + ); + } + global + .values + .insert(g("Version"), Value::Str(TARGET_ENGINE.to_string())); + if let Some(v) = compatible_version(&filtered) { + global + .values + .insert(g("CompatibleVersion"), Value::Int(v.packed())); + } + if let Some(pv) = spec.process_version { + global + .values + .insert(g("ProcessVersion"), Value::Str(pv.to_string())); + } + global.values.insert(g("HasSettings"), Value::Bool(true)); + + // Not reported on purpose: the preset form puts these back as the source + // had them or as Lightroom recomputes them per photo (see + // `written_back`); nothing was lost that the user could act on. A value + // the form really replaces stays reported. + let wb_custom = global.values.get(&g("WhiteBalance")) == Some(&text("Custom")); + let back = written_back(settings, wb_custom); + conv.skipped.retain(|k| !back.contains(&k.path)); + + let fields = conv.top_level(&header, &global)?; + let xmp = Xml::document(&fields)?; + Ok(Written { + xmp, + skipped: conv.skipped, + }) +} + +/// The paths of values the policy filter removes and the preset form puts +/// back, where nothing is lost: +/// +/// - an AI mask's `ReferencePoint` and `ErrorReason` (Subject, Sky, +/// Background, a landscape class): Lightroom recomputes both per photo +/// when it updates the mask, and Adobe's own presets carry the centre and +/// 0 whatever photo they were made on; +/// - a people part's (`MaskSubType` 3) `ReferencePoint` only at the centre: +/// another point may pick the person (Lightroom's sidecars have many), so +/// replacing it is a change and stays reported until the hand test shows +/// what the preset form selects; its `ErrorReason` as above; +/// - a luminance range's `SampleType` 0 (another value is replaced, and +/// reported); +/// - `WhiteBalance="Custom"` when the writer stamps `Custom` next to the +/// numbers. +fn written_back(settings: &DevelopSettings, wb_custom: bool) -> HashSet { + let mut out = HashSet::new(); + let custom = settings.get_by_name("WhiteBalance").and_then(Value::as_str) == Some("Custom"); + if wb_custom && custom { + out.insert("WhiteBalance".to_owned()); + } + let centred = |v: Option<&Value>| { + let nums: Option> = v + .and_then(Value::as_str) + .map(|s| s.split_whitespace().map(|x| x.parse().ok()).collect()) + .unwrap_or(None); + nums.as_deref() == Some(&[0.5, 0.5]) + }; + for (i, c) in settings.corrections.iter().enumerate() { + for (j, m) in c.masks.iter().enumerate() { + let base = format!("MaskGroupBasedCorrections[{i}].CorrectionMasks[{j}]"); + match &m.tool { + MaskTool::Semantic(Semantic::PersonPartAt { .. }) => {} + MaskTool::Semantic(s) => { + let people = matches!(s, Semantic::PeoplePart(_)); + let point = m.extra.get(Level::MaskTool, "ReferencePoint"); + if !people || centred(point) { + out.insert(format!("{base}.ReferencePoint")); + } + out.insert(format!("{base}.ErrorReason")); + } + MaskTool::LuminanceRange(lr) + if lr.rest.get("SampleType").and_then(Value::as_int) == Some(0) => + { + out.insert(format!("{base}.CorrectionRangeMask.SampleType")); + } + _ => {} + } + } + } + out +} + +#[cfg(feature = "test-roundtrip")] +fn write_round_trip( + settings: &DevelopSettings, + header: Option<&PresetHeader>, +) -> Result { + let mut conv = Conv::new(false, Vec::new()); + let header = header.map(header_fields).unwrap_or_default(); + let global = global_fields(settings); + let fields = conv.top_level(&header, &global)?; + let xmp = Xml::document(&fields)?; + Ok(Written { + xmp, + skipped: conv.skipped, + }) +} + +/// The header's typed fields back in their registry rows, next to `rest`. +#[cfg(feature = "test-roundtrip")] +fn header_fields(h: &PresetHeader) -> Fields { + const HD: Level = Level::Header; + let mut f = h.rest.clone(); + let mut put = |name: &str, v: Option| { + if let Some(v) = v { + f.values.insert(id(HD, name), v); + } + }; + let s = |v: &Option| v.clone().map(Value::Str); + let alt = |v: &Option| v.clone().map(Value::Alt); + let b = |v: Option| v.map(Value::Bool); + put("PresetType", s(&h.preset_type)); + put( + "UUID", + h.uuid.as_ref().map(|u| Value::Str(u.as_str().to_owned())), + ); + put("Name", alt(&h.name)); + put("ShortName", alt(&h.short_name)); + put("SortName", alt(&h.sort_name)); + put("Group", alt(&h.group)); + put("Description", alt(&h.description)); + put("Cluster", s(&h.cluster)); + put("CameraModelRestriction", s(&h.camera_model_restriction)); + put("SupportsAmount", b(h.supports_amount)); + put("SupportsAmount2", b(h.supports_amount2)); + put("SupportsColor", b(h.supports_color)); + put("SupportsMonochrome", b(h.supports_monochrome)); + f +} + +/// The global values with the `Look` and the corrections back in their +/// registry rows, and the opaque entries. +fn global_fields(settings: &DevelopSettings) -> Fields { + let mut f = Fields { + values: settings.values().map(|(k, v)| (k, v.clone())).collect(), + opaque: settings.opaque.clone(), + }; + if let Some(look) = &settings.look { + f.values + .insert(id(Level::Global, "Look"), Value::Struct(look_struct(look))); + } + if !settings.corrections.is_empty() { + f.values.insert( + id(Level::Global, "MaskGroupBasedCorrections"), + Value::Corrections(settings.corrections.clone()), + ); + } + f +} + +/// The `Look`'s typed fields back in their registry rows, next to `rest` +/// (shared with the Lua writer). +pub(crate) fn look_struct(look: &Look) -> Struct { + const L: Level = Level::Struct(StructKind::Look); + let mut s = look.rest.clone(); + let mut put = |name: &str, v: Option| { + if let Some(v) = v { + s.fields.values.insert(id(L, name), v); + } + }; + put("Name", look.name.clone().map(Value::Str)); + put( + "UUID", + look.uuid + .as_ref() + .map(|u| Value::Str(u.as_str().to_owned())), + ); + put("Amount", look.amount.map(Value::Real)); + put( + "CameraModelRestriction", + look.camera_restriction.clone().map(Value::Str), + ); + s +} + +/// Header keys in the order Adobe's presets write them; any other header key +/// follows in registry order. +const HEADER_ORDER: &[&str] = &[ + "PresetType", + "Cluster", + "UUID", + "SupportsAmount2", + "SupportsAmount", + "SupportsColor", + "SupportsMonochrome", + "SupportsHighDynamicRange", + "SupportsNormalDynamicRange", + "SupportsSceneReferred", + "SupportsOutputReferred", + "RequiresRGBTables", + "ShowInPresets", + "ShowInQuickActions", + "CameraModelRestriction", + "Copyright", + "ContactInfo", + "Name", + "ShortName", + "SortName", + "Group", + "Description", +]; + +/// Version stamps, written right after the header (as Adobe does). +const STAMPS: &[&str] = &["Version", "CompatibleVersion", "ProcessVersion"]; + +/// Keys a preset never carries, whatever their policy says (checked after +/// the policy filter, as a last guard): digests (every key with `Digest` in +/// its name), runtime ids and reference points, mask payload bookkeeping, +/// `MaskBrushTable*`, brush dabs, retouch and red-eye content, filter +/// payloads, computed profile state, orientation, the auto-white-balance +/// stamp, sidecar bookkeeping, and — not listed — the `Enable*` panel +/// switches and every key Lightroom only has in its Lua tables or was never +/// seen writing. +pub const NEVER_WRITE: &[&str] = &[ + "CorrectionID", + "MaskID", + "CorrectionReferenceX", + "CorrectionReferenceY", + "FullMaskSize", + "WholeImageArea", + "Origin", + "ModelVersion", + "Dabs", + "RetouchAreas", + "RemoveAreas", + "RetouchInfo", + "RedEyeInfo", + "FilterList", + "AILook", + "DepthMapInfo", + "orientation", + "AutoWhiteVersion", + "RawFileName", + "AlreadyApplied", +]; + +/// Whether `spec` is on the never-write list. +pub fn never_written(spec: &KeySpec) -> bool { + NEVER_WRITE.contains(&spec.name) + || spec.name.contains("Digest") + || spec.name.starts_with("MaskBrushTable") + || (spec.level == Level::Global && spec.name.starts_with("Enable")) + || matches!(spec.presence, Presence::LuaOnly | Presence::Unobserved) +} + +/// Model → XMP data model ([`XmpField`]s with formatted text). +struct Conv { + /// Preset mode: guard, quantize, upper-case ids, add the fixed form. + preset: bool, + crs: Arc, + skipped: Vec, +} + +fn text_node(s: String) -> XmpNode { + XmpNode::text(s) +} + +fn seq(items: Vec) -> XmpNode { + XmpNode { + value: XmpValue::Array { + kind: XmpArrayKind::Seq, + items, + }, + lang: None, + qualifiers: Vec::new(), + } +} + +fn is_simple(n: &XmpNode) -> bool { + n.is_plain() && matches!(n.value, XmpValue::Text(_)) +} + +fn variant(v: &Value) -> &'static str { + match v { + Value::Int(_) => "an integer", + Value::Real(_) => "a real", + Value::Bool(_) => "a boolean", + Value::Str(_) => "text", + Value::Curve(_) => "a curve", + Value::StrList(_) => "a text list", + Value::Alt(_) => "a language alternative", + Value::Struct(_) => "a structure", + Value::StructList(_) => "a structure list", + Value::Tools(_) => "mask components", + Value::Corrections(_) => "corrections", + Value::Opaque(_) => "opaque content", + } +} + +impl Conv { + fn new(preset: bool, skipped: Vec) -> Conv { + Conv { + preset, + crs: Arc::from(CRS_NS), + skipped, + } + } + + fn lossless(&self) -> bool { + !self.preset + } + + fn skip(&mut self, path: String, reason: SkipReason) { + self.skipped.push(Skipped { path, reason }); + } + + /// Content read from a Lua table: skipped in a preset, an error in a + /// test round trip. + fn json(&mut self, path: &str) -> Result<(), WriteError> { + if self.preset { + self.skip(path.to_owned(), SkipReason::Opaque); + Ok(()) + } else { + Err(WriteError::NotXmp { + path: path.to_owned(), + }) + } + } + + fn crs_field(&self, name: &str, node: XmpNode) -> XmpField { + XmpField { + ns: self.crs.clone(), + name: name.to_owned(), + node, + } + } + + /// The top-level description: header and stamps, global values, + /// `HasSettings`, then the complex values, then the opaque entries. + fn top_level(&mut self, header: &Fields, global: &Fields) -> Result, WriteError> { + let mut ranked: Vec<((u8, usize), XmpField)> = Vec::new(); + for (kid, node) in self.typed(header, "")? { + let name = kid.spec().name; + let pos = HEADER_ORDER + .iter() + .position(|n| *n == name) + .unwrap_or(HEADER_ORDER.len() + kid.index()); + let group = if is_simple(&node) { 0 } else { 4 }; + ranked.push(((group, pos), self.crs_field(name, node))); + } + for (kid, node) in self.typed(global, "")? { + let name = kid.spec().name; + let rank = if let Some(p) = STAMPS.iter().position(|n| *n == name) { + (1, p) + } else if name == "HasSettings" { + (3, 0) + } else if is_simple(&node) { + (2, kid.index()) + } else { + (5, kid.index()) + }; + ranked.push((rank, self.crs_field(name, node))); + } + ranked.sort_by_key(|(rank, _)| *rank); + let (simple, complex): (Vec<_>, Vec<_>) = + ranked.into_iter().partition(|((group, _), _)| *group < 4); + let mut opaque = self.opaque(&header.opaque, "")?; + opaque.extend(self.opaque(&global.opaque, "")?); + Ok(arrange( + simple.into_iter().map(|(_, f)| f).collect(), + complex.into_iter().map(|(_, f)| f).collect(), + opaque, + true, + )) + } + + /// The registry values of `f` as nodes, in registry order; in a preset + /// without the never-write keys. + fn typed(&mut self, f: &Fields, path: &str) -> Result, WriteError> { + let mut out = Vec::with_capacity(f.values.len()); + for (kid, v) in &f.values { + let spec = kid.spec(); + let p = child(path, spec.name); + if self.preset && never_written(spec) { + self.skip(p, SkipReason::Policy(spec.policy)); + continue; + } + if let Some(node) = self.node(spec, v, &p)? { + out.push((*kid, node)); + } + } + Ok(out) + } + + /// Opaque entries as fields, in their order (a preset has none left + /// after the policy filter; any that come are skipped and reported). + fn opaque(&mut self, entries: &[OpaqueEntry], path: &str) -> Result, WriteError> { + let mut out = Vec::new(); + for e in entries { + let p = child(path, &e.name); + if self.preset { + self.skip(p, SkipReason::Opaque); + continue; + } + match &e.value { + Opaque::Xmp(node) => out.push(XmpField { + ns: e.ns.clone().unwrap_or_else(|| self.crs.clone()), + name: e.name.clone(), + node: node.clone(), + }), + Opaque::Json(_) => self.json(&p)?, + } + } + Ok(out) + } + + /// A structure: its registry values in registry order and its opaque + /// entries in theirs, arranged by [`arrange`]. + fn structure(&mut self, f: &Fields, path: &str) -> Result { + let (simple, complex): (Vec<_>, Vec<_>) = self + .typed(f, path)? + .into_iter() + .partition(|(_, n)| is_simple(n)); + let named = |this: &Self, v: Vec<(KeyId, XmpNode)>| -> Vec { + v.into_iter() + .map(|(kid, n)| this.crs_field(kid.spec().name, n)) + .collect() + }; + let simple = named(self, simple); + let complex = named(self, complex); + let opaque = self.opaque(&f.opaque, path)?; + Ok(XmpNode { + value: XmpValue::Struct(arrange(simple, complex, opaque, false)), + lang: None, + qualifiers: Vec::new(), + }) + } + + /// One registry value as a node; `None` when it is skipped. + fn node( + &mut self, + spec: &'static KeySpec, + v: &Value, + path: &str, + ) -> Result, WriteError> { + let lossless = self.lossless(); + let kind = spec.kind.xmp_form(); + let node = match (kind, v) { + // Kept whole by the reader: its meaning is not established, so a + // preset never carries it (the policy filter removes it first; + // this is the guard). + (_, Value::Opaque(Opaque::Xmp(_))) if self.preset => { + self.skip(path.to_owned(), SkipReason::Opaque); + return Ok(None); + } + (_, Value::Opaque(Opaque::Xmp(n))) => n.clone(), + (_, Value::Opaque(Opaque::Json(_))) => { + self.json(path)?; + return Ok(None); + } + ( + ValueKind::Int | ValueKind::IntFlag | ValueKind::EnumInt(_) | ValueKind::VersionU32, + Value::Int(i), + ) => text_node(format_int(spec, *i)), + (ValueKind::Real, Value::Real(x)) => text_node(format_real(spec, *x, lossless)), + (ValueKind::Real, Value::Int(i)) => { + text_node(format_real(spec, Finite::from_i64(*i), lossless)) + } + (ValueKind::Bool(style), Value::Bool(b)) => text_node(format_bool(style, *b).into()), + (ValueKind::Hex32, Value::Str(s)) => text_node(self.hex(s, path)?), + (ValueKind::Enum(_) | ValueKind::Str | ValueKind::VersionStr, Value::Str(s)) => { + text_node(self.compound(spec, s, path)?) + } + (ValueKind::Curve(curve), Value::Curve(points)) => seq(points + .iter() + .map(|p| text_node(format_curve_point(curve, spec.fmt, *p, lossless))) + .collect()), + (ValueKind::StrSeq, Value::StrList(items)) => { + let mut nodes = Vec::with_capacity(items.len()); + for (i, s) in items.iter().enumerate() { + nodes.push(text_node(self.compound( + spec, + s, + &format!("{path}[{i}]"), + )?)); + } + seq(nodes) + } + (ValueKind::LangAlt, Value::Alt(s)) => XmpNode { + value: XmpValue::Array { + kind: XmpArrayKind::Alt, + items: vec![XmpNode { + lang: Some("x-default".into()), + ..XmpNode::text(s.clone()) + }], + }, + lang: None, + qualifiers: Vec::new(), + }, + (ValueKind::Struct(_), Value::Struct(s)) => self.structure(&s.fields, path)?, + (ValueKind::StructSeq(_), Value::StructList(items)) => { + let mut nodes = Vec::with_capacity(items.len()); + for (i, s) in items.iter().enumerate() { + nodes.push(self.structure(&s.fields, &format!("{path}[{i}]"))?); + } + seq(nodes) + } + (ValueKind::CorrectionSeq, Value::Corrections(items)) => { + let mut nodes = Vec::with_capacity(items.len()); + for (i, c) in items.iter().enumerate() { + let f = self.correction_fields(c); + nodes.push(self.structure(&f, &format!("{path}[{i}]"))?); + } + seq(nodes) + } + (ValueKind::ComponentSeq, Value::Tools(items)) => { + let mut nodes = Vec::with_capacity(items.len()); + for (i, f) in items.iter().enumerate() { + nodes.push(self.structure(f, &format!("{path}[{i}]"))?); + } + seq(nodes) + } + (kind, v) => { + return Err(WriteError::WrongValue { + path: path.to_owned(), + expected: format!("{kind:?}"), + found: variant(v), + }) + } + }; + Ok(Some(node)) + } + + /// An id: upper-cased and checked in a preset, verbatim in a round trip + /// (the reader keeps ids as read, case included). + fn hex(&self, s: &str, path: &str) -> Result { + if !self.preset { + return Ok(s.to_owned()); + } + hex32_upper(s).ok_or_else(|| WriteError::NotHex32 { + path: path.to_owned(), + value: s.to_owned(), + }) + } + + /// Text; a compound number string re-spelled at `%.6f` in a preset. + fn compound(&self, spec: &KeySpec, s: &str, path: &str) -> Result { + if !self.preset || spec.fmt != NumFmt::CompoundFixed6 { + return Ok(s.to_owned()); + } + reformat_compound(s).ok_or_else(|| WriteError::NotCompound { + path: path.to_owned(), + value: s.to_owned(), + }) + } + + /// A correction's typed parts back in their registry rows (the inverse + /// of `Correction::from_fields`), plus the preset form. + fn correction_fields(&self, c: &Correction) -> Fields { + const C: Level = Level::Correction; + let mut f = c.extra.clone(); + let v = &mut f.values; + for (kid, value) in &c.local { + v.insert(*kid, value.clone()); + } + if let Some(n) = &c.name { + v.insert(id(C, "CorrectionName"), Value::Str(n.clone())); + } + if let Some(s) = &c.sync_id { + v.insert(id(C, "CorrectionSyncID"), Value::Str(s.as_str().to_owned())); + } + if let Some(a) = c.amount { + v.insert(id(C, "CorrectionAmount"), Value::Real(a)); + } + if let Some(a) = c.active { + v.insert(id(C, "CorrectionActive"), Value::Bool(a)); + } + if !c.masks.is_empty() { + let tools = c.masks.iter().map(|m| self.component_fields(m)).collect(); + v.insert(id(C, "CorrectionMasks"), Value::Tools(tools)); + } + if self.preset { + let defaults = [ + ("What", Value::Str("Correction".into())), + ("CorrectionAmount", Value::Real(Finite::new_const(1.0))), + ("CorrectionActive", Value::Bool(true)), + ]; + for (k, d) in defaults { + v.entry(id(C, k)).or_insert(d); + } + } + f + } + + /// A mask component's typed parts back in their registry rows (the + /// inverse of `MaskComponent::from_fields`), plus the preset form. + fn component_fields(&self, m: &MaskComponent) -> Fields { + const M: Level = Level::MaskTool; + let mut f = m.extra.clone(); + let v = &mut f.values; + let mut put = |name: &str, value: Value| { + v.insert(id(M, name), value); + }; + if let Some(n) = &m.name { + put("MaskName", Value::Str(n.clone())); + } + if let Some(s) = &m.sync_id { + put("MaskSyncID", Value::Str(s.as_str().to_owned())); + } + if let Some(a) = m.active { + put("MaskActive", Value::Bool(a)); + } + if let Some((mode, value, inverted)) = m.combine.encode() { + put("MaskBlendMode", Value::Int(mode)); + put("MaskValue", Value::Real(value)); + put("MaskInverted", Value::Bool(inverted)); + } + let lossless = self.lossless(); + let real = Value::Real; + match &m.tool { + MaskTool::Semantic(s) => { + put("What", Value::Str("Mask/Image".into())); + let (sub, category) = s.encoding(); + put("MaskSubType", Value::Int(sub)); + if let Some(c) = category { + put("MaskSubCategoryID", Value::Int(c)); + } + if let Semantic::PersonPartAt { point, .. } = s { + put( + "ReferencePoint", + Value::Str(format_compound(&[point.x, point.y], lossless)), + ); + } + } + MaskTool::Linear(g) => { + put("What", Value::Str("Mask/Gradient".into())); + put("ZeroX", real(g.zero.x)); + put("ZeroY", real(g.zero.y)); + put("FullX", real(g.full.x)); + put("FullY", real(g.full.y)); + } + MaskTool::Radial(g) => { + put("What", Value::Str("Mask/CircularGradient".into())); + put("Top", real(g.top)); + put("Left", real(g.left)); + put("Bottom", real(g.bottom)); + put("Right", real(g.right)); + put("Angle", real(Finite::ZERO)); + put("Midpoint", Value::Int(g.midpoint)); + put("Roundness", Value::Int(g.roundness)); + put("Feather", Value::Int(g.feather)); + put("Flipped", Value::Bool(g.flipped)); + } + MaskTool::LuminanceRange(lr) => { + put("What", Value::Str("Mask/RangeMask".into())); + const R: Level = Level::Struct(StructKind::CorrectionRangeMask); + let mut crm = lr.rest.clone(); + let rv = &mut crm.fields.values; + rv.insert(id(R, "Type"), Value::Int(2)); + rv.insert(id(R, "Invert"), Value::Bool(lr.invert)); + rv.insert( + id(R, "LumRange"), + Value::Str(format_compound(&lr.range, lossless)), + ); + if self.preset { + rv.entry(id(R, "Version")).or_insert(Value::Int(3)); + rv.entry(id(R, "SampleType")).or_insert(Value::Int(0)); + } + put("CorrectionRangeMask", Value::Struct(crm)); + } + MaskTool::Opaque { what } => { + if let Some(w) = what { + put("What", Value::Str(w.clone())); + } + } + } + if self.preset { + let mut defaults = vec![("MaskActive", Value::Bool(true))]; + if matches!(&m.tool, MaskTool::Semantic(s) if !matches!(s, Semantic::PersonPartAt { .. })) + { + defaults.extend([ + ("MaskVersion", Value::Int(1)), + ("ReferencePoint", Value::Str("0.500000 0.500000".into())), + ("ErrorReason", Value::Int(0)), + ]); + } + for (k, d) in defaults { + f.values.entry(id(M, k)).or_insert(d); + } + } + f + } +} + +/// The field order of one description: simple registry values, the +/// leading opaque entries that can be attributes too, complex registry +/// values, the remaining opaque entries. +/// +/// The reader reads attributes before elements and keeps opaque entries in +/// that order, and the first occurrence of a name wins; so an opaque entry +/// only becomes an attribute when every opaque entry before it does, and +/// never when it repeats a name (a registry key's second occurrence must +/// stay behind the first). `crs_only`: at the top level, where only `crs:` +/// attributes are read. +fn arrange( + simple: Vec, + complex: Vec, + mut opaque: Vec, + crs_only: bool, +) -> Vec { + let key = |f: &XmpField| (f.ns.to_string(), f.name.clone()); + let mut seen: std::collections::HashSet<(String, String)> = + simple.iter().chain(&complex).map(key).collect(); + let lead = opaque + .iter() + .take_while(|f| { + is_simple(&f.node) && (!crs_only || &*f.ns == CRS_NS) && seen.insert(key(f)) + }) + .count(); + let rest = opaque.split_off(lead); + let mut out = simple; + out.extend(opaque); + out.extend(complex); + out.extend(rest); + out +} + +/// Namespace prefixes: `crs`, `rdf` and the unprefixed "no namespace" are +/// fixed; any other namespace gets `ns1`, `ns2`, ... in the order it is +/// first written, declared on each element that uses it. +#[derive(Default)] +struct Prefixes(BTreeMap, String>, usize); + +impl Prefixes { + /// The qualified name of `(ns, name)` and the declaration it needs. + fn qname(&mut self, ns: &Arc, name: &str) -> (String, Option<(String, Arc)>) { + match &**ns { + "" => (name.to_owned(), None), + CRS_NS => (format!("crs:{name}"), None), + RDF_NS => (format!("rdf:{name}"), None), + _ => { + let prefix = match self.0.get(ns) { + Some(p) => p.clone(), + None => { + self.1 += 1; + let p = format!("ns{}", self.1); + self.0.insert(ns.clone(), p.clone()); + p + } + }; + (format!("{prefix}:{name}"), Some((prefix, ns.clone()))) + } + } + } +} + +/// XMP data model → XML text. +struct Xml { + out: String, + prefixes: Prefixes, +} + +fn pad(out: &mut String, n: usize) { + out.extend(std::iter::repeat_n(' ', n)); +} + +fn esc_attr(s: &str, path: &str) -> Result { + escape_attr(s).map_err(|source| WriteError::InvalidChar { + path: path.to_owned(), + source, + }) +} + +fn esc_text(s: &str, path: &str) -> Result { + escape_text(s).map_err(|source| WriteError::InvalidChar { + path: path.to_owned(), + source, + }) +} + +/// How many leading fields can be attributes: plain text, and no name twice +/// (an element may not repeat an attribute). +fn attribute_prefix(fields: &[XmpField]) -> usize { + let mut seen: Vec<(&str, &str)> = Vec::new(); + for (i, f) in fields.iter().enumerate() { + let key = (&*f.ns, f.name.as_str()); + if !is_simple(&f.node) || seen.contains(&key) { + return i; + } + seen.push(key); + } + fields.len() +} + +impl Xml { + fn document(fields: &[XmpField]) -> Result { + let mut x = Xml { + out: String::with_capacity(4096), + prefixes: Prefixes::default(), + }; + let tk = esc_attr(&xmptk(), "x:xmptk")?; + x.out.push_str(&format!( + "\n \n \n"); + } else { + x.out.push_str(">\n"); + for f in &fields[n..] { + x.element(&f.ns, &f.name, &f.node, 3, &f.name)?; + } + x.out.push_str(" \n"); + } + x.out.push_str(" \n\n"); + Ok(x.out) + } + + /// One field as an attribute on its own line at `depth`. + fn attribute(&mut self, f: &XmpField, depth: usize, path: &str) -> Result<(), WriteError> { + let XmpValue::Text(s) = &f.node.value else { + unreachable!("only plain text fields become attributes") + }; + let (qn, _) = self.prefixes.qname(&f.ns, &f.name); + let p = child(path, &f.name); + self.out.push('\n'); + pad(&mut self.out, depth); + self.out.push_str(&format!("{qn}=\"{}\"", esc_attr(s, &p)?)); + Ok(()) + } + + /// Opens `)>, + lang: Option<&str>, + attrs: &[XmpField], + depth: usize, + path: &str, + ) -> Result<(), WriteError> { + for f in attrs { + if let (_, Some(d)) = self.prefixes.qname(&f.ns, &f.name) { + decls.push(d); + } + } + decls.sort(); + decls.dedup(); + pad(&mut self.out, depth); + self.out.push('<'); + self.out.push_str(qn); + for (prefix, uri) in decls.iter() { + let uri = esc_attr(uri, path)?; + self.out.push_str(&format!(" xmlns:{prefix}=\"{uri}\"")); + } + if let Some(l) = lang { + let l = esc_attr(l, path)?; + self.out.push_str(&format!(" xml:lang=\"{l}\"")); + } + for f in attrs { + self.attribute(f, depth + 1, path)?; + } + Ok(()) + } + + /// One property (or `rdf:li`) element at `depth`. + fn element( + &mut self, + ns: &Arc, + name: &str, + node: &XmpNode, + depth: usize, + path: &str, + ) -> Result<(), WriteError> { + let (qn, decl) = self.prefixes.qname(ns, name); + let mut decls: Vec<_> = decl.into_iter().collect(); + let lang = node.lang.as_deref(); + if !node.qualifiers.is_empty() { + // A qualified value: rdf:value and the qualifiers in a nested + // description. + self.open(&qn, &mut decls, lang, &[], depth, path)?; + self.out.push_str(">\n"); + pad(&mut self.out, depth + 1); + self.out.push_str("\n"); + let bare = XmpNode { + value: node.value.clone(), + lang: None, + qualifiers: Vec::new(), + }; + let rdf: Arc = Arc::from(RDF_NS); + self.element(&rdf, "value", &bare, depth + 1, path)?; + for q in &node.qualifiers { + let p = child(path, &q.name); + self.element(&q.ns, &q.name, &q.node, depth + 1, &p)?; + } + pad(&mut self.out, depth + 1); + self.out.push_str("\n"); + return self.close(&qn, depth); + } + match &node.value { + XmpValue::Text(s) => { + self.open(&qn, &mut decls, lang, &[], depth, path)?; + if s.is_empty() { + self.out.push_str("/>\n"); + } else { + let t = esc_text(s, path)?; + self.out.push_str(&format!(">{t}\n")); + } + Ok(()) + } + XmpValue::Array { kind, items } => { + self.open(&qn, &mut decls, lang, &[], depth, path)?; + self.out.push_str(">\n"); + pad(&mut self.out, depth + 1); + let container = format!("rdf:{}", kind.name()); + if items.is_empty() { + self.out.push_str(&format!("<{container}/>\n")); + } else { + self.out.push_str(&format!("<{container}>\n")); + let rdf: Arc = Arc::from(RDF_NS); + for (i, item) in items.iter().enumerate() { + self.element(&rdf, "li", item, depth + 2, &format!("{path}[{i}]"))?; + } + pad(&mut self.out, depth + 1); + self.out.push_str(&format!("\n")); + } + self.close(&qn, depth) + } + XmpValue::Struct(fields) => { + let n = attribute_prefix(fields); + if !fields.is_empty() && n == fields.len() { + // Simple fields only: attributes on the element itself. + self.open(&qn, &mut decls, lang, fields, depth, path)?; + self.out.push_str("/>\n"); + return Ok(()); + } + self.open(&qn, &mut decls, lang, &[], depth, path)?; + self.out.push_str(">\n"); + let mut inner = Vec::new(); + self.open( + "rdf:Description", + &mut inner, + None, + &fields[..n], + depth + 1, + path, + )?; + if fields.is_empty() { + self.out.push_str("/>\n"); + } else { + self.out.push_str(">\n"); + // Adobe's indentation: the description's elements at + // the description's own depth. + for f in &fields[n..] { + let p = child(path, &f.name); + self.element(&f.ns, &f.name, &f.node, depth + 1, &p)?; + } + pad(&mut self.out, depth + 1); + self.out.push_str("\n"); + } + self.close(&qn, depth) + } + } + } + + fn close(&mut self, qn: &str, depth: usize) -> Result<(), WriteError> { + pad(&mut self.out, depth); + self.out.push_str(&format!("\n")); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::correction::{ + Combine, LinearGradient, LuminanceRange, RadialGradient, SensorPoint, + }; + use crate::xmp::read::{parse, XmpKind}; + + fn f(x: f64) -> Finite { + Finite::new(x).unwrap() + } + + fn set(s: &mut DevelopSettings, name: &str, v: Value) { + s.insert(id(Level::Global, name), v).unwrap(); + } + + fn hex(n: u32) -> Hex32 { + Hex32::parse(&format!("{n:032x}")).unwrap() + } + + fn component(tool: MaskTool, combine: Combine) -> MaskComponent { + MaskComponent { + tool, + combine, + name: Some("LrGenius · Mask".into()), + sync_id: Some(hex(0xb1)), + active: None, + extra: Fields::default(), + } + } + + fn correction(name: &str, masks: Vec) -> Correction { + let mut local = BTreeMap::new(); + local.insert( + id(Level::Correction, "LocalExposure2012"), + Value::Real(f(0.0625)), + ); + Correction { + name: Some(name.into()), + sync_id: Some(hex(0xa1)), + amount: None, + active: None, + local, + masks, + extra: Fields::default(), + } + } + + fn settings() -> DevelopSettings { + let mut s = DevelopSettings::new(); + s.file_kind = Some(crate::model::FileKind::Raw); + set(&mut s, "Exposure2012", Value::Real(f(0.5))); + set(&mut s, "Contrast2012", Value::Int(-12)); + set(&mut s, "Temperature", Value::Int(5600)); + set(&mut s, "Tint", Value::Int(6)); + set(&mut s, "SharpenRadius", Value::Real(f(1.0))); + set( + &mut s, + "ToneCurvePV2012", + Value::Curve(vec![ + (f(0.0), f(0.0)), + (f(64.0), f(58.0)), + (f(255.0), f(255.0)), + ]), + ); + set(&mut s, "ProcessVersion", Value::Str("11.0".into())); + s.corrections.push(correction( + "LrGenius · Subject", + vec![component( + MaskTool::Semantic(Semantic::Subject), + Combine::Add { inverted: false }, + )], + )); + s.corrections.push(correction( + "LrGenius · Sky", + vec![ + component( + MaskTool::Semantic(Semantic::Sky), + Combine::Add { inverted: false }, + ), + component( + MaskTool::LuminanceRange(LuminanceRange { + range: [f(0.0), f(0.2), f(0.8), f(1.0)], + invert: false, + rest: Struct::new(StructKind::CorrectionRangeMask), + }), + Combine::Intersect, + ), + ], + )); + s + } + + fn spec(mixed: bool) -> WriteMode { + let mut header = PresetHeader::lrgenius(hex(0xabc), "LrGenius · Test & "); + header.description = Some("Say \"hi\" 'there'".into()); + WriteMode::Preset(PresetSpec { + header, + mixed_file_kinds: mixed, + process_version: Some(ProcessVersion::V6), + }) + } + + fn preset(s: &DevelopSettings, mixed: bool) -> Written { + write(s, &spec(mixed)).unwrap_or_else(|e| panic!("{e}")) + } + + fn has(xmp: &str, needle: &str) -> bool { + xmp.contains(needle) + } + + #[test] + fn a_preset_has_adobes_document_shape() { + let out = preset(&settings(), false).xmp; + assert!(out.starts_with(&format!( + "\n \n\n")); + for line in [ + " \n \n \n LrGenius · Test & <Co>\n \n \n \n \n \n", + " LrGeniusAI", + " Say "hi" 'there'", + " crs:Exposure2012=\"+0.50\"", + " crs:Contrast2012=\"-12\"", + " crs:Temperature=\"5600\"", + " crs:Tint=\"+6\"", + " crs:SharpenRadius=\"+1.0\"", + " crs:ToneCurveName2012=\"Custom\"", + " \n \n 0, 0\n 64, 58\n 255, 255\n \n ", + " \n \n \n \n \n \n \n \n \n \n ", + " \n \n \n \n ", + ] { + assert!(has(&out, line), "missing:\n{line}\n--- in ---\n{out}"); + } + // The example's own process version never reaches a preset. + assert!(!has(&out, "\"11.0\"")); + } + + #[test] + fn a_preset_reads_back_as_the_filtered_settings() { + let s = settings(); + let written = preset(&s, false); + let doc = parse(written.xmp.as_bytes()).unwrap(); + assert_eq!(doc.kind, XmpKind::Preset); + assert!(doc.warnings.is_empty(), "{:?}", doc.warnings); + let header = doc.header.unwrap(); + assert_eq!( + header.uuid.as_ref().map(Hex32::as_str), + Some("00000000000000000000000000000ABC"), + "upper-cased" + ); + assert_eq!(header.name.as_deref(), Some("LrGenius · Test & ")); + assert_eq!(header.group.as_deref(), Some(PRESET_GROUP)); + assert_eq!(header.short_name.as_deref(), Some("")); + let d = doc.develop; + assert_eq!(d.get_by_name("Exposure2012"), Some(&Value::Real(f(0.5)))); + assert_eq!(d.get_by_name("Tint"), Some(&Value::Int(6))); + assert_eq!(d.process_version(), Some(ProcessVersion::V6)); + assert_eq!(d.corrections.len(), 2); + assert_eq!( + d.corrections[0].masks[0].tool, + MaskTool::Semantic(Semantic::Subject) + ); + assert_eq!(d.corrections[1].masks[1].combine, Combine::Intersect); + assert_eq!( + d.corrections[0].local_value("LocalExposure2012"), + Some(&Value::Real(f(0.0625))) + ); + assert!(written.skipped.is_empty(), "{:?}", written.skipped); + } + + #[test] + fn output_is_byte_deterministic() { + let s = settings(); + let a = preset(&s, false).xmp; + assert_eq!(a, preset(&s, false).xmp); + // Insertion order does not matter: the registry decides. + let mut t = DevelopSettings::new(); + t.file_kind = s.file_kind; + for (k, v) in s.values().collect::>().into_iter().rev() { + t.insert(k, v.clone()).unwrap(); + } + t.corrections = s.corrections.clone(); + assert_eq!(a, preset(&t, false).xmp); + } + + #[test] + fn a_mixed_preset_drops_raw_only_white_balance_and_reports_it() { + let written = preset(&settings(), true); + assert!(!has(&written.xmp, "Temperature")); + assert!(!has(&written.xmp, "crs:Tint")); + assert!(!has(&written.xmp, "WhiteBalance"), "no numbers, no mode"); + let paths: Vec<&str> = written.skipped.iter().map(|k| k.path.as_str()).collect(); + assert_eq!(paths, ["Temperature", "Tint"]); + } + + #[test] + fn photo_computed_and_opaque_content_never_reaches_a_preset() { + let mut s = settings(); + set(&mut s, "CropTop", Value::Real(f(0.1))); + set(&mut s, "WhiteBalance", Value::Str("Daylight".into())); + set( + &mut s, + "FilterList", + Value::Struct(Struct::new(StructKind::FilterList)), + ); + set( + &mut s, + "RetouchAreas", + Value::StructList(vec![Struct::new(StructKind::RetouchArea)]), + ); + set(&mut s, "EnableDetail", Value::Bool(true)); + s.corrections[0].masks[0].extra.values.insert( + id(Level::MaskTool, "MaskDigest"), + Value::Str(format!("{:032X}", 7)), + ); + s.corrections.push(correction( + "LrGenius · Gradient", + vec![component( + MaskTool::Linear(LinearGradient { + zero: SensorPoint { + x: f(0.5), + y: f(0.0), + }, + full: SensorPoint { + x: f(0.5), + y: f(0.4), + }, + }), + Combine::Add { inverted: false }, + )], + )); + s.opaque.push(OpaqueEntry { + ns: None, + name: "SomethingNew".into(), + value: Opaque::Json(serde_json::json!(1)), + }); + let written = preset(&s, false); + for gone in [ + "CropTop", + "Daylight", + "WhiteBalance", + "Temperature", + "FilterList", + "RetouchAreas", + "EnableDetail", + "MaskDigest", + "Mask/Gradient", + "SomethingNew", + ] { + assert!(!has(&written.xmp, gone), "{gone} written"); + } + let paths: Vec<&str> = written.skipped.iter().map(|k| k.path.as_str()).collect(); + for want in [ + "CropTop", + "WhiteBalance", + "Temperature", + "Tint", + "RetouchAreas", + "MaskGroupBasedCorrections[0].CorrectionMasks[0].MaskDigest", + "MaskGroupBasedCorrections[2]", + "SomethingNew", + ] { + assert!(paths.contains(&want), "{want} not reported: {paths:?}"); + } + } + + #[test] + fn the_never_write_guard_catches_what_the_filter_would_have_removed() { + let mut conv = Conv::new(true, Vec::new()); + let mut fields = Fields::default(); + fields.values.insert( + id(Level::MaskTool, "MaskDigest"), + Value::Str(format!("{:032X}", 1)), + ); + fields + .values + .insert(id(Level::MaskTool, "MaskID"), Value::Str("x".into())); + fields + .values + .insert(id(Level::MaskTool, "MaskSubType"), Value::Int(1)); + let typed = conv.typed(&fields, "M").unwrap(); + let names: Vec<&str> = typed.iter().map(|(k, _)| k.spec().name).collect(); + assert_eq!(names, ["MaskSubType"]); + let skipped: Vec<&str> = conv.skipped.iter().map(|k| k.path.as_str()).collect(); + assert_eq!(skipped, ["M.MaskID", "M.MaskDigest"]); + } + + #[test] + fn every_never_write_key_is_also_outside_the_preset_policy() { + let mut flagged = 0; + for (_, s) in registry::iter().filter(|(_, s)| never_written(s)) { + flagged += 1; + let meta_global = s.policy == registry::Policy::Meta && s.level == Level::Global; + let outside = matches!( + s.policy, + registry::Policy::Computed + | registry::Policy::Never + | registry::Policy::Unknown + | registry::Policy::Photo + ); + assert!( + meta_global || outside, + "{}/{} is on the never-write list but {}", + s.level, + s.name, + s.policy.class_name() + ); + } + for name in NEVER_WRITE { + assert!( + registry::iter().any(|(_, s)| s.name == *name), + "{name} is no registry key" + ); + } + assert!(flagged > NEVER_WRITE.len()); + } + + #[test] + fn compatible_version_follows_the_feature_table() { + let mut s = DevelopSettings::new(); + assert_eq!(compatible_version(&s), None); + let subject = || { + component( + MaskTool::Semantic(Semantic::Subject), + Combine::Add { inverted: false }, + ) + }; + s.corrections.push(correction("a", vec![subject()])); + assert_eq!(compatible_version(&s), Some(EngineVersion::new(14, 0))); + let background = component( + MaskTool::Semantic(Semantic::Background), + Combine::Add { inverted: false }, + ); + let mut alone = DevelopSettings::new(); + alone.corrections.push(correction("bg", vec![background])); + assert_eq!(compatible_version(&alone), Some(EngineVersion::new(15, 0))); + let mut curved = correction("curve", vec![subject()]); + curved.local.insert( + id(Level::Correction, "MainCurve"), + Value::Curve(vec![(f(0.0), f(0.0)), (f(255.0), f(250.0))]), + ); + let mut alone = DevelopSettings::new(); + alone.corrections.push(curved); + assert_eq!(compatible_version(&alone), Some(EngineVersion::new(15, 3))); + let people = component( + MaskTool::Semantic(Semantic::PeoplePart(5)), + Combine::Add { inverted: false }, + ); + s.corrections.push(correction("b", vec![people])); + assert_eq!(compatible_version(&s), Some(EngineVersion::new(15, 0))); + let land = component( + MaskTool::Semantic(Semantic::Landscape(50001)), + Combine::Add { inverted: false }, + ); + s.corrections.push(correction("c", vec![land])); + assert_eq!(compatible_version(&s), Some(EngineVersion::new(15, 3))); + set( + &mut s, + "LensBlur", + Value::Struct(Struct::new(StructKind::LensBlur)), + ); + assert_eq!(compatible_version(&s), Some(EngineVersion::new(16, 0))); + set(&mut s, "CurveRefineSaturation", Value::Real(f(100.0))); + assert_eq!(compatible_version(&s), Some(EngineVersion::new(17, 0))); + for (_, v) in COMPATIBLE_VERSIONS { + assert!(*v <= TARGET_ENGINE); + } + let mut plain = DevelopSettings::new(); + set(&mut plain, "Exposure2012", Value::Real(f(0.5))); + let out = preset(&plain, true).xmp; + assert!(!has(&out, "CompatibleVersion"), "no feature, no stamp"); + } + + #[test] + fn a_linear_point_curve_is_named_linear() { + let mut s = DevelopSettings::new(); + set( + &mut s, + "ToneCurvePV2012", + Value::Curve(vec![(f(0.0), f(0.0)), (f(255.0), f(255.0))]), + ); + assert!(has( + &preset(&s, true).xmp, + "crs:ToneCurveName2012=\"Linear\"" + )); + } + + #[test] + fn a_preset_needs_a_uuid_and_a_name_and_is_a_normal_preset() { + let s = DevelopSettings::new(); + let mut header = PresetHeader::lrgenius(hex(1), "x"); + header.uuid = None; + let e = write(&s, &WriteMode::Preset(PresetSpec::new(header))).unwrap_err(); + assert_eq!(e, WriteError::MissingHeaderField("UUID")); + let mut header = PresetHeader::lrgenius(hex(1), "x"); + header.name = None; + let e = write(&s, &WriteMode::Preset(PresetSpec::new(header))).unwrap_err(); + assert_eq!(e, WriteError::MissingHeaderField("Name")); + for blank in ["", " \t"] { + let header = PresetHeader::lrgenius(hex(1), blank); + let e = write(&s, &WriteMode::Preset(PresetSpec::new(header))).unwrap_err(); + assert_eq!(e, WriteError::MissingHeaderField("Name"), "{blank:?}"); + } + let mut header = PresetHeader::lrgenius(hex(1), "x"); + header.preset_type = Some("Look".into()); + let e = write(&s, &WriteMode::Preset(PresetSpec::new(header))).unwrap_err(); + assert_eq!(e, WriteError::UnsupportedPresetType("Look".into())); + } + + #[test] + fn header_flags_and_extra_meta_keys_are_kept_and_others_reported() { + let mut header = PresetHeader::lrgenius(hex(2), "x"); + header.supports_monochrome = Some(false); + header.camera_model_restriction = Some("Synthetic Camera".into()); + header + .rest + .values + .insert(id(Level::Header, "ShowInPresets"), Value::Bool(true)); + header.rest.values.insert( + id(Level::Header, "SupportsOutputReferred"), + Value::Bool(false), + ); + header + .rest + .values + .insert(id(Level::Header, "PresetType"), Value::Str("Look".into())); + header.rest.values.insert( + id(Level::Header, "Baseline"), + Value::Str("Adobe Default".into()), + ); + let written = write( + &DevelopSettings::new(), + &WriteMode::Preset(PresetSpec::new(header)), + ) + .unwrap(); + let out = &written.xmp; + assert!( + has(out, "crs:PresetType=\"Normal\""), + "the writer's stamp wins" + ); + assert!(has(out, "crs:SupportsMonochrome=\"False\"")); + assert!(has(out, "crs:SupportsOutputReferred=\"False\"")); + assert!(has(out, "crs:ShowInPresets=\"True\"")); + assert!(has(out, "crs:CameraModelRestriction=\"Synthetic Camera\"")); + assert!(!has(out, "Baseline")); + assert_eq!(written.skipped[0].path, "Baseline"); + assert!(!has(out, "ProcessVersion"), "not asked for"); + } + + #[test] + fn text_xml_cannot_hold_is_an_error_not_a_broken_file() { + let header = PresetHeader::lrgenius(hex(3), "bad\u{1}name"); + let e = write( + &DevelopSettings::new(), + &WriteMode::Preset(PresetSpec::new(header)), + ) + .unwrap_err(); + assert!( + matches!(e, WriteError::InvalidChar { ref path, .. } if path == "Name[0]"), + "{e:?}" + ); + } + + #[test] + fn wrongly_typed_values_are_an_error() { + let mut s = DevelopSettings::new(); + set(&mut s, "Exposure2012", Value::Str("abc".into())); + let e = write(&s, &spec(true)).unwrap_err(); + assert!(matches!(e, WriteError::WrongValue { ref path, .. } if path == "Exposure2012")); + } + + #[test] + fn radial_and_person_parts_write_their_typed_fields() { + let conv = Conv::new(false, Vec::new()); + let radial = component( + MaskTool::Radial(RadialGradient { + top: f(0.1), + left: f(0.2), + bottom: f(0.9), + right: f(0.8), + feather: 50, + midpoint: 50, + roundness: 0, + flipped: true, + }), + Combine::Add { inverted: false }, + ); + let fields = conv.component_fields(&radial); + let get = |name: &str| fields.get(Level::MaskTool, name).cloned(); + assert_eq!(get("Angle"), Some(Value::Real(Finite::ZERO))); + assert_eq!(get("Flipped"), Some(Value::Bool(true))); + assert_eq!( + get("What"), + Some(Value::Str("Mask/CircularGradient".into())) + ); + let at = component( + MaskTool::Semantic(Semantic::PersonPartAt { + part: 5, + point: SensorPoint { + x: f(0.25), + y: f(0.75), + }, + }), + Combine::Add { inverted: false }, + ); + let fields = Conv::new(true, Vec::new()).component_fields(&at); + assert_eq!( + fields.get(Level::MaskTool, "ReferencePoint"), + Some(&Value::Str("0.250000 0.750000".into())) + ); + assert_eq!( + fields.get(Level::MaskTool, "MaskSubCategoryID"), + Some(&Value::Int(5)) + ); + } + + /// A sidecar-shaped document: a Look with its embedded profile + /// definition (`Parameters`, referring to a lookup table), the table + /// blob itself, and a white balance. + const SIDECAR_WITH_LOOK: &str = r#" + + + + Profiles + + + + + +"#; + + #[test] + fn a_preset_refers_to_the_look_without_its_profile_definition() { + let doc = parse(SIDECAR_WITH_LOOK.as_bytes()).unwrap(); + assert!(doc.warnings.is_empty(), "{:?}", doc.warnings); + assert!(doc.develop.look.is_some()); + for mixed in [false, true] { + let written = preset(&doc.develop, mixed); + let out = &written.xmp; + for gone in [ + "crs:Parameters", + "LookTable", + "crs:RGBTable", + "Table_", + "Copyright=\"(c)\"", + "Profiles", + "ColorVariance", + ] { + assert!(!has(out, gone), "{gone} written:\n{out}"); + } + assert!(has(out, "crs:RequiresRGBTables=\"False\"")); + assert!(has( + out, + " " + ), "{out}"); + let paths: Vec<&str> = written.skipped.iter().map(|k| k.path.as_str()).collect(); + // The table blob is opaque and reported; the Look's own copies of + // the profile record (Parameters, Group, ...) are not. + assert!(paths.contains(&"Table_00000000000000000000000000001234")); + assert!(!paths.iter().any(|p| p.starts_with("Look")), "{paths:?}"); + } + } + + #[test] + fn values_kept_whole_never_reach_a_preset_and_are_reported() { + let text = r#" + + 0, 0255, 255 + 5q +"#; + let doc = parse(text.as_bytes()).unwrap(); + assert_eq!(doc.skipped_subtrees.kept_whole, 2); + let written = preset(&doc.develop, true); + for gone in ["ToneCurve", "rdf:Bag", "Texture", "urn:q"] { + assert!(!has(&written.xmp, gone), "{gone}:\n{}", written.xmp); + } + assert!(has(&written.xmp, "crs:Exposure2012=\"+0.30\"")); + assert_eq!( + written.skipped, + ["Texture", "ToneCurvePV2012"] + .map(|p| Skipped { + path: p.into(), + reason: SkipReason::Opaque + }) + .to_vec() + ); + let back = parse(written.xmp.as_bytes()).unwrap(); + assert_eq!(back.skipped_subtrees.kept_whole, 0); + // The writer's own guard, should the filter ever let one through. + let mut conv = Conv::new(true, Vec::new()); + let node = XmpNode::text("5"); + let spec = id(Level::Global, "Texture").spec(); + let v = Value::Opaque(Opaque::Xmp(node)); + assert_eq!(conv.node(spec, &v, "Texture").unwrap(), None); + assert_eq!(conv.skipped[0].reason, SkipReason::Opaque); + } + + fn white_balance(mode: &str) -> DevelopSettings { + let mut s = DevelopSettings::new(); + s.file_kind = Some(crate::model::FileKind::Raw); + set(&mut s, "WhiteBalance", Value::Str(mode.into())); + set(&mut s, "Temperature", Value::Int(5150)); + set(&mut s, "Tint", Value::Int(8)); + set(&mut s, "Exposure2012", Value::Real(f(0.25))); + s + } + + #[test] + fn a_resolved_white_balance_never_becomes_a_custom_one() { + for mode in ["As Shot", "Auto"] { + let written = preset(&white_balance(mode), false); + for gone in ["WhiteBalance", "Temperature", "crs:Tint"] { + assert!(!has(&written.xmp, gone), "{mode}: {gone}"); + } + let numbers: Vec<_> = written + .skipped + .iter() + .filter(|k| k.path != "WhiteBalance") + .map(|k| (k.path.as_str(), k.reason.clone())) + .collect(); + let why = SkipReason::WhiteBalanceMode(mode.into()); + assert_eq!( + numbers, + [("Temperature", why.clone()), ("Tint", why)], + "{mode}" + ); + } + let written = preset(&white_balance("Custom"), false); + assert!(has(&written.xmp, "crs:WhiteBalance=\"Custom\"")); + assert!(has(&written.xmp, "crs:Temperature=\"5150\"")); + assert!(has(&written.xmp, "crs:Tint=\"+8\"")); + assert!( + written.skipped.is_empty(), + "Custom is written back: {:?}", + written.skipped + ); + // A mixed preset has no white-balance numbers, so its Custom mode is + // not carried and says so. + let written = preset(&white_balance("Custom"), true); + assert!(!has(&written.xmp, "WhiteBalance")); + let paths: Vec<&str> = written.skipped.iter().map(|k| k.path.as_str()).collect(); + assert_eq!(paths, ["WhiteBalance", "Temperature", "Tint"]); + } + + #[test] + fn values_the_preset_form_writes_back_unchanged_are_not_reported() { + let text = |point: &str| { + format!( + r#" + + + + + + + + +"# + ) + }; + for kind in [None, Some(crate::model::FileKind::NonRaw)] { + let mut doc = parse(text("0.500000 0.500000").as_bytes()).unwrap(); + assert!(doc.warnings.is_empty(), "{:?}", doc.warnings); + doc.develop.file_kind = kind; + let written = preset(&doc.develop, true); + assert!( + written.skipped.is_empty(), + "{kind:?}: {:?}", + written.skipped + ); + assert!(has( + &written.xmp, + "crs:ReferencePoint=\"0.500000 0.500000\"" + )); + } + // A subject's point is Lightroom's per-photo result: replaced + // silently, like Adobe's presets do. + let doc = parse(text("0.300000 0.600000").as_bytes()).unwrap(); + assert!(preset(&doc.develop, true).skipped.is_empty()); + // A people part's point may pick the person: replacing it is + // reported (at the centre it is not, see above). + let people = |point: &str| { + text(point).replace( + "crs:MaskSubType=\"1\"", + "crs:MaskSubType=\"3\" crs:MaskSubCategoryID=\"5\"", + ) + }; + let doc = parse(people("0.300000 0.600000").as_bytes()).unwrap(); + let paths: Vec = preset(&doc.develop, true) + .skipped + .into_iter() + .map(|k| k.path) + .collect(); + assert_eq!( + paths, + ["MaskGroupBasedCorrections[0].CorrectionMasks[0].ReferencePoint"] + ); + let doc = parse(people("0.500000 0.500000").as_bytes()).unwrap(); + assert!(preset(&doc.develop, true).skipped.is_empty()); + } + + #[cfg(feature = "test-roundtrip")] + mod round_trip { + use super::*; + use crate::xmp::read::XmpDocument; + + fn again(doc: &XmpDocument) -> (String, XmpDocument) { + let written = write(&doc.develop, &WriteMode::TestRoundTrip(doc.header.clone())) + .unwrap_or_else(|e| panic!("{e}")); + assert!(written.skipped.is_empty()); + let back = + parse(written.xmp.as_bytes()).unwrap_or_else(|e| panic!("{e}\n{}", written.xmp)); + (written.xmp, back) + } + + // Every committed fixture: tests/xmp_roundtrip.rs. + + #[test] + fn a_preset_round_trips_through_the_test_mode() { + let doc = parse(preset(&settings(), false).xmp.as_bytes()).unwrap(); + let (_, back) = again(&doc); + assert_eq!(back.develop, doc.develop); + assert_eq!(back.header, doc.header); + } + + #[test] + fn numbers_the_format_would_round_survive_the_test_mode() { + let mut s = DevelopSettings::new(); + set(&mut s, "Exposure2012", Value::Real(f(0.333))); + set(&mut s, "CropTop", Value::Real(f(0.1234567))); + let written = write(&s, &WriteMode::TestRoundTrip(None)).unwrap(); + assert!(has(&written.xmp, "crs:Exposure2012=\"+0.333\"")); + let back = parse(written.xmp.as_bytes()).unwrap(); + assert_eq!( + back.develop.get_by_name("CropTop"), + s.get_by_name("CropTop") + ); + } + + #[test] + fn lua_content_has_no_xmp_form() { + let mut s = DevelopSettings::new(); + s.opaque.push(OpaqueEntry { + ns: None, + name: "X".into(), + value: Opaque::Json(serde_json::json!([1])), + }); + let e = write(&s, &WriteMode::TestRoundTrip(None)).unwrap_err(); + assert_eq!(e, WriteError::NotXmp { path: "X".into() }); + } + + #[test] + fn foreign_namespaces_qualifiers_and_languages_survive() { + let text = r#" + + t + vn + AB + + 1 + dup +"#; + let doc = parse(text.as_bytes()).unwrap(); + assert_eq!(doc.develop.opaque.len(), 7, "{:?}", doc.develop.opaque); + let (xmp, back) = again(&doc); + assert_eq!(back.develop, doc.develop, "\n{xmp}"); + assert!(has(&xmp, "xmlns:ns1=\"urn:q\"")); + assert_eq!(xmp.matches("").count(), 1, "the one kept whole"); + } + } +} diff --git a/server-rs/crates/lrg-develop/tests/builder_presets.rs b/server-rs/crates/lrg-develop/tests/builder_presets.rs new file mode 100644 index 00000000..a6eaf07e --- /dev/null +++ b/server-rs/crates/lrg-develop/tests/builder_presets.rs @@ -0,0 +1,253 @@ +//! The hand-test presets of PR 1b, built with the real builders and writer. +//! +//! Always: all three presets build, write without skipping anything, and read +//! back as develop presets whose corrections are the built ones; and, with +//! synthetic ids in place of the derived ones, they match their byte goldens +//! in `testdata/develop/written/` (the writer's exact output, pinned). After +//! an intended format change, regenerate the goldens with +//! `LRG_BLESS=1 cargo test -p lrg-develop --test builder_presets`. +//! +//! By hand: with `LRG_HANDTEST_DIR` set, the test also writes them there, to +//! be imported in the installed Lightroom Classic (Develop → Presets → +//! Import) and applied to photos with a subject, sky and vegetation, and to +//! one with two or more people (does the people part reach all of them?): +//! +//! ```text +//! LRG_HANDTEST_DIR=/some/dir cargo test -p lrg-develop --test builder_presets +//! ``` +//! +//! | File | `CompatibleVersion` | Expect in the Masking panel | +//! |---|---|---| +//! | `LrGenius Handtest Subject+Sky.xmp` | 14.0 | Contrast +15; "LrGenius · Subject" (Subject, Exposure +0.30); "LrGenius · Sky" (Sky, Highlights −40, Dehaze +15) | +//! | `LrGenius Handtest Landscape+People.xmp` | 15.3 | "LrGenius · Vegetation" (Vegetation, Saturation +20); "LrGenius · Eyes" (Iris and Pupil of all people, Saturation +30) | +//! | `LrGenius Handtest Combinations+Background.xmp` | 15.0 | "LrGenius · Bright sky" (Sky, minus Subject, intersected with a luminance range 0.3–0.5–1–1: Highlights −30); "LrGenius · Background" (Background, Exposure −0.50, Saturation −30) | +//! +//! The third preset carries what no preset of Adobe's does: a subtract, an +//! intersect, a luminance range (without the eyedropper sample every range +//! in Lightroom's sidecars has) and Select Background. + +use std::path::PathBuf; + +use lrg_develop::build::{ + corrections, BuildError, CorrectionBuilder, IdSlot, LandscapeClass, LuminanceRange, PeoplePart, + SyncNamespace, +}; +use lrg_develop::model::{ + Correction, DevelopSettings, Fields, Finite, Hex32, MaskTool, Semantic, Value, +}; +use lrg_develop::registry::{self, Level, ProcessVersion}; +use lrg_develop::xmp::write::xmptk; +use lrg_develop::xmp::{ + compatible_version, parse, write, EngineVersion, PresetHeader, PresetSpec, WriteMode, XmpKind, +}; + +struct HandTest { + name: &'static str, + /// The golden's file name in `testdata/develop/written/`. + golden: &'static str, + settings: DevelopSettings, + compatible: EngineVersion, +} + +fn subject_and_sky() -> Result { + let ns = SyncNamespace::lrgenius(); + let mut settings = DevelopSettings::new(); + let contrast = registry::lookup(Level::Global, "Contrast2012").unwrap(); + let (value, clamped) = + registry::from_ui_value(contrast.spec(), Finite::new(15.0).unwrap()).unwrap(); + assert!(clamped.is_none()); + settings.insert(contrast, value).unwrap(); + settings.corrections = corrections([ + CorrectionBuilder::new("Subject", &ns) + .local_ui("LocalExposure2012", 0.3)? + .add(Semantic::Subject), + CorrectionBuilder::new("Sky", &ns) + .local_ui("LocalHighlights2012", -40.0)? + .local_ui("LocalDehaze", 15.0)? + .add(Semantic::Sky), + ])?; + Ok(HandTest { + name: "LrGenius Handtest Subject+Sky", + golden: "handtest_subject_sky.xmp", + settings, + compatible: EngineVersion::new(14, 0), + }) +} + +fn landscape_and_people() -> Result { + let ns = SyncNamespace::lrgenius(); + let mut settings = DevelopSettings::new(); + settings.corrections = corrections([ + CorrectionBuilder::new("Vegetation", &ns) + .local_ui("LocalSaturation", 20.0)? + .add(Semantic::landscape(LandscapeClass::Vegetation)), + CorrectionBuilder::new("Eyes", &ns) + .local_ui("LocalSaturation", 30.0)? + .add(Semantic::people_part(PeoplePart::IrisAndPupil)), + ])?; + Ok(HandTest { + name: "LrGenius Handtest Landscape+People", + golden: "handtest_landscape_people.xmp", + settings, + compatible: EngineVersion::new(15, 3), + }) +} + +/// What no preset of Adobe's has: subtract, intersect, a luminance range, +/// Select Background. +fn combinations_and_background() -> Result { + let ns = SyncNamespace::lrgenius(); + let mut settings = DevelopSettings::new(); + settings.corrections = corrections([ + CorrectionBuilder::new("Bright sky", &ns) + .local_ui("LocalHighlights2012", -30.0)? + .add(Semantic::Sky) + .subtract(Semantic::Subject) + .intersect(LuminanceRange::highs(0.5, 0.2)?), + CorrectionBuilder::new("Background", &ns) + .local_ui("LocalExposure2012", -0.5)? + .local_ui("LocalSaturation", -30.0)? + .add(Semantic::Background), + ])?; + Ok(HandTest { + name: "LrGenius Handtest Combinations+Background", + golden: "handtest_combinations_background.xmp", + settings, + compatible: EngineVersion::new(15, 0), + }) +} + +fn all() -> [Result; 3] { + [ + subject_and_sky(), + landscape_and_people(), + combinations_and_background(), + ] +} + +/// The typed parts a preset must carry back. A luminance range's +/// `CorrectionRangeMask` rest is left out: the writer completes `Version` 3 +/// and `SampleType` 0 there (checked below). `active` and `extra` are not +/// compared here; the byte goldens pin them. +fn typed(c: &Correction) -> impl PartialEq + std::fmt::Debug + '_ { + let masks: Vec<_> = c + .masks + .iter() + .map(|m| { + let mut tool = m.tool.clone(); + if let MaskTool::LuminanceRange(lr) = &mut tool { + lr.rest.fields = Fields::default(); + } + (tool, m.combine, &m.name, &m.sync_id) + }) + .collect(); + (&c.name, &c.sync_id, c.amount, &c.local, masks) +} + +#[test] +fn the_hand_test_presets_write_and_read_back() { + let out_dir = std::env::var_os("LRG_HANDTEST_DIR").map(PathBuf::from); + for t in all() { + let t = t.unwrap_or_else(|e| panic!("{e}")); + let uuid = SyncNamespace::new("LrGenius hand test").id(t.name, IdSlot::Correction); + let mut spec = PresetSpec::new(PresetHeader::lrgenius(uuid, t.name)); + // As Adobe's adaptive presets do: AI masks need the current process + // version. + spec.process_version = Some(ProcessVersion::V6); + let written = write(&t.settings, &WriteMode::Preset(spec)).unwrap(); + assert!(written.skipped.is_empty(), "{:?}", written.skipped); + assert_eq!(compatible_version(&t.settings), Some(t.compatible)); + + let doc = parse(written.xmp.as_bytes()).unwrap(); + assert_eq!(doc.kind, XmpKind::Preset); + assert!(doc.warnings.is_empty(), "{:?}", doc.warnings); + assert_eq!(doc.header.and_then(|h| h.name).as_deref(), Some(t.name)); + let read: Vec<_> = doc.develop.corrections.iter().map(typed).collect(); + let built: Vec<_> = t.settings.corrections.iter().map(typed).collect(); + assert_eq!(read, built); + for (id, v) in t.settings.values() { + assert_eq!(doc.develop.get(id), Some(v)); + } + for m in doc.develop.corrections.iter().flat_map(|c| &c.masks) { + if let MaskTool::LuminanceRange(lr) = &m.tool { + let crm = |n| lr.rest.get(n).cloned(); + assert_eq!( + (crm("Version"), crm("SampleType")), + (Some(Value::Int(3)), Some(Value::Int(0))) + ); + let (_, _, inverted) = m.combine.encode().unwrap(); + assert_eq!(lr.invert, inverted, "Invert mirrors MaskInverted"); + } + } + + if let Some(dir) = &out_dir { + std::fs::create_dir_all(dir).unwrap(); + let path = dir.join(format!("{}.xmp", t.name)); + std::fs::write(&path, &written.xmp).unwrap(); + // Re-parse what landed on disk, not the string. + let again = parse(&std::fs::read(&path).unwrap()).unwrap(); + assert!(again.warnings.is_empty()); + assert_eq!( + again.develop.corrections.len(), + t.settings.corrections.len() + ); + println!("wrote {}", path.display()); + } + } +} + +/// The ids the hygiene rules accept in a committed file: twenty zeros and +/// twelve hex digits (`testdata/develop/README.md`, "Hygiene rules"). +fn synthetic(n: u64) -> Hex32 { + Hex32::parse(&format!("{:020}{n:012X}", 0)).unwrap() +} + +/// The derived sync ids (and the preset UUID) replaced by synthetic ones: +/// a derived id is a SHA-256 digest, which the hygiene rules cannot tell +/// from a catalog's. The ids' own determinism is tested in `build::tests`. +fn with_synthetic_ids(mut t: HandTest, first: u64) -> (HandTest, Hex32) { + let mut n = first; + let mut next = || { + n += 1; + synthetic(n) + }; + for c in &mut t.settings.corrections { + c.sync_id = Some(next()); + for m in &mut c.masks { + m.sync_id = Some(next()); + } + } + let uuid = next(); + (t, uuid) +} + +#[test] +fn the_hand_test_presets_match_their_byte_goldens() { + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../testdata/develop/written"); + let bless = std::env::var_os("LRG_BLESS").is_some_and(|v| v == "1"); + for (i, t) in all().into_iter().enumerate() { + let (t, uuid) = with_synthetic_ids(t.unwrap(), 0xB00 + 0x100 * i as u64); + let mut spec = PresetSpec::new(PresetHeader::lrgenius(uuid, t.name)); + spec.process_version = Some(ProcessVersion::V6); + let written = write(&t.settings, &WriteMode::Preset(spec)).unwrap(); + // The toolkit string carries the release version when one is baked + // into the build; the golden holds the development one. + let text = written.xmp.replace(&xmptk(), "LrGeniusAI 0.0.0-dev"); + let path = dir.join(t.golden); + if bless { + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(&path, &text).unwrap(); + continue; + } + let golden = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("{}: {e} (bless with LRG_BLESS=1)", t.golden)); + // A Windows checkout may have turned LF into CRLF; `.gitattributes` + // keeps this folder LF, but compare content either way. + assert_eq!( + golden.replace("\r\n", "\n"), + text, + "{}: the writer's output changed; if intended, bless with LRG_BLESS=1", + t.golden + ); + } +} diff --git a/server-rs/crates/lrg-develop/tests/fixture_hygiene.rs b/server-rs/crates/lrg-develop/tests/fixture_hygiene.rs new file mode 100644 index 00000000..22dcc306 --- /dev/null +++ b/server-rs/crates/lrg-develop/tests/fixture_hygiene.rs @@ -0,0 +1,1003 @@ +//! No private or third-party content in `server-rs/testdata/develop/`. +//! +//! Mirrors `extract_fixtures.py --check-only` +//! (`server-rs/scripts/develop_registry/`); the rules are documented in +//! `server-rs/testdata/develop/README.md` ("Hygiene rules"), and a change to +//! one side must be made on the other. In short: +//! +//! - scope: every `*.json`/`*.xmp` below the folder (never the README); +//! - hard text rules everywhere: photo-id prefix, `file:`, user/volume/temp +//! paths, drive and UNC paths, raw/image file names, the dropped table-key +//! names (the preset-header key `RequiresRGBTables`, a whole word, is not +//! one); +//! - keys are identifiers, no table keys, `Look.Parameters` only as the stub, +//! `Look.UUID`/`Name` only from [`ADOBE_LOOKS`] or synthetic, every 32-hex +//! value and GUID synthetic, no dates, no number ≥ 1e9 outside the version +//! keys and the synthetic `GrainSeed`; +//! - generated files (listed in `lua/manifest.json`) additionally follow the +//! per-key value rules; any other `lua/*.json` must be named `hand_*.json`; +//! - an XMP file must be well-formed XML with an `rdf:RDF`; its data model +//! ([`xmp_as_tree`]) gets the structural rules of a hand-written JSON file, +//! and every `rdf:Alt` item needs a valid `xml:lang`; +//! - `registry_snapshot.json` (the registry's own description) gets the text +//! rules minus the table-key names, and the 32-hex/GUID rule. +//! +//! Patterns match whole strings (Python: `\Z`, not `$`, which also matches +//! before a trailing newline) and digits are ASCII (`[0-9]`; Python compiles +//! with `re.ASCII`), so both sides accept exactly the same inputs. +//! `dispatch_rules_flag_exactly_the_crafted_files` pins the file-level rules +//! and the inputs the two sides once disagreed on; +//! `extract_fixtures.py --self-test` runs the same cases. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use std::sync::LazyLock; + +use regex::Regex; +use serde_json::{Map, Value as J}; + +/// Profiles whose name and UUID may be published: verified to exist as +/// `crs:UUID` in Lightroom Classic's bundled profiles (LrC 15.5.1). Same +/// constant as `ADOBE_LOOKS` in `extract_fixtures.py`; never read from the +/// manifest. +const ADOBE_LOOKS: &[(&str, &str)] = &[ + ("B952C231111CD8E0ECCF14B86BAA7077", "Adobe Color"), + ("6F9C877E84273F4E8271E6B91BEB36A1", "Adobe Landscape"), + ("0CFE8F8AB5F63B2A73CE0B0077D20817", "Adobe Monochrome"), + ("D6496412E06A83789C499DF9540AA616", "Adobe Portrait"), + ("EA1DE074F188405965EF399C72C221D9", "Adobe Vivid"), + ("226E6263ED94405B973B41F5A6E67BE9", "Adaptive Color"), + ("4B2C86502E5F2637A81F96F8D96AC1F9", "Artistic 04"), + ("ADB81986EAF84E0999C0112BDE1F93D1", "B&W 03"), + ("9D1ED025F3DE4C94BC78481AA67B3C89", "B&W 11"), + ("8FD143CED5B04137ACE4B5A63962293F", "B&W 12"), + ("7B79093CEC7726D67B352CAD78D1601F", "Modern 07"), + ("DA1C3775662D6B6A75F8BC2CEEB3724A", "Modern 08"), + ("97291D549FC232787BDFD3353151BB62", "Vintage 01"), +]; + +const SYNTHETIC_DATE: &str = "2000-01-01T00:00:00+00:00"; +const SYNTHETIC_TEXT: &str = "Synthetic Text"; +const SYNTHETIC_PROFILE: &str = "Synthetic Profile"; +const SYNTHETIC_GRAIN_SEED: u64 = 4_000_000_001; +const STUB_KEYS: &[&str] = &["ConvertToGrayscale", "ProcessVersion"]; +const BIG_NUMBER: f64 = 1e9; +const BIG_NUMBER_KEYS: &[&str] = &[ + "UprightVersion", + "ModelVersion", + "MinEditVersion", + "MinDisplayVersion", + "CompatibleVersion", + "AutoWhiteVersion", +]; +const REGISTRY_FILES: &[&str] = &["registry_snapshot.json"]; +const CAMERA_PROFILES: &[&str] = &[ + "Adobe Standard", + "Adobe Standard v2", + "Camera Standard", + "Camera Standard v2", + "Camera Neutral", + "Camera Neutral v2", + "Camera Landscape", + "Camera Landscape v2", + "Camera Faithful", + "Camera Faithful v2", + "Camera Portrait", + "Camera Portrait v2", + "Camera Fine Detail", + "Camera Monochrome", +]; +const TONE_CURVE_NAMES: &[&str] = &[ + "Linear", + "Medium Contrast", + "Strong Contrast", + "Custom", + "Matter Kontrast", +]; +const FIXED_VALUES: &[(&str, &str)] = &[ + ("LensProfileName", "Adobe (Synthetic Lens)"), + ("LensProfileFilename", "Synthetic Lens - RAW.lcp"), + ("pm_clio_model_version", "synthetic-model-version"), + ("Copyright", "Synthetic copyright notice"), + ("Cluster", "Synthetic"), + ("CameraModelRestriction", "Synthetic Camera"), +]; +const FIXED_IN_PARENT: &[((&str, &str), &str)] = &[ + (("GenAIInfo", "Name"), "Synthetic Generative Model"), + (("GenAIInfo", "SoftwareAgent"), "Synthetic Agent"), +]; +const LOOK_ALT_FIELDS: &[(&str, &str)] = &[("Group", "Synthetic Group"), ("SortName", "Synthetic")]; +const FILTER_NAMES: &[&str] = &[ + "Enhance", + "Denoise", + "Raw Details", + "Super Resolution", + "Dust Removal", + "Distracting People Removal", + "Reflection Removal", +]; + +fn re(s: &str) -> Regex { + Regex::new(s).expect("valid pattern") +} + +const NUM_TOKEN: &str = r"[-+]?([0-9]+\.?[0-9]*|\.[0-9]+)([eE][-+]?[0-9]+)?"; + +static GUID_ANY: LazyLock = LazyLock::new(|| { + re(r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}") +}); +static SYNTH_HEX: LazyLock = LazyLock::new(|| re(r"^0{20}[0-9A-Fa-f]{12}$")); +static SYNTH_GUID: LazyLock = + LazyLock::new(|| re(r"^00000000-0000-4000-8000-[0-9A-Fa-f]{12}$")); +static DATE: LazyLock = LazyLock::new(|| re(r"[0-9]{4}[-:][0-9]{2}[-:][0-9]{2}")); +static TABLE_KEY: LazyLock = LazyLock::new(|| re(r"^(Brush)?Table_[0-9A-Fa-f]{32}$")); +static KEY: LazyLock = LazyLock::new(|| re(r"^[A-Za-z][A-Za-z0-9_]*$")); +static LANG: LazyLock = + LazyLock::new(|| re(r"^(x-default|[a-z]{2,3}(-[A-Za-z0-9]{2,8})*)$")); +static NUMERIC_TEXT: LazyLock = + LazyLock::new(|| re(&format!(r"^{NUM_TOKEN}([ ,/]+{NUM_TOKEN})*$"))); +static NUMERIC_SPLIT: LazyLock = LazyLock::new(|| re(r"[ ,/]+")); +static DAB: LazyLock = LazyLock::new(|| re(&format!(r"^[A-Za-z]( {NUM_TOKEN})+$"))); +static VERSION: LazyLock = LazyLock::new(|| re(r"^[0-9]+(\.[0-9]+){0,2}$")); + +/// The hard text rules; the last three are the dropped table-key names. +static HARD_TEXT: LazyLock> = LazyLock::new(|| { + [ + r"(?i)md5p:", + r"(?i)\bfile:", + r"/Users/", + r"/Volumes/", + r"/home/", + r"/private/", + r"/tmp/", + r"/var/", + r"/Library/", + r"(?i)\b[A-Z]:[\\/]", + r"\\\\", + r"~/", + r"(?i)\.(cr2|cr3|crw|nef|nrw|arw|srf|sr2|raf|dng|orf|rw2|pef|srw|x3f|3fr|iiq|jpe?g|tiff?|heic|heif|psd)\b", + r"Table_", + r"LookTable", + r"RGBTable", + ] + .iter() + .map(|p| re(p)) + .collect() +}); +const TABLE_NAME_RULES: usize = 3; + +/// Preset-header keys whose names contain a dropped table-key name but are +/// not table content: `RequiresRGBTables` (a preset-header key Adobe's newer +/// presets — the 163 of 446 bundled ones that carry `SupportsAmount2`, every +/// adaptive preset included — and every preset the writer produces carry). +/// Removed as whole ASCII words +/// before the text rules run, so `RGBTable_` or `RequiresRGBTablesX` +/// still fail. +static ALLOWED_HEADER_KEYS: LazyLock = + LazyLock::new(|| re(r"(?-u:\b)RequiresRGBTables(?-u:\b)")); + +/// 32-hex runs not embedded in a longer hex run (the Python look-arounds). +fn hex32_runs(s: &str) -> Vec<&str> { + let bytes = s.as_bytes(); + let mut out = Vec::new(); + let mut i = 0; + while i < bytes.len() { + if bytes[i].is_ascii_hexdigit() { + let start = i; + while i < bytes.len() && bytes[i].is_ascii_hexdigit() { + i += 1; + } + if i - start == 32 { + out.push(&s[start..i]); + } + } else { + i += 1; + } + } + out +} + +fn adobe_look_name(uuid: &str) -> Option<&'static str> { + let upper = uuid.to_ascii_uppercase(); + ADOBE_LOOKS + .iter() + .find(|(u, _)| *u == upper) + .map(|(_, n)| *n) +} + +fn numeric_text_ok(v: &str) -> bool { + if !NUMERIC_TEXT.is_match(v) { + return false; + } + let tokens: Vec<&str> = NUMERIC_SPLIT.split(v).filter(|t| !t.is_empty()).collect(); + let lone_huge = tokens.len() == 1 + && !v.contains('.') + && v.parse::().is_ok_and(|x| x.abs() >= BIG_NUMBER); + !lone_huge +} + +fn generic_string_ok(v: &str) -> bool { + matches!(v, "" | SYNTHETIC_TEXT | SYNTHETIC_DATE) + || SYNTH_HEX.is_match(v) + || SYNTH_GUID.is_match(v) + || numeric_text_ok(v) + || DAB.is_match(v) +} + +/// The per-key value rule (`STRING_RULES` in the extractor), if any. +fn key_rule(v: &str, key: &str, parent: &str) -> Option { + if let Some((_, fixed)) = FIXED_IN_PARENT.iter().find(|(k, _)| *k == (parent, key)) { + return Some(v == *fixed); + } + match (parent, key) { + ("Filters", "Name") => return Some(FILTER_NAMES.contains(&v)), + ("Filters", "Title") => { + return Some(re(r"^\$\$\$/CRaw/Filter/[A-Za-z/]+=[A-Za-z ]+$").is_match(v)) + } + _ => {} + } + if let Some((_, fixed)) = FIXED_VALUES.iter().find(|(k, _)| *k == key) { + return Some(v == *fixed); + } + let one_of = |set: &[&str]| set.contains(&v); + Some(match key { + "What" => re(r"^(Correction|Mask/[A-Za-z]+)$").is_match(v), + "WhiteBalance" => one_of(&[ + "As Shot", + "Auto", + "Custom", + "Daylight", + "Cloudy", + "Shade", + "Tungsten", + "Fluorescent", + "Flash", + ]), + "ProcessVersion" | "Version" => VERSION.is_match(v), + "orientation" => re(r"^[A-D]{2}$").is_match(v), + "LensProfileSetup" => one_of(&["LensDefaults", "Custom", "Auto"]), + "CameraProfile" => one_of(CAMERA_PROFILES), + "ToneCurveName" | "ToneCurveName2012" => one_of(TONE_CURVE_NAMES), + "Method" => one_of(&["poisson", "gaussian", "clone"]), + "SourceState" | "sourceState" => one_of(&["sourceAutoComputed", "sourceSetExplicitly"]), + "SpotType" | "spotType" => one_of(&["heal", "clone", "heal_patchmatch"]), + "fill_method" => one_of(&["firefly"]), + "pm_source_type" => one_of(&["full"]), + "CorrectionName" => re(r"^Correction [0-9]+$").is_match(v), + "MaskName" => re(r"^Mask [0-9]+$").is_match(v), + _ => return None, + }) +} + +fn string_ok(v: &str, key: &str, parent: &str) -> bool { + if v == SYNTHETIC_TEXT || SYNTH_HEX.is_match(v) || SYNTH_GUID.is_match(v) { + return true; + } + key_rule(v, key, parent).unwrap_or_else(|| generic_string_ok(v)) +} + +fn is_dropped_key(key: &str) -> bool { + TABLE_KEY.is_match(key) + || key.starts_with("LookTable") + || key.starts_with("RGBTable") + || key == "MaskBrushTable" +} + +fn hard_text_problems(text: &str, name: &str, skip_table_names: bool) -> Vec { + let text = ALLOWED_HEADER_KEYS.replace_all(text, ""); + let text = text.as_ref(); + let rules = if skip_table_names { + &HARD_TEXT[..HARD_TEXT.len() - TABLE_NAME_RULES] + } else { + &HARD_TEXT[..] + }; + rules + .iter() + .filter(|r| r.is_match(text)) + .map(|r| format!("{name}: forbidden text matching {:?}", r.as_str())) + .collect() +} + +/// Every 32-hex value and GUID in keys and strings must be synthetic. +fn check_ids_only(data: &J, name: &str) -> Vec { + fn text(s: &str, name: &str, out: &mut Vec) { + for h in hex32_runs(s) { + if !SYNTH_HEX.is_match(h) { + out.push(format!( + "{name}: 32-hex value {}... is not synthetic", + &h[..6] + )); + } + } + for g in GUID_ANY.find_iter(s) { + if !SYNTH_GUID.is_match(g.as_str()) { + out.push(format!( + "{name}: GUID {}... is not synthetic", + &g.as_str()[..8] + )); + } + } + } + fn walk(node: &J, name: &str, out: &mut Vec) { + match node { + J::Object(o) => { + for (k, v) in o { + text(k, name, out); + walk(v, name, out); + } + } + J::Array(a) => a.iter().for_each(|v| walk(v, name, out)), + J::String(s) => text(s, name, out), + _ => {} + } + } + let mut out = Vec::new(); + walk(data, name, &mut out); + out +} + +/// `check_json_tree` of the extractor. +struct Tree<'a> { + name: &'a str, + strict: bool, + problems: Vec, +} + +impl Tree<'_> { + fn number(&mut self, n: &serde_json::Number, path: &str, key: &str) { + let Some(x) = n.as_f64() else { return }; + if x.abs() < BIG_NUMBER { + return; + } + if BIG_NUMBER_KEYS.contains(&key) + || (key == "GrainSeed" && n.as_u64() == Some(SYNTHETIC_GRAIN_SEED)) + { + return; + } + self.problems.push(format!( + "{}: number >= 1e9 at {path} (per-photo value?)", + self.name + )); + } + + fn string(&mut self, v: &str, path: &str, key: &str, parent: &str, value_rule: bool) { + let name = self.name; + for h in hex32_runs(v) { + if !SYNTH_HEX.is_match(h) { + self.problems.push(format!( + "{name}: 32-hex value {}... at {path} is not synthetic", + &h[..6] + )); + } + } + for g in GUID_ANY.find_iter(v) { + if !SYNTH_GUID.is_match(g.as_str()) { + self.problems.push(format!( + "{name}: GUID {}... at {path} is not synthetic", + &g.as_str()[..8] + )); + } + } + if DATE.is_match(v) && v != SYNTHETIC_DATE { + self.problems + .push(format!("{name}: date-like text at {path}")); + } + // Numbers are text in XMP, so the version keys and the synthetic + // GrainSeed are exempt as numeric strings too. + let big_ok = BIG_NUMBER_KEYS.contains(&key) + || (key == "GrainSeed" && v == SYNTHETIC_GRAIN_SEED.to_string()); + if NUMERIC_TEXT.is_match(v) && !numeric_text_ok(v) && !big_ok { + self.problems + .push(format!("{name}: numeric string >= 1e9 at {path}")); + } + if value_rule && !string_ok(v, key, parent) { + let short: String = v.chars().take(30).collect(); + self.problems + .push(format!("{name}: unexplained string at {path}: {short:?}")); + } + } + + fn look(&mut self, node: &Map, path: &str) { + let name = self.name; + if let Some(params) = node.get("Parameters") { + let is_stub = match params { + J::Array(a) => a.is_empty(), + J::Object(o) => { + let version = match o.get("ProcessVersion") { + None => "1".to_owned(), + Some(J::String(s)) => s.clone(), + Some(other) => other.to_string(), + }; + !o.is_empty() + && o.keys().all(|k| STUB_KEYS.contains(&k.as_str())) + && o.get("ConvertToGrayscale").is_none_or(J::is_boolean) + && VERSION.is_match(&version) + } + _ => false, + }; + if !is_stub { + self.problems.push(format!( + "{name}: {path}.Parameters is not the stub (keys within {STUB_KEYS:?})" + )); + } + } + let uuid = node.get("UUID").and_then(J::as_str); + // `null` counts as absent, as in the Python check. + let lname = node.get("Name").filter(|n| !n.is_null()); + match uuid { + Some(u) if adobe_look_name(u).is_some() => { + if lname.is_some_and(|n| n.as_str() != adobe_look_name(u)) { + self.problems + .push(format!("{name}: {path}.Name does not match its Adobe UUID")); + } + } + Some(u) if !SYNTH_HEX.is_match(u) => self.problems.push(format!( + "{name}: {path}.UUID is neither a verified Adobe profile nor synthetic" + )), + _ => { + if lname.is_some_and(|n| n.as_str() != Some(SYNTHETIC_PROFILE)) { + self.problems + .push(format!("{name}: {path}.Name without a verified Adobe UUID")); + } + } + } + for (k, v) in node { + if matches!(k.as_str(), "Parameters" | "UUID" | "Name") { + continue; + } + if let (Some((_, synthetic)), J::Object(alt)) = + (LOOK_ALT_FIELDS.iter().find(|(f, _)| f == k), v) + { + for (lang, text) in alt { + if !LANG.is_match(lang) { + self.problems + .push(format!("{name}: bad language tag at {path}.{k}")); + } + if self.strict && text.as_str() != Some(synthetic) { + self.problems + .push(format!("{name}: unexplained string at {path}.{k}.{lang}")); + } + if let J::String(t) = text { + self.string(t, &format!("{path}.{k}.{lang}"), lang, k, false); + } + } + continue; + } + self.walk(v, &format!("{path}.{k}"), k, "Look"); + } + } + + fn walk(&mut self, node: &J, path: &str, key: &str, parent: &str) { + match node { + J::Object(o) => { + for (k, v) in o { + let p = if path.is_empty() { + k.clone() + } else { + format!("{path}.{k}") + }; + if !KEY.is_match(k) { + self.problems + .push(format!("{}: key {p:?} is not an identifier", self.name)); + } + if is_dropped_key(k) || k.starts_with("Table_") || k.starts_with("BrushTable_") + { + self.problems + .push(format!("{}: forbidden key {p}", self.name)); + } + if let ("Look", J::Object(look)) = (k.as_str(), v) { + self.look(look, &p); + continue; + } + self.walk(v, &p, k, key); + } + } + J::Array(a) => { + for v in a { + self.walk(v, &format!("{path}[]"), key, parent); + } + } + J::String(s) => { + let strict = self.strict; + self.string(s, path, key, parent, strict); + } + J::Number(n) => self.number(n, path, key), + J::Bool(_) | J::Null => {} + } + } +} + +fn check_json_tree(data: &J, name: &str, strict: bool) -> Vec { + let mut t = Tree { + name, + strict, + problems: Vec::new(), + }; + t.walk(data, "", "", ""); + t.problems +} + +const RDF_NS: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"; +const XML_NS: &str = "http://www.w3.org/XML/1998/namespace"; + +/// `xmp_as_tree` of the extractor: an XMP file's data model as a JSON tree +/// for [`check_json_tree`]. Every `rdf:Description` under `rdf:RDF` becomes +/// an object (the tree is the list of them); properties are keyed by local +/// name whatever their namespace; attributes in `rdf:`/`xml:` are skipped. A +/// property element whose first container child is `rdf:Seq`/`rdf:Bag`/ +/// `rdf:Alt` is a list of its `rdf:li` items; else one with an +/// `rdf:Description` child is that object; else one with +/// `rdf:parseType="Resource"`, child elements or field attributes is an +/// object of those; else its text. `True`/`False` (either case) become +/// booleans. Every `rdf:Alt` item needs a valid `xml:lang`. +fn xmp_as_tree(text: &str, name: &str) -> (Option, Vec) { + use roxmltree::{Document, Node, ParsingOptions}; + + fn scalar(v: &str) -> J { + match v { + "True" | "true" => J::Bool(true), + "False" | "false" => J::Bool(false), + _ => J::String(v.to_owned()), + } + } + fn is_field_attr(ns: Option<&str>) -> bool { + !matches!(ns, Some(RDF_NS | XML_NS)) + } + fn is_rdf(n: &Node, local: &str) -> bool { + n.is_element() && n.tag_name().namespace() == Some(RDF_NS) && n.tag_name().name() == local + } + struct Conv<'a> { + name: &'a str, + problems: Vec, + } + impl Conv<'_> { + fn fields(&mut self, node: Node, path: &str) -> J { + let mut d = Map::new(); + for a in node.attributes() { + if is_field_attr(a.namespace()) { + d.insert(a.name().to_owned(), scalar(a.value())); + } + } + for c in node.children().filter(Node::is_element) { + let local = c.tag_name().name(); + let p = if path.is_empty() { + local.to_owned() + } else { + format!("{path}.{local}") + }; + let v = self.value(c, &p); + d.insert(local.to_owned(), v); + } + J::Object(d) + } + fn value(&mut self, el: Node, path: &str) -> J { + let kids: Vec = el.children().filter(Node::is_element).collect(); + for c in &kids { + if c.tag_name().namespace() != Some(RDF_NS) + || !matches!(c.tag_name().name(), "Seq" | "Bag" | "Alt") + { + continue; + } + let items: Vec = c.children().filter(|n| is_rdf(n, "li")).collect(); + if c.tag_name().name() == "Alt" { + for li in &items { + if !li + .attribute((XML_NS, "lang")) + .is_some_and(|l| LANG.is_match(l)) + { + self.problems + .push(format!("{}: bad language tag at {path}", self.name)); + } + } + } + return J::Array( + items + .into_iter() + .map(|li| self.value(li, &format!("{path}[]"))) + .collect(), + ); + } + if let Some(desc) = kids.iter().find(|c| is_rdf(c, "Description")) { + return self.fields(*desc, path); + } + if el.attribute((RDF_NS, "parseType")) == Some("Resource") + || !kids.is_empty() + || el.attributes().any(|a| is_field_attr(a.namespace())) + { + return self.fields(el, path); + } + scalar(el.text().unwrap_or("")) + } + } + + let text = text.strip_prefix('\u{feff}').unwrap_or(text); + // Python's parser accepts a DTD; so does this one, to judge alike. + let options = ParsingOptions { + allow_dtd: true, + ..ParsingOptions::default() + }; + let doc = match Document::parse_with_options(text, options) { + Ok(d) => d, + Err(e) => return (None, vec![format!("{name}: not well-formed XML ({e})")]), + }; + let Some(rdf) = doc.descendants().find(|n| is_rdf(n, "RDF")) else { + return (None, vec![format!("{name}: no rdf:RDF element")]); + }; + let mut conv = Conv { + name, + problems: Vec::new(), + }; + let tree = rdf + .children() + .filter(|n| is_rdf(n, "Description")) + .map(|d| conv.fields(d, "")) + .collect(); + (Some(J::Array(tree)), conv.problems) +} + +fn root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../testdata/develop") +} + +fn files_below(dir: &Path, out: &mut Vec) { + for entry in std::fs::read_dir(dir).expect("readable fixture folder") { + let path = entry.unwrap().path(); + if path.is_dir() { + files_below(&path, out); + } else if matches!( + path.extension().and_then(|e| e.to_str()), + Some("json" | "xmp") + ) { + out.push(path); + } + } +} + +/// `hygiene_check(out_dir=root/lua, root=root)` of the extractor. +fn hygiene_check(root: &Path) -> Vec { + let lua = root.join("lua"); + let owned: BTreeSet = std::fs::read_to_string(lua.join("manifest.json")) + .ok() + .and_then(|t| serde_json::from_str::(&t).ok()) + .and_then(|m| { + m["files"].as_array().map(|files| { + files + .iter() + .filter_map(|e| e["file"].as_str().map(str::to_owned)) + .collect() + }) + }) + .unwrap_or_default(); + let mut files = Vec::new(); + files_below(root, &mut files); + files.sort(); + let mut problems = Vec::new(); + for f in files { + let name = f.display().to_string(); + let file_name = f.file_name().unwrap().to_string_lossy().into_owned(); + let text = std::fs::read_to_string(&f).unwrap_or_else(|e| panic!("{name}: {e}")); + let in_root = f.parent() == Some(root); + if in_root && REGISTRY_FILES.contains(&file_name.as_str()) { + problems.extend(hard_text_problems(&text, &name, true)); + match serde_json::from_str::(&text) { + Ok(data) => problems.extend(check_ids_only(&data, &name)), + Err(e) => problems.push(format!("{name}: not valid JSON ({e})")), + } + continue; + } + problems.extend(hard_text_problems(&text, &name, false)); + if f.extension().is_some_and(|e| e == "xmp") { + for h in hex32_runs(&text) { + if !SYNTH_HEX.is_match(h) && adobe_look_name(h).is_none() { + problems.push(format!( + "{name}: 32-hex value {}... is not synthetic", + &h[..6] + )); + } + } + let (tree, xml_problems) = xmp_as_tree(&text, &name); + problems.extend(xml_problems); + if let Some(tree) = tree { + problems.extend(check_json_tree(&tree, &name, false)); + } + continue; + } + let data: J = match serde_json::from_str(&text) { + Ok(d) => d, + Err(e) => { + problems.push(format!("{name}: not valid JSON ({e})")); + continue; + } + }; + let in_lua = f.parent() == Some(lua.as_path()); + if in_lua && file_name == "manifest.json" { + problems.extend(check_ids_only(&data, &name)); + continue; + } + if in_lua && !owned.contains(&file_name) && !file_name.starts_with("hand_") { + problems.push(format!( + "{name}: not listed in manifest.json; hand-written fixtures must be named hand_*.json" + )); + } + problems.extend(check_json_tree( + &data, + &name, + in_lua && owned.contains(&file_name), + )); + } + problems +} + +#[test] +fn testdata_holds_no_private_or_third_party_content() { + let problems = hygiene_check(&root()); + assert!( + problems.is_empty(), + "HYGIENE CHECK FAILED ({} problems):\n{}", + problems.len(), + problems.join("\n") + ); +} + +// ---- the checker itself ---------------------------------------------------- + +fn tree(v: J, strict: bool) -> Vec { + check_json_tree(&v, "t", strict) +} + +#[test] +fn checker_rejects_private_ids_and_table_keys() { + use serde_json::json; + let hex = "0123456789ABCDEF0123456789ABCDEF"; + assert!(!tree(json!({ "MaskDigest": hex }), false).is_empty()); + assert!(!tree(json!({ format!("Table_{hex}"): "x" }), false).is_empty()); + assert!(!tree( + json!({ "CorrectionID": "12345678-1234-1234-1234-123456789012" }), + false + ) + .is_empty()); + assert!(!tree(json!({ "Seed": 1_500_000_000u64 }), false).is_empty()); + assert!(!tree(json!({ "Note": "2024:05:12 10:00:00" }), false).is_empty()); + assert!(!tree(json!({ "bad key": 1 }), false).is_empty()); + assert!(tree( + json!({ "MaskDigest": "00000000000000000000000000000001" }), + false + ) + .is_empty()); + assert!(tree( + json!({ "GrainSeed": 4_000_000_001u64, "ModelVersion": 3_000_000_000u64 }), + false + ) + .is_empty()); + assert!(!hard_text_problems("{\"p\": \"md5p:abc\"}", "t", false).is_empty()); + assert!(!hard_text_problems("C:\\photos", "t", false).is_empty()); + assert!(!hard_text_problems("IMG_0001.CR3", "t", false).is_empty()); + assert!(hard_text_problems("LookTable", "t", true).is_empty()); +} + +#[test] +fn checker_enforces_the_look_rules() { + use serde_json::json; + let stub = json!({ "Look": { "Parameters": { "ConvertToGrayscale": false, "ProcessVersion": "15.4" }, + "UUID": "6F9C877E84273F4E8271E6B91BEB36A1", "Name": "Adobe Landscape" } }); + assert!(tree(stub, true).is_empty()); + let full = json!({ "Look": { "Parameters": { "Exposure2012": 0.5 } } }); + assert!(!tree(full, false).is_empty()); + let wrong_name = + json!({ "Look": { "UUID": "6F9C877E84273F4E8271E6B91BEB36A1", "Name": "Other" } }); + assert!(!tree(wrong_name, false).is_empty()); + let foreign = + json!({ "Look": { "UUID": "11111111111111111111111111111111", "Name": "Camera X" } }); + assert!(!tree(foreign, false).is_empty()); + let unnamed = + json!({ "Look": { "UUID": "00000000000000000000000000000009", "Name": "Real Name" } }); + assert!(!tree(unnamed, false).is_empty()); +} + +#[test] +fn checker_applies_value_rules_to_generated_files_only() { + use serde_json::json; + let v = json!({ "MaskGroupBasedCorrections": [{ "CorrectionName": "Himmel" }] }); + assert!(!tree(v.clone(), true).is_empty()); + assert!(tree(v, false).is_empty()); + assert!(tree( + json!({ "CameraProfile": "Adobe Standard", "Dabs": ["d 0.1 0.2"] }), + true + ) + .is_empty()); + assert!(!tree(json!({ "CameraProfile": "My Profile" }), true).is_empty()); +} + +#[test] +fn hex_runs_ignore_longer_hex_strings() { + let h = "0123456789abcdef0123456789abcdef"; + assert_eq!(hex32_runs(&format!("x{h}y")), [h]); + assert!(hex32_runs(&format!("{h}0")).is_empty()); +} + +fn copy_dir(from: &Path, to: &Path) { + std::fs::create_dir_all(to).unwrap(); + for entry in std::fs::read_dir(from).unwrap() { + let path = entry.unwrap().path(); + let target = to.join(path.file_name().unwrap()); + if path.is_dir() { + copy_dir(&path, &target); + } else { + std::fs::copy(&path, &target).unwrap(); + } + } +} + +/// A minimal well-formed XMP file (same text as `crafted_xmp` in the +/// extractor). +fn crafted_xmp(attrs: &str, body: &str) -> String { + format!( + concat!( + r#""#, + r#"{}"#, + "" + ), + attrs, body + ) +} + +#[test] +fn xmp_trees_follow_the_data_model_not_the_syntax() { + let attrs = xmp_as_tree( + &crafted_xmp( + r#"crs:A="True" crs:B="+1""#, + r#"a"#, + ), + "t", + ); + let elements = xmp_as_tree( + &crafted_xmp( + "", + concat!( + "True+1", + r#"1"#, + "a" + ), + ), + "t", + ); + let want = serde_json::json!([{ "A": true, "B": "+1", "S": { "X": "1" }, "L": ["a"] }]); + assert_eq!(attrs, (Some(want.clone()), Vec::new())); + assert_eq!(elements, (Some(want), Vec::new())); +} + +/// The file-level rules (scope, manifest ownership, the registry exemption, +/// XMP ids and trees) and the inputs the Rust and Python checks once disagreed on, on a +/// copy of the real tree with one crafted file per case. Mirrored by +/// `extract_fixtures.py --self-test`. +#[test] +fn dispatch_rules_flag_exactly_the_crafted_files() { + let tmp = std::env::temp_dir().join(format!( + "lrg-develop-hygiene-{}-dispatch", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&tmp); + copy_dir(&root(), &tmp); + let foreign_hex = "0123456789ABCDEF0123456789ABCDEF"; + let masked = + r#"{"MaskGroupBasedCorrections": [{"CorrectionMasks": [{"MaskName": "Himmel"}]}]}"#; + let manifest_path = tmp.join("lua/manifest.json"); + let mut manifest: J = + serde_json::from_str(&std::fs::read_to_string(&manifest_path).unwrap()).unwrap(); + manifest["files"] + .as_array_mut() + .unwrap() + .push(serde_json::json!({ "file": "owned_crafted.json", "covers": [] })); + std::fs::write(&manifest_path, manifest.to_string()).unwrap(); + std::fs::create_dir_all(tmp.join("xmp")).unwrap(); + let crafted: &[(&str, String, bool)] = &[ + // (file, content, flagged) + ("lua/unlisted.json", r#"{"Exposure2012": 0.5}"#.into(), true), + ( + "lua/registry_snapshot.json", + r#"{"rows": ["LookTable"]}"#.into(), + true, + ), + ( + "xmp/foreign_id.xmp", + crafted_xmp(&format!("crs:MaskDigest=\"{foreign_hex}\""), ""), + true, + ), + ( + "xmp/adobe_look.xmp", + crafted_xmp( + "", + r#""#, + ), + false, + ), + ( + "xmp/look_parameters.xmp", + crafted_xmp( + "", + r#""#, + ), + true, + ), + ("xmp/not_xml.xmp", "Synthetic Text"#, + ), + true, + ), + ("lua/owned_crafted.json", masked.into(), true), + ("lua/hand_localised.json", masked.into(), false), + ("lua/stray.xmp", "".into(), true), + // Inputs the two checks used to judge differently. + ("lua/hand_newline_number.json", "{\"Foo\": \"5000000000\\n\"}".into(), false), + ( + "lua/hand_arabic_digits.json", + "{\"Foo\": \"\u{0665}\u{0660}\u{0660}\u{0660}\u{0660}\u{0660}\u{0660}\u{0660}\u{0660}\u{0660}\"}" + .into(), + false, + ), + ("lua/hand_newline_key.json", "{\"Exposure\\n\": 1}".into(), true), + ( + "lua/hand_newline_version.json", + "{\"Look\": {\"Parameters\": {\"ProcessVersion\": \"15.4\\n\"}}}".into(), + true, + ), + ("lua/hand_null_name.json", r#"{"Look": {"Name": null}}"#.into(), false), + // The preset-header key is allowed as a whole word only. + ( + "xmp/requires_rgb_tables.xmp", + crafted_xmp(r#"crs:RequiresRGBTables="False""#, ""), + false, + ), + ( + "xmp/requires_rgb_tables_suffix.xmp", + crafted_xmp(r#"crs:RequiresRGBTablesX="False""#, ""), + true, + ), + ( + "xmp/rgb_table_key.xmp", + crafted_xmp(r#"crs:RequiresRGBTables="False" crs:RGBTable="x""#, ""), + true, + ), + ]; + for (file, content, _) in crafted { + std::fs::write(tmp.join(file), content).unwrap(); + } + let problems = hygiene_check(&tmp); + let flagged = |file: &str| { + let prefix = format!("{}: ", tmp.join(file).display()); + problems.iter().any(|p| p.starts_with(&prefix)) + }; + let wrong: Vec = crafted + .iter() + .filter(|(file, _, want)| flagged(file) != *want) + .map(|(file, _, want)| format!("{file}: expected flagged={want}")) + .collect(); + let crafted_prefixes: Vec = crafted + .iter() + .map(|(f, _, _)| format!("{}: ", tmp.join(f).display())) + .collect(); + let other: Vec<&String> = problems + .iter() + .filter(|p| !crafted_prefixes.iter().any(|c| p.starts_with(c.as_str()))) + .collect(); + let _ = std::fs::remove_dir_all(&tmp); + assert!(wrong.is_empty(), "{wrong:#?}\nproblems: {problems:#?}"); + assert!( + other.is_empty(), + "the real files must stay clean: {other:#?}" + ); +} diff --git a/server-rs/crates/lrg-develop/tests/lua_read.rs b/server-rs/crates/lrg-develop/tests/lua_read.rs new file mode 100644 index 00000000..1fd12c12 --- /dev/null +++ b/server-rs/crates/lrg-develop/tests/lua_read.rs @@ -0,0 +1,543 @@ +//! The Lua/JSON reader over every fixture in `testdata/develop/lua/`. +//! +//! Generated fixtures (listed in `manifest.json`, scrubbed training rows, +//! all raw files) must parse without any warning, and every shape the +//! manifest says a file covers must be recognised by the model. Hand-written +//! fixtures (`hand_*.json`) cover the shapes the training rows lack; each +//! has its own test with the warnings it must produce. No fixture may +//! produce a `ParseError` or an `UnknownKey` warning. + +use std::collections::BTreeSet; +use std::path::PathBuf; + +use lrg_develop::lua::from_lua_str; +use lrg_develop::model::{ + Combine, Correction, DevelopSettings, Fields, FileKind, MaskComponent, MaskTool, Opaque, + Semantic, Value, WbFamily, WbMode, +}; +use lrg_develop::model::{SkipReason, Target}; +use lrg_develop::registry::{KeyId, Level, Policy, ProcessVersion, StructKind}; +use lrg_develop::{FileKindHint, ParseWarning, WarningKind}; +use serde_json::Value as J; + +fn lua_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../testdata/develop/lua") +} + +fn read_text(name: &str) -> String { + std::fs::read_to_string(lua_dir().join(name)).unwrap_or_else(|e| panic!("{name}: {e}")) +} + +fn manifest() -> J { + serde_json::from_str(&read_text("manifest.json")).expect("manifest.json is JSON") +} + +/// Every fixture file name (not the manifest), sorted. +fn fixture_names() -> Vec { + let mut names: Vec = std::fs::read_dir(lua_dir()) + .expect("lua fixture folder") + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .filter(|n| n.ends_with(".json") && n != "manifest.json") + .collect(); + names.sort(); + names +} + +/// Generated fixtures come from raw training rows; hand-written ones say +/// nothing about the file kind unless a test passes a hint. +fn default_hint(name: &str) -> FileKindHint { + if name.starts_with("hand_") { + FileKindHint::Unknown + } else { + FileKindHint::Raw + } +} + +fn parse(name: &str, hint: FileKindHint) -> (DevelopSettings, Vec) { + from_lua_str(&read_text(name), hint).unwrap_or_else(|e| panic!("{name}: ParseError {e}")) +} + +fn kinds(w: &[ParseWarning]) -> Vec<&'static str> { + w.iter().map(|w| w.kind.name()).collect() +} + +#[test] +fn every_fixture_parses_without_errors_or_unknown_keys() { + let names = fixture_names(); + assert!(names.len() >= 17, "fixtures missing: {names:?}"); + for name in names { + let (_, warnings) = parse(&name, default_hint(&name)); + let unknown: Vec<&str> = warnings + .iter() + .filter(|w| matches!(w.kind, WarningKind::UnknownKey)) + .map(|w| w.path.as_str()) + .collect(); + assert!(unknown.is_empty(), "{name}: unknown keys {unknown:?}"); + } +} + +#[test] +fn generated_fixtures_parse_without_any_warning() { + for name in fixture_names().iter().filter(|n| !n.starts_with("hand_")) { + let (_, warnings) = parse(name, FileKindHint::Raw); + let text: Vec = warnings.iter().map(ToString::to_string).collect(); + assert!(text.is_empty(), "{name}: {text:#?}"); + } +} + +fn components(s: &DevelopSettings) -> impl Iterator { + s.corrections.iter().flat_map(|c| &c.masks) +} + +fn any_tool(s: &DevelopSettings, pred: impl Fn(&MaskComponent) -> bool) -> bool { + components(s).any(pred) +} + +fn opaque_what(m: &MaskComponent, what: &str) -> bool { + matches!(&m.tool, MaskTool::Opaque { what: Some(w) } if w == what) +} + +fn struct_of<'a>(s: &'a DevelopSettings, name: &str) -> Option<&'a lrg_develop::model::Struct> { + match s.get_by_name(name)? { + Value::Struct(st) => Some(st), + _ => None, + } +} + +fn struct_list_len(s: &DevelopSettings, name: &str) -> usize { + match s.get_by_name(name) { + Some(Value::StructList(items)) => items.len(), + _ => 0, + } +} + +fn is_fraction(v: Option<&Value>) -> bool { + matches!(v, Some(Value::Real(r)) if r.get().fract() != 0.0) +} + +/// Asserts that `shape` (a manifest `covers` entry) is recognised in `s`. +fn check_shape(file: &str, shape: &str, raw: &J, text: &str, s: &DevelopSettings) { + let ok = match shape { + "top_level_empty" => *raw == J::Array(vec![]) && s.is_empty(), + "pv_11_0" => s.process_version() == Some(ProcessVersion::V5), + "pv_15_4" => s.process_version() == Some(ProcessVersion::V6), + "look" => s.look.as_ref().is_some_and(|l| { + l.uuid.is_some() + && l.name.is_some() + && matches!(l.rest.get("Parameters"), None | Some(Value::Opaque(Opaque::Json(_)))) + }), + "no_look" => s.look.is_none() && raw.get("Look").is_none(), + "look_camera_restricted" => s + .look + .as_ref() + .is_some_and(|l| l.camera_restriction.is_some()), + "look_adaptive" => s + .look + .as_ref() + .is_some_and(|l| matches!(l.rest.get("isAdobeAdaptive"), Some(Value::Bool(_)))), + "look_amount_float" => s + .look + .as_ref() + .and_then(|l| l.amount) + .is_some_and(|a| a.get().fract() != 0.0), + "lensblur_empty" => raw["LensBlur"] == J::Array(vec![]) && s.get_by_name("LensBlur").is_none(), + "lensblur_object" => struct_of(s, "LensBlur").is_some_and(|b| { + b.kind == StructKind::LensBlur && matches!(b.get("Active"), Some(Value::Bool(_))) + }), + "filterlist_empty" => { + raw["FilterList"] == J::Array(vec![]) && s.get_by_name("FilterList").is_none() + } + "filterlist_filters" => struct_of(s, "FilterList").is_some_and(|f| { + f.kind == StructKind::FilterList && f.fields.opaque.iter().any(|o| o.name == "Filters") + }), + "ailook_object" => struct_of(s, "AILook").is_some_and(|a| !a.fields.is_empty()), + "point_colors" => struct_list_len(s, "PointColors") > 0, + "retouch_areas" => match s.get_by_name("RetouchAreas") { + Some(Value::StructList(items)) => items + .iter() + .any(|a| a.fields.opaque.iter().any(|o| o.name.starts_with("pm_"))), + _ => false, + }, + "remove_areas" => struct_list_len(s, "RemoveAreas") > 0, + "retouch_info" => struct_list_len(s, "RetouchInfo") > 0, + "upright_transform" => s + .opaque + .iter() + .any(|o| o.name.starts_with("UprightTransform_")), + "depth_map_info" => struct_of(s, "DepthMapInfo").is_some(), + "wb_auto" => { + s.wb().is_some_and(|wb| wb.mode == WbMode::Auto) + && s.get_by_name("AutoWhiteVersion").is_some() + } + "grain_seed" => s.get_by_name("GrainSeed") == Some(&Value::Int(4_000_000_001)), + "perspective_rotate_float" => is_fraction(s.get_by_name("PerspectiveRotate")), + "number_exponent" => { + // Every global key whose token is in exponent form holds that + // number in the model. + let token = + regex::Regex::new(r#""([A-Za-z0-9_]+)"\s*:\s*(-?[0-9.]+[eE][-+]?[0-9]+)"#).unwrap(); + let mut checked = 0; + for c in token.captures_iter(text) { + if let Some(v) = s.get_by_name(&c[1]) { + let want: f64 = c[2].parse().unwrap(); + assert!( + matches!(v, Value::Real(r) if r.get() == want), + "{file}: {} is {v:?}, the token says {}", + &c[1], + &c[2] + ); + checked += 1; + } + } + checked > 0 + } + "local_curve" => s.corrections.iter().any(|c| { + matches!(c.local_value("MainCurve"), Some(Value::Curve(points)) if points.len() >= 2) + }), + "local_legacy_float" => s.corrections.iter().any(|c| { + ["LocalExposure", "LocalContrast", "LocalClarity"] + .iter() + .any(|k| is_fraction(c.extra.get(Level::Correction, k))) + }), + "mask_ai_subject" => any_tool(s, |m| m.tool == MaskTool::Semantic(Semantic::Subject)), + "mask_ai_sky" => any_tool(s, |m| m.tool == MaskTool::Semantic(Semantic::Sky)), + "mask_ai_background" => any_tool(s, |m| m.tool == MaskTool::Semantic(Semantic::Background)), + "mask_ai_people_part_all" => { + any_tool(s, |m| matches!(m.tool, MaskTool::Semantic(Semantic::PeoplePart(_)))) + } + "mask_ai_people_part_person" => any_tool(s, |m| { + matches!(m.tool, MaskTool::Semantic(Semantic::PersonPartAt { .. })) + }), + "mask_ai_landscape" => { + any_tool(s, |m| matches!(m.tool, MaskTool::Semantic(Semantic::Landscape(_)))) + } + "mask_select_object_polygon" => any_tool(s, |m| { + opaque_what(m, "Mask/Image") && m.extra.get(Level::MaskTool, "Gesture").is_some() + }), + "mask_linear_gradient" => any_tool(s, |m| matches!(m.tool, MaskTool::Linear(_))), + "mask_radial_gradient" => any_tool(s, |m| { + matches!(m.tool, MaskTool::Radial(_)) || opaque_what(m, "Mask/CircularGradient") + }), + "mask_brush_aggregate" => any_tool(s, |m| { + opaque_what(m, "Mask/Aggregate") + && matches!(m.extra.get(Level::MaskTool, "Masks"), Some(Value::Tools(t)) if !t.is_empty()) + }), + "mask_range_luminance" => any_tool(s, |m| matches!(m.tool, MaskTool::LuminanceRange(_))), + "mask_range_color" => any_tool(s, |m| opaque_what(m, "Mask/RangeMask")), + "mask_range_color_area" => any_tool(s, |m| { + opaque_what(m, "Mask/RangeMask") + && matches!( + m.extra.get(Level::MaskTool, "CorrectionRangeMask"), + Some(Value::Struct(crm)) if matches!(crm.get("AreaModels"), Some(Value::StructList(_))) + ) + }), + "mask_intersect" => any_tool(s, |m| m.combine == Combine::Intersect), + "mask_subtract" => any_tool(s, |m| m.combine == Combine::Subtract), + other => panic!("{file}: shape {other:?} has no check in lua_read.rs; add one"), + }; + assert!(ok, "{file}: shape {shape} not recognised"); +} + +#[test] +fn every_manifest_shape_is_recognised() { + let manifest = manifest(); + let files = manifest["files"].as_array().expect("files"); + let mut seen = BTreeSet::new(); + for entry in files { + let file = entry["file"].as_str().expect("file name"); + let text = read_text(file); + let raw: J = serde_json::from_str(&text).unwrap(); + let (s, _) = parse(file, FileKindHint::Raw); + for shape in entry["covers"].as_array().expect("covers") { + let shape = shape.as_str().unwrap(); + check_shape(file, shape, &raw, &text, &s); + seen.insert(shape.to_owned()); + } + } + // The reader rules (Dev-Develop-Model, "Reading the Lua form") need at + // least these shapes. + for must in [ + "lensblur_empty", + "lensblur_object", + "filterlist_empty", + "filterlist_filters", + "pv_11_0", + "pv_15_4", + "top_level_empty", + "mask_intersect", + "mask_subtract", + "look", + ] { + assert!(seen.contains(must), "no fixture covers {must}"); + } +} + +#[test] +fn every_generated_fixture_is_in_the_manifest_and_vice_versa() { + let listed: BTreeSet = manifest()["files"] + .as_array() + .unwrap() + .iter() + .map(|e| e["file"].as_str().unwrap().to_owned()) + .collect(); + let on_disk: BTreeSet = fixture_names() + .into_iter() + .filter(|n| !n.starts_with("hand_")) + .collect(); + assert_eq!(listed, on_disk); +} + +#[test] +fn raw_rows_are_raw_and_every_correction_has_a_recognised_combination() { + for name in fixture_names().iter().filter(|n| !n.starts_with("hand_")) { + let (s, _) = parse(name, FileKindHint::Unknown); + if s.is_empty() { + continue; + } + assert_eq!(s.file_kind, Some(FileKind::Raw), "{name}: family says raw"); + assert_eq!(s.wb().map(|w| w.family), Some(WbFamily::Raw), "{name}"); + for c in &s.corrections { + assert!(!c.masks.is_empty(), "{name}: correction without masks"); + assert!( + matches!(c.masks[0].combine, Combine::Add { .. }), + "{name}: first component must add" + ); + assert!(c.masks.iter().all(|m| m.combine != Combine::Unrecognised)); + } + } +} + +/// Asserts that everything below a kept value may go into a shared preset: +/// every nested key LEARN, LEARN† or (bookkeeping) META, no opaque entry. +struct PresetWalk<'a> { + file: &'a str, +} + +impl PresetWalk<'_> { + fn key(&self, id: KeyId, v: &Value, path: &str) { + let spec = id.spec(); + let path = format!("{path}.{}", spec.name); + assert!( + matches!( + spec.policy, + Policy::Learn | Policy::LearnGated(_) | Policy::Meta + ), + "{}: {path} ({}) kept", + self.file, + spec.policy.class_name() + ); + self.value(v, &path); + } + + fn value(&self, v: &Value, path: &str) { + match v { + Value::Struct(st) => self.fields(&st.fields, path), + Value::StructList(items) => items.iter().for_each(|st| self.fields(&st.fields, path)), + Value::Tools(items) => items.iter().for_each(|f| self.fields(f, path)), + Value::Corrections(cs) => cs.iter().for_each(|c| self.correction(c, path)), + _ => {} + } + } + + fn fields(&self, f: &Fields, path: &str) { + let opaque: Vec<&str> = f.opaque.iter().map(|o| o.name.as_str()).collect(); + assert!(opaque.is_empty(), "{}: {path} keeps {opaque:?}", self.file); + for (id, v) in &f.values { + self.key(*id, v, path); + } + } + + fn correction(&self, c: &Correction, path: &str) { + assert!( + matches!( + c.policy(), + Policy::Learn | Policy::LearnGated(_) | Policy::Meta + ), + "{}: correction kept at {path}", + self.file + ); + for (id, v) in &c.local { + self.key(*id, v, path); + } + self.fields(&c.extra, path); + for m in &c.masks { + self.fields(&m.extra, path); + if let MaskTool::LuminanceRange(lr) = &m.tool { + self.fields(&lr.rest.fields, &format!("{path}.CorrectionRangeMask")); + } + } + } +} + +#[test] +fn a_mixed_preset_from_any_fixture_holds_only_shareable_content() { + for name in fixture_names() { + let (s, _) = parse(&name, default_hint(&name)); + let (out, skipped) = s.filtered(&Target::Preset { + mixed_file_kinds: true, + }); + let walk = PresetWalk { file: &name }; + for (id, v) in out.values() { + let spec = id.spec(); + assert_ne!( + spec.policy, + Policy::Meta, + "{name}: {} is the writer's", + spec.name + ); + assert_ne!(spec.name, "Temperature", "{name}"); + walk.key(id, v, ""); + } + assert!(out.opaque.is_empty(), "{name}"); + for c in &out.corrections { + walk.correction(c, "MaskGroupBasedCorrections[]"); + } + // Nothing disappears silently: every correction the preset lost is + // reported. + let dropped = s.corrections.len() - out.corrections.len(); + let reported = skipped + .iter() + .filter(|k| { + k.path.starts_with("MaskGroupBasedCorrections[") + && !k.path.contains("].") + && matches!(k.reason, SkipReason::Policy(_)) + }) + .count(); + assert_eq!(dropped, reported, "{name}"); + let (all, none) = s.filtered(&Target::TestRoundTrip); + assert_eq!(all, s); + assert!(none.is_empty()); + } +} + +// ---- hand-written shapes ------------------------------------------------- + +#[test] +fn hand_bool_as_int_normalises_to_the_key_kind() { + let (s, w) = parse("hand_bool_as_int.json", FileKindHint::Raw); + assert!(w.is_empty(), "{w:?}"); + assert_eq!( + s.get_by_name("ConvertToGrayscale"), + Some(&Value::Bool(true)) + ); + assert_eq!(s.get_by_name("EnableDetail"), Some(&Value::Bool(false))); + assert_eq!(s.get_by_name("AutoLateralCA"), Some(&Value::Int(1))); + assert_eq!(s.get_by_name("LensProfileEnable"), Some(&Value::Int(0))); + let c = &s.corrections[0]; + assert_eq!(c.active, Some(true)); + let m = &c.masks[0]; + assert_eq!(m.active, Some(true)); + assert_eq!(m.combine, Combine::Add { inverted: false }); + assert_eq!(m.tool, MaskTool::Semantic(Semantic::Sky)); +} + +#[test] +fn hand_non_integer_on_int_key_is_rounded_with_a_warning() { + let (s, w) = parse("hand_non_integer_on_int_key.json", FileKindHint::Raw); + let mut paths: Vec<&str> = w.iter().map(|w| w.path.as_str()).collect(); + paths.sort_unstable(); + assert_eq!(paths, ["Contrast2012", "Temperature", "Vibrance"]); + assert!(kinds(&w).iter().all(|k| *k == "NonIntegerForIntKey")); + assert_eq!( + s.get_by_name("Contrast2012"), + Some(&Value::Int(12)), + "half to even" + ); + assert_eq!( + s.get_by_name("Vibrance"), + Some(&Value::Int(14)), + "half to even" + ); + assert_eq!(s.get_by_name("Temperature"), Some(&Value::Int(5650))); + assert!(matches!( + s.get_by_name("Exposure2012"), + Some(Value::Real(_)) + )); +} + +#[test] +fn hand_null_in_array_is_kept_verbatim_with_a_warning() { + let (s, w) = parse("hand_null_in_array.json", FileKindHint::Unknown); + let mut paths: Vec<&str> = w.iter().map(|w| w.path.as_str()).collect(); + paths.sort_unstable(); + assert_eq!(paths, ["RedEyeInfo", "ToneCurvePV2012"]); + assert!(kinds(&w).iter().all(|k| *k == "WrongType")); + assert!(s.get_by_name("ToneCurvePV2012").is_none()); + let kept = s + .opaque + .iter() + .find(|o| o.name == "ToneCurvePV2012") + .unwrap(); + assert_eq!( + kept.value, + Opaque::Json(serde_json::json!([0, 0, null, 128, 255, 255])) + ); + assert!(matches!(s.get_by_name("ToneCurvePV2012Blue"), Some(Value::Curve(p)) if p.len() == 2)); +} + +#[test] +fn hand_mixed_array_is_kept_verbatim_with_a_warning() { + let (s, w) = parse("hand_mixed_array.json", FileKindHint::Unknown); + let mut paths: Vec<&str> = w.iter().map(|w| w.path.as_str()).collect(); + paths.sort_unstable(); + assert_eq!( + paths, + [ + "MaskGroupBasedCorrections[0].MainCurve", + "RetouchInfo", + "ToneCurvePV2012Red" + ] + ); + assert!(kinds(&w).iter().all(|k| *k == "WrongType")); + assert_eq!(s.opaque.len(), 2); + let c = &s.corrections[0]; + assert!(c.local_value("MainCurve").is_none()); + assert!(c.extra.opaque.iter().any(|o| o.name == "MainCurve")); +} + +#[test] +fn hand_non_raw_white_balance_uses_the_incremental_family() { + let (s, w) = parse("hand_non_raw_white_balance.json", FileKindHint::NonRaw); + assert!(w.is_empty(), "{w:?}"); + assert_eq!(s.file_kind, Some(FileKind::NonRaw)); + let wb = s.wb().unwrap(); + assert_eq!(wb.mode, WbMode::Custom); + assert_eq!(wb.family, WbFamily::NonRaw); + assert_eq!(wb.temperature.map(|t| t.get()), Some(12.0)); + assert_eq!(wb.tint.map(|t| t.get()), Some(-4.0)); + // Without a hint the family alone decides, silently. + let (s, w) = parse("hand_non_raw_white_balance.json", FileKindHint::Unknown); + assert!(w.is_empty()); + assert_eq!(s.file_kind, Some(FileKind::NonRaw)); +} + +#[test] +fn white_balance_family_contradicting_is_raw_warns_and_the_family_wins() { + let (s, w) = parse("hand_non_raw_white_balance.json", FileKindHint::Raw); + assert_eq!(kinds(&w), ["FileKindMismatch"]); + assert_eq!(s.file_kind, Some(FileKind::NonRaw)); + assert_eq!(s.wb().unwrap().family, WbFamily::NonRaw); +} + +#[test] +fn hand_wb_both_families_is_a_conflict() { + let (s, w) = parse("hand_wb_both_families.json", FileKindHint::Raw); + assert_eq!(kinds(&w), ["ConflictingFileKind"]); + assert_eq!(s.file_kind, Some(FileKind::Raw), "falls back to the hint"); +} + +#[test] +fn hand_pv_2010_is_read_but_flagged() { + let (s, w) = parse("hand_pv_2010.json", FileKindHint::Raw); + assert_eq!(kinds(&w), ["UnsupportedProcessVersion"]); + assert_eq!(s.process_version(), Some(ProcessVersion::PV2010)); + assert!(s.get_by_name("Exposure").is_some()); + assert!(s.get_by_name("HighlightRecovery").is_some()); +} + +#[test] +fn top_level_empty_fixture_is_empty_settings() { + let (s, w) = parse("top_level_empty.json", FileKindHint::Raw); + assert!(s.is_empty()); + assert!(w.is_empty()); +} diff --git a/server-rs/crates/lrg-develop/tests/lua_roundtrip.rs b/server-rs/crates/lrg-develop/tests/lua_roundtrip.rs new file mode 100644 index 00000000..6e2763b6 --- /dev/null +++ b/server-rs/crates/lrg-develop/tests/lua_roundtrip.rs @@ -0,0 +1,482 @@ +//! The Lua writer against the Lua reader (plan §7.3, round trips 2 and 3 +//! and the key enumeration), on committed data only; the same over the +//! private training dump is in `training_dump_local.rs`. +//! +//! - **Round trip 2**, model → Lua → model, lossless: every fixture in +//! `testdata/develop/lua/` and every wire golden (opaque content and +//! warnings included), under every encoding the experiments may choose +//! (mask enums as numbers or strings, flags as numbers or booleans), and +//! every XMP fixture without what has no Lua form (XMP-only keys, +//! `PerFormat` values in their XMP shape, content kept as XMP); writing +//! the read-back model again gives the same table. +//! - **Round trip 3**, Lua → model → XMP preset → model → Lua: every Lua +//! fixture, every wire golden and every XMP fixture (without what has no +//! Lua form), mixed and single-kind preset; the values the two Lua tables +//! share are equal after rounding to the XMP writer's number formats +//! (`Value::quantized`; Lua carries Lua 5.1 `%.14g` doubles, a preset +//! `Fixed(2)`/`Trim6`). The check itself is `support/lua_round.rs`, which +//! the local sweeps run over private data. +//! - **Key enumeration**: every LEARN and PHOTO key Lightroom has in its Lua +//! tables, at every level, with the enumeration's values +//! (`support/enumerate.rs`), survives the lossless mode exactly; and in +//! apply mode each global, correction, `LensBlur` and `Look.Amount` value +//! either reaches the photo exactly or is reported in +//! `LuaWritten::skipped` (or is its key's default), never lost silently. +//! Mask-tool and range-mask values are not placed by the enumeration in +//! apply mode; the next check covers them. +//! - **Apply mode on the committed fixtures** (`support/lua_apply.rs`): +//! every Lua and XMP fixture written for its own photo, with the +//! provisional options and with every single option flip, in apply and +//! preset mode: wire-safe, read back without a warning, every filtered +//! value written exactly or reported, nothing added beyond the writer's +//! fixed form. The same check the local sweeps run over private data. +//! +//! The lossless mode (`LuaMode::TestRoundTrip`) exists only with the crate +//! feature `test-roundtrip`, which this crate's own dev-dependency turns on +//! for its tests. + +#[path = "support/enumerate.rs"] +mod enumerate; +#[allow(dead_code)] // `header` is for the XMP tests +#[path = "support/flat.rs"] +mod flat; +#[allow(dead_code)] // the counted sweep entry point is for the local sweeps +#[path = "support/lua_apply.rs"] +mod lua_apply; +#[allow(dead_code)] // round trip 2 is asserted per file here, not counted +#[path = "support/lua_round.rs"] +mod lua_round; +#[allow(dead_code)] // only `quantized` and `same` are used (by `lua_round`) +#[path = "support/preset.rs"] +mod preset; +#[allow(dead_code)] // only `generic_path` is used (by `lua_round`) +mod support; + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use enumerate::{correction, kid, leaf, learn_or_photo, one, place, samples}; +use lrg_develop::lua::{ + check_wire, from_lua_str, from_lua_value, to_lua_value, LuaMode, LuaOptions, LuaWritten, + PhotoContext, +}; +use lrg_develop::lua::{EnumAs, FlagAs}; +use lrg_develop::model::{ + Combine, Correction, DevelopSettings, Fields, FileKind, Hex32, Look, MaskComponent, MaskTool, + Semantic, Struct, Value, +}; +use lrg_develop::registry::{ + self, Def, KeyId, Level, Presence, ProcessVersion, StructKind, ValueKind, +}; +use lrg_develop::xmp::parse; +use lrg_develop::{FileKindHint, ParseWarning}; +use lua_apply::{flips, LuaApply}; +use lua_round::{round_trip, round_trip_with, without_xmp_only, LuaRoundTrips}; + +fn testdata(dir: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../testdata/develop") + .join(dir) +} + +/// The files with `extension` in `testdata/develop/`, sorted. +fn files(dir: &str, extension: &str) -> Vec<(String, Vec)> { + let mut out: Vec<(String, Vec)> = std::fs::read_dir(testdata(dir)) + .unwrap() + .map(|e| e.unwrap().path()) + .filter(|p| p.extension().and_then(|e| e.to_str()) == Some(extension)) + .map(|p| { + let name = p.file_name().unwrap().to_string_lossy().into_owned(); + (name, std::fs::read(&p).unwrap()) + }) + .filter(|(n, _)| n != "manifest.json") + .collect(); + out.sort(); + out +} + +fn kinds(w: &[ParseWarning]) -> Vec<(&'static str, &str)> { + w.iter().map(|w| (w.kind.name(), w.path.as_str())).collect() +} + +// --- round trip 2 ------------------------------------------------------------ + +/// Round trip 2 under every encoding the lossless mode takes from the +/// options, so whichever form E2 settles on (or a flag form chosen later) +/// reads back: the reader accepts both forms of each. +#[test] +fn every_lua_fixture_and_wire_golden_round_trips_losslessly() { + let mut n = 0; + for mask_enum_as in [EnumAs::Number, EnumAs::String] { + for int_flag_as in [FlagAs::Number, FlagAs::Bool] { + let opt = LuaOptions { + mask_enum_as, + int_flag_as, + ..LuaOptions::PROVISIONAL + }; + let how = format!("{mask_enum_as:?}/{int_flag_as:?}"); + for dir in ["lua", "wire"] { + for (name, bytes) in files(dir, "json") { + let text = std::str::from_utf8(&bytes).unwrap(); + let what = format!("{dir}/{name} ({how})"); + let (s, w0) = from_lua_str(text, FileKindHint::Unknown) + .unwrap_or_else(|e| panic!("{what}: {e}")); + let (table, back, w1) = round_trip_with(&s, &opt); + assert_eq!( + flat::diff(&flat::settings(&s), &flat::settings(&back)), + Vec::::new(), + "{what}" + ); + assert_eq!(back, s, "{what}"); + // The same findings again (unknown keys, wrong types, + // ...), apart from a rounding: the model holds the + // rounded integer. + let w0: Vec = w0 + .into_iter() + .filter(|w| w.kind.name() != "NonIntegerForIntKey") + .collect(); + assert_eq!(kinds(&w1), kinds(&w0), "{what}"); + let (again, _, _) = round_trip_with(&back, &opt); + assert_eq!(again, table, "{what}: not idempotent"); + n += 1; + } + } + } + } + eprintln!("round trip 2: {n} Lua tables (4 encodings)"); + assert!(n >= 4 * 35, "only {n} files"); +} + +#[test] +fn every_xmp_fixture_round_trips_through_lua_without_its_xmp_only_parts() { + let mut n = 0; + for (name, bytes) in files("xmp", "xmp") { + let doc = parse(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); + let s = without_xmp_only(&doc.develop); + let (_, back, warnings) = round_trip(&s); + // Only the fixtures with findings of their own have them again. + assert!( + warnings.is_empty() || !doc.warnings.is_empty(), + "{name}: {warnings:?}" + ); + assert_eq!( + flat::diff(&flat::settings(&s), &flat::settings(&back)), + Vec::::new(), + "{name}" + ); + n += 1; + } + assert!(n >= 20, "only {n} fixtures"); +} + +// --- round trip 3 ------------------------------------------------------------ + +#[test] +fn lua_through_an_xmp_preset_and_back_to_lua_agrees_after_quantization() { + let mut all = LuaRoundTrips::default(); + let mut sources: Vec<(String, DevelopSettings)> = Vec::new(); + for dir in ["lua", "wire"] { + for (name, bytes) in files(dir, "json") { + let text = std::str::from_utf8(&bytes).unwrap(); + let (s, _) = from_lua_str(text, FileKindHint::Unknown).unwrap(); + sources.push((format!("{dir}/{name}"), s)); + } + } + for (name, bytes) in files("xmp", "xmp") { + let doc = parse(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); + sources.push((format!("xmp/{name}"), without_xmp_only(&doc.develop))); + } + for (name, s) in &sources { + // Per source, so a failure names it (the fixtures are public). + let mut one = LuaRoundTrips::default(); + one.round_trip_3_only(s); + if one.rt3_preset_errors.is_empty() + && one.rt3_not_lossless.is_empty() + && one.rt3_warnings.is_empty() + && one.rt3_changed.is_empty() + { + all.round_trip_3_only(s); + continue; + } + one.report(""); + panic!("{name}: round trip 3 is not clean"); + } + all.report(""); + assert!( + all.rt3_compared > 4000, + "only {} values compared", + all.rt3_compared + ); +} + +// --- the key enumeration ------------------------------------------------------- + +/// Every LEARN and PHOTO key Lightroom has in its Lua tables, with its +/// values: written losslessly, each value comes back exactly, the rest of +/// the model is unchanged, and writing again gives the same table. +#[test] +fn every_learn_and_photo_key_survives_a_lossless_lua_write() { + let mut checked = 0; + let mut not_placed: Vec = Vec::new(); + for (id, spec) in registry::iter() { + if !learn_or_photo(spec) || !matches!(spec.presence, Presence::Both | Presence::LuaOnly) { + continue; + } + let values = samples(spec, true); + if values.is_empty() { + not_placed.push(format!("{}/{}", spec.level, spec.name)); + continue; + } + for v in &values { + let what = format!("{}/{} = {v:?}", spec.level, spec.name); + let Some((s, path)) = place(id, v) else { + panic!("{what}: no place for this key; extend place()"); + }; + let (table, back, warnings) = round_trip(&s); + let warnings: Vec<_> = warnings + .iter() + .filter(|w| w.kind.name() != "UnrecognisedMaskCombine") + .collect(); + assert!(warnings.is_empty(), "{what}: {warnings:?}\n{table}"); + let flat_back = flat::settings(&back); + assert_eq!(flat_back.get(&path), Some(&leaf(id, v)), "{what}\n{table}"); + let mut changed = flat::diff(&flat::settings(&s), &flat_back); + changed.retain(|p| p != "(file kind)"); + assert!(changed.is_empty(), "{what}: {changed:?}\n{table}"); + let (again, _, _) = round_trip(&back); + assert_eq!(again, table, "{what}: not idempotent"); + checked += 1; + } + } + eprintln!("lossless Lua: {checked} values checked"); + not_placed.sort(); + let mut expected: Vec = [ + "global/MaskGroupBasedCorrections", + "global/LensBlur", + "global/Look", + "correction/CorrectionMasks", + "mask-tool/CorrectionRangeMask", + "mask-tool/Gesture", + "struct:CorrectionRangeMask/AreaModels", + "struct:Gesture/Points", + ] + .map(String::from) + .to_vec(); + expected.sort(); + assert_eq!(not_placed, expected); + assert!(checked > 600, "only {checked} values checked"); +} + +/// A correction that reaches a photo: one subject mask, added. +fn subject_correction(local: BTreeMap) -> Correction { + let subject = MaskComponent { + tool: MaskTool::Semantic(Semantic::Subject), + combine: Combine::Add { inverted: false }, + name: None, + sync_id: None, + active: Some(true), + extra: Fields::default(), + }; + correction(local, vec![subject]) +} + +/// `v` of `id` where the apply-mode check places it, and how to read it +/// back; `None` for mask-tool, range-mask, gesture and `Look` name fields, +/// which `every_committed_fixture_applies_cleanly_under_every_option_flip` +/// covers through the fixtures that hold them. +fn place_for_apply(id: KeyId, v: &Value) -> Option<(DevelopSettings, String)> { + let spec = id.spec(); + let mut s = DevelopSettings::new(); + let path = match spec.level { + Level::Global => { + s.insert(id, v.clone()).ok()?; + spec.name.to_owned() + } + Level::Correction if spec.name == "CorrectionAmount" => { + let mut c = subject_correction( + [( + kid(Level::Correction, "LocalExposure2012"), + Value::Real(lrg_develop::Finite::new(0.1).unwrap()), + )] + .into(), + ); + c.amount = v.as_finite(); + s.corrections = vec![c]; + "MaskGroupBasedCorrections[0].CorrectionAmount".to_owned() + } + Level::Correction if !matches!(spec.kind, ValueKind::ComponentSeq) => { + s.corrections = vec![subject_correction([(id, v.clone())].into())]; + format!("MaskGroupBasedCorrections[0].{}", spec.name) + } + Level::Struct(StructKind::LensBlur) => { + let lens_blur = one(StructKind::LensBlur, id, v.clone()); + s.insert(kid(Level::Global, "LensBlur"), Value::Struct(lens_blur)) + .unwrap(); + format!("LensBlur.{}", spec.name) + } + Level::Struct(StructKind::Look) if spec.name == "Amount" => { + s.look = Some(Look { + name: Some("Adobe Color".into()), + uuid: Hex32::parse("B952C231111CD8E0ECCF14B86BAA7077"), + amount: v.as_finite(), + camera_restriction: None, + rest: Struct::new(StructKind::Look), + }); + "Look.Amount".to_owned() + } + _ => return None, + }; + Some((s, path)) +} + +/// Whether `path` or one of its ancestors was reported. +fn reported(w: &LuaWritten, path: &str) -> bool { + let skipped: Vec = w.skipped.iter().map(|k| k.path.clone()).collect(); + lua_apply::reported(&skipped, path) +} + +/// In apply mode, for a raw and a non-raw photo: every LEARN and PHOTO +/// value the enumeration places (global, correction, `LensBlur`, +/// `Look.Amount`; see [`place_for_apply`]) reaches the photo exactly (read +/// back from the table), or is reported, or is its key's default. What is +/// held back is pinned per key and photo kind, so a value that starts going +/// missing fails here. +#[test] +fn every_learn_and_photo_value_reaches_the_photo_or_is_reported() { + let mut written = 0; + let mut held: BTreeMap = BTreeMap::new(); + for (id, spec) in registry::iter() { + if !learn_or_photo(spec) || !matches!(spec.presence, Presence::Both | Presence::LuaOnly) { + continue; + } + for v in samples(spec, true) { + let Some((s, path)) = place_for_apply(id, &v) else { + continue; + }; + for kind in [FileKind::Raw, FileKind::NonRaw] { + let what = format!("{}/{} = {v:?} ({kind:?})", spec.level, spec.name); + let photo = PhotoContext { + file_kind: Some(kind), + process_version: Some(ProcessVersion::V6), + camera: None, + }; + let w = to_lua_value(&s, &LuaMode::Apply(photo), &LuaOptions::default()) + .unwrap_or_else(|e| panic!("{what}: {e}")); + check_wire(&w.table).unwrap(); + let (back, warnings) = from_lua_value(&w.table, FileKindHint::Unknown).unwrap(); + assert!(warnings.is_empty(), "{what}: {warnings:?}\n{}", w.table); + let got = flat::settings(&back); + match got.get(&path) { + Some(g) if *g == leaf(id, &v) => written += 1, + // Held back, or replaced by the writer's own form (a + // reported correction value under the adaptive-preset + // zero): reported, or the key's default. + _ => { + let default = [spec.default.raw, spec.default.non_raw] + .iter() + .any(|d| matches!(d, Def::Value(lit) if v.equals_lit(lit))); + assert!( + reported(&w, &path) || default, + "{what}: neither written nor reported: {:?}\n{}", + w.skipped, + w.table + ); + *held + .entry(format!("{}/{} {kind:?}", spec.level, spec.name)) + .or_default() += 1; + } + } + } + } + } + eprintln!("apply: {written} values written; held back {held:?}"); + assert!(written > 900, "only {written} values written"); + // Pinned: the white-balance family by file kind, a mode without + // numbers (`wb_mode_only` off), `LensProfileSetup` outside its listed + // values, and two PHOTO keys the never-write guard keeps out of every + // table (a digest, and `orientation`). + let expected: BTreeMap = [ + ("global/IncrementalTemperature Raw", 4), + ("global/IncrementalTint Raw", 4), + ("global/LensProfileDigest NonRaw", 1), + ("global/LensProfileDigest Raw", 1), + ("global/LensProfileSetup NonRaw", 1), + ("global/LensProfileSetup Raw", 1), + ("global/orientation NonRaw", 1), + ("global/orientation Raw", 1), + ("global/Temperature NonRaw", 3), + ("global/Tint NonRaw", 4), + ("global/WhiteBalance NonRaw", 9), + ("global/WhiteBalance Raw", 9), + ] + .into_iter() + .map(|(k, n)| (k.to_owned(), n)) + .collect(); + assert_eq!(held, expected); +} + +// --- apply mode on the committed fixtures -------------------------------------- + +/// Every committed Lua fixture (read as Lightroom wrote it) and XMP fixture +/// (its develop settings) written for its own photo, with the provisional +/// options and with every single option flip, in apply and preset mode: +/// wire-safe, read back without a warning, every filtered value written +/// exactly or reported, nothing added beyond the writer's fixed form. This +/// covers the mask-tool, range-mask and gesture values the enumeration does +/// not place, and proves the Rust side of "flip a field, re-bless". +#[test] +fn every_committed_fixture_applies_cleanly_under_every_option_flip() { + let mut sources: Vec<(String, DevelopSettings)> = Vec::new(); + for (name, bytes) in files("lua", "json") { + let text = std::str::from_utf8(&bytes).unwrap(); + let (s, _) = from_lua_str(text, FileKindHint::Unknown).unwrap(); + sources.push((format!("lua/{name}"), s)); + } + for (name, bytes) in files("xmp", "xmp") { + let doc = parse(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); + // Camera profiles carry a profile's tables, not develop settings + // (as in the local sweeps). + if doc.kind == lrg_develop::xmp::XmpKind::Profile { + continue; + } + sources.push((format!("xmp/{name}"), doc.develop)); + } + let mut options = vec![("provisional".to_owned(), LuaOptions::PROVISIONAL)]; + options.extend(flips(LuaOptions::PROVISIONAL)); + let mut all = LuaApply::default(); + for (how, opt) in &options { + for (name, s) in &sources { + let photo = PhotoContext { + file_kind: s.file_kind, + process_version: s.process_version(), + camera: None, + }; + for mode in [ + LuaMode::Apply(photo.clone()), + LuaMode::Preset(photo.clone()), + ] { + // Per source, so a failure names it (the fixtures are public). + let mut one = LuaApply::default(); + one.settings_for(s, &mode, opt); + if !one.is_clean() { + one.report(" "); + panic!("{name} ({how}, {mode:?}): not clean"); + } + all.settings_for(s, &mode, opt); + } + } + } + all.report(""); + all.assert_clean("committed fixtures"); + eprintln!( + "{} sources x {} option sets x 2 modes", + sources.len(), + options.len() + ); + assert!(sources.len() >= 40, "only {} sources", sources.len()); + assert!( + all.compared > 50_000, + "only {} values compared", + all.compared + ); +} diff --git a/server-rs/crates/lrg-develop/tests/lua_wire_goldens.rs b/server-rs/crates/lrg-develop/tests/lua_wire_goldens.rs new file mode 100644 index 00000000..2e9bbc4a --- /dev/null +++ b/server-rs/crates/lrg-develop/tests/lua_wire_goldens.rs @@ -0,0 +1,881 @@ +//! The Lua writer's wire goldens: `server-rs/testdata/develop/wire/*.json`. +//! +//! Each golden is the table `lua::to_lua_value` writes in apply mode for one +//! shape, with the provisional options ([`LuaOptions::PROVISIONAL`]): exactly +//! what the plugin decodes with JSON.lua and hands to +//! `photo:applyDevelopSettings()`. The plugin spec +//! `plugin/spec/native_wire_format_spec.lua` reads the same files. +//! +//! **Not frozen**: they change when experiments E1, E2, E4 and E11 settle a +//! provisional option. After an intended change to the writer or to +//! `LuaOptions::PROVISIONAL`, regenerate them and review the diff: +//! +//! ```text +//! LRG_BLESS=1 cargo test -p lrg-develop --test lua_wire_goldens +//! ``` +//! +//! Source models are committed scrubbed fixtures (`testdata/develop/lua/`) +//! or built here (builders, hand-written tables); built sync ids are replaced +//! by synthetic ones (twenty zeros and twelve hex digits), as in the XMP +//! writer goldens, so the files pass the fixture-hygiene checks. +//! +//! Besides the bytes, every case pins what was skipped, how to apply it +//! ([`ApplyCall`]), that the table passes [`check_wire`], and that the +//! golden file reads back without a warning into exactly the values that +//! were written: every filtered value is in it or reported, and it holds +//! nothing beyond those and the writer's fixed form +//! (`support/lua_apply.rs`, the check the sweeps run). +//! +//! Also here, because the plugin side reads them: +//! +//! - every case under every single option flip +//! (`every_golden_case_is_clean_under_every_option_flip`): flipping a +//! `LuaOptions` field and re-blessing needs no test or reader change; +//! - `wire/key_classes.txt`, the registry's key classes for the plugin +//! spec, generated here like the goldens; +//! - the adaptive-preset `Local*` list against the experiments' own +//! (`DevelopExperiments.lua`). + +#[allow(dead_code)] // `header` is for the XMP tests +#[path = "support/flat.rs"] +mod flat; +#[allow(dead_code)] // the counted sweep entry point is for the local sweeps +#[path = "support/lua_apply.rs"] +mod lua_apply; +#[allow(dead_code)] // only `same` is used (by `lua_apply`) +#[path = "support/preset.rs"] +mod preset; +#[allow(dead_code)] // only `generic_path` is used (by `lua_apply`) +mod support; + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; + +use lrg_develop::build::{ + corrections, CorrectionBuilder, LuminanceRange, PeoplePart, SensorPoint, SyncNamespace, +}; +use lrg_develop::lua::{ + check_wire, from_lua_str, from_lua_value, lua_never_written, to_lua_value, ApplyCall, LookForm, + LuaMode, LuaOptions, PhotoContext, ADAPTIVE_PRESET_LOCALS, +}; +use lrg_develop::model::{ + DevelopSettings, FileKind, Hex32, LinearGradient, RadialGradient, Semantic, +}; +use lrg_develop::registry::{self, CurveKind, Level, NumFmt, ProcessVersion, ValueKind}; +use lrg_develop::FileKindHint; +use lua_apply::{flips, LuaApply}; +use serde_json::{json, Map, Value as J}; + +fn dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../testdata/develop/wire") +} + +fn fixture(name: &str) -> DevelopSettings { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../testdata/develop/lua") + .join(name); + let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{name}: {e}")); + let (s, w) = from_lua_str(&text, FileKindHint::Unknown).unwrap(); + assert!(w.is_empty(), "{name}: {w:?}"); + s +} + +fn table(v: J, hint: FileKindHint) -> DevelopSettings { + let (s, w) = from_lua_value(&v, hint).unwrap(); + assert!(w.is_empty(), "{w:?}"); + s +} + +fn synthetic(n: u64) -> Hex32 { + Hex32::parse(&format!("{:020}{n:012X}", 0)).unwrap() +} + +/// Replaces the derived (SHA-256) sync ids with synthetic ones. +fn with_synthetic_ids(mut s: DevelopSettings, first: u64) -> DevelopSettings { + let mut n = first; + for c in &mut s.corrections { + n += 1; + c.sync_id = Some(synthetic(n)); + for m in &mut c.masks { + n += 1; + m.sync_id = Some(synthetic(n)); + } + } + s +} + +struct Case { + golden: &'static str, + settings: DevelopSettings, + photo: PhotoContext, + options: LuaOptions, + /// The paths `LuaWritten::skipped` must name, in order. + skipped: Vec, + call: ApplyCall, +} + +fn raw() -> PhotoContext { + PhotoContext { + file_kind: Some(FileKind::Raw), + process_version: Some(ProcessVersion::V6), + camera: None, + } +} + +fn non_raw() -> PhotoContext { + PhotoContext { + file_kind: Some(FileKind::NonRaw), + ..raw() + } +} + +fn case(golden: &'static str, settings: DevelopSettings, photo: PhotoContext) -> Case { + Case { + golden, + settings, + photo, + options: LuaOptions::PROVISIONAL, + skipped: Vec::new(), + call: ApplyCall::default(), + } +} + +const AI: ApplyCall = ApplyCall { + update_ai_settings: true, + flatten_auto_now: false, +}; + +fn cases() -> Vec { + let ns = SyncNamespace::lrgenius(); + let mut out = Vec::new(); + + // Basic global sliders. `ProcessVersion` is the example's and is never + // written; the flags go as 0/1. + out.push(case( + "global_basic.json", + table( + json!({ + "ProcessVersion": "15.4", + "CameraProfile": "Adobe Standard", + "Exposure2012": 0.35, "Contrast2012": 12, "Highlights2012": -40, + "Shadows2012": 30, "Whites2012": 10, "Blacks2012": -8, + "Texture": 10, "Clarity2012": 15, "Dehaze": 5, + "Vibrance": 20, "Saturation": -5, + "Sharpness": 40, "SharpenRadius": 1, "SharpenDetail": 25, + "LuminanceSmoothing": 10, + "PostCropVignetteAmount": -10, "GrainAmount": 15, + "LensProfileEnable": 1, "AutoLateralCA": 1, + "ConvertToGrayscale": false + }), + FileKindHint::Raw, + ), + raw(), + )); + + // White balance of a raw file: the Kelvin family with Custom. + out.push(case( + "wb_raw_custom.json", + table( + json!({"WhiteBalance": "Custom", "Temperature": 5600, "Tint": 6}), + FileKindHint::Raw, + ), + raw(), + )); + + // White balance of a non-raw file: the incremental family; no mode in + // the source, so the writer stamps Custom. + out.push(case( + "wb_non_raw_incremental.json", + table( + json!({"IncrementalTemperature": 12, "IncrementalTint": -4}), + FileKindHint::NonRaw, + ), + non_raw(), + )); + + // Global tone curves (flat number lists) and the parametric curve. + out.push(case( + "tone_curves.json", + table( + json!({ + "ToneCurvePV2012": [0, 0, 64, 52, 128, 135, 192, 210, 255, 255], + "ToneCurvePV2012Red": [0, 0, 128, 132, 255, 255], + "ToneCurvePV2012Green": [0, 0, 255, 255], + "ToneCurvePV2012Blue": [0, 8, 128, 124, 255, 245], + "ParametricShadows": -10, "ParametricDarks": 5, + "ParametricLights": 8, "ParametricHighlights": -12, + "ParametricShadowSplit": 25, "ParametricMidtoneSplit": 50, + "ParametricHighlightSplit": 75 + }), + FileKindHint::Raw, + ), + raw(), + )); + + // HSL, colour grading and the split-toning keys it shares. + out.push(case( + "hsl_color_grading.json", + table( + json!({ + "HueAdjustmentRed": 5, "HueAdjustmentOrange": -8, "HueAdjustmentBlue": 12, + "SaturationAdjustmentOrange": -10, "SaturationAdjustmentGreen": -25, + "SaturationAdjustmentBlue": 15, + "LuminanceAdjustmentOrange": 10, "LuminanceAdjustmentBlue": -20, + "SplitToningShadowHue": 215, "SplitToningShadowSaturation": 12, + "SplitToningHighlightHue": 45, "SplitToningHighlightSaturation": 10, + "SplitToningBalance": 10, + "ColorGradeMidtoneHue": 30, "ColorGradeMidtoneSat": 6, + "ColorGradeShadowLum": -5, "ColorGradeHighlightLum": 4, + "ColorGradeBlending": 60, "ColorGradeGlobalHue": 0, "ColorGradeGlobalSat": 0 + }), + FileKindHint::Raw, + ), + raw(), + )); + + // A correction set with semantic masks: subject and sky. + let mut s = DevelopSettings::new(); + s.corrections = corrections([ + CorrectionBuilder::new("Subject", &ns) + .local_ui("LocalExposure2012", 0.3) + .unwrap() + .local_ui("LocalClarity2012", 10.0) + .unwrap() + .add(Semantic::Subject), + CorrectionBuilder::new("Sky", &ns) + .local_ui("LocalHighlights2012", -40.0) + .unwrap() + .local_ui("LocalDehaze", 15.0) + .unwrap() + .add(Semantic::Sky), + ]) + .unwrap(); + out.push(Case { + call: AI, + ..case( + "masks_subject_sky.json", + with_synthetic_ids(s, 0xD00), + raw(), + ) + }); + + // Linear and radial gradients (PHOTO: for the photo they were made for). + let mut s = DevelopSettings::new(); + s.corrections = corrections([ + CorrectionBuilder::new("Darker sky", &ns) + .local_ui("LocalExposure2012", -0.5) + .unwrap() + .add( + LinearGradient::new( + SensorPoint::new(0.5, 0.55).unwrap(), + SensorPoint::new(0.5, 0.15).unwrap(), + ) + .unwrap(), + ), + CorrectionBuilder::new("Spotlight", &ns) + .local_ui("LocalExposure2012", 0.25) + .unwrap() + .add(RadialGradient::new(0.25, 0.3, 0.75, 0.7, 60).unwrap()), + ]) + .unwrap(); + out.push(case( + "masks_linear_radial.json", + with_synthetic_ids(s, 0xE00), + raw(), + )); + + // A luminance range intersected with a semantic mask, and a subtract. + let mut s = DevelopSettings::new(); + s.corrections = corrections([CorrectionBuilder::new("Bright sky", &ns) + .local_ui("LocalHighlights2012", -30.0) + .unwrap() + .add(Semantic::Sky) + .subtract(Semantic::Subject) + .intersect(LuminanceRange::highs(0.5, 0.2).unwrap())]) + .unwrap(); + out.push(Case { + call: AI, + ..case( + "mask_luminance_intersect.json", + with_synthetic_ids(s, 0xF00), + raw(), + ) + }); + + // A people part in preset form (every person). + let mut s = DevelopSettings::new(); + s.corrections = corrections([CorrectionBuilder::new("Eyes", &ns) + .local_ui("LocalSaturation", 30.0) + .unwrap() + .add(Semantic::people_part(PeoplePart::IrisAndPupil))]) + .unwrap(); + out.push(Case { + call: AI, + ..case( + "mask_people_part.json", + with_synthetic_ids(s, 0x1000), + raw(), + ) + }); + + // Local point curves on a correction: lists of "x,y" strings (rule 5), + // next to the subject mask they apply through. + out.push(Case { + call: AI, + ..case( + "mask_local_curves.json", + table( + json!({ + "MaskGroupBasedCorrections": [{ + "What": "Correction", "CorrectionAmount": 1, "CorrectionActive": true, + "CorrectionName": "Curves", + "CorrectionSyncID": synthetic(0x1101).as_str(), + "LocalCurveRefineSaturation": 90, + "MainCurve": ["0,0", "64,56", "128,136", "255,255"], + "BlueCurve": ["0,8", "255,248"], + "CorrectionMasks": [{ + "What": "Mask/Image", "MaskActive": true, "MaskName": "Subject", + "MaskBlendMode": 0, "MaskInverted": false, "MaskValue": 1, + "MaskSubType": 1, "MaskSyncID": synthetic(0x1102).as_str() + }] + }] + }), + FileKindHint::Raw, + ), + raw(), + ) + }); + + // The Look of a committed fixture, as a stub (no longer the default) and whole. + let mut look = DevelopSettings::new(); + look.look = fixture("mask_range_color_area.json").look; + assert!(look.look.is_some()); + out.push(Case { + options: LuaOptions { + look_form: LookForm::Stub, + ..LuaOptions::PROVISIONAL + }, + ..case("look_stub.json", look.clone(), raw()) + }); + out.push(Case { + options: LuaOptions { + look_form: LookForm::Full, + ..LuaOptions::PROVISIONAL + }, + ..case("look_full.json", look, raw()) + }); + + // No masks: the source's only correction is a brush, which never + // reaches a photo, so `MaskGroupBasedCorrections` is absent (never `[]`). + out.push(Case { + skipped: vec!["MaskGroupBasedCorrections[0]".into()], + ..case( + "no_masks.json", + table( + json!({ + "Exposure2012": 0.5, + "MaskGroupBasedCorrections": [{ + "What": "Correction", "CorrectionAmount": 1, "CorrectionActive": true, + "LocalExposure2012": 0.1, + "CorrectionMasks": [{"What": "Mask/Aggregate", "MaskActive": true, + "MaskBlendMode": 0, "MaskValue": 1, "MaskInverted": false}] + }] + }), + FileKindHint::Raw, + ), + raw(), + ) + }); + + // A real read-back (a committed, scrubbed fixture) applied to its own + // photo: digests, runtime ids and computed mask fields are gone (and + // reported, as every non-default value the filter removes), the legacy + // PV2010 sliders and Upright's computed state too, the As Shot white + // balance is not pinned, the sky mask stays. + const M: &str = "MaskGroupBasedCorrections[0].CorrectionMasks[0]"; + out.push(Case { + skipped: [ + "Exposure", + "Contrast", + "Brightness", + "Shadows", + "FillLight", + "HighlightRecovery", + "Clarity", + "Defringe", + "ChromaticAberrationR", + "ChromaticAberrationB", + "UprightVersion", + "UprightTransformCount", + "UprightCenterMode", + "UprightCenterNormX", + "UprightCenterNormY", + "UprightFocalMode", + "UprightFocalLength35mm", + "UprightPreview", + "UprightFourSegmentsCount", + "GrainSeed", + ] + .into_iter() + .map(String::from) + .chain( + [ + "MaskID", + "MaskDigest", + "InputDigest", + "InputDigestVersion", + "LocalInputDigest", + "LocalInputDigestVersion", + "ModelVersion", + "WholeImageArea", + "Origin", + "FullMaskSize", + ] + .into_iter() + .map(|k| format!("{M}.{k}")), + ) + .chain( + [ + "CorrectionID", + "CorrectionReferenceX", + "CorrectionReferenceY", + ] + .into_iter() + .map(|k| format!("MaskGroupBasedCorrections[0].{k}")), + ) + .chain( + ["Temperature", "Tint", "LensProfileDigest", "orientation"] + .into_iter() + .map(String::from), + ) + .collect(), + call: AI, + ..case( + "fixture_mask_ai_sky.json", + fixture("mask_ai_sky.json"), + raw(), + ) + }); + + // More committed, scrubbed readbacks applied to their own photo, for + // shapes apply mode writes that the cases above do not hold. Each loses + // what the sky fixture loses: the legacy sliders and Upright's state, + // runtime ids, and (where present) digests and computed mask fields. + let c = |i: usize| -> Vec { + [ + "CorrectionID", + "CorrectionReferenceX", + "CorrectionReferenceY", + ] + .iter() + .map(|k| format!("MaskGroupBasedCorrections[{i}].{k}")) + .collect() + }; + let m = |i: usize, j: usize, keys: &[&str]| -> Vec { + keys.iter() + .map(|k| format!("MaskGroupBasedCorrections[{i}].CorrectionMasks[{j}].{k}")) + .collect() + }; + let strings = |keys: &[&str]| -> Vec { keys.iter().map(|k| (*k).to_owned()).collect() }; + const COMPUTED_AI: &[&str] = &[ + "MaskDigest", + "InputDigest", + "InputDigestVersion", + "ModelVersion", + "WholeImageArea", + "Origin", + "FullMaskSize", + ]; + let with_override = |i: usize| -> Vec { + let mut keys = vec!["MaskID"]; + keys.extend(COMPUTED_AI); + keys.push("DidOverrideInputDigestMismatch"); + [m(i, 0, &keys), c(i)].concat() + }; + let object = |i: usize| -> Vec { + let mut keys = vec!["MaskID", "Gesture[0].MaskID"]; + keys.extend(COMPUTED_AI); + [m(i, 0, &keys), m(i, 1, &["MaskID"]), c(i)].concat() + }; + let plain = |i: usize| -> Vec { [m(i, 0, &["MaskID"]), c(i)].concat() }; + let legacy = || strings(LEGACY_AND_UPRIGHT); + + // A colour range: `PointModels` (a list of compound strings), + // `ColorAmount`, `CorrectionRangeMask.Type` 1. + out.push(Case { + skipped: [ + legacy(), + plain(0), + strings(&["Temperature", "Tint", "LensProfileDigest", "orientation"]), + ] + .concat(), + ..case( + "fixture_mask_range_color.json", + fixture("mask_range_color.json"), + raw(), + ) + }); + // AI masks next to an area colour range (`AreaModels`, + // `ColorRangeMaskAreaSampleInfo`); the range's `SampleType` (not 0 + // here) has an UNKNOWN policy and is reported. + out.push(Case { + skipped: [ + legacy(), + with_override(0), + with_override(1), + with_override(2), + m(3, 0, &["MaskID", "CorrectionRangeMask.SampleType"]), + c(3), + strings(&["Temperature", "Tint", "orientation"]), + ] + .concat(), + call: AI, + ..case( + "fixture_mask_range_color_area.json", + fixture("mask_range_color_area.json"), + raw(), + ) + }); + // Select Object: the polygon `Gesture`/`Points`, an untyped AI mask + // (`What = "Mask/Image"` without a subtype), and an adaptive profile's + // `AILook` state, which is never written. + out.push(Case { + skipped: [ + legacy(), + strings(&["AILook"]), + plain(0), + plain(1), + object(2), + object(3), + plain(4), + plain(5), + strings(&["Temperature", "Tint", "LensProfileDigest", "orientation"]), + ] + .concat(), + call: AI, + ..case( + "fixture_mask_select_object_polygon.json", + fixture("mask_select_object_polygon.json"), + raw(), + ) + }); + // The `LensBlur` struct, whose gate asks for the AI update (its sampled + // focal state belongs to the source photo). + out.push(Case { + skipped: [ + legacy(), + strings(&[ + "LensBlur.FocalRange", + "LensBlur.FocalRangeSource", + "LensBlur.SampledArea", + "LensBlur.SampledRange", + "LensBlur.SubjectRange", + "LensBlur.ImageOrientation", + ]), + plain(0), + plain(1), + strings(&["LensProfileDigest", "orientation"]), + ] + .concat(), + call: AI, + ..case( + "fixture_lensblur_object.json", + fixture("lensblur_object.json"), + raw(), + ) + }); + out +} + +/// What every committed readback fixture loses when applied: the legacy +/// PV2010 sliders and Upright's computed state. +const LEGACY_AND_UPRIGHT: &[&str] = &[ + "Exposure", + "Contrast", + "Brightness", + "Shadows", + "FillLight", + "HighlightRecovery", + "Clarity", + "Defringe", + "ChromaticAberrationR", + "ChromaticAberrationB", + "UprightVersion", + "UprightTransformCount", + "UprightCenterMode", + "UprightCenterNormX", + "UprightCenterNormY", + "UprightFocalMode", + "UprightFocalLength35mm", + "UprightPreview", + "UprightFourSegmentsCount", +]; + +/// `j` with every object's keys sorted, whatever map order `serde_json` was +/// built with. +fn canonical(j: &J) -> J { + match j { + J::Object(m) => { + let sorted: BTreeMap<&String, J> = m.iter().map(|(k, v)| (k, canonical(v))).collect(); + let mut out = Map::new(); + for (k, v) in sorted { + out.insert(k.clone(), v); + } + J::Object(out) + } + J::Array(a) => J::Array(a.iter().map(canonical).collect()), + v => v.clone(), + } +} + +fn text(j: &J) -> String { + serde_json::to_string_pretty(&canonical(j)).unwrap() + "\n" +} + +#[test] +fn the_wire_goldens_match_the_writer() { + let bless = std::env::var_os("LRG_BLESS").is_some_and(|v| v == "1"); + let cases = cases(); + for c in &cases { + let w = to_lua_value(&c.settings, &LuaMode::Apply(c.photo.clone()), &c.options) + .unwrap_or_else(|e| panic!("{}: {e}", c.golden)); + check_wire(&w.table).unwrap_or_else(|e| panic!("{}: {e}", c.golden)); + assert!(!w.is_empty(), "{}: an empty table", c.golden); + let skipped: Vec = w.skipped.iter().map(|k| k.path.clone()).collect(); + assert_eq!(skipped, c.skipped, "{}", c.golden); + assert_eq!(w.call, c.call, "{}", c.golden); + let fresh = text(&w.table); + let path = dir().join(c.golden); + if bless { + std::fs::create_dir_all(dir()).unwrap(); + std::fs::write(&path, &fresh).unwrap(); + continue; + } + let golden = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("{}: {e} (bless with LRG_BLESS=1)", c.golden)); + // A Windows checkout may have turned LF into CRLF; `.gitattributes` + // keeps this folder LF, but compare content either way. + assert_eq!( + golden.replace("\r\n", "\n"), + fresh, + "{}: the writer's output changed; if intended, bless with LRG_BLESS=1", + c.golden + ); + } + // No stale golden: every file in wire/ is a case. + let mut on_disk: Vec = std::fs::read_dir(dir()) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .filter(|n| n.ends_with(".json")) + .collect(); + on_disk.sort(); + let mut expected: Vec = cases.iter().map(|c| c.golden.to_owned()).collect(); + expected.sort(); + assert_eq!(on_disk, expected, "wire/ holds a file no case writes"); +} + +/// Each golden file reads back without a warning into exactly what was +/// written: every value of the filtered settings is in it (or named, or an +/// ancestor named, in the case's `skipped`), and it holds nothing beyond +/// those and the writer's fixed form under the case's options. +#[test] +fn the_wire_goldens_read_back_into_what_was_written() { + // A blessing run writes the files in the other test, concurrently; this + // one checks them on the next, ordinary run. + if std::env::var_os("LRG_BLESS").is_some_and(|v| v == "1") { + return; + } + let mut all = LuaApply::default(); + for c in cases() { + let path = dir().join(c.golden); + let Ok(text) = std::fs::read_to_string(&path) else { + panic!("{}: missing (bless with LRG_BLESS=1)", c.golden); + }; + let table: J = serde_json::from_str(&text).unwrap(); + let mut one = LuaApply::default(); + one.check(&c.settings, &c.photo, false, &c.options, &table, &c.skipped); + if !one.is_clean() { + one.report(" "); + panic!("{}: does not read back into what was written", c.golden); + } + all.check(&c.settings, &c.photo, false, &c.options, &table, &c.skipped); + } + all.report(""); + assert!(all.compared > 300, "only {} values compared", all.compared); +} + +/// Every case with each `LuaOptions` field flipped to each of its other +/// values in turn, in apply mode and as a plugin preset: a wire-safe table +/// that reads back clean both ways. So when an experiment flips a field in +/// `LuaOptions::PROVISIONAL`, re-blessing is all the Rust side needs. +#[test] +fn every_golden_case_is_clean_under_every_option_flip() { + let mut n = 0; + for c in cases() { + for (how, opt) in flips(c.options) { + for mode in [ + LuaMode::Apply(c.photo.clone()), + LuaMode::Preset(c.photo.clone()), + ] { + let mut one = LuaApply::default(); + let w = one + .settings_for(&c.settings, &mode, &opt) + .unwrap_or_else(|| panic!("{} ({how}): write error", c.golden)); + if !one.is_clean() { + one.report(" "); + panic!("{} ({how}, {mode:?}): not clean", c.golden); + } + assert!(!w.is_empty(), "{} ({how})", c.golden); + n += 1; + } + } + } + eprintln!("{n} flipped tables checked"); + assert!(n > 300, "only {n} tables"); +} + +// --- the plugin side's inputs ---------------------------------------------- + +/// The `Local*` keys of Adobe's adaptive-preset corrections: the writer's +/// [`ADAPTIVE_PRESET_LOCALS`] is the list the experiments apply +/// (`LOCAL_KEYS` in `DevelopExperiments.lua`), in the same order, so a +/// passing E2/E4 vouches for the form `LocalForm::AdaptivePreset` writes. +#[test] +fn the_adaptive_preset_locals_are_the_experiments_own() { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../../plugin/LrGeniusAI.lrdevplugin/DevelopExperiments.lua"); + let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{path:?}: {e}")); + let start = text + .find("local LOCAL_KEYS = {") + .expect("DevelopExperiments.lua has no LOCAL_KEYS"); + let body = &text[start..]; + let body = &body[..body.find('}').expect("LOCAL_KEYS is not closed")]; + let theirs: Vec<&str> = body.split('"').skip(1).step_by(2).collect(); + assert_eq!(theirs, ADAPTIVE_PRESET_LOCALS); + for k in ADAPTIVE_PRESET_LOCALS { + assert!( + registry::lookup(Level::Correction, k).is_some(), + "{k} is not a correction key" + ); + } +} + +/// The registry's classes of every key a wire table can hold, by name, for +/// `plugin/spec/native_wire_format_spec.lua` (which checks every value's +/// Lua type against them). A name at several levels gets every class it +/// has; `never` alone marks a name the writer never writes at any level. +fn key_classes() -> String { + let mut classes: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new(); + let mut written: BTreeSet<&str> = BTreeSet::new(); + let mut names: BTreeSet<&str> = BTreeSet::new(); + for (_, spec) in registry::iter() { + // The preset header never reaches a Lua table. + if spec.level == Level::Header { + continue; + } + names.insert(spec.name); + // The panel switches are written on request (`panel_switches`). + let switch = spec.level == Level::Global && spec.name.starts_with("Enable"); + if lua_never_written(spec) && !switch { + continue; + } + written.insert(spec.name); + let compound = spec.fmt == NumFmt::CompoundFixed6; + let class = if spec.is_mask_enum() { + "maskenum" + } else { + match spec.kind.lua_form() { + ValueKind::Int | ValueKind::EnumInt(_) | ValueKind::VersionU32 => "integer", + ValueKind::Real => "number", + ValueKind::IntFlag => "flag", + ValueKind::Bool(_) => "boolean", + ValueKind::Enum(_) | ValueKind::Str | ValueKind::VersionStr | ValueKind::Hex32 => { + if compound { + "compound" + } else { + "string" + } + } + ValueKind::StrSeq => { + if compound { + "compounds" + } else { + "strings" + } + } + ValueKind::Curve(CurveKind::Global) => "globalcurve", + ValueKind::Curve(CurveKind::Local) => "localcurve", + ValueKind::LangAlt | ValueKind::Struct(_) | ValueKind::Settings => "table", + ValueKind::StructSeq(_) | ValueKind::CorrectionSeq | ValueKind::ComponentSeq => { + "list" + } + ValueKind::Any | ValueKind::PerFormat { .. } => "any", + } + }; + classes.entry(spec.name).or_default().insert(class); + } + // A plugin preset's amount flags (`LuaMode::Preset`): `SupportsAmount` + // is a `Look` field too, `SupportsAmount2` only a preset header key. + for flag in ["SupportsAmount", "SupportsAmount2"] { + names.insert(flag); + written.insert(flag); + classes.entry(flag).or_default().insert("boolean"); + } + let mut out = String::from( + "# Generated by server-rs/crates/lrg-develop/tests/lua_wire_goldens.rs from\n\ + # lrg-develop's key registry; do not edit. Regenerate with\n\ + # LRG_BLESS=1 cargo test -p lrg-develop --test lua_wire_goldens\n\ + # Read by plugin/spec/native_wire_format_spec.lua: one key name per line,\n\ + # then the classes of its values (a name at several levels has several);\n\ + # `never`: the Lua writer never writes it, at any level.\n", + ); + for name in &names { + let line = match classes.get(name) { + Some(c) => c.iter().copied().collect::>().join(" "), + None => { + assert!(!written.contains(name)); + "never".to_owned() + } + }; + out.push_str(&format!("{name} {line}\n")); + } + out +} + +#[test] +fn the_key_classes_match_the_registry() { + let path = dir().join("key_classes.txt"); + let fresh = key_classes(); + if std::env::var_os("LRG_BLESS").is_some_and(|v| v == "1") { + std::fs::write(&path, &fresh).unwrap(); + return; + } + let committed = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("{path:?}: {e} (bless with LRG_BLESS=1)")); + assert_eq!( + committed.replace("\r\n", "\n"), + fresh, + "the registry changed; bless with LRG_BLESS=1 and re-run busted" + ); + // The classes the spec relies on are there. + for (name, class) in [ + ("Exposure2012", "number"), + ("MaskSubType", "maskenum"), + ("ErrorReason", "maskenum"), + ("Type", "maskenum"), + ("LensProfileEnable", "flag"), + ("ReferencePoint", "compound"), + ("PointModels", "compounds"), + ("MainCurve", "localcurve"), + ("ToneCurvePV2012", "globalcurve"), + ("CorrectionID", "never"), + ("AILook", "never"), + ] { + assert!( + fresh + .lines() + .any(|l| l.split(' ').next() == Some(name) && l.split(' ').any(|c| c == class)), + "{name} is not {class}" + ); + } +} diff --git a/server-rs/crates/lrg-develop/tests/no_temp_key.rs b/server-rs/crates/lrg-develop/tests/no_temp_key.rs new file mode 100644 index 00000000..3f966aad --- /dev/null +++ b/server-rs/crates/lrg-develop/tests/no_temp_key.rs @@ -0,0 +1,192 @@ +//! `Temp` is not a Lightroom develop key. White balance is `Temperature`/ +//! `Tint` on raw files and `IncrementalTemperature`/`IncrementalTint` on +//! everything else; code that reads or writes `Temp` silently does nothing +//! (the style engine learned no white balance from any example because of +//! it). This test keeps the name from coming back as a key literal. +//! +//! Scanned: every `.rs` file under `server-rs/crates/*/src` and the plugin's +//! `DevelopEditManager.lua`, comments excluded. Allowed: the develop +//! experiments (`*DevelopExperiments*.lua`), which write `Temp` on purpose to +//! observe what Lightroom does with it, and the one constant the Lua writer +//! refuses it with (`ALLOWED_LINES`). +//! +//! CI runs it twice: with the rest of `cargo test --workspace` in +//! `server-rs-tests.yml` (only on `server-rs/**` changes), and on its own in +//! the unfiltered `format-lint-rust` job of `lint-format.yml`, so a PR that +//! touches only `DevelopEditManager.lua` is checked too. + +use std::path::{Path, PathBuf}; + +use regex::Regex; + +fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../..") + .canonicalize() + .expect("repository root") +} + +fn rust_sources(dir: &Path, out: &mut Vec) { + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + rust_sources(&path, out); + } else if path.extension().is_some_and(|e| e == "rs") { + out.push(path); + } + } +} + +/// Lines allowed to spell the key, by file (relative to the repository +/// root) and trimmed text: the one constant the Lua writer's wire guard +/// refuses the key with. +const ALLOWED_LINES: &[(&str, &str)] = &[( + "server-rs/crates/lrg-develop/src/lua/write.rs", + r#"pub const RETIRED_TEMP_KEY: &str = "Temp";"#, +)]; + +fn is_allowlisted(path: &Path) -> bool { + path.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.contains("DevelopExperiments")) +} + +/// The line without its comment. Naive about comment markers inside string +/// literals, which is the safe direction: at worst it hides the end of a line +/// that contains a string with `//` or `--` in it. +fn code_of<'a>(line: &'a str, marker: &str) -> &'a str { + match line.find(marker) { + Some(i) => &line[..i], + None => line, + } +} + +/// Offending `(line number, line)` pairs of one file. +fn offences(text: &str, lua: bool, pattern: &Regex) -> Vec<(usize, String)> { + let mut found = Vec::new(); + let mut in_block = false; + for (i, line) in text.lines().enumerate() { + let code = if lua { + // Lua block comments: `--[[ ... ]]` (also `--[==[ ... ]==]`). + if in_block { + if line.contains("]]") || line.contains("]=") { + in_block = false; + } + continue; + } + if let Some(start) = line.find("--[") { + let rest = &line[start..]; + if (rest.starts_with("--[[") || rest.starts_with("--[=")) + && !rest.contains("]]") + && !rest.contains("]=") + { + in_block = true; + } + } + code_of(line, "--") + } else { + if in_block { + if line.contains("*/") { + in_block = false; + } + continue; + } + let code = code_of(line, "//"); + match code.find("/*") { + Some(start) => { + if !code[start..].contains("*/") { + in_block = true; + } + &code[..start] + } + None => code, + } + }; + if pattern.is_match(code) { + found.push((i + 1, line.trim().to_string())); + } + } + found +} + +fn temp_key_pattern() -> Regex { + // `"Temp"`, `'Temp'`, `Temp =` (a table field or assignment), `.Temp` + // (field access); `Temperature`, `TempDir` and friends do not match. + Regex::new(r#"["']Temp["']|\bTemp\s*=|\.Temp\b"#).unwrap() +} + +#[test] +fn the_pattern_matches_key_literals_only() { + let p = temp_key_pattern(); + for bad in [ + r#"("Temp", "temperature"),"#, + "\ttemperature = 'Temp',", + "\tTemp = { min = 2000, max = 50000 },", + "if developSettings.Temp then", + r#"if key == "Temp" then"#, + ] { + assert!(p.is_match(bad), "{bad}"); + } + for good in [ + r#""Temperature""#, + "settings.Temperature = 5600", + "IncrementalTemperature = 12,", + "let tmp = TempDir::new();", + "let temp = 3;", + ] { + assert!(!p.is_match(good), "{good}"); + } + assert!(offences("// \"Temp\" in a comment", false, &p).is_empty()); + assert!(offences("x = 1 -- Temp = 2", true, &p).is_empty()); + assert!(offences("--[[\nTemp = 1\n]]\ny = 2", true, &p).is_empty()); + assert_eq!(offences("a = 1\nb.Temp = 2", true, &p).len(), 1); +} + +#[test] +fn no_code_uses_temp_as_a_develop_key() { + let root = repo_root(); + let pattern = temp_key_pattern(); + + let mut files = Vec::new(); + let crates = root.join("server-rs/crates"); + for entry in std::fs::read_dir(&crates).unwrap() { + let src = entry.unwrap().path().join("src"); + if src.is_dir() { + rust_sources(&src, &mut files); + } + } + let lua = root.join("plugin/LrGeniusAI.lrdevplugin/DevelopEditManager.lua"); + assert!(lua.is_file(), "{} is missing", lua.display()); + files.push(lua); + assert!(files.len() > 50, "only {} files found", files.len()); + + let mut report = Vec::new(); + let mut allowed_seen = 0; + for file in files.iter().filter(|f| !is_allowlisted(f)) { + let text = std::fs::read_to_string(file).unwrap(); + let is_lua = file.extension().is_some_and(|e| e == "lua"); + let rel = file.strip_prefix(&root).unwrap_or(file); + // `/`-joined, so `ALLOWED_LINES` matches on Windows too (the release + // job runs this test there; its canonical `\\?\` root yields `\`). + let rel_s = rel + .components() + .map(|c| c.as_os_str().to_string_lossy()) + .collect::>() + .join("/"); + for (line, code) in offences(&text, is_lua, &pattern) { + if ALLOWED_LINES.contains(&(rel_s.as_str(), code.as_str())) { + allowed_seen += 1; + continue; + } + report.push(format!("{}:{line}: {code}", rel.display())); + } + } + // No stale allowance: each allowed line exists, once. + assert_eq!(allowed_seen, ALLOWED_LINES.len(), "{ALLOWED_LINES:?}"); + assert!( + report.is_empty(), + "`Temp` is not a Lightroom develop key; use Temperature/Tint (raw) or \ + IncrementalTemperature/IncrementalTint (non-raw):\n{}", + report.join("\n") + ); +} diff --git a/server-rs/crates/lrg-develop/tests/registry_snapshot.rs b/server-rs/crates/lrg-develop/tests/registry_snapshot.rs new file mode 100644 index 00000000..47d93e08 --- /dev/null +++ b/server-rs/crates/lrg-develop/tests/registry_snapshot.rs @@ -0,0 +1,149 @@ +//! The registry as a reviewable JSON file. +//! +//! `server-rs/testdata/develop/registry_snapshot.json` is generated from the +//! registry and compared on every run, so any change to `table.rs` or +//! `patterns.rs` shows up as a readable diff in review. After an intended +//! change, regenerate it: +//! +//! ```text +//! LRG_BLESS=1 cargo test -p lrg-develop --test registry_snapshot +//! ``` + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use lrg_develop::registry::{self, Def, KeySpec, Lit, PatternSpec, Policy, UiScale, PATTERNS}; +use serde_json::{json, Value}; + +fn snapshot_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../testdata/develop/registry_snapshot.json") +} + +fn default_json(d: Def) -> Value { + match d { + Def::Value(lit) => json!({ "value": match lit { + Lit::Int(i) => json!(i), + Lit::Real(r) => json!(r.get()), + Lit::Bool(b) => json!(b), + Lit::Str(s) => json!(s), + Lit::IntList(v) => json!(v), + }}), + Def::Absent => json!("absent"), + Def::Unverified => json!("unverified"), + Def::NoDefault => json!("no_default"), + } +} + +fn policy_json(p: Policy) -> Value { + match p.gate() { + Some(g) => json!({ "class": p.class_name(), "gate": format!("{g:?}") }), + None => json!({ "class": p.class_name() }), + } +} + +fn row_json(s: &KeySpec) -> Value { + json!({ + "level": s.level.to_string(), + "name": s.name, + "kind": format!("{:?}", s.kind), + "range": s.range.map(|(lo, hi)| json!([lo.get(), hi.get()])), + "ui": match s.ui { + UiScale::Identity => json!("identity"), + UiScale::Div(d) => json!({ "div": d }), + UiScale::Unknown => json!("unknown"), + }, + "fmt": format!("{:?}", s.fmt), + "plus_sign": s.plus_sign, + "default_raw": default_json(s.default.raw), + "default_non_raw": default_json(s.default.non_raw), + "policy": policy_json(s.policy), + "frame": format!("{:?}", s.frame), + "file_kind": format!("{:?}", s.file_kind), + "presence": format!("{:?}", s.presence), + "min_pv": s.min_pv.map(|pv| pv.to_string()), + "recipe_alias": s.recipe_alias, + "note": s.note, + }) +} + +fn pattern_json(p: &PatternSpec) -> Value { + json!({ + "id": p.id, + "levels": p.levels.iter().map(ToString::to_string).collect::>(), + "matcher": format!("{:?}", p.matcher), + "kind": format!("{:?}", p.kind), + "policy": policy_json(p.policy), + "frame": format!("{:?}", p.frame), + "presence": format!("{:?}", p.presence), + "note": p.note, + }) +} + +/// Rebuilds every object with its keys in sorted order. +/// +/// `serde_json` keeps insertion order when its `preserve_order` feature is on +/// and sorts otherwise; the feature is switched on for the whole workspace +/// when a crate that enables it is built alongside (`cargo test --workspace`) +/// and off under `cargo test -p lrg-develop`. Inserting sorted keys gives the +/// same text either way. +fn canonical(v: Value) -> Value { + match v { + Value::Object(map) => { + let sorted: BTreeMap = + map.into_iter().map(|(k, v)| (k, canonical(v))).collect(); + Value::Object(sorted.into_iter().collect()) + } + Value::Array(items) => Value::Array(items.into_iter().map(canonical).collect()), + other => other, + } +} + +fn snapshot() -> String { + let mut counts: BTreeMap> = BTreeMap::new(); + for s in registry::table() { + *counts + .entry(s.level.to_string()) + .or_default() + .entry(s.policy.class_name()) + .or_default() += 1; + } + let doc = json!({ + "description": "Generated from lrg-develop's key registry by tests/registry_snapshot.rs; \ + do not edit. Regenerate with LRG_BLESS=1 cargo test -p lrg-develop \ + --test registry_snapshot.", + "row_count": registry::table().len(), + "rows_per_level_and_class": counts, + "rows": registry::table().iter().map(row_json).collect::>(), + "patterns": PATTERNS.iter().map(pattern_json).collect::>(), + }); + serde_json::to_string_pretty(&canonical(doc)).expect("snapshot serializes") + "\n" +} + +#[test] +fn registry_matches_the_committed_snapshot() { + let path = snapshot_path(); + let fresh = snapshot(); + if std::env::var_os("LRG_BLESS").is_some_and(|v| v == "1") { + std::fs::write(&path, &fresh).expect("write registry snapshot"); + return; + } + // A Windows checkout may turn the committed file's line endings into + // CRLF (`core.autocrlf`); the content is what must match. + let committed = std::fs::read_to_string(&path) + .unwrap_or_default() + .replace("\r\n", "\n"); + if committed == fresh { + return; + } + let first_diff = committed + .lines() + .zip(fresh.lines()) + .position(|(a, b)| a != b) + .unwrap_or_else(|| committed.lines().count().min(fresh.lines().count())); + panic!( + "{} is out of date (first difference at line {}). If the registry change is intended, \ + run `LRG_BLESS=1 cargo test -p lrg-develop --test registry_snapshot` and commit the file.", + path.display(), + first_diff + 1 + ); +} diff --git a/server-rs/crates/lrg-develop/tests/support/enumerate.rs b/server-rs/crates/lrg-develop/tests/support/enumerate.rs new file mode 100644 index 00000000..825c904c --- /dev/null +++ b/server-rs/crates/lrg-develop/tests/support/enumerate.rs @@ -0,0 +1,289 @@ +//! The key enumeration of plan §7.3 point 4, shared by the round trips of +//! both writers (`xmp_roundtrip.rs`, `lua_roundtrip.rs`): for every key, the +//! values to try (minimum, maximum, midpoint, default, a negative value for +//! a signed range, a value with more digits than any format keeps, every +//! member of a closed set) and where the reader would put each one in a +//! model, with the path [`crate::flat`] shows it under. +//! +//! Included by path next to `support/flat.rs` (as `crate::flat`). + +use std::collections::BTreeMap; + +use lrg_develop::model::{ + Combine, Correction, DevelopSettings, Fields, Finite, Hex32, Look, MaskComponent, MaskTool, + Struct, Value, +}; +use lrg_develop::registry::{ + self, CurveKind, Def, KeyId, KeySpec, Level, Lit, Policy, StructKind, ValueKind, +}; + +use crate::flat; + +pub fn f(x: f64) -> Finite { + Finite::new(x).unwrap() +} + +/// The numeric default of `spec`, raw first. +pub fn numeric_default(spec: &KeySpec) -> Option { + [spec.default.raw, spec.default.non_raw] + .into_iter() + .find_map(|d| match d { + Def::Value(Lit::Int(i)) => Some(i as f64), + Def::Value(Lit::Real(r)) => Some(r.get()), + _ => None, + }) +} + +pub fn numbers(spec: &KeySpec, integer: bool) -> Vec { + let mut out = Vec::new(); + match spec.range { + Some((min, max)) => { + let (min, max) = (min.get(), max.get()); + out.extend([min, max, (min + max) / 2.0]); + if min < 0.0 { + out.push(min / 3.0); + } + if !integer { + // More digits than any number format keeps. + out.push(min + (max - min) * 0.123_456_789); + } + } + None => { + out.extend([0.0, 1.0, -1.0]); + if !integer { + out.extend([0.123_456_789, -0.25, 1.25]); + } + } + } + out.extend(numeric_default(spec)); + if integer { + for x in &mut out { + *x = x.round(); + } + } + out.sort_by(f64::total_cmp); + out.dedup(); + out +} + +pub fn curve(points: &[(f64, f64)]) -> Value { + Value::Curve(points.iter().map(|&(x, y)| (f(x), f(y))).collect()) +} + +/// The values to try for one key in its Lua (`lua`) or XMP form; empty for the kinds that are containers +/// or typed elsewhere (structures, sequences of structures, corrections, +/// mask components), which the fixtures and builder tests cover. +pub fn samples(spec: &KeySpec, lua: bool) -> Vec { + let int = |xs: Vec| xs.into_iter().map(|x| Value::Int(x as i64)).collect(); + let kind = if lua { + spec.kind.lua_form() + } else { + spec.kind.xmp_form() + }; + match kind { + ValueKind::Int => int(numbers(spec, true)), + ValueKind::Real => numbers(spec, false) + .into_iter() + .map(|x| Value::Real(f(x))) + .collect(), + ValueKind::IntFlag => vec![Value::Int(0), Value::Int(1)], + ValueKind::EnumInt(values) => values.iter().map(|v| Value::Int(*v)).collect(), + ValueKind::Bool(_) => vec![Value::Bool(false), Value::Bool(true)], + ValueKind::Enum(labels) => labels.iter().map(|l| Value::Str((*l).into())).collect(), + ValueKind::Hex32 => vec![Value::Str("0000000000000000000000000000ABCD".into())], + ValueKind::Str => vec![Value::Str( + match spec.name { + "LumRange" => "0.000000 0.250000 0.750000 1.000000", + "ReferencePoint" => "0.250000 0.750000", + "CameraProfile" => "Adobe Standard", + _ => "LrGenius sample", + } + .into(), + )], + ValueKind::StrSeq => vec![Value::StrList(vec!["1 2".into(), "3 4".into()])], + ValueKind::Curve(kind) => { + let mut out = vec![curve(&[ + (0.0, 0.0), + (64.0, 50.0), + (192.0, 210.0), + (255.0, 255.0), + ])]; + if let Def::Value(Lit::IntList(flat)) = spec.default.raw { + let points: Vec<(f64, f64)> = flat + .as_chunks::<2>() + .0 + .iter() + .map(|p| (p[0] as f64, p[1] as f64)) + .collect(); + out.push(curve(&points)); + } + assert!(matches!(kind, CurveKind::Global | CurveKind::Local)); + out + } + _ => Vec::new(), + } +} + +pub fn kid(level: Level, name: &str) -> KeyId { + registry::lookup(level, name).unwrap_or_else(|| panic!("registry row {level}/{name}")) +} + +pub fn one(kind: StructKind, id: KeyId, v: Value) -> Struct { + let mut s = Struct::new(kind); + s.fields.values.insert(id, v); + s +} + +/// A mask component that is neither typed nor combined: whatever lands in +/// `extra` stays there on reading. +pub fn bare_component(extra: Fields) -> MaskComponent { + MaskComponent { + tool: MaskTool::Opaque { what: None }, + combine: Combine::Unrecognised, + name: None, + sync_id: None, + active: None, + extra, + } +} + +pub fn correction(local: BTreeMap, masks: Vec) -> Correction { + Correction { + name: None, + sync_id: None, + amount: None, + active: None, + local, + masks, + extra: Fields::default(), + } +} + +pub fn in_component(id: KeyId, v: Value) -> DevelopSettings { + let mut extra = Fields::default(); + extra.values.insert(id, v); + let mut s = DevelopSettings::new(); + s.corrections = vec![correction(BTreeMap::new(), vec![bare_component(extra)])]; + s +} + +/// `value` of `id` placed where the reader would put it, and the path +/// [`flat::settings`] shows it under; `None` for a key this test does not +/// place (its row says why it is covered elsewhere). +pub fn place(id: KeyId, v: &Value) -> Option<(DevelopSettings, String)> { + let spec = id.spec(); + let mut s = DevelopSettings::new(); + let m = "MaskGroupBasedCorrections[0].CorrectionMasks[0]"; + let path = match spec.level { + Level::Global => { + s.insert(id, v.clone()).ok()?; + spec.name.to_owned() + } + Level::Correction => { + if spec.name == "CorrectionAmount" { + let mut c = correction(BTreeMap::new(), Vec::new()); + c.amount = v.as_finite(); + s.corrections = vec![c]; + } else { + s.corrections = vec![correction([(id, v.clone())].into(), Vec::new())]; + } + format!("MaskGroupBasedCorrections[0].{}", spec.name) + } + Level::MaskTool if spec.name == "What" => { + let mut c = bare_component(Fields::default()); + c.tool = MaskTool::Opaque { + what: Some(v.as_str()?.to_owned()), + }; + s.corrections = vec![correction(BTreeMap::new(), vec![c])]; + format!("{m}.What") + } + Level::MaskTool => { + s = in_component(id, v.clone()); + format!("{m}.{}", spec.name) + } + Level::Struct(StructKind::Look) => { + let mut look = Look { + name: None, + uuid: None, + amount: None, + camera_restriction: None, + rest: Struct::new(StructKind::Look), + }; + match spec.name { + "Name" => look.name = Some(v.as_str()?.to_owned()), + "UUID" => look.uuid = Some(Hex32::parse(v.as_str()?)?), + "Amount" => look.amount = v.as_finite(), + _ => return None, + } + s.look = Some(look); + format!("Look.{}", spec.name) + } + Level::Struct(StructKind::LensBlur) => { + let lens_blur = one(StructKind::LensBlur, id, v.clone()); + s.insert(kid(Level::Global, "LensBlur"), Value::Struct(lens_blur)) + .unwrap(); + format!("LensBlur.{}", spec.name) + } + Level::Struct(StructKind::CorrectionRangeMask) => { + let crm = one(StructKind::CorrectionRangeMask, id, v.clone()); + s = in_component( + kid(Level::MaskTool, "CorrectionRangeMask"), + Value::Struct(crm), + ); + format!("{m}.CorrectionRangeMask.{}", spec.name) + } + Level::Struct(StructKind::AreaModel) => { + let area = one(StructKind::AreaModel, id, v.clone()); + let crm = one( + StructKind::CorrectionRangeMask, + kid(Level::Struct(StructKind::CorrectionRangeMask), "AreaModels"), + Value::StructList(vec![area]), + ); + s = in_component( + kid(Level::MaskTool, "CorrectionRangeMask"), + Value::Struct(crm), + ); + format!("{m}.CorrectionRangeMask.AreaModels[0].{}", spec.name) + } + Level::Struct(StructKind::Gesture) => { + let gesture = one(StructKind::Gesture, id, v.clone()); + s = in_component( + kid(Level::MaskTool, "Gesture"), + Value::StructList(vec![gesture]), + ); + format!("{m}.Gesture[0].{}", spec.name) + } + Level::Struct(StructKind::GesturePoint) => { + let point = one(StructKind::GesturePoint, id, v.clone()); + let gesture = one( + StructKind::Gesture, + kid(Level::Struct(StructKind::Gesture), "Points"), + Value::StructList(vec![point]), + ); + s = in_component( + kid(Level::MaskTool, "Gesture"), + Value::StructList(vec![gesture]), + ); + format!("{m}.Gesture[0].Points[0].{}", spec.name) + } + _ => return None, + }; + Some((s, path)) +} + +/// The leaf [`flat`] shows for `v` at `id` (typed fields are numbers). +pub fn leaf(id: KeyId, v: &Value) -> flat::Leaf { + let spec = id.spec(); + let typed_number = matches!( + (spec.level, spec.name), + (Level::Correction, "CorrectionAmount") | (Level::Struct(StructKind::Look), "Amount") + ); + match v.as_finite() { + Some(x) if typed_number => flat::Leaf::Number(x), + _ => flat::Leaf::Value(v.clone()), + } +} + +pub fn learn_or_photo(spec: &KeySpec) -> bool { + spec.policy.is_learnable() || spec.policy == Policy::Photo +} diff --git a/server-rs/crates/lrg-develop/tests/support/flat.rs b/server-rs/crates/lrg-develop/tests/support/flat.rs new file mode 100644 index 00000000..b8547e93 --- /dev/null +++ b/server-rs/crates/lrg-develop/tests/support/flat.rs @@ -0,0 +1,283 @@ +//! A develop model flattened to `path → leaf`, for comparing two models key +//! by key: which keys differ after a round trip, or between a rebuilt +//! correction and Adobe's. +//! +//! The typed parts of the model are expanded back into the XMP keys they +//! stand for (a [`Combine`] into `MaskBlendMode`/`MaskValue`/`MaskInverted`, +//! a [`Semantic`] into `What`/`MaskSubType`/`MaskSubCategoryID`, ...), so a +//! difference is reported under the key a reader of the file would name. +//! Paths look like `Exposure2012`, `Look.Amount`, +//! `MaskGroupBasedCorrections[0].CorrectionMasks[1].MaskSubType`; opaque +//! entries are `?` (`?#` outside `crs:`), numbered +//! when a name repeats. +//! +//! Included by path from the tests that use all of it (a file of its own, +//! apart from `support/mod.rs`, whose other users would see dead code). + +use std::collections::BTreeMap; + +use lrg_develop::model::{ + Correction, DevelopSettings, Fields, Finite, Look, MaskComponent, MaskTool, Opaque, Semantic, + Value, +}; +use lrg_develop::xmp::PresetHeader; + +/// One flattened value. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Leaf { + /// A registry key's value (never a container). + Value(Value), + /// A number the model holds in a typed field. + Number(Finite), + /// Content kept verbatim. + Opaque(Opaque), +} + +/// `path → leaf`, sorted by path. +pub type Flat = BTreeMap; + +fn join(path: &str, key: &str) -> String { + if path.is_empty() { + key.to_owned() + } else { + format!("{path}.{key}") + } +} + +fn value(out: &mut Flat, path: String, v: &Value) { + match v { + Value::Struct(s) => fields(out, &path, &s.fields), + Value::StructList(items) if !items.is_empty() => { + for (i, s) in items.iter().enumerate() { + fields(out, &format!("{path}[{i}]"), &s.fields); + } + } + Value::Tools(items) if !items.is_empty() => { + for (i, f) in items.iter().enumerate() { + fields(out, &format!("{path}[{i}]"), f); + } + } + Value::Corrections(cs) if !cs.is_empty() => { + for (i, c) in cs.iter().enumerate() { + correction(out, &format!("{path}[{i}]"), c); + } + } + leaf => { + out.insert(path, Leaf::Value(leaf.clone())); + } + } +} + +fn fields(out: &mut Flat, path: &str, f: &Fields) { + for (id, v) in &f.values { + value(out, join(path, id.spec().name), v); + } + let mut seen: BTreeMap = BTreeMap::new(); + for o in &f.opaque { + let name = match &o.ns { + Some(ns) => format!("?{ns}#{}", o.name), + None => format!("?{}", o.name), + }; + let n = seen.entry(name.clone()).or_default(); + let key = if *n == 0 { name } else { format!("{name}#{n}") }; + *n += 1; + out.insert(join(path, &key), Leaf::Opaque(o.value.clone())); + } +} + +fn put(out: &mut Flat, path: &str, key: &str, leaf: Leaf) { + out.insert(join(path, key), leaf); +} + +fn text(s: &str) -> Leaf { + Leaf::Value(Value::Str(s.to_owned())) +} + +fn int(i: i64) -> Leaf { + Leaf::Number(Finite::from_i64(i)) +} + +fn boolean(b: bool) -> Leaf { + Leaf::Value(Value::Bool(b)) +} + +/// One correction below `path` (`MaskGroupBasedCorrections[2]`). +fn correction(out: &mut Flat, path: &str, c: &Correction) { + if let Some(n) = &c.name { + put(out, path, "CorrectionName", text(n)); + } + if let Some(s) = &c.sync_id { + put(out, path, "CorrectionSyncID", text(s.as_str())); + } + if let Some(a) = c.amount { + put(out, path, "CorrectionAmount", Leaf::Number(a)); + } + if let Some(a) = c.active { + put(out, path, "CorrectionActive", boolean(a)); + } + for (id, v) in &c.local { + value(out, join(path, id.spec().name), v); + } + fields(out, path, &c.extra); + for (i, m) in c.masks.iter().enumerate() { + component(out, &format!("{path}.CorrectionMasks[{i}]"), m); + } +} + +/// One mask component below `path`. +fn component(out: &mut Flat, path: &str, m: &MaskComponent) { + let n = Leaf::Number; + if let Some(name) = &m.name { + put(out, path, "MaskName", text(name)); + } + if let Some(s) = &m.sync_id { + put(out, path, "MaskSyncID", text(s.as_str())); + } + if let Some(a) = m.active { + put(out, path, "MaskActive", boolean(a)); + } + if let Some((mode, v, inverted)) = m.combine.encode() { + put(out, path, "MaskBlendMode", int(mode)); + put(out, path, "MaskValue", n(v)); + put(out, path, "MaskInverted", boolean(inverted)); + } + match &m.tool { + MaskTool::Semantic(s) => { + put(out, path, "What", text("Mask/Image")); + let (sub, category) = s.encoding(); + put(out, path, "MaskSubType", int(sub)); + if let Some(c) = category { + put(out, path, "MaskSubCategoryID", int(c)); + } + if let Semantic::PersonPartAt { point, .. } = s { + put(out, path, "ReferencePoint[0]", n(point.x)); + put(out, path, "ReferencePoint[1]", n(point.y)); + } + } + MaskTool::Linear(g) => { + put(out, path, "What", text("Mask/Gradient")); + put(out, path, "ZeroX", n(g.zero.x)); + put(out, path, "ZeroY", n(g.zero.y)); + put(out, path, "FullX", n(g.full.x)); + put(out, path, "FullY", n(g.full.y)); + } + MaskTool::Radial(g) => { + put(out, path, "What", text("Mask/CircularGradient")); + put(out, path, "Top", n(g.top)); + put(out, path, "Left", n(g.left)); + put(out, path, "Bottom", n(g.bottom)); + put(out, path, "Right", n(g.right)); + put(out, path, "Angle", n(Finite::ZERO)); + put(out, path, "Feather", int(g.feather)); + put(out, path, "Midpoint", int(g.midpoint)); + put(out, path, "Roundness", int(g.roundness)); + put(out, path, "Flipped", boolean(g.flipped)); + } + MaskTool::LuminanceRange(lr) => { + put(out, path, "What", text("Mask/RangeMask")); + let crm = join(path, "CorrectionRangeMask"); + put(out, &crm, "Type", int(2)); + put(out, &crm, "Invert", boolean(lr.invert)); + for (i, x) in lr.range.iter().enumerate() { + put(out, &crm, &format!("LumRange[{i}]"), n(*x)); + } + fields(out, &crm, &lr.rest.fields); + } + MaskTool::Opaque { what } => { + if let Some(w) = what { + put(out, path, "What", text(w)); + } + } + } + fields(out, path, &m.extra); +} + +fn look(out: &mut Flat, l: &Look) { + let path = "Look"; + if let Some(name) = &l.name { + put(out, path, "Name", text(name)); + } + if let Some(u) = &l.uuid { + put(out, path, "UUID", text(u.as_str())); + } + if let Some(a) = l.amount { + put(out, path, "Amount", Leaf::Number(a)); + } + if let Some(r) = &l.camera_restriction { + put(out, path, "CameraModelRestriction", text(r)); + } + fields(out, path, &l.rest.fields); +} + +/// The whole model: globals, `Look`, corrections, opaque entries, and the +/// file kind as `(file kind)`. +pub fn settings(s: &DevelopSettings) -> Flat { + let mut out = Flat::new(); + for (id, v) in s.values() { + value(&mut out, id.spec().name.to_owned(), v); + } + if let Some(l) = &s.look { + look(&mut out, l); + } + for (i, c) in s.corrections.iter().enumerate() { + correction(&mut out, &format!("MaskGroupBasedCorrections[{i}]"), c); + } + let top = Fields { + values: BTreeMap::new(), + opaque: s.opaque.clone(), + }; + fields(&mut out, "", &top); + if let Some(k) = s.file_kind { + out.insert("(file kind)".into(), text(&format!("{k:?}"))); + } + out +} + +/// A preset header, every path below `header`. +pub fn header(h: &PresetHeader) -> Flat { + let mut out = Flat::new(); + let p = "header"; + let texts = [ + ("PresetType", &h.preset_type), + ("Name", &h.name), + ("ShortName", &h.short_name), + ("SortName", &h.sort_name), + ("Group", &h.group), + ("Description", &h.description), + ("Cluster", &h.cluster), + ("CameraModelRestriction", &h.camera_model_restriction), + ]; + for (k, v) in texts { + if let Some(v) = v { + put(&mut out, p, k, text(v)); + } + } + if let Some(u) = &h.uuid { + put(&mut out, p, "UUID", text(u.as_str())); + } + let flags = [ + ("SupportsAmount", h.supports_amount), + ("SupportsAmount2", h.supports_amount2), + ("SupportsColor", h.supports_color), + ("SupportsMonochrome", h.supports_monochrome), + ]; + for (k, v) in flags { + if let Some(v) = v { + put(&mut out, p, k, boolean(v)); + } + } + fields(&mut out, p, &h.rest); + out +} + +/// The paths whose leaves differ: present on one side only, or unequal. +pub fn diff(a: &Flat, b: &Flat) -> Vec { + let mut out: Vec = a + .iter() + .filter(|(k, v)| b.get(*k) != Some(*v)) + .map(|(k, _)| k.clone()) + .collect(); + out.extend(b.keys().filter(|k| !a.contains_key(*k)).cloned()); + out.sort(); + out +} diff --git a/server-rs/crates/lrg-develop/tests/support/lua_apply.rs b/server-rs/crates/lrg-develop/tests/support/lua_apply.rs new file mode 100644 index 00000000..8a45b860 --- /dev/null +++ b/server-rs/crates/lrg-develop/tests/support/lua_apply.rs @@ -0,0 +1,442 @@ +//! The Lua writer in apply mode, checked both ways: one check for the +//! committed fixtures (`lua_roundtrip.rs`, asserted, under every option +//! flip), the wire goldens (`lua_wire_goldens.rs`, per case) and every +//! local sweep (`training_dump_local.rs` over the training dump, +//! `xmp_goldens_local.rs` over Lightroom's bundle, Camera Raw's folder and a +//! sidecar corpus), counts only. +//! +//! Each settings value is written for a photo (its own file kind and +//! process version unless a caller names one): no write error, a table +//! that passes `check_wire`, reads back without a warning, and +//! +//! - holds every policy-filtered value exactly or reports it in +//! `LuaWritten::skipped` (a global value at its default and the `Look` +//! stub's computed profile fields apart); +//! - holds nothing else than those values and the writer's own fixed form +//! ([`writer_adds`]: `WhiteBalance = "Custom"`, `ToneCurveName2012`, the +//! correction and component form, the adaptive-preset zero `Local*` keys, +//! the panel switches the options ask for; a preset's amount flags), so a value +//! that is dropped or changed without a report shows up as a difference +//! either way. +//! +//! Included by path next to `support/flat.rs` (as `crate::flat`), +//! `support/preset.rs` (as `crate::preset`) and `support/mod.rs` (as +//! `crate::support`). + +use std::collections::BTreeMap; + +use lrg_develop::lua::{ + check_wire, from_lua_value, to_lua_value, EnumAs, FlagAs, LocalForm, LookForm, LuaMode, + LuaOptions, LuaWritten, MaskForm, PanelSwitches, PhotoContext, ADAPTIVE_PRESET_LOCALS, + MASK_SWITCH, PANEL_SWITCHES, +}; +use lrg_develop::model::{DevelopSettings, FileKind, Value}; +use lrg_develop::registry::{self, Def, Level}; +use lrg_develop::FileKindHint; +use serde_json::Value as J; + +use crate::flat::{self, Leaf}; +use crate::preset; +use crate::support::generic_path; + +/// The variant of a `Debug` text, without the value it may carry. +pub fn variant(debug: String) -> String { + debug + .split(['(', ' ', '{']) + .next() + .unwrap_or("?") + .to_owned() +} + +/// Apply-mode counts over many settings values. +#[derive(Default)] +pub struct LuaApply { + pub written: usize, + pub write_errors: BTreeMap, + pub not_wire_safe: usize, + pub read_back_warnings: BTreeMap, + /// Filtered values neither in the table nor reported. + pub lost: BTreeMap, + /// Filtered values in the table with another value. + pub changed: BTreeMap, + /// Values in the table that the filtered settings do not hold and that + /// are not the writer's fixed form. + pub added: BTreeMap, + pub compared: usize, + /// What was held back, per key (indices removed) and reason variant. + pub skipped: BTreeMap, +} + +fn bump(map: &mut BTreeMap, key: String) { + *map.entry(key).or_default() += 1; +} + +fn number(l: &Leaf) -> Option { + match l { + Leaf::Number(x) => Some(x.get()), + Leaf::Value(v) => v.as_finite().map(|x| x.get()), + Leaf::Opaque(_) => None, + } +} + +/// Whether `path` (or one of its ancestors) is among the `skipped` paths. +pub fn reported(skipped: &[String], path: &str) -> bool { + skipped.iter().any(|k| { + path == k + || path + .strip_prefix(k.as_str()) + .is_some_and(|rest| rest.starts_with('.') || rest.starts_with('[')) + }) +} + +/// What the writer adds to an applied table on its own, with the value it +/// writes, under `options` (a preset's amount flags are checked before the +/// read-back, in [`LuaApply::check`]). +pub fn writer_adds(path: &str, leaf: &Leaf, options: &LuaOptions) -> bool { + let top = !path.contains('.') && !path.contains('['); + let key = path.rsplit('.').next().unwrap_or(path); + let text = |s: &str| *leaf == Leaf::Value(Value::Str(s.into())); + let is = |x: f64| number(leaf) == Some(x); + let yes = *leaf == Leaf::Value(Value::Bool(true)); + let range = path.contains(".CorrectionRangeMask."); + // `MaskGroupBasedCorrections[i].`, not a component's. + let on_correction = path.starts_with("MaskGroupBasedCorrections[") + && !path.contains(".CorrectionMasks[") + && path.matches('.').count() == 1; + if top { + return match key { + "WhiteBalance" => text("Custom"), + "ToneCurveName2012" => true, + k if k == MASK_SWITCH => options.panel_switches != PanelSwitches::None && yes, + k if PANEL_SWITCHES.iter().any(|(s, _)| *s == k) => { + options.panel_switches == PanelSwitches::All && yes + } + _ => false, + }; + } + match key { + "Stubbed" if path == "Look.Stubbed" => yes, + "What" if on_correction => text("Correction"), + "CorrectionAmount" => is(1.0), + "CorrectionActive" | "MaskActive" => yes, + "MaskVersion" => is(1.0), + "ReferencePoint" => text("0.500000 0.500000"), + "ErrorReason" => is(0.0), + "Version" if range => is(3.0), + "SampleType" if range => is(0.0), + k if on_correction && ADAPTIVE_PRESET_LOCALS.contains(&k) => { + options.local_form == LocalForm::AdaptivePreset && is(0.0) + } + _ => false, + } +} + +/// `base` with one field at a time set to each of its other values: the +/// option sets an experiment's answer can lead to, named `field=value`. +/// `wb_mode_only` and `flatten_auto_now` also flip together (the second +/// matters only with the first). A new `LuaOptions` field fails to compile +/// here until it is listed. +pub fn flips(base: LuaOptions) -> Vec<(String, LuaOptions)> { + let LuaOptions { + mask_enum_as, + int_flag_as, + panel_switches, + mask_form, + local_form, + wb_custom_with_numbers, + wb_mode_only, + flatten_auto_now, + ai_update, + look_form, + preset_amount_flags, + } = base; + let mut out = Vec::new(); + let mut add = |name: String, o: LuaOptions| out.push((name, o)); + for v in [EnumAs::Number, EnumAs::String] { + if v != mask_enum_as { + add( + format!("mask_enum_as={v:?}"), + LuaOptions { + mask_enum_as: v, + ..base + }, + ); + } + } + for v in [FlagAs::Number, FlagAs::Bool] { + if v != int_flag_as { + add( + format!("int_flag_as={v:?}"), + LuaOptions { + int_flag_as: v, + ..base + }, + ); + } + } + for v in [ + PanelSwitches::None, + PanelSwitches::MaskOnly, + PanelSwitches::All, + ] { + if v != panel_switches { + add( + format!("panel_switches={v:?}"), + LuaOptions { + panel_switches: v, + ..base + }, + ); + } + } + for v in [MaskForm::PresetForm, MaskForm::Minimal] { + if v != mask_form { + add( + format!("mask_form={v:?}"), + LuaOptions { + mask_form: v, + ..base + }, + ); + } + } + for v in [LocalForm::AdaptivePreset, LocalForm::Sparse] { + if v != local_form { + add( + format!("local_form={v:?}"), + LuaOptions { + local_form: v, + ..base + }, + ); + } + } + for v in [LookForm::Stub, LookForm::BareStub, LookForm::Full] { + if v != look_form { + add( + format!("look_form={v:?}"), + LuaOptions { + look_form: v, + ..base + }, + ); + } + } + add( + format!("wb_custom_with_numbers={}", !wb_custom_with_numbers), + LuaOptions { + wb_custom_with_numbers: !wb_custom_with_numbers, + ..base + }, + ); + add( + format!("wb_mode_only={}", !wb_mode_only), + LuaOptions { + wb_mode_only: !wb_mode_only, + ..base + }, + ); + add( + format!("flatten_auto_now={}", !flatten_auto_now), + LuaOptions { + flatten_auto_now: !flatten_auto_now, + ..base + }, + ); + add( + format!( + "wb_mode_only={} flatten_auto_now={}", + !wb_mode_only, !flatten_auto_now + ), + LuaOptions { + wb_mode_only: !wb_mode_only, + flatten_auto_now: !flatten_auto_now, + ..base + }, + ); + add( + format!("ai_update={}", !ai_update), + LuaOptions { + ai_update: !ai_update, + ..base + }, + ); + add( + format!("preset_amount_flags={}", !preset_amount_flags), + LuaOptions { + preset_amount_flags: !preset_amount_flags, + ..base + }, + ); + out +} + +impl LuaApply { + /// Writes `settings` for its own photo with the provisional options and + /// checks the table. + pub fn settings(&mut self, settings: &DevelopSettings) { + let photo = PhotoContext { + file_kind: settings.file_kind, + process_version: settings.process_version(), + camera: None, + }; + self.settings_for(settings, &LuaMode::Apply(photo), &LuaOptions::PROVISIONAL); + } + + /// Writes `settings` in `mode` (`Apply` or `Preset`, which names the + /// photo) with `options`, and checks the table both ways. The written + /// table, unless the write failed. + pub fn settings_for( + &mut self, + settings: &DevelopSettings, + mode: &LuaMode, + options: &LuaOptions, + ) -> Option { + let (photo, preset) = match mode { + LuaMode::Apply(p) => (p, false), + LuaMode::Preset(p) => (p, true), + _ => panic!("settings_for checks the apply and preset modes"), + }; + self.written += 1; + let w = match to_lua_value(settings, mode, options) { + Ok(w) => w, + Err(e) => { + bump(&mut self.write_errors, variant(format!("{e:?}"))); + return None; + } + }; + for k in &w.skipped { + bump( + &mut self.skipped, + format!( + "{} {}", + generic_path(&k.path), + variant(format!("{:?}", k.reason)) + ), + ); + } + let skipped: Vec = w.skipped.iter().map(|k| k.path.clone()).collect(); + self.check(settings, photo, preset, options, &w.table, &skipped); + Some(w) + } + + /// Checks `table`, written from `settings` for `photo` with `options` + /// (`preset`: for a plugin preset) and reporting the `skipped` paths: + /// wire-safe, read back without a warning, and both directions of the + /// module docs. The wire-goldens test hands it a golden file's table. + pub fn check( + &mut self, + settings: &DevelopSettings, + photo: &PhotoContext, + preset: bool, + options: &LuaOptions, + table: &J, + skipped: &[String], + ) { + if check_wire(table).is_err() { + self.not_wire_safe += 1; + } + // A plugin preset's amount flags are no develop setting (no registry + // row; `getDevelopSettings()` never has them): checked here, then + // left out of the read-back. + let mut table = table.clone(); + if let Some(m) = table.as_object_mut() { + for flag in ["SupportsAmount", "SupportsAmount2"] { + if let Some(v) = m.remove(flag) { + let wanted = preset && options.preset_amount_flags && v == J::Bool(true); + if !wanted { + bump(&mut self.added, flag.to_owned()); + } + } + } + } + let hint = FileKindHint::from(photo.file_kind.map(|k| k == FileKind::Raw)); + let Ok((back, warnings)) = from_lua_value(&table, hint) else { + self.not_wire_safe += 1; + return; + }; + for x in &warnings { + bump( + &mut self.read_back_warnings, + format!("{} {}", x.kind.name(), generic_path(&x.path)), + ); + } + let (filtered, _) = settings.filtered(&photo.target()); + let want = flat::settings(&filtered); + let got = flat::settings(&back); + for (path, leaf) in &want { + if path == "(file kind)" { + continue; + } + // The Look goes as a stub: Lightroom fills in its computed + // profile fields itself. + if path.starts_with("Look.") + && !matches!(path.as_str(), "Look.Name" | "Look.UUID" | "Look.Amount") + { + continue; + } + match got.get(path) { + Some(g) => { + self.compared += 1; + if !preset::same(g, leaf) { + bump(&mut self.changed, generic_path(path)); + } + } + None => { + // A global value at its default (`WhiteBalance` "As + // Shot") goes without a report: dropping it loses nothing. + let default = registry::lookup(Level::Global, path).is_some_and(|id| { + let spec = id.spec(); + [spec.default.raw, spec.default.non_raw].iter().any(|d| { + matches!((d, leaf), (Def::Value(lit), Leaf::Value(v)) if v.equals_lit(lit)) + }) + }); + if !reported(skipped, path) && !default { + bump(&mut self.lost, generic_path(path)); + } + } + } + } + for (path, leaf) in &got { + if path != "(file kind)" + && !want.contains_key(path) + && !writer_adds(path, leaf, options) + { + bump(&mut self.added, generic_path(path)); + } + } + } + + pub fn report(&self, indent: &str) { + eprintln!( + "{indent}as Lua tables for their own photo: {} written, write errors {:?}, \ + not wire-safe {}, read-back warnings {:?}", + self.written, self.write_errors, self.not_wire_safe, self.read_back_warnings + ); + eprintln!( + "{indent}applied table against the filtered settings: {} values compared, \ + changed {:?}, lost without a report {:?}, added beyond the writer's form {:?}", + self.compared, self.changed, self.lost, self.added + ); + eprintln!("{indent}held back from the applied tables (key, reason: count):"); + for (what, n) in &self.skipped { + eprintln!("{indent} {n:>6} {what}"); + } + } + + pub fn is_clean(&self) -> bool { + self.write_errors.is_empty() + && self.not_wire_safe == 0 + && self.read_back_warnings.is_empty() + && self.changed.is_empty() + && self.lost.is_empty() + && self.added.is_empty() + } + + pub fn assert_clean(&self, title: &str) { + assert!( + self.is_clean(), + "{title}: writing as Lua tables is not clean (see the report above)" + ); + } +} diff --git a/server-rs/crates/lrg-develop/tests/support/lua_round.rs b/server-rs/crates/lrg-develop/tests/support/lua_round.rs new file mode 100644 index 00000000..ecb39e57 --- /dev/null +++ b/server-rs/crates/lrg-develop/tests/support/lua_round.rs @@ -0,0 +1,360 @@ +//! Round trips 2 and 3 of the plan (§7.3) through the Lua writer, shared by +//! `lua_roundtrip.rs` (the committed fixtures, asserted per file) and the +//! local sweeps (`training_dump_local.rs`, `xmp_goldens_local.rs`, counts +//! only). +//! +//! - **Round trip 2**, model → Lua → model ([`LuaMode::TestRoundTrip`]): +//! the same model back, and writing it again gives the same table. A model +//! read from XMP goes in without what has no Lua form +//! ([`without_xmp_only`]): XMP-only keys (`HasSettings`, `HasCrop`, ...; +//! the preset header is not part of the model at all), `PerFormat` values +//! in their XMP shape, content kept as XMP. +//! - **Round trip 3**, Lua → model → XMP preset → model → Lua, mixed and +//! single-kind preset: the model the preset reads back into goes through +//! Lua losslessly, and the values it shares with the source (policy +//! filtered for the preset, rounded to the preset writer's number formats +//! with `preset::quantized`, then through Lua) are equal +//! (`preset::same`). Opaque content is excepted by definition: a preset +//! never carries it. +//! +//! Included by path next to `support/flat.rs` (as `crate::flat`), +//! `support/preset.rs` (as `crate::preset`) and `support/mod.rs` (as +//! `crate::support`). + +use std::collections::BTreeMap; + +use lrg_develop::build::{IdSlot, SyncNamespace}; +use lrg_develop::lua::{from_lua_value, to_lua_value, LuaMode, LuaOptions, LuaWritten}; +use lrg_develop::model::{ + Correction, DevelopSettings, Fields, FileKind, MaskTool, Opaque, Target, Value, +}; +use lrg_develop::registry::{KeyId, Presence, ProcessVersion, ValueKind}; +use lrg_develop::xmp::{parse, write, PresetHeader, PresetSpec, WriteMode}; +use lrg_develop::{FileKindHint, ParseWarning}; +use serde_json::Value as J; + +use crate::flat; +use crate::preset; +use crate::support::generic_path; + +/// The reader's file-kind hint for `s`. +pub fn hint(s: &DevelopSettings) -> FileKindHint { + FileKindHint::from(s.file_kind.map(|k| k == FileKind::Raw)) +} + +/// `s` written losslessly with the provisional encodings; panics on an +/// error (committed data only). +pub fn lossless(s: &DevelopSettings) -> LuaWritten { + lossless_with(s, &LuaOptions::PROVISIONAL) +} + +/// `s` written losslessly with the encodings of `options` (the only fields +/// the lossless mode reads: `mask_enum_as`, `int_flag_as`). +pub fn lossless_with(s: &DevelopSettings, options: &LuaOptions) -> LuaWritten { + let w = to_lua_value(s, &LuaMode::TestRoundTrip, options).unwrap_or_else(|e| panic!("{e}")); + assert!(w.skipped.is_empty()); + w +} + +/// Writes `s` losslessly and reads it back. +pub fn round_trip(s: &DevelopSettings) -> (J, DevelopSettings, Vec) { + round_trip_with(s, &LuaOptions::PROVISIONAL) +} + +/// [`round_trip`] with the encodings of `options`. +pub fn round_trip_with( + s: &DevelopSettings, + options: &LuaOptions, +) -> (J, DevelopSettings, Vec) { + let w = lossless_with(s, options); + let (back, warnings) = from_lua_value(&w.table, hint(s)).unwrap(); + (w.table, back, warnings) +} + +/// Whether a value has no Lua form: an XMP-only key, a `PerFormat` key in +/// its XMP shape, content kept as XMP. +fn xmp_only(id: KeyId, v: &Value) -> bool { + let spec = id.spec(); + spec.presence == Presence::XmpOnly + || matches!(spec.kind, ValueKind::PerFormat { .. }) + || matches!(v, Value::Opaque(Opaque::Xmp(_))) +} + +fn strip_fields(f: &mut Fields) { + f.values.retain(|id, v| !xmp_only(*id, v)); + for v in f.values.values_mut() { + strip_value(v); + } + f.opaque.retain(|o| matches!(o.value, Opaque::Json(_))); +} + +fn strip_value(v: &mut Value) { + match v { + Value::Struct(s) => strip_fields(&mut s.fields), + Value::StructList(items) => items.iter_mut().for_each(|s| strip_fields(&mut s.fields)), + Value::Tools(items) => items.iter_mut().for_each(strip_fields), + Value::Corrections(cs) => cs.iter_mut().for_each(strip_correction), + _ => {} + } +} + +fn strip_correction(c: &mut Correction) { + c.local.retain(|id, v| !xmp_only(*id, v)); + strip_fields(&mut c.extra); + for m in &mut c.masks { + strip_fields(&mut m.extra); + if let MaskTool::LuminanceRange(lr) = &mut m.tool { + strip_fields(&mut lr.rest.fields); + } + } +} + +/// `s` without what has no Lua form (see the module docs). +pub fn without_xmp_only(s: &DevelopSettings) -> DevelopSettings { + let mut out = DevelopSettings::new(); + out.file_kind = s.file_kind; + for (id, v) in s.values() { + if !xmp_only(id, v) { + let mut v = v.clone(); + strip_value(&mut v); + out.insert(id, v).unwrap(); + } + } + out.look = s.look.clone().map(|mut l| { + strip_fields(&mut l.rest.fields); + l + }); + out.corrections = s.corrections.clone(); + out.corrections.iter_mut().for_each(strip_correction); + out.opaque = s + .opaque + .iter() + .filter(|o| matches!(o.value, Opaque::Json(_))) + .cloned() + .collect(); + out +} + +/// The preset round trip 3 goes through. +pub fn preset_spec(mixed_file_kinds: bool) -> WriteMode { + let uuid = SyncNamespace::new("lua_roundtrip").id("preset", IdSlot::Correction); + WriteMode::Preset(PresetSpec { + mixed_file_kinds, + process_version: Some(ProcessVersion::V6), + ..PresetSpec::new(PresetHeader::lrgenius(uuid, "Round trip")) + }) +} + +/// The variant of a `Debug` text, without the value it may carry. +fn variant(debug: String) -> String { + debug + .split(['(', ' ', '{']) + .next() + .unwrap_or("?") + .to_owned() +} + +fn bump(map: &mut BTreeMap, key: String) { + *map.entry(key).or_default() += 1; +} + +/// Round trips 2 and 3 over many settings values, counted; nothing here +/// prints a value. +#[derive(Default)] +pub struct LuaRoundTrips { + /// Settings values through round trip 2. + pub rt2_settings: usize, + /// Lossless writes that failed, per error variant. + pub write_errors: BTreeMap, + /// Tables the reader rejected. + pub read_errors: usize, + /// Round trip 2: values compared (flattened leaves). + pub rt2_values: usize, + /// Round trip 2: paths (indices removed) that differ after reading back. + pub rt2_changed: BTreeMap, + /// Round trip 2: read-back warnings per kind and path. + pub rt2_warnings: BTreeMap, + /// Round trip 2: a second write that differs from the first. + pub rt2_not_idempotent: usize, + /// Round trip 3: presets written (two per settings value). + pub rt3_presets: usize, + /// Round trip 3: preset write or read errors, per variant. + pub rt3_preset_errors: BTreeMap, + /// Round trip 3: the preset's model not lossless through Lua, per path. + pub rt3_not_lossless: BTreeMap, + /// Round trip 3: read-back warnings of the preset's model, per kind and + /// path. + pub rt3_warnings: BTreeMap, + /// Round trip 3: shared values compared after quantisation. + pub rt3_compared: usize, + /// Round trip 3: shared values that changed, per mode and path. + pub rt3_changed: BTreeMap, +} + +impl LuaRoundTrips { + /// Lossless write, or the error variant counted. + fn write(&mut self, s: &DevelopSettings) -> Option { + match to_lua_value(s, &LuaMode::TestRoundTrip, &LuaOptions::PROVISIONAL) { + Ok(w) => Some(w.table), + Err(e) => { + bump(&mut self.write_errors, variant(format!("{e:?}"))); + None + } + } + } + + fn read( + &mut self, + table: &J, + s: &DevelopSettings, + ) -> Option<(DevelopSettings, Vec)> { + match from_lua_value(table, hint(s)) { + Ok(r) => Some(r), + Err(_) => { + self.read_errors += 1; + None + } + } + } + + /// Both round trips for `s`, a model of Lua's form (read from a Lua + /// table, or [`without_xmp_only`] of one read from XMP). + pub fn settings(&mut self, s: &DevelopSettings) { + self.round_trip_2(s); + self.round_trip_3(s); + } + + fn round_trip_2(&mut self, s: &DevelopSettings) { + self.rt2_settings += 1; + let Some(table) = self.write(s) else { return }; + let Some((back, warnings)) = self.read(&table, s) else { + return; + }; + for w in &warnings { + bump( + &mut self.rt2_warnings, + format!("{} {}", w.kind.name(), generic_path(&w.path)), + ); + } + let (a, b) = (flat::settings(s), flat::settings(&back)); + self.rt2_values += a.len(); + for path in flat::diff(&a, &b) { + bump(&mut self.rt2_changed, generic_path(&path)); + } + if self.write(&back).is_some_and(|again| again != table) { + self.rt2_not_idempotent += 1; + } + } + + /// Round trip 3 alone, for sources whose round trip 2 is checked + /// elsewhere. + pub fn round_trip_3_only(&mut self, s: &DevelopSettings) { + self.round_trip_3(s); + } + + fn round_trip_3(&mut self, s: &DevelopSettings) { + for mixed in [false, true] { + let mode = if mixed { "mixed" } else { "single-kind" }; + self.rt3_presets += 1; + let written = match write(s, &preset_spec(mixed)) { + Ok(w) => w, + Err(e) => { + bump( + &mut self.rt3_preset_errors, + format!("write {}", variant(format!("{e:?}"))), + ); + continue; + } + }; + let m2 = match parse(written.xmp.as_bytes()) { + Ok(doc) => doc.develop, + Err(e) => { + bump( + &mut self.rt3_preset_errors, + format!("parse {}", variant(format!("{e:?}"))), + ); + continue; + } + }; + // The preset's model through Lua: lossless ... + let Some(t2) = self.write(&m2) else { continue }; + let Some((m3, warnings)) = self.read(&t2, &m2) else { + continue; + }; + for w in &warnings { + bump( + &mut self.rt3_warnings, + format!("{} {}", w.kind.name(), generic_path(&w.path)), + ); + } + for path in flat::diff(&flat::settings(&m2), &flat::settings(&m3)) { + bump(&mut self.rt3_not_lossless, generic_path(&path)); + } + // ... and equal to the source on every value both hold, the + // source rounded as the preset writer rounds. + let (filtered, _) = s.filtered(&Target::Preset { + mixed_file_kinds: mixed, + }); + let q = preset::quantized(&filtered); + let Some(t1) = self.write(&q) else { continue }; + let Some((m1, _)) = self.read(&t1, &q) else { + continue; + }; + let b = flat::settings(&m3); + for (path, leaf) in &flat::settings(&m1) { + if let Some(other) = b.get(path) { + self.rt3_compared += 1; + if !preset::same(leaf, other) { + bump( + &mut self.rt3_changed, + format!("{mode} {}", generic_path(path)), + ); + } + } + } + } + } + + pub fn report(&self, indent: &str) { + if self.rt2_settings > 0 { + eprintln!( + "{indent}round trip 2 (model -> Lua -> model): {} settings, {} values compared, \ + write errors {:?}, read errors {}, changed {:?}, read-back warnings {:?}, \ + not idempotent {}", + self.rt2_settings, + self.rt2_values, + self.write_errors, + self.read_errors, + self.rt2_changed, + self.rt2_warnings, + self.rt2_not_idempotent + ); + } + eprintln!( + "{indent}round trip 3 (Lua -> model -> XMP preset -> model -> Lua): {} presets, \ + {} shared values compared, preset errors {:?}, preset model not lossless {:?}, \ + read-back warnings {:?}, changed {:?}", + self.rt3_presets, + self.rt3_compared, + self.rt3_preset_errors, + self.rt3_not_lossless, + self.rt3_warnings, + self.rt3_changed + ); + } + + pub fn assert_clean(&self, title: &str) { + assert!( + self.write_errors.is_empty() + && self.read_errors == 0 + && self.rt2_changed.is_empty() + && self.rt2_warnings.is_empty() + && self.rt2_not_idempotent == 0 + && self.rt3_preset_errors.is_empty() + && self.rt3_not_lossless.is_empty() + && self.rt3_warnings.is_empty() + && self.rt3_changed.is_empty(), + "{title}: round trips 2 and 3 through Lua are not clean (see the report above)" + ); + } +} diff --git a/server-rs/crates/lrg-develop/tests/support/mod.rs b/server-rs/crates/lrg-develop/tests/support/mod.rs new file mode 100644 index 00000000..b1dc6789 --- /dev/null +++ b/server-rs/crates/lrg-develop/tests/support/mod.rs @@ -0,0 +1,200 @@ +//! Helpers shared by the local-only sweeps (`training_dump_local.rs`, +//! `xmp_goldens_local.rs`): counting what a parsed model holds without ever +//! printing a value. +//! +//! The sweeps run over private data (the maintainer's training dump, a +//! sidecar corpus) and Adobe's own preset files, so everything here reduces +//! a model to key names and counts. + +use std::collections::BTreeMap; + +use lrg_develop::model::{ + Combine, Correction, DevelopSettings, Fields, Finite, MaskTool, Semantic, Value, +}; +use lrg_develop::registry::{lookup, KeySpec, Level, StructKind}; + +/// A mask tool as a counting label (`"semantic:sky"`, `"linear"`, ...). +pub fn tool_name(tool: &MaskTool) -> String { + match tool { + MaskTool::Semantic(Semantic::Subject) => "semantic:subject".into(), + MaskTool::Semantic(Semantic::Sky) => "semantic:sky".into(), + MaskTool::Semantic(Semantic::Background) => "semantic:background".into(), + MaskTool::Semantic(Semantic::PeoplePart(_)) => "semantic:people-part".into(), + MaskTool::Semantic(Semantic::Landscape(_)) => "semantic:landscape".into(), + MaskTool::Semantic(Semantic::PersonPartAt { .. }) => "semantic:person-part-at".into(), + MaskTool::Linear(_) => "linear".into(), + MaskTool::Radial(_) => "radial".into(), + MaskTool::LuminanceRange(_) => "luminance-range".into(), + MaskTool::Opaque { what } => format!("opaque:{}", what.as_deref().unwrap_or("?")), + } +} + +/// A mask combination as a counting label. +pub fn combine_name(c: Combine) -> &'static str { + match c { + Combine::Add { inverted: false } => "add", + Combine::Add { inverted: true } => "add-inverted", + Combine::Subtract => "subtract", + Combine::Intersect => "intersect", + Combine::Unrecognised => "unrecognised", + } +} + +/// A warning path with its indices removed +/// (`MaskGroupBasedCorrections[2].CorrectionMasks[0].What` → +/// `MaskGroupBasedCorrections[].CorrectionMasks[].What`), so one key counts +/// once however often it appears. +pub fn generic_path(path: &str) -> String { + path.split('[') + .map(|part| part.split_once(']').map_or(part, |(_, rest)| rest)) + .collect::>() + .join("[]") +} + +/// Mask tools and combinations over the corrections of one model, counted. +#[derive(Default)] +pub struct MaskCounts { + /// Corrections seen. + pub corrections: usize, + /// Mask components per tool label. + pub tools: BTreeMap, + /// Mask components per combination label. + pub combines: BTreeMap<&'static str, usize>, + /// The fixed relations and forms the builders and the preset writer + /// rely on, counted over Lightroom's own components: a radial + /// gradient's `Flipped` against `MaskInverted`, a luminance range's + /// `CorrectionRangeMask.Invert` against `MaskInverted`, its `Version` + /// and its `SampleType` (a typed luminance range is `Type` 2). + pub forms: BTreeMap, +} + +impl MaskCounts { + /// Counts the corrections of `s`. + pub fn add(&mut self, s: &DevelopSettings) { + self.corrections += s.corrections.len(); + for m in s.corrections.iter().flat_map(|c| &c.masks) { + *self.tools.entry(tool_name(&m.tool)).or_default() += 1; + *self.combines.entry(combine_name(m.combine)).or_default() += 1; + let inverted = m.combine.encode().map(|(_, _, inverted)| inverted); + let mut form = |f: String| *self.forms.entry(f).or_default() += 1; + match &m.tool { + MaskTool::Radial(g) => form( + if Some(!g.flipped) == inverted { + "radial: Flipped = !MaskInverted" + } else { + "radial: Flipped != !MaskInverted" + } + .into(), + ), + MaskTool::LuminanceRange(lr) => { + form( + if Some(lr.invert) == inverted { + "luminance range: Invert = MaskInverted" + } else { + "luminance range: Invert != MaskInverted" + } + .into(), + ); + let int = |n| { + lr.rest + .get(n) + .and_then(Value::as_int) + .map_or_else(|| "none".to_owned(), |v| v.to_string()) + }; + form(format!("luminance range: Version {}", int("Version"))); + form(format!("luminance range: SampleType {}", int("SampleType"))); + } + _ => {} + } + } + } + + /// The counted forms that break a relation the builders rely on: + /// `Flipped` other than `!MaskInverted`, `Invert` other than + /// `MaskInverted`, a range mask `Version` other than 3. + pub fn broken_forms(&self) -> Vec<(&String, &usize)> { + self.forms + .iter() + .filter(|(f, _)| { + f.contains("!= ") + || (f.starts_with("luminance range: Version ") && !f.ends_with(" 3")) + }) + .collect() + } +} + +/// Counts, per key (`level/name`), the numbers outside the key's registry +/// range. Keys and counts only. +#[derive(Default)] +pub struct RangeCheck { + /// Numbers outside their key's range, per key. + pub outside: BTreeMap, +} + +impl RangeCheck { + fn number(&mut self, spec: &KeySpec, x: Finite) { + if let Some((min, max)) = spec.range { + if x < min || x > max { + *self + .outside + .entry(format!("{}/{}", spec.level, spec.name)) + .or_default() += 1; + } + } + } + + fn value(&mut self, spec: &KeySpec, v: &Value) { + match v { + Value::Int(_) | Value::Real(_) => self.number(spec, v.as_finite().unwrap()), + Value::Struct(s) => self.fields(&s.fields), + Value::StructList(items) => items.iter().for_each(|s| self.fields(&s.fields)), + Value::Tools(items) => items.iter().for_each(|f| self.fields(f)), + Value::Corrections(cs) => cs.iter().for_each(|c| self.correction(c)), + _ => {} + } + } + + fn fields(&mut self, f: &Fields) { + for (id, v) in &f.values { + self.value(id.spec(), v); + } + } + + fn correction(&mut self, c: &Correction) { + if let Some(a) = c.amount { + self.number( + lookup(Level::Correction, "CorrectionAmount") + .unwrap() + .spec(), + a, + ); + } + for (id, v) in &c.local { + self.value(id.spec(), v); + } + self.fields(&c.extra); + for m in &c.masks { + self.fields(&m.extra); + if let MaskTool::LuminanceRange(lr) = &m.tool { + self.fields(&lr.rest.fields); + } + } + } + + /// Checks every number in `s`, corrections and `Look` included. + pub fn settings(&mut self, s: &DevelopSettings) { + for (id, v) in s.values() { + self.value(id.spec(), v); + } + for c in &s.corrections { + self.correction(c); + } + if let Some(look) = &s.look { + if let Some(a) = look.amount { + let spec = lookup(Level::Struct(StructKind::Look), "Amount").unwrap(); + self.number(spec.spec(), a); + } + self.fields(&look.rest.fields); + } + } +} diff --git a/server-rs/crates/lrg-develop/tests/support/preset.rs b/server-rs/crates/lrg-develop/tests/support/preset.rs new file mode 100644 index 00000000..be4bc8fb --- /dev/null +++ b/server-rs/crates/lrg-develop/tests/support/preset.rs @@ -0,0 +1,275 @@ +//! What a written develop preset may carry, and whether it carries what the +//! policy filter kept: one definition for every test that writes presets +//! (`xmp_roundtrip.rs` over the committed fixtures, `xmp_goldens_local.rs` +//! over Adobe's bundle and a sidecar corpus, `training_dump_local.rs` over +//! the training dump). +//! +//! Both checks work on a preset read back by the XMP reader: +//! +//! - [`violations`]: every registry key must have a policy that reaches a +//! preset (LEARN, LEARN† or META, and not on the never-write list), apart +//! from the values the writer sets itself in Adobe's fixed form; nothing +//! may be kept whole ([`Value::Opaque`] at any depth, opaque entries). +//! - [`differences`]: the preset holds exactly the filtered settings +//! (`DevelopSettings::filtered(Target::Preset { .. })`), every value +//! rounded to its key's number format, apart from the writer's stamps and +//! fixed form. +//! +//! Included by path next to `support/flat.rs` (as `crate::flat`). + +use lrg_develop::model::{Correction, DevelopSettings, Fields, MaskTool, Value}; +use lrg_develop::registry::{KeySpec, Level, NumFmt, Policy, StructKind}; +use lrg_develop::xmp::format::reformat_compound; +use lrg_develop::xmp::write::never_written; +use lrg_develop::xmp::XmpDocument; + +use crate::flat::{self, Leaf}; + +/// Values outside the preset policy that the preset writer sets itself, +/// with the one value it uses: `WhiteBalance="Custom"` next to +/// white-balance numbers, an AI mask's `ReferencePoint` and `ErrorReason` +/// and a range mask's `SampleType` in Adobe's preset form. +pub fn writer_fixed_form(spec: &KeySpec, v: &Value) -> bool { + match (spec.level, spec.name) { + (Level::Global, "WhiteBalance") => v.as_str() == Some("Custom"), + (Level::MaskTool, "ReferencePoint") => v.as_str() == Some("0.500000 0.500000"), + (Level::MaskTool, "ErrorReason") => v.as_int() == Some(0), + (Level::Struct(StructKind::CorrectionRangeMask), "SampleType") => v.as_int() == Some(0), + _ => false, + } +} + +/// Whether a key may appear in a preset at all. +pub fn preset_key(spec: &KeySpec) -> bool { + !never_written(spec) + && matches!( + spec.policy, + Policy::Learn | Policy::LearnGated(_) | Policy::Meta + ) +} + +fn check_value(spec: &'static KeySpec, v: &Value, out: &mut Vec) { + let key = format!("{}/{}", spec.level, spec.name); + if let Value::Opaque(_) = v { + out.push(format!("kept whole {key}")); + } else if !preset_key(spec) && !writer_fixed_form(spec, v) { + out.push(format!("{} {key}", spec.policy.class_name())); + } + match v { + Value::Struct(s) => check_fields(&s.fields, out), + Value::StructList(items) => items.iter().for_each(|s| check_fields(&s.fields, out)), + Value::Tools(items) => items.iter().for_each(|f| check_fields(f, out)), + Value::Corrections(cs) => cs.iter().for_each(|c| check_correction(c, out)), + _ => {} + } +} + +fn check_fields(f: &Fields, out: &mut Vec) { + for (id, v) in &f.values { + check_value(id.spec(), v, out); + } + for o in &f.opaque { + out.push(format!("opaque ?{}", o.name)); + } +} + +fn check_correction(c: &Correction, out: &mut Vec) { + for (id, v) in &c.local { + check_value(id.spec(), v, out); + } + check_fields(&c.extra, out); + for m in &c.masks { + check_fields(&m.extra, out); + if let MaskTool::LuminanceRange(lr) = &m.tool { + check_fields(&lr.rest.fields, out); + } + } +} + +/// Every key of a read-back preset that must not be there, as +/// `" /"`, `"kept whole /"` or +/// `"opaque ?"`; one entry per occurrence. The reader's own count of +/// keys kept whole must be 0 too. +pub fn violations(doc: &XmpDocument) -> Vec { + let mut out = Vec::new(); + if let Some(h) = &doc.header { + check_fields(&h.rest, &mut out); + } + let d = &doc.develop; + for (id, v) in d.values() { + check_value(id.spec(), v, &mut out); + } + if let Some(l) = &d.look { + check_fields(&l.rest.fields, &mut out); + } + for c in &d.corrections { + check_correction(c, &mut out); + } + for o in &d.opaque { + out.push(format!("opaque ?{}", o.name)); + } + if doc.skipped_subtrees.kept_whole > 0 { + out.push("kept whole (the reader's count)".into()); + } + out +} + +// --- the preset holds the filtered settings ---------------------------- + +fn quantize_fields(f: &mut Fields) { + for (id, v) in f.values.iter_mut() { + *v = quantize(id.spec(), v); + } +} + +/// `v` as the preset writer spells it for `spec` and the reader reads it +/// back: numbers rounded to the key's format, a compound number string at +/// `%.6f`, containers field by field. +fn quantize(spec: &KeySpec, v: &Value) -> Value { + match v { + Value::Struct(s) => { + let mut s = s.clone(); + quantize_fields(&mut s.fields); + Value::Struct(s) + } + Value::StructList(items) => Value::StructList( + items + .iter() + .map(|s| { + let mut s = s.clone(); + quantize_fields(&mut s.fields); + s + }) + .collect(), + ), + Value::Tools(items) => Value::Tools( + items + .iter() + .map(|f| { + let mut f = f.clone(); + quantize_fields(&mut f); + f + }) + .collect(), + ), + Value::Corrections(cs) => Value::Corrections(cs.iter().map(quantize_correction).collect()), + Value::Str(s) if spec.fmt == NumFmt::CompoundFixed6 => { + Value::Str(reformat_compound(s).unwrap_or_else(|| s.clone())) + } + v => v.quantized(spec), + } +} + +fn quantize_correction(c: &Correction) -> Correction { + let mut c = c.clone(); + for (id, v) in c.local.iter_mut() { + *v = quantize(id.spec(), v); + } + quantize_fields(&mut c.extra); + for m in &mut c.masks { + quantize_fields(&mut m.extra); + if let MaskTool::LuminanceRange(lr) = &mut m.tool { + quantize_fields(&mut lr.rest.fields); + } + } + c +} + +/// `s` with every registry value quantized as the writer writes it. +pub fn quantized(s: &DevelopSettings) -> DevelopSettings { + let mut out = s.clone(); + for (id, v) in s.values() { + out.insert(id, quantize(id.spec(), v)) + .expect("same key, same kind"); + } + if let Some(l) = &mut out.look { + quantize_fields(&mut l.rest.fields); + } + out.corrections = s.corrections.iter().map(quantize_correction).collect(); + out +} + +/// The number of a leaf, if it is one. +fn number(l: &Leaf) -> Option { + match l { + Leaf::Number(x) => Some(x.get()), + Leaf::Value(Value::Int(i)) => Some(*i as f64), + Leaf::Value(Value::Real(x)) => Some(x.get()), + _ => None, + } +} + +/// Leaves equal as the writer and reader treat them: numbers by value +/// (within `%.6f`, the finest format), a 32-digit hex id in any case. +pub fn same(a: &Leaf, b: &Leaf) -> bool { + if let (Some(x), Some(y)) = (number(a), number(b)) { + return (x - y).abs() <= 5e-7; + } + let hex = |l: &Leaf| match l { + Leaf::Value(Value::Str(s)) if s.len() == 32 && s.bytes().all(|b| b.is_ascii_hexdigit()) => { + Some(s.to_ascii_uppercase()) + } + _ => None, + }; + match (hex(a), hex(b)) { + (Some(x), Some(y)) => x == y, + _ => a == b, + } +} + +/// What the writer adds to a preset on its own, with the one value it +/// writes: the version stamps and derived names at the top, the fixed form +/// of corrections, components and range masks. +fn writer_adds(path: &str, leaf: &Leaf) -> bool { + let top = !path.contains('.'); + let key = path.rsplit('.').next().unwrap_or(path); + let text = |s: &str| *leaf == Leaf::Value(Value::Str(s.into())); + let is = |x: f64| number(leaf) == Some(x); + let yes = *leaf == Leaf::Value(Value::Bool(true)); + let range = path.contains(".CorrectionRangeMask."); + match key { + "Version" | "CompatibleVersion" | "ProcessVersion" | "HasSettings" + | "ToneCurveName2012" + if top => + { + true + } + "WhiteBalance" if top => text("Custom"), + "What" => text("Correction"), + "CorrectionAmount" => is(1.0), + "CorrectionActive" | "MaskActive" => yes, + "MaskVersion" => is(1.0), + "ReferencePoint" => text("0.500000 0.500000"), + "ErrorReason" => is(0.0), + "Version" if range => is(3.0), + "SampleType" if range => is(0.0), + _ => false, + } +} + +/// How a read-back preset differs from the settings it was written from +/// after the policy filter (`filtered`): `"missing "`, +/// `"changed "`, `"extra "`, indices kept. Values are compared +/// as the writer spells them; the writer's stamps and fixed form and the +/// derived file kind are allowed. +pub fn differences(filtered: &DevelopSettings, back: &DevelopSettings) -> Vec { + let want = flat::settings(&quantized(filtered)); + let got = flat::settings(back); + let mut out = Vec::new(); + for (path, leaf) in &want { + if path == "(file kind)" { + continue; + } + match got.get(path) { + None => out.push(format!("missing {path}")), + Some(g) if !same(leaf, g) => out.push(format!("changed {path}")), + Some(_) => {} + } + } + for (path, leaf) in &got { + if path != "(file kind)" && !want.contains_key(path) && !writer_adds(path, leaf) { + out.push(format!("extra {path}")); + } + } + out +} diff --git a/server-rs/crates/lrg-develop/tests/training_dump_local.rs b/server-rs/crates/lrg-develop/tests/training_dump_local.rs new file mode 100644 index 00000000..5c4b0536 --- /dev/null +++ b/server-rs/crates/lrg-develop/tests/training_dump_local.rs @@ -0,0 +1,317 @@ +//! Local-only: the Lua reader over every row of the maintainer's private +//! training dump. +//! +//! The dump (`edit_training.lance` exported by +//! `server-rs/scripts/develop_registry/dump_training.py`) is private and never +//! enters the repository, so CI cannot run this. Point +//! `LRG_DEVELOP_TRAINING_DUMP` at it to run it: +//! +//! ```text +//! LRG_DEVELOP_TRAINING_DUMP=/path/to/training_rows.json \ +//! cargo test -p lrg-develop --test training_dump_local -- --nocapture +//! ``` +//! +//! Format: a JSON array of `{ "id", "metadata" }`, where `metadata` is a JSON +//! string whose `develop_settings` is itself a JSON string (the blob the +//! plugin sent) and `is_raw` the plugin's flag. +//! +//! Without the variable the test skips through the shared golden gate +//! (family `training-dump`, local-only: `LRG_REQUIRE_GOLDENS=all` does not +//! include it; `LRG_REQUIRE_GOLDENS=training-dump` turns the skip into a +//! failure). Setting the variable asks for the run, so a path that is not a +//! file is a failure, never a skip. +//! +//! Every row must parse without an error and without any warning (the rows +//! are Lightroom's own output, so any warning means the registry is wrong), +//! and every number must lie within its key's registry range. Every row is +//! also written as a develop preset, mixed and single-kind (the production +//! path: learned settings come from Lua): no write error, a preset without +//! a warning, no key outside the preset policy, and exactly the +//! policy-filtered settings (`support/preset.rs`). And every row goes +//! through the Lua writer (plan §7.3): losslessly (round trip 2: the same +//! model back, the same table again), through an XMP preset and back +//! (round trip 3: Lua → model → preset → model → Lua, equal after +//! quantisation; `support/lua_round.rs`), and in apply mode for its own +//! photo (`support/lua_apply.rs`: a table that passes `check_wire`, reads +//! back without a warning, holds every filtered value exactly or reports +//! it, and holds nothing beyond those and the writer's fixed form). Output names keys, row numbers and +//! counts only, never values or photo ids: the rows are private. + +#[path = "../../lrg-ml/tests/common/mod.rs"] +mod common; +// Only `settings` and `Leaf` are used here (through `preset`). +#[allow(dead_code)] +#[path = "support/flat.rs"] +mod flat; +#[allow(dead_code)] // the option flips and the single-table check are for the committed data +#[path = "support/lua_apply.rs"] +mod lua_apply; +// `round_trip_3_only` and the panicking helpers are for the committed tests. +#[allow(dead_code)] +#[path = "support/lua_round.rs"] +mod lua_round; +#[path = "support/preset.rs"] +mod preset; +mod support; + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use lrg_develop::build::{IdSlot, SyncNamespace}; +use lrg_develop::lua::from_lua_str; +use lrg_develop::model::{DevelopSettings, Target}; +use lrg_develop::xmp::{parse, write, PresetHeader, PresetSpec, WriteMode, XmpKind}; +use lrg_develop::FileKindHint; +use lua_apply::LuaApply; +use lua_round::LuaRoundTrips; +use serde_json::Value as J; +use support::{generic_path, MaskCounts, RangeCheck}; + +const VAR: &str = "LRG_DEVELOP_TRAINING_DUMP"; + +#[test] +fn every_training_row_parses_without_errors_or_unknown_keys() { + let path = match std::env::var_os(VAR) { + None => { + if !common::assets_ready("training-dump", false, &format!("{VAR} is not set")) { + return; + } + unreachable!("assets_ready panics when the family is required") + } + Some(p) => { + let p = PathBuf::from(p); + assert!(p.is_file(), "{VAR}={} is not a file", p.display()); + p + } + }; + let text = std::fs::read_to_string(&path).expect("read the dump"); + let rows: Vec = serde_json::from_str(&text).expect("the dump is a JSON array"); + assert!(!rows.is_empty(), "empty dump"); + + let mut errors: Vec = Vec::new(); + let mut per_kind: BTreeMap<&'static str, usize> = BTreeMap::new(); + // Key paths with indices removed, so one key counts once however often + // it appears; values are never printed. + let mut unexpected: BTreeMap = BTreeMap::new(); + let mut masks = MaskCounts::default(); + let mut process_versions: BTreeMap = BTreeMap::new(); + let mut ranges = RangeCheck::default(); + let mut presets = PresetCounts::default(); + let mut lua = LuaCounts::default(); + + // Rows are named by their position only: their ids are private photo ids. + for (i, row) in rows.iter().enumerate() { + let meta: J = match row["metadata"].as_str().map(serde_json::from_str) { + Some(Ok(m)) => m, + _ => { + errors.push(format!("row #{i}: metadata is not a JSON string")); + continue; + } + }; + let Some(blob) = meta["develop_settings"].as_str() else { + errors.push(format!("row #{i}: no develop_settings string")); + continue; + }; + let hint = FileKindHint::from(meta["is_raw"].as_bool()); + let (settings, warnings) = match from_lua_str(blob, hint) { + Ok(r) => r, + Err(e) => { + errors.push(format!("row #{i}: {e}")); + continue; + } + }; + ranges.settings(&settings); + for w in &warnings { + *per_kind.entry(w.kind.name()).or_default() += 1; + *unexpected + .entry(format!("{} {}", w.kind.name(), generic_path(&w.path))) + .or_default() += 1; + } + *process_versions + .entry( + settings + .process_version() + .map_or_else(|| "none".into(), |pv| pv.to_string()), + ) + .or_default() += 1; + masks.add(&settings); + presets.row(&settings); + lua.row(&settings); + } + + eprintln!( + "training dump: {} rows, {} parse errors", + rows.len(), + errors.len() + ); + eprintln!("warnings per kind: {per_kind:?}"); + eprintln!("process versions: {process_versions:?}"); + eprintln!( + "corrections: {}; mask tools: {:?}", + masks.corrections, masks.tools + ); + eprintln!("combinations: {:?}", masks.combines); + eprintln!("mask forms: {:?}", masks.forms); + eprintln!("values outside their registry range: {:?}", ranges.outside); + for (what, n) in &unexpected { + eprintln!(" {n:>5} {what}"); + } + presets.report(); + lua.report(); + + assert!(errors.is_empty(), "parse errors: {errors:#?}"); + // The dump is Lightroom's own output: any warning (an unknown key, a + // type mismatch, a file-kind or process-version finding, a kind added + // later) means the registry or the reader is wrong, not the row. An + // allow-list, empty today, so a new warning kind fails here too. + assert!( + per_kind.is_empty(), + "warnings (extend the registry or fix the reader): {unexpected:#?}" + ); + assert_eq!(masks.combines.get("unrecognised"), None); + assert!( + masks.broken_forms().is_empty(), + "mask components against a relation the builders rely on: {:?}", + masks.broken_forms() + ); + assert!( + ranges.outside.is_empty(), + "values outside the registry range (widen the range in registry/table.rs): {:#?}", + ranges.outside + ); + presets.assert_clean(); + lua.assert_clean(); +} + +/// Every row through the Lua writer: round trips 2 and 3 +/// (`support/lua_round.rs`) and for its own photo (`support/lua_apply.rs`); +/// counts only. +#[derive(Default)] +struct LuaCounts { + round: LuaRoundTrips, + apply: LuaApply, +} + +impl LuaCounts { + fn row(&mut self, settings: &DevelopSettings) { + self.round.settings(settings); + self.apply.settings(settings); + } + + fn report(&self) { + self.round.report(""); + self.apply.report(""); + } + + fn assert_clean(&self) { + self.round.assert_clean("training dump"); + self.apply.assert_clean("training dump"); + } +} + +/// Every row written as a preset, mixed and single-kind; counts only. +#[derive(Default)] +struct PresetCounts { + written: usize, + write_errors: BTreeMap, + parse_errors: usize, + not_a_preset: usize, + warnings: BTreeMap, + violations: BTreeMap, + differences: BTreeMap, + compared: usize, + skipped: BTreeMap, +} + +impl PresetCounts { + fn row(&mut self, settings: &DevelopSettings) { + let uuid = SyncNamespace::new("training_dump_local").id("row", IdSlot::Correction); + for mixed in [true, false] { + let mode = if mixed { "mixed" } else { "single-kind" }; + let spec = PresetSpec { + mixed_file_kinds: mixed, + ..PresetSpec::new(PresetHeader::lrgenius(uuid.clone(), "Row")) + }; + self.written += 1; + let written = match write(settings, &WriteMode::Preset(spec)) { + // The error's text can quote a value; only its variant is kept. + Err(e) => { + let variant = format!("{e:?}"); + let variant = variant.split(['(', ' ', '{']).next().unwrap_or("?"); + *self.write_errors.entry(variant.to_owned()).or_default() += 1; + continue; + } + Ok(w) => w, + }; + for k in &written.skipped { + // Only the reason's variant: a `WhiteBalanceMode` or a camera + // restriction carries a value. + let reason = format!("{:?}", k.reason); + let reason = reason.split(['(', ' ', '{']).next().unwrap_or("?"); + *self + .skipped + .entry(format!("{mode} {} {reason}", generic_path(&k.path))) + .or_default() += 1; + } + let Ok(back) = parse(written.xmp.as_bytes()) else { + self.parse_errors += 1; + continue; + }; + if back.kind != XmpKind::Preset { + self.not_a_preset += 1; + } + for w in &back.warnings { + *self + .warnings + .entry(format!("{} {}", w.kind.name(), w.key)) + .or_default() += 1; + } + for v in preset::violations(&back) { + *self.violations.entry(format!("{mode} {v}")).or_default() += 1; + } + let (filtered, _) = settings.filtered(&Target::Preset { + mixed_file_kinds: mixed, + }); + self.compared += flat::settings(&filtered).len(); + for d in preset::differences(&filtered, &back.develop) { + *self + .differences + .entry(format!("{mode} {}", generic_path(&d))) + .or_default() += 1; + } + } + } + + fn report(&self) { + eprintln!( + "as presets: {} written, write errors {:?}, parse errors {}, not a preset {}, \ + warnings {:?}, keys a preset must not carry {:?}", + self.written, + self.write_errors, + self.parse_errors, + self.not_a_preset, + self.warnings, + self.violations + ); + eprintln!( + "preset against the filtered settings: {} values compared, differing {:?}", + self.compared, self.differences + ); + eprintln!("held back from the presets (mode, key, reason: count):"); + for (what, n) in &self.skipped { + eprintln!(" {n:>5} {what}"); + } + } + + fn assert_clean(&self) { + assert!( + self.write_errors.is_empty() + && self.parse_errors == 0 + && self.not_a_preset == 0 + && self.warnings.is_empty() + && self.violations.is_empty() + && self.differences.is_empty(), + "writing the rows as presets is not clean (see the report above)" + ); + } +} diff --git a/server-rs/crates/lrg-develop/tests/xmp_fixtures.rs b/server-rs/crates/lrg-develop/tests/xmp_fixtures.rs new file mode 100644 index 00000000..729f1278 --- /dev/null +++ b/server-rs/crates/lrg-develop/tests/xmp_fixtures.rs @@ -0,0 +1,715 @@ +//! The XMP reader over every fixture in `testdata/develop/xmp/`. +//! +//! The fixtures are self-authored (no Adobe files, no excerpts of real +//! sidecars), one per syntax form or develop shape. Every one must parse +//! without a `ParseError`; only `unknown_keys.xmp` may produce an +//! `UnknownKey` warning and only it and `coercion_warnings.xmp` any warning +//! at all. Every fixture is listed in [`FIXTURES`], so a new file needs a +//! test, and uses only keys Lightroom writes to XMP (no Lua-only key). + +use std::path::PathBuf; + +use lrg_develop::model::{ + Combine, Correction, DevelopSettings, Fields, FileKind, Finite, MaskTool, Opaque, Semantic, + SensorPoint, Struct, Value, WbMode, XmpArrayKind, XmpValue, +}; +use lrg_develop::registry::{KeyId, Level, Presence, ProcessVersion, StructKind}; +use lrg_develop::xmp::{parse, XmpDocument, XmpKind}; +use lrg_develop::WarningKind; + +/// Every fixture file, in name order. +const FIXTURES: &[&str] = &[ + "alt_second_language.xmp", + "bag.xmp", + "bom.xmp", + "coercion_warnings.xmp", + "curves.xmp", + "element_form.xmp", + "look_stub.xmp", + "mask_brush.xmp", + "mask_combine.xmp", + "mask_linear.xmp", + "mask_radial.xmp", + "mask_range.xmp", + "masks_ai.xmp", + "multiple_descriptions.xmp", + "not_develop.xmp", + "number_formats.xmp", + "parse_type_resource.xmp", + "preset_header.xmp", + "preset_record.xmp", + "profile.xmp", + "snapshot_crss.xmp", + "unknown_keys.xmp", +]; + +/// The fixtures that exist to produce warnings. +const WITH_WARNINGS: &[&str] = &["coercion_warnings.xmp", "unknown_keys.xmp"]; + +fn xmp_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../testdata/develop/xmp") +} + +fn bytes(name: &str) -> Vec { + std::fs::read(xmp_dir().join(name)).unwrap_or_else(|e| panic!("{name}: {e}")) +} + +fn read(name: &str) -> XmpDocument { + parse(&bytes(name)).unwrap_or_else(|e| panic!("{name}: ParseError {e}")) +} + +fn warnings(d: &XmpDocument) -> Vec<(String, &'static str)> { + d.warnings + .iter() + .map(|w| (w.path.clone(), w.kind.name())) + .collect() +} + +fn real(x: f64) -> Value { + Value::Real(Finite::new_const(x)) +} + +fn f(x: f64) -> Finite { + Finite::new_const(x) +} + +fn points(p: &[(f64, f64)]) -> Value { + Value::Curve(p.iter().map(|&(x, y)| (f(x), f(y))).collect()) +} + +fn struct_of<'a>(s: &'a DevelopSettings, name: &str) -> &'a Struct { + match s.get_by_name(name) { + Some(Value::Struct(st)) => st, + other => panic!("{name}: {other:?}"), + } +} + +fn mask_field<'a>(c: &'a Correction, mask: usize, name: &str) -> Option<&'a Value> { + c.masks[mask].extra.get(Level::MaskTool, name) +} + +// ---- every fixture --------------------------------------------------------- + +#[test] +fn the_fixture_list_matches_the_folder() { + let mut names: Vec = std::fs::read_dir(xmp_dir()) + .expect("xmp fixture folder") + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .filter(|n| n.ends_with(".xmp")) + .collect(); + names.sort(); + assert_eq!(names, FIXTURES, "add a new fixture to FIXTURES and test it"); +} + +#[test] +fn every_fixture_parses_and_only_the_warning_fixtures_warn() { + for name in FIXTURES { + let d = read(name); + let unknown: Vec<&str> = d + .warnings + .iter() + .filter(|w| w.kind == WarningKind::UnknownKey) + .map(|w| w.path.as_str()) + .collect(); + if *name != "unknown_keys.xmp" { + assert!(unknown.is_empty(), "{name}: unknown keys {unknown:?}"); + } + if !WITH_WARNINGS.contains(name) { + assert!(d.warnings.is_empty(), "{name}: {:?}", d.warnings); + } + // Only the two fixtures built for it keep a registry key whole. + let kept_whole = match *name { + "bag.xmp" => 1, + "alt_second_language.xmp" => 2, + _ => 0, + }; + assert_eq!(d.skipped_subtrees.kept_whole, kept_whole, "{name}"); + } +} + +/// Every key id reachable from `v`. +fn value_keys(v: &Value, out: &mut Vec) { + match v { + Value::Struct(s) => fields_keys(&s.fields, out), + Value::StructList(items) => items.iter().for_each(|s| fields_keys(&s.fields, out)), + Value::Tools(items) => items.iter().for_each(|f| fields_keys(f, out)), + Value::Corrections(cs) => cs.iter().for_each(|c| correction_keys(c, out)), + _ => {} + } +} + +fn fields_keys(f: &Fields, out: &mut Vec) { + for (id, v) in &f.values { + out.push(*id); + value_keys(v, out); + } +} + +fn correction_keys(c: &Correction, out: &mut Vec) { + for (id, v) in &c.local { + out.push(*id); + value_keys(v, out); + } + fields_keys(&c.extra, out); + for m in &c.masks { + fields_keys(&m.extra, out); + } +} + +#[test] +fn fixtures_use_only_keys_lightroom_writes_to_xmp() { + for name in FIXTURES { + let d = read(name); + let mut ids = Vec::new(); + for (id, v) in d.develop.values() { + ids.push(id); + value_keys(v, &mut ids); + } + d.develop + .corrections + .iter() + .for_each(|c| correction_keys(c, &mut ids)); + if let Some(look) = &d.develop.look { + fields_keys(&look.rest.fields, &mut ids); + } + let lua_only: Vec<&str> = ids + .iter() + .map(|id| id.spec()) + .filter(|s| s.presence == Presence::LuaOnly) + .map(|s| s.name) + .collect(); + assert!(lua_only.is_empty(), "{name}: Lua-only keys {lua_only:?}"); + } +} + +// ---- per form ---------------------------------------------------------------- + +#[test] +fn preset_header_with_empty_alternatives() { + let d = read("preset_header.xmp"); + assert_eq!(d.kind, XmpKind::Preset); + let h = d.header.as_ref().expect("header"); + assert_eq!(h.preset_type.as_deref(), Some("Normal")); + assert_eq!( + h.uuid.as_ref().map(|u| u.as_str()), + Some("00000000000000000000000000000A01") + ); + assert_eq!(h.name.as_deref(), Some("Synthetic Text")); + assert_eq!(h.group.as_deref(), Some("Synthetic Group")); + // Empty `rdf:li` items are empty strings, not missing values. + assert_eq!(h.short_name.as_deref(), Some("")); + assert_eq!(h.sort_name.as_deref(), Some("")); + assert_eq!(h.description.as_deref(), Some("")); + assert_eq!(h.cluster.as_deref(), Some("")); + assert_eq!(h.supports_amount, Some(true)); + assert_eq!(h.supports_amount2, Some(true)); + assert_eq!(h.supports_color, Some(true)); + assert_eq!(h.supports_monochrome, Some(true)); + // An empty restriction means "none" and stays in `rest` as it was. + assert_eq!(h.camera_model_restriction, None); + assert_eq!( + h.rest.get(Level::Header, "CameraModelRestriction"), + Some(&Value::Str(String::new())) + ); + assert_eq!( + h.rest.get(Level::Header, "SupportsOutputReferred"), + Some(&Value::Bool(false)) + ); + assert_eq!( + h.rest.get(Level::Header, "ShowInQuickActions"), + Some(&Value::Bool(false)) + ); + assert_eq!( + h.rest.get(Level::Header, "RequiresRGBTables"), + Some(&Value::Bool(false)) + ); + let s = &d.develop; + assert_eq!(s.get_by_name("Exposure2012"), Some(&real(0.5))); + assert_eq!(s.get_by_name("Contrast2012"), Some(&Value::Int(10))); + assert_eq!(s.get_by_name("HasSettings"), Some(&Value::Bool(true))); + assert_eq!(s.get_by_name("Version"), Some(&Value::Str("18.5".into()))); + assert_eq!(s.file_kind, None, "a preset without white balance"); +} + +#[test] +fn number_formats_are_typed_by_the_registry() { + let d = read("number_formats.xmp"); + assert_eq!(d.kind, XmpKind::Sidecar); + let s = &d.develop; + let cases = [ + ("Exposure2012", real(1.35)), + ("Tint", Value::Int(6)), + ("Temperature", Value::Int(5500)), + ("Contrast2012", Value::Int(-12)), + ("Whites2012", Value::Int(0)), + ("Shadows2012", Value::Int(35)), + ("SharpenRadius", real(1.0)), + ("PerspectiveRotate", real(-1.5)), + ("PerspectiveX", real(0.5)), + ("CropTop", real(0.012345)), + ("CropLeft", real(0.0)), + ("CropRight", real(1.0)), + ("CropAngle", real(-0.8)), + ("CompatibleVersion", Value::Int(251_920_384)), + ("ConvertToGrayscale", Value::Bool(false)), + ("HasCrop", Value::Bool(true)), + ("AutoLateralCA", Value::Int(1)), + ("LensProfileEnable", Value::Int(1)), + ("WhiteBalance", Value::Str("Custom".into())), + ("ProcessVersion", Value::Str("15.4".into())), + ]; + for (key, want) in cases { + assert_eq!(s.get_by_name(key), Some(&want), "{key}"); + } + assert_eq!(s.process_version(), Some(ProcessVersion::V6)); + assert_eq!(s.file_kind, Some(FileKind::Raw)); + let wb = s.wb().expect("white balance"); + assert_eq!(wb.mode, WbMode::Custom); + // Struct booleans are lower case in XMP; both spellings read the same. + let blur = struct_of(s, "LensBlur"); + assert_eq!(blur.kind, StructKind::LensBlur); + assert_eq!(blur.get("Active"), Some(&Value::Bool(true))); + assert_eq!(blur.get("Version"), Some(&Value::Int(1))); + assert_eq!(blur.get("BlurAmount"), Some(&Value::Int(50))); +} + +#[test] +fn both_curve_kinds_read_into_points() { + let d = read("curves.xmp"); + let s = &d.develop; + assert_eq!( + s.get_by_name("ToneCurvePV2012"), + Some(&points(&[ + (0.0, 0.0), + (64.0, 58.0), + (192.0, 200.0), + (255.0, 255.0) + ])) + ); + assert_eq!( + s.get_by_name("ToneCurvePV2012Red"), + Some(&points(&[(0.0, 12.0), (255.0, 255.0)])) + ); + let c = &s.corrections[0]; + assert_eq!( + c.local_value("MainCurve"), + Some(&points(&[ + (0.0, 0.0), + (32.0, 16.0), + (128.0, 128.0), + (255.0, 255.0) + ])) + ); + assert_eq!( + c.local_value("RedCurve"), + Some(&points(&[(0.0, 0.0), (255.0, 240.0)])) + ); +} + +#[test] +fn ai_masks_are_classified_by_structure() { + let d = read("masks_ai.xmp"); + let tools: Vec<&MaskTool> = d + .develop + .corrections + .iter() + .map(|c| &c.masks[0].tool) + .collect(); + assert_eq!( + tools, + [ + &MaskTool::Semantic(Semantic::Subject), + &MaskTool::Semantic(Semantic::Sky), + &MaskTool::Semantic(Semantic::Background), + &MaskTool::Semantic(Semantic::PeoplePart(2)), + &MaskTool::Semantic(Semantic::Landscape(50002)), + &MaskTool::Semantic(Semantic::PersonPartAt { + part: 5, + point: SensorPoint { + x: f(0.433594), + y: f(0.659824) + } + }), + ] + ); + let c = &d.develop.corrections; + assert_eq!(c[0].local_value("LocalExposure2012"), Some(&real(0.0625))); + assert_eq!(c[1].amount, Some(f(0.8))); + assert_eq!(c[5].active, Some(false)); + assert_eq!(c[0].name.as_deref(), Some("Correction 1")); + assert_eq!( + c[0].sync_id.as_ref().map(|h| h.as_str()), + Some("00000000000000000000000000000C01") + ); + let m = &c[0].masks[0]; + assert_eq!(m.combine, Combine::Add { inverted: false }); + assert_eq!(m.name.as_deref(), Some("Mask 1")); + assert_eq!(m.active, Some(true)); + assert_eq!(mask_field(&c[0], 0, "MaskVersion"), Some(&Value::Int(1))); +} + +#[test] +fn a_linear_gradient_and_its_element_form_twin_read_the_same() { + let attrs = read("mask_linear.xmp"); + let elements = read("element_form.xmp"); + assert_eq!(attrs.develop, elements.develop); + let MaskTool::Linear(g) = &attrs.develop.corrections[0].masks[0].tool else { + panic!("linear gradient"); + }; + assert_eq!( + g.zero, + SensorPoint { + x: f(0.5), + y: f(0.55) + } + ); + assert_eq!( + g.full, + SensorPoint { + x: f(0.5), + y: f(0.2) + } + ); + assert_eq!( + attrs.develop.corrections[0].local_value("LocalExposure2012"), + Some(&real(-0.125)) + ); +} + +#[test] +fn radial_gradients_are_typed_only_without_rotation() { + let d = read("mask_radial.xmp"); + let c = &d.develop.corrections; + let MaskTool::Radial(r) = &c[0].masks[0].tool else { + panic!("radial gradient with Angle 0"); + }; + assert_eq!( + (r.top, r.left, r.bottom, r.right), + (f(0.4), f(0.3), f(0.6), f(0.7)) + ); + assert_eq!((r.feather, r.midpoint, r.roundness), (68, 50, 0)); + assert!(r.flipped); + assert_eq!( + c[1].masks[0].tool, + MaskTool::Opaque { + what: Some("Mask/CircularGradient".into()) + } + ); + // The rotated one keeps every field for the round trip. + assert_eq!(mask_field(&c[1], 0, "Angle"), Some(&real(-29.0882))); + assert_eq!(mask_field(&c[1], 0, "Flipped"), Some(&Value::Bool(false))); +} + +#[test] +fn range_masks_and_their_parse_type_twin_read_the_same() { + let plain = read("mask_range.xmp"); + let resource = read("parse_type_resource.xmp"); + assert_eq!(plain.develop, resource.develop); + let c = &plain.develop.corrections; + let MaskTool::LuminanceRange(l) = &c[0].masks[0].tool else { + panic!("luminance range"); + }; + assert_eq!(l.range, [f(0.0), f(0.1), f(0.35), f(0.5)]); + assert!(!l.invert); + assert_eq!(l.rest.get("Version"), Some(&Value::Int(3))); + assert_eq!( + l.rest.get("LuminanceDepthSampleInfo"), + Some(&Value::Str("0.250000 0.200000 0.300000".into())) + ); + // A colour range stays opaque with its whole CorrectionRangeMask. + assert_eq!( + c[1].masks[0].tool, + MaskTool::Opaque { + what: Some("Mask/RangeMask".into()) + } + ); + let Some(Value::Struct(crm)) = mask_field(&c[1], 0, "CorrectionRangeMask") else { + panic!("CorrectionRangeMask"); + }; + assert_eq!(crm.get("Type"), Some(&Value::Int(1))); + assert_eq!( + crm.get("PointModels"), + Some(&Value::StrList(vec![ + "0.255405 0.034566 0.419137 0.692175 0.651248 0".into() + ])) + ); +} + +#[test] +fn intersect_subtract_and_inverted_add() { + let d = read("mask_combine.xmp"); + let c = &d.develop.corrections; + let combos: Vec = c[0].masks.iter().map(|m| m.combine).collect(); + assert_eq!( + combos, + [ + Combine::Add { inverted: false }, + Combine::Subtract, + Combine::Intersect + ] + ); + assert_eq!(c[0].masks[1].tool, MaskTool::Semantic(Semantic::Sky)); + assert!(matches!(c[0].masks[2].tool, MaskTool::LuminanceRange(_))); + assert_eq!(c[1].masks[0].combine, Combine::Add { inverted: true }); + // The combination fields are consumed by the typed view. + assert!(mask_field(&c[0], 1, "MaskBlendMode").is_none()); +} + +#[test] +fn a_brush_aggregate_keeps_its_strokes() { + let d = read("mask_brush.xmp"); + let c = &d.develop.corrections[0]; + assert_eq!( + c.masks[0].tool, + MaskTool::Opaque { + what: Some("Mask/Aggregate".into()) + } + ); + let Some(Value::Tools(strokes)) = mask_field(c, 0, "Masks") else { + panic!("Masks"); + }; + assert_eq!(strokes.len(), 1); + let stroke = &strokes[0]; + assert_eq!( + stroke.get(Level::MaskTool, "What"), + Some(&Value::Str("Mask/Paint".into())) + ); + assert_eq!(stroke.get(Level::MaskTool, "Radius"), Some(&real(0.053214))); + assert_eq!( + stroke.get(Level::MaskTool, "Dabs"), + Some(&Value::StrList(vec![ + "d 0.520898 0.305817".into(), + "d 0.530000 0.310000".into() + ])) + ); +} + +#[test] +fn a_look_keeps_its_parameters_opaque() { + let d = read("look_stub.xmp"); + assert_eq!(d.skipped_subtrees.look_parameters, 1); + let look = d.develop.look.as_ref().expect("Look"); + assert_eq!(look.name.as_deref(), Some("Adobe Landscape")); + assert_eq!( + look.uuid.as_ref().map(|u| u.as_str()), + Some("6F9C877E84273F4E8271E6B91BEB36A1") + ); + assert_eq!(look.amount, Some(f(0.85))); + assert_eq!(look.camera_restriction, None); + assert_eq!( + look.rest.get("Group"), + Some(&Value::Alt("Synthetic Group".into())) + ); + assert_eq!(look.rest.get("SupportsAmount"), Some(&Value::Bool(false))); + let Some(Value::Opaque(Opaque::Xmp(params))) = look.rest.get("Parameters") else { + panic!("Parameters kept as XMP"); + }; + let XmpValue::Struct(fields) = ¶ms.value else { + panic!("Parameters is a structure"); + }; + let names: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect(); + assert_eq!(names, ["ProcessVersion", "ConvertToGrayscale"]); +} + +#[test] +fn a_preset_record_is_read_without_its_parameters() { + let d = read("preset_record.xmp"); + assert_eq!(d.kind, XmpKind::Sidecar); + assert_eq!(d.skipped_subtrees.preset_parameters, 1); + // The photo's own value, not the one inside Preset.Parameters. + assert_eq!(d.develop.get_by_name("Exposure2012"), Some(&real(0.3))); + let preset = struct_of(&d.develop, "Preset"); + assert_eq!(preset.kind, StructKind::Preset); + assert_eq!( + preset.get("Name"), + Some(&Value::Str("Synthetic Text".into())) + ); + assert_eq!(preset.get("Amount"), Some(&real(0.75))); + assert_eq!(preset.get("SupportsAmount"), Some(&Value::Bool(true))); + assert_eq!( + preset.get("Group"), + Some(&Value::Alt("Synthetic Group".into())) + ); + assert!(matches!( + preset.get("Parameters"), + Some(Value::Opaque(Opaque::Xmp(_))) + )); +} + +#[test] +fn snapshots_are_skipped_entirely() { + let d = read("snapshot_crss.xmp"); + assert_eq!(d.skipped_subtrees.crss, 1); + assert_eq!(d.develop.get_by_name("Exposure2012"), Some(&real(0.2))); + assert!(d.develop.opaque.is_empty()); + assert_eq!(d.develop.value_count(), 2); +} + +#[test] +fn several_descriptions_are_merged() { + let d = read("multiple_descriptions.xmp"); + assert_eq!(d.kind, XmpKind::Sidecar); + let s = &d.develop; + assert_eq!(s.get_by_name("Exposure2012"), Some(&real(0.25))); + assert_eq!(s.get_by_name("Vibrance"), Some(&Value::Int(10))); + assert_eq!(s.value_count(), 8); + assert_eq!(s.wb().expect("white balance").mode, WbMode::AsShot); + // xmp:ModifyDate, xmp:Rating, dc:format, dc:subject. + assert_eq!(d.skipped_subtrees.other_namespaces, 4); +} + +#[test] +fn unknown_keys_are_kept_and_reported_at_every_level() { + let d = read("unknown_keys.xmp"); + let expected = [ + ("FutureSlider2031", "UnknownKey"), + ("FutureStructure", "UnknownKey"), + ("LensBlur.FutureLensBlurField", "UnknownKey"), + ( + "MaskGroupBasedCorrections[0].LocalFutureAdjustment", + "UnknownKey", + ), + ("MaskGroupBasedCorrections[0].Annotation", "UnknownKey"), + ( + "MaskGroupBasedCorrections[0].CorrectionMasks[0].FutureMaskField", + "UnknownKey", + ), + ]; + let got = warnings(&d); + let got: Vec<(&str, &str)> = got.iter().map(|(p, k)| (p.as_str(), *k)).collect(); + assert_eq!(got, expected); + // Kept in document order, verbatim. + let top: Vec<&str> = d.develop.opaque.iter().map(|o| o.name.as_str()).collect(); + assert_eq!(top, ["FutureSlider2031", "FutureStructure"]); + let Opaque::Xmp(slider) = &d.develop.opaque[0].value else { + panic!("XMP node"); + }; + assert_eq!(slider.as_text(), Some("+7")); + let blur = struct_of(&d.develop, "LensBlur"); + assert_eq!(blur.fields.opaque[0].name, "FutureLensBlurField"); + let c = &d.develop.corrections[0]; + let foreign = c + .extra + .opaque + .iter() + .find(|o| o.name == "Annotation") + .expect("foreign field kept"); + assert_eq!(foreign.ns.as_deref(), Some("urn:lrgenius:test:extension")); + assert_eq!(c.masks[0].tool, MaskTool::Semantic(Semantic::Subject)); + assert_eq!(c.masks[0].extra.opaque[0].name, "FutureMaskField"); +} + +#[test] +fn a_bag_where_a_seq_belongs_is_kept_whole() { + let d = read("bag.xmp"); + let Some(Value::Opaque(Opaque::Xmp(node))) = d.develop.get_by_name("PointColors") else { + panic!("PointColors kept whole"); + }; + let XmpValue::Array { kind, items } = &node.value else { + panic!("array"); + }; + assert_eq!(*kind, XmpArrayKind::Bag); + assert_eq!(items.len(), 2); + assert_eq!( + d.develop.get_by_name("ColorVariance"), + Some(&Value::StrList(vec!["0.000000, 0.000000, 0.000000".into()])) + ); + assert_eq!(d.skipped_subtrees.other_namespaces, 1, "dc:subject"); + assert_eq!(d.skipped_subtrees.kept_whole, 1, "PointColors"); +} + +#[test] +fn a_second_language_keeps_the_alternative_whole() { + let d = read("alt_second_language.xmp"); + let h = d.header.as_ref().expect("header"); + assert_eq!(h.name, None); + assert_eq!(h.description, None); + assert_eq!(h.group.as_deref(), Some("Synthetic Group")); + assert_eq!(h.supports_amount, Some(false)); + for key in ["Name", "Description"] { + let Some(Value::Opaque(Opaque::Xmp(alt))) = h.rest.get(Level::Header, key) else { + panic!("{key} kept whole"); + }; + assert_eq!(alt.alt_default(), Some("Synthetic Text"), "{key}"); + let XmpValue::Array { items, .. } = &alt.value else { + panic!("{key}: array"); + }; + assert_eq!(items.len(), 2, "{key}"); + } + assert_eq!(d.skipped_subtrees.kept_whole, 2, "Name and Description"); +} + +#[test] +fn a_byte_order_mark_is_ignored() { + let with_bom = bytes("bom.xmp"); + assert_eq!( + &with_bom[..3], + b"\xEF\xBB\xBF", + "the fixture starts with a BOM" + ); + let d = read("bom.xmp"); + let without = parse(&with_bom[3..]).expect("parses without the BOM"); + assert_eq!(d, without); + assert_eq!(d.develop.get_by_name("Exposure2012"), Some(&real(0.5))); +} + +#[test] +fn a_profile_is_its_definition() { + let d = read("profile.xmp"); + assert_eq!(d.kind, XmpKind::Profile); + let h = d.header.as_ref().expect("header"); + assert_eq!(h.preset_type.as_deref(), Some("Look")); + assert_eq!(h.name.as_deref(), Some("Synthetic Profile")); + assert_eq!( + d.develop.get_by_name("ConvertToGrayscale"), + Some(&Value::Bool(true)) + ); +} + +#[test] +fn a_file_without_develop_settings_is_not_an_error() { + let d = read("not_develop.xmp"); + assert_eq!(d.kind, XmpKind::NotDevelop); + assert!(d.header.is_none()); + assert!(d.develop.is_empty()); + assert_eq!(d.skipped_subtrees.other_namespaces, 3); +} + +#[test] +fn coercion_problems_are_warnings_with_paths() { + let d = read("coercion_warnings.xmp"); + let expected = [ + ("Exposure2012", "WrongType"), + ("Contrast2012", "NonIntegerForIntKey"), + ("WhiteBalance", "ValueNotInSet"), + ("LensProfileDigest", "NotHex32"), + ("LensProfileEnable", "WrongType"), + ("ToneCurvePV2012", "WrongType"), + ( + "MaskGroupBasedCorrections[0].CorrectionMasks[0].MaskSubType", + "NonIntegerForIntKey", + ), + ( + "MaskGroupBasedCorrections[0].CorrectionMasks[0].MaskBlendMode", + "UnrecognisedMaskCombine", + ), + ]; + let got = warnings(&d); + let got: Vec<(&str, &str)> = got.iter().map(|(p, k)| (p.as_str(), *k)).collect(); + assert_eq!(got, expected); + let s = &d.develop; + // Rounded values stay in the model, wrong shapes go to `opaque`. + assert_eq!(s.get_by_name("Contrast2012"), Some(&Value::Int(12))); + assert_eq!( + s.get_by_name("WhiteBalance"), + Some(&Value::Str("Candlelight".into())) + ); + let opaque: Vec<&str> = s.opaque.iter().map(|o| o.name.as_str()).collect(); + assert_eq!( + opaque, + ["Exposure2012", "LensProfileEnable", "ToneCurvePV2012"] + ); + let m = &s.corrections[0].masks[0]; + assert_eq!(m.tool, MaskTool::Semantic(Semantic::Sky), "1.5 rounds to 2"); + assert_eq!(m.combine, Combine::Unrecognised); +} diff --git a/server-rs/crates/lrg-develop/tests/xmp_goldens_local.rs b/server-rs/crates/lrg-develop/tests/xmp_goldens_local.rs new file mode 100644 index 00000000..70004cf2 --- /dev/null +++ b/server-rs/crates/lrg-develop/tests/xmp_goldens_local.rs @@ -0,0 +1,1175 @@ +//! Local-only: the XMP reader and writer over real files that can never +//! enter the repository or CI: Lightroom Classic's own preset and profile +//! bundle, the user's Camera Raw presets, and a sidecar corpus. +//! +//! ```text +//! LRG_LRC_PRESETS_DIR="/Applications/Adobe Lightroom Classic/Adobe Lightroom Classic.app/Contents/Resources/Settings" \ +//! LRG_ACR_PRESETS_DIR="$HOME/Library/Application Support/Adobe/CameraRaw" \ +//! LRG_XMP_CORPUS_DIR=/path/to/photos LRG_XMP_CORPUS_SAMPLE=3000 \ +//! cargo test -p lrg-develop --test xmp_goldens_local -- --nocapture +//! ``` +//! +//! | Variable | Family | Files | Must hold | +//! |---|---|---|---| +//! | `LRG_LRC_PRESETS_DIR` | `lrc-presets` | every `*.xmp` below it | 0 `ParseError`, 0 `UnknownKey`, no warning outside [`ADOBE_ALLOWED`], 0 keys kept whole | +//! | `LRG_ACR_PRESETS_DIR` | `acr-presets` | the presets and profiles among the `*.xmp` below it (Camera Raw's user folder: its presets and profiles) | as the bundle; keys kept whole are only printed | +//! | `LRG_XMP_CORPUS_DIR` | `xmp-corpus` | sidecars with `crs:ProcessVersion` | 0 `ParseError`, no warning at all, 0 keys kept whole, every file a `Sidecar` | +//! +//! Camera Raw's folder also holds its own state in `crs:` form +//! (`Defaults/Preferences.xmp`, `Defaults/Previous.xmp`, `GPU/...`), which +//! is not a develop preset and uses keys no preset or sidecar carries; that +//! sweep therefore parses every file but checks only the presets and +//! profiles, and counts the rest per document kind. The bundle must consist +//! of presets and profiles only. +//! +//! Every sweep also fails on a mask combination the model does not +//! recognise, on a mask component against a relation the builders rely on +//! (`Flipped` other than `!MaskInverted`, a range's `Invert` other than +//! `MaskInverted`, a range `Version` other than 3) and on a number outside +//! its key's registry range: the files are Adobe's own output, so any of +//! these means the registry or the builders are wrong, not the file. +//! +//! Every checked document is also written back ([`RoundTrip`], +//! [`PresetOut`]): losslessly (round trip 1 of the plan: the same model, +//! header, kind and opaque content after reading back, idempotent bytes, +//! and every `crs:` value spelled as the source spelled it apart from +//! [`ADOBE_SPELLING`]/[`SIDECAR_SPELLING`]; differences counted per key) and +//! as a develop preset, mixed and single-kind (no write error, a preset +//! without warnings, without keys outside the preset policy or anything kept +//! whole, holding exactly the policy-filtered settings; what the policy +//! holds back is printed per mode, key and reason). A profile is not written +//! as a preset. Every other document also goes through the Lua writer for +//! its own photo (`support/lua_apply.rs`): a wire-safe table that reads +//! back without a warning, holds every policy-filtered value or reports +//! it, and holds nothing beyond those and the writer's fixed form; and, +//! without what has no Lua form, through round trips 2 and 3 of +//! the plan (`support/lua_round.rs`: model → Lua → model losslessly, and +//! Lua → model → preset → model → Lua equal after quantisation). [`lightroom_adaptive_presets_rebuild_with_the_builders`] rebuilds +//! the bundle's adaptive presets with the mask builders and compares them +//! field by field. +//! +//! The corpus walk skips `* [conflicted].xmp` (sync-conflict copies), +//! darktable's `..xmp` sidecars without any `crs:` property, and +//! every other file without `crs:ProcessVersion` (metadata-only sidecars), +//! and counts each group. `LRG_XMP_CORPUS_SAMPLE=N` parses a random sample +//! of N eligible files instead of all of them; the seed is fixed, so the +//! same corpus gives the same sample. +//! +//! An unset variable skips through the shared golden gate; the families are +//! local-only, so `LRG_REQUIRE_GOLDENS=all` does not include them and only +//! naming one (`LRG_REQUIRE_GOLDENS=lrc-presets`) turns its skip into a +//! failure. A set variable asks for the run: a path that is not a directory, +//! or a directory without a single eligible file, fails. +//! +//! Privacy: the corpus is private and the bundle is Adobe's. Output names +//! warning kinds, key names and counts, never a value; a corpus or +//! Camera Raw file is named by its ordinal only, a bundle file by its path +//! below the bundle root. + +#[path = "../../lrg-ml/tests/common/mod.rs"] +mod common; +#[path = "support/flat.rs"] +mod flat; +#[allow(dead_code)] // the option flips and the single-table check are for the committed data +#[path = "support/lua_apply.rs"] +mod lua_apply; +// `round_trip_3_only` and the panicking helpers are for the committed tests. +#[allow(dead_code)] +#[path = "support/lua_round.rs"] +mod lua_round; +#[path = "support/preset.rs"] +mod preset; +mod support; + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use lrg_develop::build::{BuildError, CorrectionBuilder, IdSlot, SyncNamespace}; +use lrg_develop::model::{ + Combine, Correction, DevelopSettings, LuminanceRange, MaskTool, Struct, Target, Value, +}; +use lrg_develop::registry::{self, to_ui, Level, StructKind}; +use lrg_develop::xmp::read::CRS_NS; +use lrg_develop::xmp::{ + parse, write, PresetHeader, PresetSpec, SkippedCounts, WriteError, WriteMode, XmpDocument, + XmpKind, +}; +use lrg_develop::{ParseError, WarningKind}; +use lua_apply::LuaApply; +use lua_round::{without_xmp_only, LuaRoundTrips}; +use support::{generic_path, MaskCounts, RangeCheck}; + +const PRESETS_VAR: &str = "LRG_LRC_PRESETS_DIR"; +const ACR_VAR: &str = "LRG_ACR_PRESETS_DIR"; +const CORPUS_VAR: &str = "LRG_XMP_CORPUS_DIR"; +const SAMPLE_VAR: &str = "LRG_XMP_CORPUS_SAMPLE"; +const SAMPLE_SEED: u64 = 42; + +/// Warnings Adobe's own preset and profile files legitimately produce, as +/// `(kind, key)`. +/// +/// - `DuplicateKey` on the header's `Group`: a few of the bundled camera +/// profiles (Google, Samsung and Xiaomi raw profiles) carry `crs:Group` +/// twice in the same `rdf:Description`, with different values. That is +/// invalid XMP in Adobe's file, not a registry gap: the reader keeps the +/// first and the second verbatim, and says so. +const ADOBE_ALLOWED: &[(&str, &str)] = &[("DuplicateKey", "Group")]; + +/// The directory in `var`, or `None` when the variable is unset (after the +/// gate has had its say). +fn dir_from_env(var: &str, family: &str) -> Option { + match std::env::var_os(var) { + None => { + if !common::assets_ready(family, false, &format!("{var} is not set")) { + return None; + } + unreachable!("assets_ready panics when the family is required") + } + Some(p) => { + let p = PathBuf::from(p); + assert!(p.is_dir(), "{var}={} is not a directory", p.display()); + Some(p) + } + } +} + +/// Every `*.xmp` (any case) below `dir`, sorted; symbolic links are not +/// followed. +fn xmp_files(dir: &Path) -> Vec { + let mut out = Vec::new(); + let mut stack = vec![dir.to_path_buf()]; + while let Some(d) = stack.pop() { + let entries = + std::fs::read_dir(&d).unwrap_or_else(|e| panic!("cannot list a directory: {e}")); + for entry in entries { + let entry = entry.unwrap_or_else(|e| panic!("cannot list a directory: {e}")); + let ty = entry + .file_type() + .unwrap_or_else(|e| panic!("cannot stat an entry: {e}")); + let path = entry.path(); + if ty.is_dir() { + stack.push(path); + } else if ty.is_file() + && path + .extension() + .is_some_and(|e| e.eq_ignore_ascii_case("xmp")) + { + out.push(path); + } + } + } + out.sort(); + out +} + +fn error_name(e: &ParseError) -> &'static str { + match e { + ParseError::Json(_) => "Json", + ParseError::TooLarge { .. } => "TooLarge", + ParseError::NotATable { .. } => "NotATable", + ParseError::Encoding(_) => "Encoding", + ParseError::Xml(_) => "Xml", + ParseError::NoRdf => "NoRdf", + ParseError::TooDeep { .. } => "TooDeep", + ParseError::Limit { .. } => "Limit", + } +} + +/// What one sweep saw, as counts and key names. +#[derive(Default)] +struct Sweep { + /// Check only presets and profiles; count other documents in + /// `not_presets` instead. + presets_only: bool, + not_presets: BTreeMap, + files: usize, + /// `label: error kind` per failing file. + errors: Vec, + kinds: BTreeMap, + warnings_per_kind: BTreeMap<&'static str, usize>, + /// Per `(kind, key)`: for the allow-list. + warnings_per_key: BTreeMap<(&'static str, String), usize>, + /// Per `kind path` with indices removed: for the report. + warnings_per_path: BTreeMap, + process_versions: BTreeMap, + skipped: SkippedCounts, + masks: MaskCounts, + ranges: RangeCheck, + round_trip: RoundTrip, + preset: PresetOut, + lua: LuaApply, + lua_round: LuaRoundTrips, +} + +/// Known differences between the source's spelling and the writer's, as +/// `(key, reason)`, for Lightroom's bundle and Camera Raw's folder. Each is +/// a spelling Adobe uses rarely for the key; the writer keeps the registry +/// row's, which is what Lightroom writes into sidecars. +const ADOBE_SPELLING: &[(&str, &str)] = &[ + ( + "Amount", + "Adobe's presets write Look.Amount as 1.000000, sidecars and the writer as 1", + ), + ( + "Clarity", + "legacy PV2010, COMPUTED: written with a + the registry row does not have", + ), + ( + "Exposure", + "legacy PV2010, COMPUTED: Adobe writes two decimals, the row is Trim6", + ), + ( + "PerspectiveX", + "Classic/General/Zeroed.xmp writes 0 where the row is Fixed(2)", + ), + ( + "PerspectiveY", + "Classic/General/Zeroed.xmp writes 0 where the row is Fixed(2)", + ), + ( + "LocalTemperature", + "one Adobe Sky preset writes two decimals (0.10), the row is Trim6", + ), + ( + "LocalShadows2012", + "a Camera Raw user preset writes two decimals, the row is Trim6", + ), +]; + +/// Known differences between Lightroom's sidecars and the writer's spelling +/// (all of them over the maintainer's 10,814 sidecars): each a key the +/// writer never puts into a preset. +const SIDECAR_SPELLING: &[(&str, &str)] = &[ + ( + "Clarity", + "legacy PV2010, COMPUTED: written with a + the registry row does not have", + ), + ( + "CropAngle", + "two sidecars write crop values untrimmed at %.6f, the row is Trim6 (PHOTO)", + ), + ( + "CropBottom", + "two sidecars write crop values untrimmed at %.6f, the row is Trim6 (PHOTO)", + ), + ( + "CropTop", + "two sidecars write crop values untrimmed at %.6f, the row is Trim6 (PHOTO)", + ), +]; + +impl Sweep { + fn file(&mut self, label: impl FnOnce() -> String, bytes: &[u8]) { + let doc = match parse(bytes) { + Ok(d) => d, + Err(e) => { + self.files += 1; + // The error's own text can quote the file; only its kind is + // printed. + self.errors.push(format!("{}: {}", label(), error_name(&e))); + return; + } + }; + let kind = format!("{:?}", doc.kind); + if self.presets_only && !matches!(doc.kind, XmpKind::Preset | XmpKind::Profile) { + *self.not_presets.entry(kind).or_default() += 1; + return; + } + self.files += 1; + *self.kinds.entry(kind).or_default() += 1; + for w in &doc.warnings { + let kind = w.kind.name(); + *self.warnings_per_kind.entry(kind).or_default() += 1; + *self + .warnings_per_key + .entry((kind, w.key.clone())) + .or_default() += 1; + *self + .warnings_per_path + .entry(format!("{kind} {}", generic_path(&w.path))) + .or_default() += 1; + } + *self + .process_versions + .entry( + doc.develop + .process_version() + .map_or_else(|| "none".into(), |pv| pv.to_string()), + ) + .or_default() += 1; + let s = &doc.skipped_subtrees; + self.skipped.crss += s.crss; + self.skipped.preset_parameters += s.preset_parameters; + self.skipped.look_parameters += s.look_parameters; + self.skipped.other_namespaces += s.other_namespaces; + self.skipped.kept_whole += s.kept_whole; + self.masks.add(&doc.develop); + self.ranges.settings(&doc.develop); + self.round_trip.file(&doc, bytes); + if doc.kind != XmpKind::Profile { + self.preset.file(&doc); + self.lua.settings(&doc.develop); + self.lua_round.settings(&without_xmp_only(&doc.develop)); + } + } + + fn report(&self, title: &str) { + eprintln!( + "{title}: {} files, {} parse errors", + self.files, + self.errors.len() + ); + eprintln!(" document kinds: {:?}", self.kinds); + if self.presets_only { + eprintln!(" not presets, not checked: {:?}", self.not_presets); + } + eprintln!(" warnings per kind: {:?}", self.warnings_per_kind); + eprintln!(" process versions: {:?}", self.process_versions); + eprintln!(" skipped subtrees: {:?}", self.skipped); + eprintln!( + " corrections: {}; mask tools: {:?}", + self.masks.corrections, self.masks.tools + ); + eprintln!(" combinations: {:?}", self.masks.combines); + eprintln!(" mask forms: {:?}", self.masks.forms); + eprintln!( + " values outside their registry range: {:?}", + self.ranges.outside + ); + for (what, n) in &self.warnings_per_path { + eprintln!(" {n:>6} {what}"); + } + self.round_trip.report(); + self.preset.report(); + self.lua.report(" "); + self.lua_round.report(" "); + } + + /// Fails on any parse error, any `UnknownKey`, any other warning not in + /// `allowed`, an unrecognised mask combination or a number outside its + /// registry range. Whether keys kept whole are allowed is up to the + /// caller ([`Self::assert_all_typed`]). + fn assert_clean(&self, title: &str, allowed: &[(&str, &str)]) { + assert!(self.files > 0, "{title}: no XMP file to check found"); + assert!( + self.errors.is_empty(), + "{title}: parse errors: {:#?}", + self.errors + ); + let unknown: Vec<&String> = self + .warnings_per_key + .keys() + .filter(|(k, _)| *k == WarningKind::UnknownKey.name()) + .map(|(_, key)| key) + .collect(); + assert!( + unknown.is_empty(), + "{title}: unknown keys (add a row to registry/table.rs): {unknown:?}" + ); + let unexpected: Vec = self + .warnings_per_key + .iter() + .filter(|((kind, key), _)| !allowed.iter().any(|(k, n)| k == kind && n == key)) + .map(|((kind, key), n)| format!("{n} x {kind} {key}")) + .collect(); + assert!( + unexpected.is_empty(), + "{title}: warnings (extend the registry or fix the reader): {unexpected:#?}" + ); + assert_eq!( + self.masks.combines.get("unrecognised"), + None, + "{title}: unrecognised mask combinations" + ); + assert!( + self.masks.broken_forms().is_empty(), + "{title}: mask components against a relation the builders rely on: {:?}", + self.masks.broken_forms() + ); + assert!( + self.ranges.outside.is_empty(), + "{title}: values outside the registry range (widen the range in registry/table.rs): {:#?}", + self.ranges.outside + ); + } + + /// Fails when a registry key was kept whole (an `rdf:Bag`, a second + /// language, qualifiers): Lightroom does not write those forms, so a + /// count means a key silently lost its typing. + fn assert_writes_back(&self, title: &str, spelling: &[(&str, &str)]) { + self.round_trip.assert_clean(title, spelling); + self.preset.assert_clean(title); + self.lua.assert_clean(title); + self.lua_round.assert_clean(title); + } + + fn assert_all_typed(&self, title: &str) { + assert_eq!( + self.skipped.kept_whole, 0, + "{title}: registry keys kept whole instead of typed (see SkippedCounts::kept_whole)" + ); + } +} + +/// Parses every `*.xmp` below `dir`. `by_path` names a failing file by its +/// path below `dir` (for Adobe's bundle), otherwise by its ordinal. +fn sweep_presets(dir: &Path, by_path: bool, presets_only: bool) -> Sweep { + let mut sweep = Sweep { + presets_only, + ..Sweep::default() + }; + for (i, path) in xmp_files(dir).iter().enumerate() { + let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("file #{i}: {e}")); + sweep.file( + || { + if by_path { + path.strip_prefix(dir).unwrap_or(path).display().to_string() + } else { + format!("file #{i}") + } + }, + &bytes, + ); + } + sweep +} + +#[test] +fn lightroom_bundle_presets_and_profiles_parse_cleanly() { + let Some(dir) = dir_from_env(PRESETS_VAR, "lrc-presets") else { + return; + }; + let sweep = sweep_presets(&dir, true, false); + sweep.report("Lightroom bundle"); + sweep.assert_clean("Lightroom bundle", ADOBE_ALLOWED); + sweep.assert_all_typed("Lightroom bundle"); + sweep.assert_writes_back("Lightroom bundle", ADOBE_SPELLING); + let other: Vec<&String> = sweep + .kinds + .keys() + .filter(|k| !matches!(k.as_str(), "Preset" | "Profile")) + .collect(); + assert!( + other.is_empty(), + "Lightroom bundle: documents that are neither a preset nor a profile: {other:?}" + ); +} + +#[test] +fn camera_raw_presets_parse_cleanly() { + let Some(dir) = dir_from_env(ACR_VAR, "acr-presets") else { + return; + }; + let sweep = sweep_presets(&dir, false, true); + sweep.report("Camera Raw presets"); + sweep.assert_clean("Camera Raw presets", ADOBE_ALLOWED); + sweep.assert_writes_back("Camera Raw presets", ADOBE_SPELLING); + // Printed in the report, not asserted: presets the user imported or + // wrote may carry a localized name, which is valid and kept whole. +} + +/// Why a corpus file was not parsed. +#[derive(Default, Debug)] +struct CorpusSkips { + conflicted: usize, + darktable: usize, + no_process_version: usize, +} + +/// The sidecars below `dir` that hold develop settings, sorted. +fn corpus_files(dir: &Path) -> (usize, Vec, CorpusSkips) { + let all = xmp_files(dir); + let mut skips = CorpusSkips::default(); + let mut eligible = Vec::new(); + for (i, path) in all.iter().enumerate() { + let name = path.file_name().unwrap_or_default().to_string_lossy(); + if name.ends_with(" [conflicted].xmp") { + skips.conflicted += 1; + continue; + } + let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("file #{i}: {e}")); + let text = String::from_utf8_lossy(&bytes); + if text.contains("crs:ProcessVersion") { + eligible.push(path.clone()); + continue; + } + // `photo.ext.xmp`: darktable's sidecar naming (Lightroom writes + // `photo.xmp`). + let double_extension = Path::new(path.file_stem().unwrap_or_default()) + .extension() + .is_some(); + if double_extension && !text.contains("crs:") { + skips.darktable += 1; + } else { + skips.no_process_version += 1; + } + } + (all.len(), eligible, skips) +} + +/// SplitMix64: a fixed-seed generator for the sample, so the test needs no +/// random-number crate and the same corpus always gives the same sample. +struct SplitMix64(u64); + +impl SplitMix64 { + fn next(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + + /// Uniform in `0..n` (`n > 0`); the modulo bias is irrelevant here. + fn below(&mut self, n: usize) -> usize { + (self.next() % n as u64) as usize + } +} + +/// `n` of `files` chosen with [`SAMPLE_SEED`] (a partial Fisher-Yates +/// shuffle of the sorted list), in path order; all of them when `n` is not +/// smaller. +fn sample(mut files: Vec, n: usize) -> Vec { + if n >= files.len() { + return files; + } + let mut rng = SplitMix64(SAMPLE_SEED); + for i in 0..n { + let j = i + rng.below(files.len() - i); + files.swap(i, j); + } + files.truncate(n); + files.sort(); + files +} + +#[test] +fn sample_is_deterministic_and_a_subset() { + let files: Vec = (0..100).map(|i| PathBuf::from(format!("{i:03}"))).collect(); + let a = sample(files.clone(), 10); + assert_eq!(a, sample(files.clone(), 10)); + assert_eq!(a.len(), 10); + assert!(a.windows(2).all(|w| w[0] < w[1]), "sorted, no repeats"); + assert!(a.iter().all(|p| files.contains(p))); + assert_eq!(sample(files.clone(), 1000), files); +} + +#[test] +fn sidecar_corpus_parses_cleanly() { + let Some(dir) = dir_from_env(CORPUS_VAR, "xmp-corpus") else { + return; + }; + let limit = std::env::var(SAMPLE_VAR).ok().map(|s| { + let n: usize = s + .trim() + .parse() + .unwrap_or_else(|_| panic!("{SAMPLE_VAR}={s} is not a number")); + assert!(n > 0, "{SAMPLE_VAR} must be at least 1"); + n + }); + let (seen, eligible, skips) = corpus_files(&dir); + let eligible_count = eligible.len(); + let files = match limit { + Some(n) => sample(eligible, n), + None => eligible, + }; + eprintln!( + "sidecar corpus: {seen} XMP files, {eligible_count} with crs:ProcessVersion, \ + {} parsed{}; skipped: {skips:?}", + files.len(), + limit.map_or_else(String::new, |n| format!( + " ({SAMPLE_VAR}={n}, seed {SAMPLE_SEED})" + )), + ); + let mut sweep = Sweep::default(); + // Corpus files are private: named by their ordinal in the parsed list. + for (i, path) in files.iter().enumerate() { + let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("file #{i}: {e}")); + sweep.file(|| format!("file #{i}"), &bytes); + } + sweep.report("sidecar corpus"); + // Lightroom's own output: any warning means the registry or the reader + // is wrong. An allow-list, empty today, so a new kind fails here too. + sweep.assert_clean("sidecar corpus", &[]); + sweep.assert_all_typed("sidecar corpus"); + sweep.assert_writes_back("sidecar corpus", SIDECAR_SPELLING); + // Eligible files carry crs:ProcessVersion, so each must read as a + // sidecar (this also proves ProcessVersion was read). + let other: Vec<_> = sweep + .kinds + .iter() + .filter(|(k, _)| k.as_str() != "Sidecar") + .collect(); + assert!( + other.is_empty(), + "sidecar corpus: eligible files that did not parse as a sidecar: {other:?}" + ); +} + +// --- writing back ------------------------------------------------------ + +fn write_error_name(e: &WriteError) -> &'static str { + match e { + WriteError::MissingHeaderField(_) => "MissingHeaderField", + WriteError::UnsupportedPresetType(_) => "UnsupportedPresetType", + WriteError::WrongValue { .. } => "WrongValue", + WriteError::NotHex32 { .. } => "NotHex32", + WriteError::NotCompound { .. } => "NotCompound", + WriteError::NotXmp { .. } => "NotXmp", + WriteError::InvalidChar { .. } => "InvalidChar", + } +} + +/// A path with pattern-family names folded (`Table_` → `Table_*`, +/// `UprightTransform_3` → `UprightTransform_*`): the md5 names a private +/// file's lookup table, and one line per family is enough. +fn family(path: &str) -> String { + path.split('.') + .map(|seg| match seg.rsplit_once('_') { + Some((head, tail)) + if !tail.is_empty() + && (tail.bytes().all(|b| b.is_ascii_digit()) + || (tail.len() == 32 && tail.bytes().all(|b| b.is_ascii_hexdigit()))) => + { + format!("{head}_*") + } + _ => seg.to_owned(), + }) + .collect::>() + .join(".") +} + +/// Adds one to `key`'s count. +fn bump(map: &mut BTreeMap, key: impl Into) { + *map.entry(key.into()).or_default() += 1; +} + +/// The keys two documents disagree on (model and header), indices removed. +fn differing_keys(a: &XmpDocument, b: &XmpDocument) -> Vec { + let empty = PresetHeader::default(); + let header = |d: &XmpDocument| flat::header(d.header.as_ref().unwrap_or(&empty)); + let mut keys = flat::diff(&flat::settings(&a.develop), &flat::settings(&b.develop)); + keys.extend(flat::diff(&header(a), &header(b))); + let mut keys: Vec = keys.iter().map(|k| generic_path(k)).collect(); + keys.dedup(); + keys +} + +const RDF_NS: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"; + +/// `(key, text)` → how often a document spells it. +type Spellings = BTreeMap<(String, String), usize>; + +/// Every `crs:` value text of a document's develop settings: attributes and +/// text-only property elements under their name, `rdf:li` texts under +/// `[]`, at any depth. Subtrees in other namespaces (`crss:` +/// snapshots, `dc:`, ...) are left out, as the model leaves them out. +/// `None` when the text is not UTF-8 XML roxmltree reads. +fn spellings(bytes: &[u8]) -> Option { + let text = std::str::from_utf8(bytes).ok()?; + let doc = roxmltree::Document::parse(text.trim_start_matches('\u{feff}')).ok()?; + let mut out = Spellings::new(); + for d in doc.descendants().filter(|n| { + n.has_tag_name((RDF_NS, "Description")) + && n.parent().is_some_and(|p| p.has_tag_name((RDF_NS, "RDF"))) + }) { + struct_spellings(d, &mut out); + } + Some(out) +} + +fn spell(out: &mut Spellings, key: String, text: &str) { + *out.entry((key, text.to_owned())).or_default() += 1; +} + +/// A description, a structure's property element or an `rdf:li`: its +/// `crs:` attributes, its `crs:` properties, nested descriptions. +fn struct_spellings(node: roxmltree::Node, out: &mut Spellings) { + for a in node.attributes() { + if a.namespace() == Some(CRS_NS) { + spell(out, a.name().to_owned(), a.value()); + } + } + for c in node.children().filter(|c| c.is_element()) { + if c.tag_name().namespace() == Some(CRS_NS) { + property_spellings(c, out); + } else if c.has_tag_name((RDF_NS, "Description")) { + struct_spellings(c, out); + } + } +} + +fn property_spellings(p: roxmltree::Node, out: &mut Spellings) { + let name = p.tag_name().name(); + let crs_attributes = p.attributes().any(|a| a.namespace() == Some(CRS_NS)); + let has_elements = p.children().any(|c| c.is_element()); + if !has_elements && !crs_attributes { + return spell(out, name.to_owned(), p.text().unwrap_or("")); + } + struct_spellings(p, out); + for array in p.children().filter(|c| { + ["Seq", "Alt", "Bag"] + .iter() + .any(|k| c.has_tag_name((RDF_NS, *k))) + }) { + for li in array.children().filter(|c| c.is_element()) { + let complex = li.children().any(|c| c.is_element()) + || li.attributes().any(|a| a.namespace() == Some(CRS_NS)); + if complex { + struct_spellings(li, out); + } else { + spell(out, format!("{name}[]"), li.text().unwrap_or("")); + } + } + } +} + +/// Round trip 1 (plan §7.3): XMP → model → XMP (lossless test mode) → +/// model gives the same model, header, kind and opaque content, and +/// writing the read-back model again gives the same bytes. Also the byte +/// rules against the source (plan §7.4): every `crs:` value the writer +/// emits is spelled as the source spelled it, compared as a multiset of +/// `(key, text)` per file ([`spellings`]); a known variance is allowed per +/// sweep by key name. +#[derive(Default)] +struct RoundTrip { + files: usize, + write_errors: BTreeMap, + parse_errors: usize, + /// Per key (indices removed): files whose key differs after the trip. + mismatches: BTreeMap, + /// Files whose model differs although no flattened key does. + unequal_models: usize, + kinds: usize, + not_idempotent: usize, + /// Files roxmltree could not read for the spelling check. + spelling_unread: usize, + /// Per key name: values spelled differently from the source. + spelling: BTreeMap, +} + +impl RoundTrip { + fn file(&mut self, doc: &XmpDocument, source: &[u8]) { + self.files += 1; + let mode = WriteMode::TestRoundTrip(doc.header.clone()); + let written = match write(&doc.develop, &mode) { + Ok(w) => w, + Err(e) => return bump(&mut self.write_errors, write_error_name(&e)), + }; + let Ok(back) = parse(written.xmp.as_bytes()) else { + self.parse_errors += 1; + return; + }; + let keys = differing_keys(doc, &back); + for k in &keys { + bump(&mut self.mismatches, k.clone()); + } + if keys.is_empty() && (back.develop != doc.develop || back.header != doc.header) { + self.unequal_models += 1; + } + if back.kind != doc.kind { + self.kinds += 1; + } + let again = write( + &back.develop, + &WriteMode::TestRoundTrip(back.header.clone()), + ); + if again.map(|w| w.xmp).ok().as_deref() != Some(written.xmp.as_str()) { + self.not_idempotent += 1; + } + match (spellings(source), spellings(written.xmp.as_bytes())) { + (Some(theirs), Some(ours)) => { + for ((key, text), n) in &theirs { + let m = ours.get(&(key.clone(), text.clone())).copied().unwrap_or(0); + if m != *n { + *self.spelling.entry(key.clone()).or_default() += n.abs_diff(m); + } + } + for ((key, text), n) in &ours { + if !theirs.contains_key(&(key.clone(), text.clone())) { + *self.spelling.entry(key.clone()).or_default() += n; + } + } + } + _ => self.spelling_unread += 1, + } + } + + fn report(&self) { + eprintln!( + " round trip: {} files, write errors {:?}, parse errors {}, keys differing {:?}, \ + unequal models {}, kind changed {}, not idempotent {}", + self.files, + self.write_errors, + self.parse_errors, + self.mismatches, + self.unequal_models, + self.kinds, + self.not_idempotent + ); + eprintln!( + " spelled unlike the source (key: values without a match, either side): {:?}; \ + files not read for it: {}", + self.spelling, self.spelling_unread + ); + } + + fn assert_clean(&self, title: &str, spelling: &[(&str, &str)]) { + assert!( + self.write_errors.is_empty() + && self.parse_errors == 0 + && self.mismatches.is_empty() + && self.unequal_models == 0 + && self.kinds == 0 + && self.not_idempotent == 0, + "{title}: the round trip is not lossless (see the report above)" + ); + let unexpected: Vec<&String> = self + .spelling + .keys() + .filter(|k| !spelling.iter().any(|(allowed, _)| allowed == k)) + .collect(); + assert!( + unexpected.is_empty() && self.spelling_unread == 0, + "{title}: values spelled unlike the source (check the registry row's \ + NumFmt/plus_sign/BoolStyle): {unexpected:?}" + ); + } +} + +/// Every develop preset and sidecar written as a preset (production mode), +/// mixed and single-kind: no write error, reads back as a preset without a +/// warning, carries no key outside the preset policy and nothing kept whole +/// ([`preset::violations`]), and holds exactly the policy-filtered settings +/// ([`preset::differences`]). What the policy filter holds back is counted +/// per key and reason, for the report. +#[derive(Default)] +struct PresetOut { + files: usize, + write_errors: BTreeMap, + parse_errors: usize, + not_a_preset: usize, + warnings: BTreeMap, + /// Keys a preset must not carry, per class and key. + violations: BTreeMap, + /// Filtered values the preset does not hold as written, per kind and + /// key (indices removed). + differences: BTreeMap, + /// Values compared. + compared: usize, + skipped: BTreeMap, +} + +fn preset_spec(label: &str, mixed_file_kinds: bool) -> WriteMode { + let uuid = SyncNamespace::new("xmp_goldens_local").id(label, IdSlot::Correction); + WriteMode::Preset(PresetSpec { + mixed_file_kinds, + ..PresetSpec::new(PresetHeader::lrgenius(uuid, "Sweep")) + }) +} + +impl PresetOut { + fn file(&mut self, doc: &XmpDocument) { + self.files += 1; + for mixed in [true, false] { + let mode = if mixed { "mixed" } else { "single-kind" }; + let written = match write(&doc.develop, &preset_spec("sweep", mixed)) { + Ok(w) => w, + Err(e) => { + bump(&mut self.write_errors, write_error_name(&e)); + continue; + } + }; + for s in &written.skipped { + bump( + &mut self.skipped, + format!("{mode} {} {:?}", family(&generic_path(&s.path)), s.reason), + ); + } + let Ok(back) = parse(written.xmp.as_bytes()) else { + self.parse_errors += 1; + continue; + }; + if back.kind != XmpKind::Preset { + self.not_a_preset += 1; + } + for w in &back.warnings { + bump(&mut self.warnings, format!("{} {}", w.kind.name(), w.key)); + } + for v in preset::violations(&back) { + bump(&mut self.violations, format!("{mode} {v}")); + } + let (filtered, _) = doc.develop.filtered(&Target::Preset { + mixed_file_kinds: mixed, + }); + self.compared += flat::settings(&filtered).len(); + for d in preset::differences(&filtered, &back.develop) { + bump( + &mut self.differences, + format!("{mode} {}", family(&generic_path(&d))), + ); + } + } + } + + fn report(&self) { + eprintln!( + " as presets: {} files (each mixed and single-kind), write errors {:?}, \ + parse errors {}, not a preset {}, warnings {:?}, keys a preset must not carry {:?}", + self.files, + self.write_errors, + self.parse_errors, + self.not_a_preset, + self.warnings, + self.violations + ); + eprintln!( + " preset against the filtered settings: {} values compared, differing {:?}", + self.compared, self.differences + ); + eprintln!(" held back from the presets (mode, key, reason: count):"); + for (what, n) in &self.skipped { + eprintln!(" {n:>6} {what}"); + } + } + + fn assert_clean(&self, title: &str) { + assert!( + self.write_errors.is_empty() + && self.parse_errors == 0 + && self.not_a_preset == 0 + && self.warnings.is_empty() + && self.violations.is_empty() + && self.differences.is_empty(), + "{title}: writing as a preset is not clean (see the report above)" + ); + } +} + +// --- builder parity ---------------------------------------------------- + +/// Why a correction of Adobe's could not be rebuilt, or a key of it was +/// left out. +fn build_error_name(e: &BuildError) -> String { + match e { + BuildError::NotWritable { key, .. } => format!("NotWritable {key}"), + BuildError::Ui(u) => format!("Ui {u:?}") + .split_whitespace() + .take(2) + .collect::>() + .join(" "), + other => format!("{other:?}") + .split(['(', ' ', '{']) + .next() + .unwrap_or("?") + .to_owned(), + } +} + +/// The builder calls for one of Adobe's corrections. Keys the builder +/// refuses (not writable: unverified scale or not learnable) are left out +/// and named in `left_out`; anything else that fails is the error. +fn rebuild( + c: &Correction, + role: &str, + left_out: &mut BTreeMap, +) -> Result { + let ns = SyncNamespace::new("builder parity"); + let mut b = CorrectionBuilder::new(role, &ns); + let amount_spec = registry::lookup(Level::Correction, "CorrectionAmount") + .unwrap() + .spec(); + if let Some(a) = c.amount { + let ui = to_ui(amount_spec, a).map_err(|e| format!("amount {e:?}"))?; + b = b.amount_ui(ui.get()).map_err(|e| build_error_name(&e))?; + } + for (id, v) in &c.local { + let spec = id.spec(); + let next = match v { + Value::Curve(points) => { + let pts: Option> = points + .iter() + .map(|(x, y)| { + let byte = |f: lrg_develop::model::Finite| { + let g = f.get(); + (g.fract() == 0.0 && (0.0..=255.0).contains(&g)).then_some(g as u8) + }; + Some((byte(*x)?, byte(*y)?)) + }) + .collect(); + let pts = pts.ok_or_else(|| format!("curve {} not in bytes", spec.name))?; + b.clone().local_curve(spec.name, &pts) + } + v => match v.as_finite().map(|x| to_ui(spec, x)) { + Some(Ok(ui)) => b.clone().local_ui(spec.name, ui.get()), + Some(Err(_)) | None => Err(BuildError::NotWritable { + key: spec.name.to_owned(), + reason: "no UI value", + }), + }, + }; + match next { + Ok(nb) => b = nb, + Err(e @ BuildError::NotWritable { .. }) => bump(left_out, build_error_name(&e)), + Err(e) => return Err(format!("{}: {}", spec.name, build_error_name(&e))), + } + } + for m in &c.masks { + let tool = match &m.tool { + MaskTool::LuminanceRange(lr) => MaskTool::LuminanceRange(LuminanceRange { + range: lr.range, + invert: lr.invert, + // Version and SampleType come from the writer. + rest: Struct::new(StructKind::CorrectionRangeMask), + }), + MaskTool::Opaque { what } => { + return Err(format!("opaque tool {}", what.as_deref().unwrap_or("?"))) + } + t => t.clone(), + }; + b = match m.combine { + Combine::Add { inverted: false } => b.add(tool), + Combine::Add { inverted: true } => b.add_inverted(tool), + Combine::Subtract => b.subtract(tool), + Combine::Intersect => b.intersect(tool), + Combine::Unrecognised => return Err("unrecognised combination".into()), + }; + } + b.build().map_err(|e| build_error_name(&e)) +} + +/// Names and sync ids are ours by design. +fn ours_by_design(path: &str) -> bool { + [ + "CorrectionName", + "CorrectionSyncID", + "MaskName", + "MaskSyncID", + ] + .iter() + .any(|k| path.ends_with(k)) +} + +fn correction_paths(s: &DevelopSettings) -> flat::Flat { + flat::settings(s) + .into_iter() + .filter(|(p, _)| p.starts_with("MaskGroupBasedCorrections[") && !ours_by_design(p)) + .collect() +} + +/// Plan §7.4: every correction of Adobe's adaptive presets, rebuilt with the +/// builders from the parsed model and written as a preset, reads back equal +/// field by field (names and sync ids apart) to Adobe's settings written by +/// the same writer. Against Adobe's file as it is, the only differences +/// allowed are fields the writer also holds back from Adobe's own +/// correction (digests, legacy PV2010 `Local*` keys, keys whose scale is not +/// verified); adjustments the builder refuses are counted in the report. +/// `CompatibleVersion` of both files is compared for the report only. +#[test] +fn lightroom_adaptive_presets_rebuild_with_the_builders() { + let Some(dir) = dir_from_env(PRESETS_VAR, "lrc-presets") else { + return; + }; + let mut presets = 0; + let mut corrections = 0; + let mut not_rebuilt: BTreeMap = BTreeMap::new(); + let mut left_out: BTreeMap = BTreeMap::new(); + let mut vs_file: BTreeMap = BTreeMap::new(); + let mut vs_file_held_back: BTreeMap = BTreeMap::new(); + let mut vs_writer: BTreeMap = BTreeMap::new(); + let mut compatible: BTreeMap = BTreeMap::new(); + let mut write_errors: BTreeMap = BTreeMap::new(); + for (i, path) in xmp_files(&dir).iter().enumerate() { + let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("file #{i}: {e}")); + let Ok(doc) = parse(&bytes) else { continue }; + if doc.kind != XmpKind::Preset || doc.develop.corrections.is_empty() { + continue; + } + presets += 1; + let mut ours = doc.develop.clone(); + for (j, c) in doc.develop.corrections.iter().enumerate() { + corrections += 1; + match rebuild(c, &format!("correction {j}"), &mut left_out) { + Ok(r) => ours.corrections[j] = r, + Err(why) => bump(&mut not_rebuilt, why), + } + } + let mode = |label: &str| match preset_spec(label, true) { + WriteMode::Preset(spec) => WriteMode::Preset(PresetSpec { + process_version: doc.develop.process_version(), + ..spec + }), + other => other, + }; + let written = match write(&ours, &mode("ours")) { + Ok(w) => w, + Err(e) => { + bump(&mut write_errors, write_error_name(&e)); + continue; + } + }; + let back = parse(written.xmp.as_bytes()).expect("our preset parses"); + assert!(back.warnings.is_empty(), "file #{i}: {:?}", back.warnings); + // Against Adobe's settings written by the same writer (both sides + // filtered by the same policy): must be equal. + let theirs = write(&doc.develop, &mode("theirs")).expect("Adobe's preset writes"); + let theirs = parse(theirs.xmp.as_bytes()).expect("parses"); + let theirs = correction_paths(&theirs.develop); + let ours = correction_paths(&back.develop); + for k in flat::diff(&theirs, &ours) { + bump(&mut vs_writer, generic_path(&k)); + } + // Against Adobe's file as it is: only what the writer holds back + // from Adobe's own correction too may differ. + let adobe = correction_paths(&doc.develop); + let held_back = flat::diff(&adobe, &theirs); + for k in flat::diff(&adobe, &ours) { + let key = generic_path(&k); + if held_back.contains(&k) { + bump(&mut vs_file_held_back, key); + } else { + bump(&mut vs_file, key); + } + } + let cv = |d: &DevelopSettings| d.get_by_name("CompatibleVersion").and_then(Value::as_int); + let version = |v: Option| { + v.map_or_else( + || "none".to_owned(), + |v| format!("{}.{}", v >> 24, (v >> 16) & 0xff), + ) + }; + bump( + &mut compatible, + format!( + "Adobe {}, ours {}", + version(cv(&doc.develop)), + version(cv(&back.develop)) + ), + ); + } + eprintln!("builder parity: {presets} adaptive presets, {corrections} corrections"); + eprintln!(" not rebuilt: {not_rebuilt:?}"); + eprintln!(" adjustments left out of the rebuild: {left_out:?}"); + eprintln!(" write errors: {write_errors:?}"); + eprintln!(" fields differing from Adobe's settings through our writer: {vs_writer:?}"); + eprintln!( + " fields differing from Adobe's file, held back by the writer from Adobe's too: \ + {vs_file_held_back:?}" + ); + eprintln!(" fields differing from Adobe's file otherwise: {vs_file:?}"); + eprintln!(" CompatibleVersion: {compatible:?}"); + assert!(presets > 0, "no adaptive preset with masks found"); + assert!( + not_rebuilt.is_empty(), + "corrections not rebuilt: {not_rebuilt:?}" + ); + assert!(write_errors.is_empty()); + assert!( + vs_writer.is_empty(), + "the builders do not reproduce Adobe's corrections: {vs_writer:?}" + ); + assert!( + vs_file.is_empty(), + "fields differing from Adobe's file that the writer does not hold back: {vs_file:?}" + ); +} diff --git a/server-rs/crates/lrg-develop/tests/xmp_lua_equivalence.rs b/server-rs/crates/lrg-develop/tests/xmp_lua_equivalence.rs new file mode 100644 index 00000000..b60c4045 --- /dev/null +++ b/server-rs/crates/lrg-develop/tests/xmp_lua_equivalence.rs @@ -0,0 +1,424 @@ +//! The XMP and Lua readers land in the same model. +//! +//! For each XMP fixture below, the same settings are written here by hand in +//! the form `photo:getDevelopSettings()` takes after JSON.lua (booleans as +//! JSON booleans, global curves as flat number lists, local curves as +//! `"x,y"` strings, language alternatives as `{"x-default": ...}`). Both +//! readers must produce equal [`DevelopSettings`]: the same key ids, value +//! variants, correction and mask classification, `Look` and file kind. +//! +//! Two differences are removed from both sides before comparing: +//! +//! - global keys that occur in only one format ([`Presence::XmpOnly`] such +//! as `HasSettings`/`HasCrop`, [`Presence::LuaOnly`] such as the `Enable*` +//! panel switches); +//! - `Look.Parameters`, which each reader keeps verbatim in its own format +//! (`Opaque::Json` vs `Opaque::Xmp`). +//! +//! The other differences that exist by design are not stripped; these +//! fixtures avoid them instead: +//! +//! - `PerFormat` keys (`PointColors`, `RetouchInfo`): a `StructList` from +//! Lua, a `StrList` from XMP; +//! - `ValueKind::Any` keys (`RGBTables`, `ProfileGainTableMap`, ...): +//! `Opaque::Json` vs `Opaque::Xmp`; +//! - struct-, correction- and mask-level Lua-only keys (`CorrectionID`, +//! `CorrectionReferenceX/Y`, `MaskID`, `Look.isAdobeAdaptive`); +//! - the `PointColors` sentinel (one item of 19 × `-1`) Lightroom writes +//! into every sidecar where `getDevelopSettings()` returns `[]`; +//! - opaque entries (unknown keys, wrong types), which differ the same way; +//! these fixtures have none (checked). + +use std::path::PathBuf; + +use lrg_develop::lua::from_lua_value; +use lrg_develop::model::DevelopSettings; +use lrg_develop::registry::{Level, Presence, StructKind}; +use lrg_develop::xmp::parse; +use lrg_develop::FileKindHint; +use serde_json::{json, Value as J}; + +fn xmp(name: &str) -> DevelopSettings { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../testdata/develop/xmp") + .join(name); + let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("{name}: {e}")); + let doc = parse(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); + assert!(doc.warnings.is_empty(), "{name}: {:?}", doc.warnings); + doc.develop +} + +fn lua(v: J) -> DevelopSettings { + let (s, w) = from_lua_value(&v, FileKindHint::Unknown).expect("a settings table"); + assert!(w.is_empty(), "{w:?}"); + s +} + +/// Removes what differs by format (see the module docs). +fn comparable(mut s: DevelopSettings) -> DevelopSettings { + assert!(s.opaque.is_empty(), "opaque entries: {:?}", s.opaque); + let one_format: Vec<_> = s + .values() + .map(|(id, _)| id) + .filter(|id| id.spec().presence != Presence::Both) + .collect(); + for id in one_format { + s.remove(id); + } + if let Some(look) = &mut s.look { + look.rest + .fields + .take(Level::Struct(StructKind::Look), "Parameters") + .expect("the fixtures' Look has Parameters"); + } + s +} + +fn assert_same(name: &str, lua_form: J) { + let from_xmp = comparable(xmp(name)); + let from_lua = comparable(lua(lua_form)); + assert_eq!(from_xmp, from_lua, "{name}"); +} + +/// One correction with a single component, as JSON.lua writes it. +fn correction(n: u32, sync: &str, local: J, mask: J) -> J { + let mut c = json!({ + "What": "Correction", + "CorrectionAmount": 1, + "CorrectionActive": true, + "CorrectionName": format!("Correction {n}"), + "CorrectionSyncID": sync, + "CorrectionMasks": [mask], + }); + for (k, v) in local.as_object().expect("local adjustments") { + c[k] = v.clone(); + } + c +} + +/// A `Mask/Image` component (added, not inverted). +fn ai_mask(n: u32, sync: &str, subtype: i64, extra: J) -> J { + let mut m = json!({ + "What": "Mask/Image", + "MaskActive": true, + "MaskName": format!("Mask {n}"), + "MaskBlendMode": 0, + "MaskInverted": false, + "MaskSyncID": sync, + "MaskValue": 1, + "MaskVersion": 1, + "MaskSubType": subtype, + }); + for (k, v) in extra.as_object().expect("extra fields") { + m[k] = v.clone(); + } + m +} + +#[test] +fn number_formats() { + assert_same( + "number_formats.xmp", + json!({ + // Synthetic: `Version` and `CompatibleVersion` are `Presence::Both` + // in the registry, but `getDevelopSettings()` returned neither in + // any training row; they are here to compare the two readers' + // typing of a version string and a packed version. + "Version": "18.5", "CompatibleVersion": 251920384, "ProcessVersion": "15.4", + "WhiteBalance": "Custom", "Temperature": 5500, "Tint": 6, + "Exposure2012": 1.35, "Contrast2012": -12, "Highlights2012": -40, + "Shadows2012": 35, "Whites2012": 0, "Blacks2012": -5, + "Texture": 8, "Clarity2012": 10, "Dehaze": 4, "Vibrance": 20, "Saturation": -5, + "Sharpness": 40, "SharpenRadius": 1, "SharpenDetail": 25, + "PostCropVignetteAmount": -20, "GrainAmount": 10, + "PerspectiveRotate": -1.5, "PerspectiveX": 0.5, + "ConvertToGrayscale": false, "AutoLateralCA": 1, "LensProfileEnable": 1, + "LensProfileSetup": "LensDefaults", "CameraProfile": "Adobe Standard", + "ToneCurveName2012": "Linear", + "CropTop": 0.012345, "CropLeft": 0, "CropBottom": 0.987654, "CropRight": 1, + "CropAngle": -0.8, + "LensBlur": {"Version": 1, "Active": true, "BlurAmount": 50, + "CatEyeAmount": 0, "HighlightsBoost": 50}, + // Lua-only panel switch: present in every Lua table, never in XMP. + "EnableDetail": true + }), + ); +} + +#[test] +fn curves() { + assert_same( + "curves.xmp", + json!({ + "ProcessVersion": "15.4", + "ToneCurveName2012": "Custom", + "ToneCurvePV2012": [0, 0, 64, 58, 192, 200, 255, 255], + "ToneCurvePV2012Red": [0, 12, 255, 255], + "ToneCurvePV2012Green": [0, 0, 255, 255], + "ToneCurvePV2012Blue": [0, 0, 255, 240], + "MaskGroupBasedCorrections": [correction( + 1, + "00000000000000000000000000000B01", + json!({"LocalExposure2012": 0, "LocalContrast2012": 0.15, + "MainCurve": ["0,0", "32,16", "128,128", "255,255"], + "RedCurve": ["0,0", "255,240"]}), + ai_mask(1, "00000000000000000000000000000B02", 1, json!({})), + )] + }), + ); +} + +#[test] +fn ai_masks() { + let mut c = vec![ + correction( + 1, + "00000000000000000000000000000C01", + json!({"LocalExposure2012": 0.0625, "LocalClarity2012": 0.1}), + ai_mask(1, "00000000000000000000000000000C02", 1, json!({})), + ), + correction( + 2, + "00000000000000000000000000000C03", + json!({"LocalHighlights2012": -0.3, "LocalSaturation": 0.15, "CorrectionAmount": 0.8}), + ai_mask(2, "00000000000000000000000000000C04", 2, json!({})), + ), + correction( + 3, + "00000000000000000000000000000C05", + json!({"LocalExposure2012": -0.05}), + ai_mask( + 3, + "00000000000000000000000000000C06", + 0, + json!({"MaskSubCategoryID": 22}), + ), + ), + correction( + 4, + "00000000000000000000000000000C07", + json!({"LocalTexture": -0.2}), + ai_mask( + 4, + "00000000000000000000000000000C08", + 3, + json!({"MaskSubCategoryID": 2}), + ), + ), + correction( + 5, + "00000000000000000000000000000C09", + json!({"LocalClarity2012": 0.25}), + ai_mask( + 5, + "00000000000000000000000000000C0A", + 0, + json!({"MaskSubCategoryID": 50002}), + ), + ), + correction( + 6, + "00000000000000000000000000000C0B", + json!({"LocalWhites2012": 0.1}), + ai_mask( + 6, + "00000000000000000000000000000C0C", + 0, + json!({"MaskSubCategoryID": 5, "ReferencePoint": "0.433594 0.659824"}), + ), + ), + ]; + c[5]["CorrectionActive"] = json!(false); + assert_same( + "masks_ai.xmp", + json!({"ProcessVersion": "15.4", "MaskGroupBasedCorrections": c}), + ); +} + +#[test] +fn linear_gradient() { + assert_same( + "mask_linear.xmp", + json!({"ProcessVersion": "15.4", "MaskGroupBasedCorrections": [correction( + 1, + "00000000000000000000000000000D01", + json!({"LocalExposure2012": -0.125, "LocalDehaze": 0.2}), + json!({"What": "Mask/Gradient", "MaskActive": true, "MaskName": "Mask 1", + "MaskBlendMode": 0, "MaskInverted": false, + "MaskSyncID": "00000000000000000000000000000D02", "MaskValue": 1, + "ZeroX": 0.5, "ZeroY": 0.55, "FullX": 0.5, "FullY": 0.2}), + )]}), + ); +} + +#[test] +fn radial_gradients() { + let radial = |n: u32, sync: &str, geometry: J| { + let mut m = json!({"What": "Mask/CircularGradient", "MaskActive": true, + "MaskName": format!("Mask {n}"), "MaskBlendMode": 0, + "MaskInverted": false, "MaskSyncID": sync, "MaskValue": 1, + "Version": 2, "Midpoint": 50, "Roundness": 0}); + for (k, v) in geometry.as_object().unwrap() { + m[k] = v.clone(); + } + m + }; + assert_same( + "mask_radial.xmp", + json!({"ProcessVersion": "15.4", "MaskGroupBasedCorrections": [ + correction( + 1, + "00000000000000000000000000000E01", + json!({"LocalExposure2012": 0.1}), + radial(1, "00000000000000000000000000000E02", + json!({"Top": 0.4, "Left": 0.3, "Bottom": 0.6, "Right": 0.7, + "Angle": 0, "Feather": 68, "Flipped": true})), + ), + correction( + 2, + "00000000000000000000000000000E03", + json!({"LocalExposure2012": -0.2}), + radial(2, "00000000000000000000000000000E04", + json!({"Top": 0.478149, "Left": 0.15409, "Bottom": 0.635755, + "Right": 0.739495, "Angle": -29.0882, "Feather": 45, + "Flipped": false})), + ), + ]}), + ); +} + +fn range_mask(n: u32, sync: &str, crm: J, blend: (i64, i64, bool)) -> J { + json!({"What": "Mask/RangeMask", "MaskActive": true, "MaskName": format!("Mask {n}"), + "MaskBlendMode": blend.0, "MaskValue": blend.1, "MaskInverted": blend.2, + "MaskSyncID": sync, "CorrectionRangeMask": crm}) +} + +#[test] +fn range_masks() { + assert_same( + "mask_range.xmp", + json!({"ProcessVersion": "15.4", "MaskGroupBasedCorrections": [ + correction( + 1, + "00000000000000000000000000000F01", + json!({"LocalShadows2012": 0.2}), + range_mask(1, "00000000000000000000000000000F02", + json!({"Version": 3, "Type": 2, "Invert": false, "SampleType": 0, + "LumRange": "0.000000 0.100000 0.350000 0.500000", + "LuminanceDepthSampleInfo": "0.250000 0.200000 0.300000"}), + (0, 1, false)), + ), + correction( + 2, + "00000000000000000000000000000F03", + json!({"LocalSaturation": -0.3}), + range_mask(2, "00000000000000000000000000000F04", + json!({"Version": 3, "Type": 1, "ColorAmount": 0.494949, "Invert": false, + "SampleType": 0, + "PointModels": ["0.255405 0.034566 0.419137 0.692175 0.651248 0"]}), + (0, 1, false)), + ), + ]}), + ); +} + +#[test] +fn combinations() { + let mut first = correction( + 1, + "00000000000000000000000000001001", + json!({"LocalExposure2012": 0.125}), + ai_mask(1, "00000000000000000000000000001002", 1, json!({})), + ); + first["CorrectionMasks"].as_array_mut().unwrap().extend([ + ai_mask( + 2, + "00000000000000000000000000001003", + 2, + json!({"MaskBlendMode": 1, "MaskValue": 0}), + ), + range_mask( + 3, + "00000000000000000000000000001004", + json!({"Version": 3, "Type": 2, "Invert": false, "SampleType": 0, + "LumRange": "0.500000 0.600000 1.000000 1.000000"}), + (1, 0, true), + ), + ]); + let second = correction( + 2, + "00000000000000000000000000001005", + json!({"LocalExposure2012": -0.1}), + ai_mask( + 4, + "00000000000000000000000000001006", + 1, + json!({"MaskInverted": true}), + ), + ); + assert_same( + "mask_combine.xmp", + json!({"ProcessVersion": "15.4", "MaskGroupBasedCorrections": [first, second]}), + ); +} + +#[test] +fn brush_aggregate() { + assert_same( + "mask_brush.xmp", + json!({"ProcessVersion": "15.4", "MaskGroupBasedCorrections": [correction( + 1, + "00000000000000000000000000001101", + json!({"LocalExposure2012": 0.05}), + json!({"What": "Mask/Aggregate", "MaskActive": true, "MaskName": "Mask 1", + "MaskBlendMode": 0, "MaskInverted": false, + "MaskSyncID": "00000000000000000000000000001102", "MaskValue": 1, + "Masks": [{"What": "Mask/Paint", "MaskActive": true, "MaskBlendMode": 0, + "MaskInverted": false, + "MaskSyncID": "00000000000000000000000000001103", + "MaskValue": 1, "Radius": 0.053214, "Flow": 1, + "CenterWeight": 0, + "Dabs": ["d 0.520898 0.305817", "d 0.530000 0.310000"]}]}), + )]}), + ); +} + +#[test] +fn look() { + assert_same( + "look_stub.xmp", + json!({ + "ProcessVersion": "15.4", + "CameraProfile": "Adobe Standard", + "ConvertToGrayscale": false, + "Look": { + "Name": "Adobe Landscape", + "Amount": 0.85, + "UUID": "6F9C877E84273F4E8271E6B91BEB36A1", + "SupportsAmount": false, + "SupportsMonochrome": false, + "SupportsOutputReferred": false, + "Copyright": "Synthetic copyright notice", + "Group": {"x-default": "Synthetic Group"}, + "Parameters": {"ProcessVersion": "15.4", "ConvertToGrayscale": false} + } + }), + ); +} + +#[test] +fn the_comparison_is_not_vacuous() { + // A changed value on either side must break the equality. + let from_xmp = comparable(xmp("mask_linear.xmp")); + let changed = comparable(lua(json!({"ProcessVersion": "15.4", + "MaskGroupBasedCorrections": [correction( + 1, + "00000000000000000000000000000D01", + json!({"LocalExposure2012": -0.125, "LocalDehaze": 0.2}), + json!({"What": "Mask/Gradient", "MaskActive": true, "MaskName": "Mask 1", + "MaskBlendMode": 0, "MaskInverted": false, + "MaskSyncID": "00000000000000000000000000000D02", "MaskValue": 1, + "ZeroX": 0.5, "ZeroY": 0.55, "FullX": 0.5, "FullY": 0.25}), + )]}))); + assert_ne!(from_xmp, changed); +} diff --git a/server-rs/crates/lrg-develop/tests/xmp_roundtrip.rs b/server-rs/crates/lrg-develop/tests/xmp_roundtrip.rs new file mode 100644 index 00000000..cd1b7ea6 --- /dev/null +++ b/server-rs/crates/lrg-develop/tests/xmp_roundtrip.rs @@ -0,0 +1,421 @@ +//! The XMP writer against the reader (plan §7.3, round trip 1 and the key +//! enumeration), on committed data only; the same round trip over +//! Lightroom's bundle and a sidecar corpus is in `xmp_goldens_local.rs`. +//! +//! - Every fixture in `testdata/develop/xmp/` and every writer golden in +//! `testdata/develop/written/`: XMP → model → XMP → model gives the same +//! model, header, kind and opaque content, and writing the result again +//! gives the same bytes. +//! - Every LEARN and PHOTO key Lightroom writes to XMP, at every level: +//! its minimum, maximum, default and midpoint (and a negative value for a +//! signed range; every value of a closed set) survive a lossless write and +//! parse exactly; the LEARN ones also survive a preset, after rounding to +//! the key's number format ([`Value::quantized`]); what a gate holds back +//! is pinned per key, gate and mode. +//! - A preset written from every Lua and XMP fixture (mixed and single-kind) +//! reads back as a preset with no warning, without any key whose policy +//! does not reach a preset or anything kept whole, and with exactly the +//! policy-filtered settings, rounded as written (`support/preset.rs`). +//! +//! The lossless mode (`WriteMode::TestRoundTrip`) exists only with the crate +//! feature `test-roundtrip`, which this crate's own dev-dependency turns on +//! for its tests. + +#[path = "support/enumerate.rs"] +mod enumerate; +#[path = "support/flat.rs"] +mod flat; +#[path = "support/preset.rs"] +mod preset; + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use enumerate::{bare_component, correction, f, kid, leaf, learn_or_photo, one, place, samples}; +use lrg_develop::build::{IdSlot, SyncNamespace}; +use lrg_develop::lua::from_lua_str; +use lrg_develop::model::{ + Combine, Correction, DevelopSettings, Fields, Hex32, Look, MaskComponent, MaskTool, Semantic, + Struct, Target, Value, +}; +use lrg_develop::registry::{self, KeyId, Level, Presence, ProcessVersion, StructKind, ValueKind}; +use lrg_develop::xmp::{parse, write, PresetHeader, PresetSpec, WriteMode, XmpDocument, XmpKind}; +use lrg_develop::FileKindHint; + +fn testdata(dir: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../testdata/develop") + .join(dir) +} + +/// The files with `extension` in `testdata/develop/`, sorted. +fn files(dir: &str, extension: &str) -> Vec<(String, Vec)> { + let mut out: Vec<(String, Vec)> = std::fs::read_dir(testdata(dir)) + .unwrap() + .map(|e| e.unwrap().path()) + .filter(|p| p.extension().and_then(|e| e.to_str()) == Some(extension)) + .map(|p| { + let name = p.file_name().unwrap().to_string_lossy().into_owned(); + (name, std::fs::read(&p).unwrap()) + }) + .collect(); + out.sort(); + out +} + +/// Writes `doc` losslessly and parses the result. +fn round_trip(doc: &XmpDocument) -> (String, XmpDocument) { + let written = write(&doc.develop, &WriteMode::TestRoundTrip(doc.header.clone())) + .unwrap_or_else(|e| panic!("write: {e}")); + assert!(written.skipped.is_empty(), "{:?}", written.skipped); + let back = parse(written.xmp.as_bytes()).unwrap_or_else(|e| panic!("{e}\n{}", written.xmp)); + (written.xmp, back) +} + +/// The keys two documents disagree on (model and header). +fn differences(a: &XmpDocument, b: &XmpDocument) -> Vec { + let mut d = flat::diff(&flat::settings(&a.develop), &flat::settings(&b.develop)); + let empty = PresetHeader::default(); + let header = |doc: &XmpDocument| flat::header(doc.header.as_ref().unwrap_or(&empty)); + d.extend(flat::diff(&header(a), &header(b))); + d +} + +#[test] +fn every_xmp_fixture_round_trips_losslessly_and_deterministically() { + let fixtures = files("xmp", "xmp"); + assert!(fixtures.len() >= 20, "only {} fixtures", fixtures.len()); + let written = files("written", "xmp"); + assert!(!written.is_empty(), "no writer golden"); + for (name, bytes) in fixtures.iter().chain(&written) { + let doc = parse(bytes).unwrap_or_else(|e| panic!("{name}: {e}")); + let (xmp, back) = round_trip(&doc); + assert_eq!(differences(&doc, &back), Vec::::new(), "{name}"); + assert_eq!(back.develop, doc.develop, "{name}"); + assert_eq!(back.header, doc.header, "{name}"); + assert_eq!(back.kind, doc.kind, "{name}"); + assert_eq!( + back.skipped_subtrees.kept_whole, doc.skipped_subtrees.kept_whole, + "{name}" + ); + let (again, _) = round_trip(&back); + assert_eq!( + again, xmp, + "{name}: writing the read-back model changed the bytes" + ); + } +} + +// --- enumeration ------------------------------------------------------- + +/// Every LEARN and PHOTO key with an XMP form, with its values: written +/// losslessly, each value comes back exactly, the rest of the model is +/// unchanged, and writing again gives the same bytes. +#[test] +fn every_learn_and_photo_key_survives_a_lossless_write() { + let mut checked = 0; + let mut not_placed: Vec = Vec::new(); + for (id, spec) in registry::iter() { + if !learn_or_photo(spec) || spec.presence == Presence::LuaOnly { + continue; + } + let values = samples(spec, false); + if values.is_empty() { + not_placed.push(format!("{}/{}", spec.level, spec.name)); + continue; + } + for v in &values { + let what = format!("{}/{} = {v:?}", spec.level, spec.name); + let Some((s, path)) = place(id, v) else { + panic!("{what}: no place for this key; extend place()"); + }; + let written = write(&s, &WriteMode::TestRoundTrip(None)) + .unwrap_or_else(|e| panic!("{what}: {e}")); + let doc = parse(written.xmp.as_bytes()).unwrap_or_else(|e| panic!("{what}: {e}")); + let warnings: Vec<_> = doc + .warnings + .iter() + .filter(|w| w.kind.name() != "UnrecognisedMaskCombine") + .collect(); + assert!(warnings.is_empty(), "{what}: {warnings:?}\n{}", written.xmp); + let back = flat::settings(&doc.develop); + assert_eq!( + back.get(&path), + Some(&leaf(id, v)), + "{what}\n{}", + written.xmp + ); + let mut changed = flat::diff(&flat::settings(&s), &back); + // The reader derives the file kind from white-balance keys. + changed.retain(|p| p != "(file kind)"); + assert!(changed.is_empty(), "{what}: {changed:?}\n{}", written.xmp); + let again = write(&doc.develop, &WriteMode::TestRoundTrip(None)).unwrap(); + assert_eq!(again.xmp, written.xmp, "{what}: not idempotent"); + checked += 1; + } + } + // Containers and sequences of structures: their fields are enumerated + // at their own level above; the containers themselves are exercised by + // the fixtures and the builder tests. + let expected_containers = [ + "global/MaskGroupBasedCorrections", + "global/LensBlur", + "global/Look", + "correction/CorrectionMasks", + "mask-tool/CorrectionRangeMask", + "mask-tool/Gesture", + "struct:CorrectionRangeMask/AreaModels", + "struct:Gesture/Points", + ]; + eprintln!("lossless: {checked} values checked"); + not_placed.sort(); + let mut expected: Vec = expected_containers.map(String::from).to_vec(); + expected.sort(); + assert_eq!(not_placed, expected); + assert!(checked > 600, "only {checked} values checked"); +} + +/// A correction that reaches a preset: one subject mask, added. +fn subject_correction(local: BTreeMap) -> Correction { + let subject = MaskComponent { + tool: MaskTool::Semantic(Semantic::Subject), + combine: Combine::Add { inverted: false }, + ..bare_component(Fields::default()) + }; + correction(local, vec![subject]) +} + +fn preset_spec(mixed_file_kinds: bool) -> WriteMode { + let uuid = SyncNamespace::new("xmp_roundtrip").id("preset", IdSlot::Correction); + WriteMode::Preset(PresetSpec { + mixed_file_kinds, + process_version: Some(ProcessVersion::V6), + ..PresetSpec::new(PresetHeader::lrgenius(uuid, "Round trip")) + }) +} + +/// The LEARN keys a preset can carry by themselves (global values, a +/// correction's adjustments, `LensBlur` fields, the `Look` amount): each +/// value comes back from a preset rounded to its number format. A LEARN† +/// key may be held back by its gate (reported in `Written::skipped`); a +/// plain LEARN key never is. +#[test] +fn every_learn_value_survives_a_preset_after_rounding() { + let mut checked = 0; + let mut gated: BTreeMap = BTreeMap::new(); + for (id, spec) in registry::iter() { + if !spec.policy.is_learnable() || spec.presence == Presence::LuaOnly { + continue; + } + for v in samples(spec, false) { + let what = format!("{}/{} = {v:?}", spec.level, spec.name); + let mut s = DevelopSettings::new(); + type Get = fn(&DevelopSettings, KeyId) -> Option; + let got: Get = match spec.level { + Level::Global => { + if s.insert(id, v.clone()).is_err() { + continue; // Look, MaskGroupBasedCorrections: typed + } + |d, id| d.get(id).cloned() + } + Level::Correction if spec.name == "CorrectionAmount" => { + let mut c = subject_correction(BTreeMap::new()); + c.local.insert( + kid(Level::Correction, "LocalExposure2012"), + Value::Real(f(0.1)), + ); + c.amount = v.as_finite(); + s.corrections = vec![c]; + |d, _| Some(Value::Real(d.corrections.first()?.amount?)) + } + Level::Correction if !matches!(spec.kind, ValueKind::ComponentSeq) => { + s.corrections = vec![subject_correction([(id, v.clone())].into())]; + |d, id| d.corrections.first()?.local.get(&id).cloned() + } + Level::Struct(StructKind::LensBlur) => { + let lens_blur = one(StructKind::LensBlur, id, v.clone()); + s.insert(kid(Level::Global, "LensBlur"), Value::Struct(lens_blur)) + .unwrap(); + |d, id| match d.get_by_name("LensBlur")? { + Value::Struct(lb) => lb.fields.values.get(&id).cloned(), + _ => None, + } + } + Level::Struct(StructKind::Look) if spec.name == "Amount" => { + s.look = Some(Look { + name: Some("Adobe Color".into()), + uuid: Hex32::parse("B952C231111CD8E0ECCF14B86BAA7077"), + amount: v.as_finite(), + camera_restriction: None, + rest: Struct::new(StructKind::Look), + }); + |d, _| Some(Value::Real(d.look.as_ref()?.amount?)) + } + // Mask and range-mask fields: typed, built by the builders + // and checked through presets in the builder tests. + _ => continue, + }; + for mixed in [false, true] { + let written = + write(&s, &preset_spec(mixed)).unwrap_or_else(|e| panic!("{what}: {e}")); + let doc = parse(written.xmp.as_bytes()).unwrap_or_else(|e| panic!("{what}: {e}")); + assert_eq!(doc.kind, XmpKind::Preset, "{what}"); + assert!(doc.warnings.is_empty(), "{what}: {:?}", doc.warnings); + match got(&doc.develop, id) { + Some(back) => { + assert_eq!(back, v.quantized(spec), "{what}\n{}", written.xmp); + checked += 1; + } + None => { + let gate = spec + .policy + .gate() + .unwrap_or_else(|| panic!("{what}: a LEARN key missing from a preset")); + let mode = if mixed { "mixed" } else { "single-kind" }; + *gated + .entry(format!("{}/{} {gate:?} {mode}", spec.level, spec.name)) + .or_default() += 1; + } + } + } + } + } + eprintln!("preset: {checked} values checked; held back by their gate: {gated:?}"); + assert!(checked > 500, "only {checked} values checked"); + // Pinned, so a gate that starts holding back more (a single-kind raw + // preset losing `Temperature`, say) fails here. Only a mixed preset + // drops a file-kind key; `LensProfileSetup` keeps only the values its + // `OnlyValues` gate lists (the sample `Custom` fails it in both modes). + let expected: BTreeMap = [ + ("global/ColorNoiseReduction FileKindDefault mixed", 4), + ("global/IncrementalTemperature NonRawOnly mixed", 4), + ("global/IncrementalTint NonRawOnly mixed", 4), + ( + "global/LensProfileSetup OnlyValues([\"LensDefaults\", \"Auto\"]) mixed", + 1, + ), + ( + "global/LensProfileSetup OnlyValues([\"LensDefaults\", \"Auto\"]) single-kind", + 1, + ), + ("global/Sharpness FileKindDefault mixed", 4), + ("global/Temperature RawOnly mixed", 3), + ("global/Tint RawOnly mixed", 4), + ] + .into_iter() + .map(|(k, n)| (k.to_owned(), n)) + .collect(); + assert_eq!(gated, expected); +} + +// --- presets from the fixtures ---------------------------------------- + +/// Writes `settings` as a mixed and a single-kind preset and checks both +/// read back clean: a preset without a warning, without any key a preset +/// must not carry or anything kept whole ([`preset::violations`]), and with +/// exactly the filtered settings ([`preset::differences`]). Returns the +/// number of values compared. +fn check_preset( + what: &str, + settings: &DevelopSettings, + bad: &mut std::collections::BTreeSet, +) -> usize { + let mut checked = 0; + for mixed in [true, false] { + let written = + write(settings, &preset_spec(mixed)).unwrap_or_else(|e| panic!("{what}: {e}")); + let again = write(settings, &preset_spec(mixed)).unwrap(); + assert_eq!(again.xmp, written.xmp, "{what}: not deterministic"); + let doc = parse(written.xmp.as_bytes()).unwrap_or_else(|e| panic!("{what}: {e}")); + assert_eq!(doc.kind, XmpKind::Preset, "{what}"); + assert!(doc.warnings.is_empty(), "{what}: {:?}", doc.warnings); + assert!(!written.xmp.contains("crss:"), "{what}"); + assert!(!written.xmp.contains("Digest"), "{what}"); + assert!(!written.xmp.contains("rdf:Bag"), "{what}"); + let mode = if mixed { "mixed" } else { "single-kind" }; + for v in preset::violations(&doc) { + bad.insert(format!("{what} ({mode}): {v}")); + } + let (filtered, _) = settings.filtered(&Target::Preset { + mixed_file_kinds: mixed, + }); + for d in preset::differences(&filtered, &doc.develop) { + bad.insert(format!("{what} ({mode}): {d}")); + } + checked += flat::settings(&doc.develop).len(); + } + checked +} + +#[test] +fn presets_written_from_every_fixture_read_back_clean() { + let mut checked = 0; + let mut bad = std::collections::BTreeSet::new(); + let lua = files("lua", "json"); + for (name, bytes) in lua.iter().filter(|(n, _)| n != "manifest.json") { + let text = std::str::from_utf8(bytes).unwrap(); + let (settings, _) = + from_lua_str(text, FileKindHint::Unknown).unwrap_or_else(|e| panic!("{name}: {e}")); + checked += check_preset(name, &settings, &mut bad); + } + for (name, bytes) in files("xmp", "xmp") { + let doc = parse(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); + checked += check_preset(&name, &doc.develop, &mut bad); + } + eprintln!("presets from fixtures: {checked} values checked"); + assert!(bad.is_empty(), "presets that are not clean: {bad:#?}"); + assert!(checked > 1000, "only {checked} values checked"); +} + +/// The two preset checks above catch what they are for: a value lost or +/// changed on the way, a key that must not be in a preset, content kept +/// whole. +#[test] +fn the_preset_checks_are_not_vacuous() { + let mut s = DevelopSettings::new(); + s.insert(kid(Level::Global, "Exposure2012"), Value::Real(f(0.5))) + .unwrap(); + s.insert(kid(Level::Global, "Contrast2012"), Value::Int(10)) + .unwrap(); + s.corrections = vec![subject_correction( + [(kid(Level::Correction, "LocalDehaze"), Value::Real(f(0.2)))].into(), + )]; + let written = write(&s, &preset_spec(true)).unwrap(); + let doc = parse(written.xmp.as_bytes()).unwrap(); + let (filtered, _) = s.filtered(&Target::Preset { + mixed_file_kinds: true, + }); + assert_eq!( + preset::differences(&filtered, &doc.develop), + Vec::::new() + ); + assert_eq!(preset::violations(&doc), Vec::::new()); + + let mut bad = doc.clone(); + bad.develop.remove(kid(Level::Global, "Exposure2012")); + bad.develop + .insert(kid(Level::Global, "Contrast2012"), Value::Int(11)) + .unwrap(); + bad.develop + .insert(kid(Level::Global, "CropTop"), Value::Real(f(0.1))) + .unwrap(); + bad.develop + .insert( + kid(Level::Global, "Texture"), + Value::Opaque(lrg_develop::model::Opaque::Json(serde_json::json!(1))), + ) + .unwrap(); + assert_eq!( + preset::differences(&filtered, &bad.develop), + [ + "changed Contrast2012", + "missing Exposure2012", + "extra CropTop", + "extra Texture" + ] + ); + assert_eq!( + preset::violations(&bad), + ["kept whole global/Texture", "PHOTO global/CropTop"] + ); +} diff --git a/server-rs/crates/lrg-imaging/src/lib.rs b/server-rs/crates/lrg-imaging/src/lib.rs index 4bb1d6d0..d856f260 100644 --- a/server-rs/crates/lrg-imaging/src/lib.rs +++ b/server-rs/crates/lrg-imaging/src/lib.rs @@ -1,6 +1,7 @@ //! Image pipeline: Pillow-exact resampling, pHash, culling metrics, //! region focus at full resolution (`focus`), -//! source-state measurement for the edit path (`scene`), +//! source-state measurement for the edit path (`scene`), a render's tones in +//! numbers for AI Edit's retoucher loop (`tone`), //! RAW decoding (`raw`, via `rawler`), JPEG/PNG conversion (`convert`), //! location metadata (`location`) and offline reverse geocoding (`geocode`). //! HEIC (libheif) decoding is still not wired in. @@ -16,3 +17,4 @@ pub mod pil_resample; pub mod raw; pub mod scene; pub mod subject_focus; +pub mod tone; diff --git a/server-rs/crates/lrg-imaging/src/tone.rs b/server-rs/crates/lrg-imaging/src/tone.rs new file mode 100644 index 00000000..c341d152 --- /dev/null +++ b/server-rs/crates/lrg-imaging/src/tone.rs @@ -0,0 +1,307 @@ +//! A rendered photo's tones in numbers, for AI Edit's retoucher loop. +//! +//! A vision model judges a picture well and a histogram badly: it will call +//! a frame "slightly dark" whose shadows are a third clipped, and "neutral" +//! when every grey leans green. So each render the loop shows the model goes +//! with these measurements, and the model is told to trust them over its eye +//! for clipping, level and cast. +//! +//! Everything is read from the 8-bit sRGB render, the way the photographer +//! sees it, not from linear light: "median 38 %" means what the eye and +//! Lightroom's histogram mean by it. + +use std::fmt::Write as _; + +/// Pixels visited at most; a larger render is sampled on a regular grid. +const SAMPLE_TARGET: usize = 250_000; + +/// A channel at or above this is clipped white in an 8-bit render. +const CLIP_HIGH: u8 = 254; +/// All channels at or below this are clipped black. +const CLIP_LOW: u8 = 2; + +/// Luminance bounds (0..1) of the midtones the cast and saturation are read +/// from: deep shadows are noise and bright highlights are clipped, and both +/// read as colourless. +const MID_LOW: f64 = 0.15; +const MID_HIGH: f64 = 0.85; + +/// Chroma below this share of luminance counts as a near-neutral pixel, the +/// kind whose colour is the light's rather than the object's. +const NEUTRAL_CHROMA: f64 = 0.12; + +/// Fewer near-neutral pixels than this share and the cast is read from every +/// midtone pixel instead (a frame of foliage has no greys to go by). +const MIN_NEUTRAL_SHARE: f64 = 0.02; + +/// What [`measure`] found. +#[derive(Clone, Debug, PartialEq)] +pub struct ToneStats { + /// Luminance percentiles 1, 5, 25, 50, 75, 95, 99, each 0..1. + pub percentiles: [f64; 7], + /// Share of pixels per band: blacks (<10 %), shadows (10–30 %), + /// midtones (30–70 %), lights (70–90 %), whites (≥90 %). + pub bands: [f64; 5], + /// Share of pixels with any channel at the top of the range. + pub clipped_highlights: f64, + /// Share of pixels black in every channel. + pub clipped_shadows: f64, + /// Mean HSV saturation of midtone pixels, 0..1. + pub midtone_saturation: f64, + /// Lean of the neutral tones, in percent of full scale: positive is + /// warm (yellow–orange), negative cool (blue). + pub warmth: f64, + /// Positive is magenta, negative green. + pub tint: f64, + /// Whether the cast was read from near-neutral pixels (true) or from + /// all midtones (false, a weaker estimate). + pub cast_from_neutrals: bool, + /// Standard deviation of luminance, 0..0.5: global contrast. + pub contrast: f64, +} + +fn luminance(r: u8, g: u8, b: u8) -> f64 { + (0.2126 * f64::from(r) + 0.7152 * f64::from(g) + 0.0722 * f64::from(b)) / 255.0 +} + +/// Measures a packed RGB8 image. `None` for an empty one. +pub fn measure(rgb: &[u8], width: usize, height: usize) -> Option { + let pixels = width.checked_mul(height)?; + if pixels == 0 || rgb.len() < pixels * 3 { + return None; + } + let step = ((pixels as f64 / SAMPLE_TARGET as f64).sqrt().floor() as usize).max(1); + + let mut histogram = [0u32; 256]; + let mut n = 0u64; + let mut sum = 0.0; + let mut sum_sq = 0.0; + let mut clipped_high = 0u64; + let mut clipped_low = 0u64; + let mut mid = (0u64, 0.0f64, 0.0f64, 0.0f64); // count, saturation, warmth, tint + let mut neutral = (0u64, 0.0f64, 0.0f64); + + for y in (0..height).step_by(step) { + for x in (0..width).step_by(step) { + let i = (y * width + x) * 3; + let (r, g, b) = (rgb[i], rgb[i + 1], rgb[i + 2]); + let l = luminance(r, g, b); + histogram[(l * 255.0).round().clamp(0.0, 255.0) as usize] += 1; + n += 1; + sum += l; + sum_sq += l * l; + if r >= CLIP_HIGH || g >= CLIP_HIGH || b >= CLIP_HIGH { + clipped_high += 1; + } + if r <= CLIP_LOW && g <= CLIP_LOW && b <= CLIP_LOW { + clipped_low += 1; + } + if !(MID_LOW..=MID_HIGH).contains(&l) { + continue; + } + let (rf, gf, bf) = ( + f64::from(r) / 255.0, + f64::from(g) / 255.0, + f64::from(b) / 255.0, + ); + let max = rf.max(gf).max(bf); + let min = rf.min(gf).min(bf); + // Opponent axes: yellow–blue and magenta–green. + let warm = (rf + gf) / 2.0 - bf; + let tint = (rf + bf) / 2.0 - gf; + mid.0 += 1; + mid.1 += if max > 0.0 { (max - min) / max } else { 0.0 }; + mid.2 += warm; + mid.3 += tint; + if max - min < NEUTRAL_CHROMA * l.max(0.05) * 2.0 { + neutral.0 += 1; + neutral.1 += warm; + neutral.2 += tint; + } + } + } + + let n_f = n as f64; + let percentile = |p: f64| { + let target = (p * n_f).ceil().max(1.0) as u64; + let mut seen = 0u64; + for (value, count) in histogram.iter().enumerate() { + seen += u64::from(*count); + if seen >= target { + return value as f64 / 255.0; + } + } + 1.0 + }; + let share = |from: f64, to: f64| { + let lo = (from * 255.0).round() as usize; + let hi = ((to * 255.0).round() as usize).min(256); + histogram[lo..hi].iter().map(|c| u64::from(*c)).sum::() as f64 / n_f + }; + let mean = sum / n_f; + let use_neutrals = mid.0 > 0 && neutral.0 as f64 / n_f >= MIN_NEUTRAL_SHARE; + let (warmth, tint) = if use_neutrals { + (neutral.1 / neutral.0 as f64, neutral.2 / neutral.0 as f64) + } else if mid.0 > 0 { + (mid.2 / mid.0 as f64, mid.3 / mid.0 as f64) + } else { + (0.0, 0.0) + }; + + Some(ToneStats { + percentiles: [0.01, 0.05, 0.25, 0.5, 0.75, 0.95, 0.99].map(percentile), + bands: [ + share(0.0, 0.1), + share(0.1, 0.3), + share(0.3, 0.7), + share(0.7, 0.9), + share(0.9, 1.01), + ], + clipped_highlights: clipped_high as f64 / n_f, + clipped_shadows: clipped_low as f64 / n_f, + midtone_saturation: if mid.0 > 0 { mid.1 / mid.0 as f64 } else { 0.0 }, + warmth: warmth * 100.0, + tint: tint * 100.0, + cast_from_neutrals: use_neutrals, + contrast: (sum_sq / n_f - mean * mean).max(0.0).sqrt(), + }) +} + +fn pct(v: f64) -> String { + format!("{:.0}", v * 100.0) +} + +fn share_pct(v: f64) -> String { + let p = v * 100.0; + if p > 0.0 && p < 0.1 { + "<0.1".to_string() + } else if p < 10.0 { + format!("{p:.1}") + } else { + format!("{p:.0}") + } +} + +impl ToneStats { + /// The measurements as a few lines for a language model. + pub fn describe(&self) -> String { + let p = &self.percentiles; + let b = &self.bands; + let mut out = String::new(); + let _ = writeln!( + out, + "Luminance (0-100): p1 {}, p5 {}, p25 {}, median {}, p75 {}, p95 {}, p99 {}; contrast (std) {}", + pct(p[0]), pct(p[1]), pct(p[2]), pct(p[3]), pct(p[4]), pct(p[5]), pct(p[6]), + pct(self.contrast) + ); + let _ = writeln!( + out, + "Histogram: blacks {}% · shadows {}% · midtones {}% · lights {}% · whites {}%", + share_pct(b[0]), + share_pct(b[1]), + share_pct(b[2]), + share_pct(b[3]), + share_pct(b[4]) + ); + let _ = writeln!( + out, + "Clipped: {}% of pixels have a channel at 255, {}% are pure black", + share_pct(self.clipped_highlights), + share_pct(self.clipped_shadows) + ); + let lean = |v: f64, pos: &str, neg: &str| { + if v.abs() < 0.5 { + "neutral".to_string() + } else { + format!("{} {:.1}", if v > 0.0 { pos } else { neg }, v.abs()) + } + }; + let _ = writeln!( + out, + "Colour of the {}: {}, {} (in % of full scale; above about 2 is a visible cast)", + if self.cast_from_neutrals { + "near-neutral tones" + } else { + "midtones (few neutral tones, so a weaker estimate)" + }, + lean(self.warmth, "warm", "cool"), + lean(self.tint, "magenta", "green"), + ); + let _ = write!(out, "Midtone saturation: {}%", pct(self.midtone_saturation)); + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn image(w: usize, h: usize, f: impl Fn(usize, usize) -> [u8; 3]) -> Vec { + let mut out = Vec::with_capacity(w * h * 3); + for y in 0..h { + for x in 0..w { + out.extend_from_slice(&f(x, y)); + } + } + out + } + + #[test] + fn an_empty_image_has_no_stats() { + assert!(measure(&[], 0, 0).is_none()); + assert!(measure(&[1, 2], 1, 1).is_none()); + } + + #[test] + fn a_ramp_spreads_over_every_band() { + let img = image(256, 4, |x, _| [x as u8; 3]); + let s = measure(&img, 256, 4).unwrap(); + assert!((s.percentiles[3] - 0.5).abs() < 0.02, "{:?}", s.percentiles); + assert!((s.bands.iter().sum::() - 1.0).abs() < 1e-9); + assert!((s.bands[2] - 0.4).abs() < 0.02, "{:?}", s.bands); + assert!(s.clipped_highlights > 0.0 && s.clipped_highlights < 0.02); + assert!(s.cast_from_neutrals); + assert!(s.warmth.abs() < 0.01 && s.tint.abs() < 0.01); + } + + #[test] + fn blown_and_crushed_pixels_are_counted() { + let img = image(10, 10, |x, _| match x { + 0..=2 => [255, 250, 240], + 3..=4 => [0, 0, 0], + _ => [120, 120, 120], + }); + let s = measure(&img, 10, 10).unwrap(); + assert!((s.clipped_highlights - 0.3).abs() < 1e-9); + assert!((s.clipped_shadows - 0.2).abs() < 1e-9); + } + + #[test] + fn a_warm_grey_reads_as_warm_and_a_green_one_as_green() { + let warm = image(20, 20, |_, _| [140, 128, 112]); + let s = measure(&warm, 20, 20).unwrap(); + assert!(s.cast_from_neutrals); + assert!(s.warmth > 3.0, "{}", s.warmth); + + let green = image(20, 20, |_, _| [120, 132, 120]); + let s = measure(&green, 20, 20).unwrap(); + assert!(s.tint < -3.0, "{}", s.tint); + assert!(s.describe().contains("green")); + } + + #[test] + fn foliage_without_greys_falls_back_to_the_midtones() { + let img = image(20, 20, |_, _| [60, 160, 40]); + let s = measure(&img, 20, 20).unwrap(); + assert!(!s.cast_from_neutrals); + assert!(s.midtone_saturation > 0.5); + assert!(s.describe().contains("weaker estimate")); + } + + #[test] + fn a_large_render_is_sampled_not_read_whole() { + let img = image(2000, 1500, |x, _| [(x % 256) as u8; 3]); + let s = measure(&img, 2000, 1500).unwrap(); + assert!((s.percentiles[3] - 0.5).abs() < 0.03); + } +} diff --git a/server-rs/crates/lrg-ml/tests/common/mod.rs b/server-rs/crates/lrg-ml/tests/common/mod.rs index 494a4c61..d51f9ceb 100644 --- a/server-rs/crates/lrg-ml/tests/common/mod.rs +++ b/server-rs/crates/lrg-ml/tests/common/mod.rs @@ -16,9 +16,24 @@ //! ```text //! LRG_REQUIRE_GOLDENS=face # just the 89 MB face pair (what PR CI provisions) //! LRG_REQUIRE_GOLDENS=face,siglip # a comma-separated subset -//! LRG_REQUIRE_GOLDENS=all # every family (the nightly full-assets job) +//! LRG_REQUIRE_GOLDENS=all # every family except the local-only ones (nightly) //! ``` //! +//! Some families can never be provisioned in CI because their data is +//! private or not redistributable. They are listed in `LOCAL_ONLY`: +//! +//! | Family | Data | Variable | +//! |---|---|---| +//! | `training-dump` | the maintainer's training dump | `LRG_DEVELOP_TRAINING_DUMP` | +//! | `lrc-presets` | Lightroom Classic's bundled presets and profiles | `LRG_LRC_PRESETS_DIR` | +//! | `acr-presets` | Camera Raw's user folder: its presets and profiles | `LRG_ACR_PRESETS_DIR` | +//! | `xmp-corpus` | a private sidecar corpus | `LRG_XMP_CORPUS_DIR` | +//! +//! `all` does not include them, so the nightly job stays green without them, +//! and only naming one explicitly (`LRG_REQUIRE_GOLDENS=lrc-presets`) makes +//! its absence a failure. Their tests (in `lrg-develop`) read a path from the +//! environment variable and skip, through this gate, when it is unset. +//! //! Per-family rather than one global on/off because the families differ in //! cost by more than an order of magnitude — face is 89 MB, BioCLIP 834 MB, //! SigLIP2 2.2 GB. A single switch would force PR CI to choose between @@ -38,6 +53,13 @@ pub fn assets_ready(family: &str, present: bool, detail: &str) -> bool { if present { return true; } + if required(family) && LOCAL_ONLY.iter().any(|f| f.eq_ignore_ascii_case(family)) { + panic!( + "LRG_REQUIRE_GOLDENS names the local-only `{family}` family, but its data is missing: \ + {detail}. Point that variable at a local copy, or drop `{family}` from \ + LRG_REQUIRE_GOLDENS." + ); + } if required(family) { panic!( "LRG_REQUIRE_GOLDENS demands the `{family}` golden assets, but they are missing: \ @@ -52,14 +74,59 @@ pub fn assets_ready(family: &str, present: bool, detail: &str) -> bool { false } +/// Families whose data only exists on a maintainer's machine (private or not +/// redistributable). `LRG_REQUIRE_GOLDENS=all` leaves them out; only naming +/// one explicitly makes its absence a failure. +const LOCAL_ONLY: &[&str] = &["training-dump", "lrc-presets", "acr-presets", "xmp-corpus"]; + /// Whether `LRG_REQUIRE_GOLDENS` names this family, either explicitly or via -/// `all`. Entries are trimmed and matched case-insensitively so that -/// `LRG_REQUIRE_GOLDENS="face, SigLIP"` behaves the way it reads. +/// `all` (which excludes [`LOCAL_ONLY`] families). Entries are trimmed and +/// matched case-insensitively so that `LRG_REQUIRE_GOLDENS="face, SigLIP"` +/// behaves the way it reads. fn required(family: &str) -> bool { - let Ok(raw) = std::env::var("LRG_REQUIRE_GOLDENS") else { + required_by(std::env::var("LRG_REQUIRE_GOLDENS").ok().as_deref(), family) +} + +/// [`required`] for a given `LRG_REQUIRE_GOLDENS` value (`None` = unset). +fn required_by(setting: Option<&str>, family: &str) -> bool { + let Some(raw) = setting else { return false; }; - raw.split(',') - .map(str::trim) - .any(|entry| entry.eq_ignore_ascii_case("all") || entry.eq_ignore_ascii_case(family)) + let local_only = LOCAL_ONLY.iter().any(|f| f.eq_ignore_ascii_case(family)); + raw.split(',').map(str::trim).any(|entry| { + entry.eq_ignore_ascii_case(family) || (!local_only && entry.eq_ignore_ascii_case("all")) + }) +} + +#[cfg(test)] +mod tests { + use super::required_by; + + #[test] + fn all_covers_every_family_except_the_local_only_ones() { + assert!(required_by(Some("all"), "face")); + assert!(required_by(Some("all"), "siglip")); + for local in ["training-dump", "lrc-presets", "acr-presets", "xmp-corpus"] { + assert!(!required_by(Some("all"), local), "{local}"); + assert!(!required_by(Some("face,all"), local), "{local}"); + } + } + + #[test] + fn local_only_families_are_required_only_when_named() { + assert!(required_by(Some("training-dump"), "training-dump")); + assert!(required_by(Some("face, Training-Dump"), "training-dump")); + assert!(!required_by(Some("face"), "training-dump")); + assert!(required_by(Some("lrc-presets"), "lrc-presets")); + assert!(required_by(Some("all, xmp-corpus"), "xmp-corpus")); + assert!(!required_by(Some("lrc-presets"), "xmp-corpus")); + assert!(required_by(Some("ACR-Presets"), "acr-presets")); + } + + #[test] + fn unset_requires_nothing() { + assert!(!required_by(None, "face")); + assert!(!required_by(None, "training-dump")); + assert!(!required_by(Some(""), "face")); + } } diff --git a/server-rs/crates/lrg-providers/Cargo.toml b/server-rs/crates/lrg-providers/Cargo.toml index 751bfa26..28e0cb7f 100644 --- a/server-rs/crates/lrg-providers/Cargo.toml +++ b/server-rs/crates/lrg-providers/Cargo.toml @@ -5,6 +5,7 @@ edition.workspace = true license.workspace = true [dependencies] +lrg-develop.workspace = true lrg-imaging.workspace = true log.workspace = true serde.workspace = true diff --git a/server-rs/crates/lrg-providers/src/edit_loop.rs b/server-rs/crates/lrg-providers/src/edit_loop.rs new file mode 100644 index 00000000..07ba436d --- /dev/null +++ b/server-rs/crates/lrg-providers/src/edit_loop.rs @@ -0,0 +1,972 @@ +//! AI Edit's retoucher loop: what the model is told, and the tools it edits +//! with. +//! +//! The earlier LLM edit path asked once and took the answer: a model looked +//! at a photo and guessed absolute slider values for a file and a profile it +//! had never seen rendered. Vision models are poor at that guess and good at +//! the comparison that follows it, so the loop works the way a retoucher +//! does: the model edits ([`APPLY_EDIT`]), Lightroom renders the result, the +//! model sees the render next to the original with its measurements, and +//! corrects — until it calls [`FINISH`] or runs out of rounds. +//! +//! Every edit is the complete edit from the photo's starting point, not a +//! change on top of the last one. That keeps each round idempotent (the +//! plugin re-applies from the starting point) and lets the model go back to +//! an earlier round without having to undo anything. +//! +//! The tool schema is built from the develop registry +//! ([`lrg_develop::build::recipe`]): the ranges the model is given are +//! Lightroom's own, and every field it can send is one the recipe builder +//! turns into a develop key. + +use serde_json::{json, Map, Value}; + +use lrg_develop::build::recipe::{ + recipe_globals, starting_values, ui_range, LOCAL_ADJUSTMENTS, MASK_TARGETS, +}; +use lrg_develop::registry::{lookup, Level}; +use lrg_develop::DevelopSettings; +use lrg_imaging::tone::ToneStats; + +use crate::chat::ToolSpec; + +/// The tool that applies an edit. +pub const APPLY_EDIT: &str = "apply_edit"; +/// The tool that ends the loop. +pub const FINISH: &str = "finish"; + +/// Masks one edit may carry. Lightroom computes each AI mask on its own, and +/// a dozen of them is a dozen seconds per render for little gain. +pub const MAX_MASKS: usize = 6; + +/// The global sliders the model is offered: recipe paths below `global`. +/// +/// Deliberately fewer than the registry has: the sharpening radius, the +/// noise-reduction fine controls, the vignette shape and the curve splits +/// are rarely what makes an edit, and every extra field costs tokens and, +/// for a small local model, attention. +const OFFERED_GLOBALS: &[&str] = &[ + "exposure", + "contrast", + "highlights", + "shadows", + "whites", + "blacks", + "texture", + "clarity", + "dehaze", + "vibrance", + "saturation", + "tone_curve.highlights", + "tone_curve.lights", + "tone_curve.darks", + "tone_curve.shadows", + "hsl.red.hue", + "hsl.red.saturation", + "hsl.red.luminance", + "hsl.orange.hue", + "hsl.orange.saturation", + "hsl.orange.luminance", + "hsl.yellow.hue", + "hsl.yellow.saturation", + "hsl.yellow.luminance", + "hsl.green.hue", + "hsl.green.saturation", + "hsl.green.luminance", + "hsl.aqua.hue", + "hsl.aqua.saturation", + "hsl.aqua.luminance", + "hsl.blue.hue", + "hsl.blue.saturation", + "hsl.blue.luminance", + "hsl.purple.hue", + "hsl.purple.saturation", + "hsl.purple.luminance", + "hsl.magenta.hue", + "hsl.magenta.saturation", + "hsl.magenta.luminance", + "color_grading.shadows.hue", + "color_grading.shadows.saturation", + "color_grading.shadows.luminance", + "color_grading.midtones.hue", + "color_grading.midtones.saturation", + "color_grading.midtones.luminance", + "color_grading.highlights.hue", + "color_grading.highlights.saturation", + "color_grading.highlights.luminance", + "color_grading.blending", + "color_grading.balance", + "sharpening", + "noise_reduction", + "vignette", + "grain", + "black_and_white_mix.red", + "black_and_white_mix.orange", + "black_and_white_mix.yellow", + "black_and_white_mix.green", + "black_and_white_mix.aqua", + "black_and_white_mix.blue", + "black_and_white_mix.purple", + "black_and_white_mix.magenta", +]; + +/// A look the user can ask for, as the model is told it. +#[derive(Clone, Copy, Debug)] +pub struct Look { + pub id: &'static str, + pub label: &'static str, + pub brief: &'static str, +} + +/// The looks the plugin offers, `natural` first (the default). +pub const LOOKS: &[Look] = &[ + Look { + id: "natural", + label: "Natural", + brief: "a clean, true-to-life edit. Correct the white balance and exposure, recover \ + highlights, open the shadows just enough, keep colours believable. The photo \ + should look like a better version of itself, not like an edit.", + }, + Look { + id: "bright_airy", + label: "Bright & airy", + brief: "high-key and soft. Lifted exposure and shadows, gentle contrast, clean whites \ + that do not clip, soft pastel colour, slightly warm skin, quieter greens.", + }, + Look { + id: "moody", + label: "Moody", + brief: "darker and atmospheric. Surroundings brought down, deep but not crushed blacks, \ + restrained highlights, muted and slightly cooler colour, the light kept on the \ + subject. Never muddy.", + }, + Look { + id: "cinematic", + label: "Cinematic", + brief: "filmic contrast with a teal-and-orange separation: cooler shadows, warm skin and \ + highlights, a soft highlight roll-off, rich blacks, controlled saturation.", + }, + Look { + id: "film", + label: "Analog film", + brief: "a print-film character: slightly faded blacks through the tone curve, warm \ + gentle highlights, softer contrast, the hue shifts of film stock, fine grain.", + }, + Look { + id: "punchy", + label: "Vivid & punchy", + brief: "bold colour and depth for landscapes and travel: strong but clean contrast, \ + rich skies, vibrance rather than saturation, crisp texture — no halos, no \ + oversaturation, no HDR look.", + }, + Look { + id: "black_and_white", + label: "Black & white", + brief: "a strong monochrome. Set `treatment` to black_and_white and shape the tones \ + with `black_and_white_mix` (darker blue skies, lighter skin), deep but not \ + crushed blacks, rich midtone contrast.", + }, +]; + +/// The look with this id, or the default. +pub fn look(id: &str) -> &'static Look { + LOOKS.iter().find(|l| l.id == id).unwrap_or(&LOOKS[0]) +} + +/// What a loop is set up with. +#[derive(Clone, Debug)] +pub struct LoopSetup { + /// The photo's white-balance family: raw (Kelvin), non-raw (offset), or + /// unknown (no white balance offered). + pub is_raw: Option, + /// Whether the plugin can apply AI masks (Lightroom 15.3 and later). + pub masks: bool, + /// The language of `assessment` and `verdict`. + pub language: String, + /// One of [`LOOKS`]. + pub look: String, + /// The user's own words about this run. + pub instructions: Option, + /// Edits the model may apply, the first one included. + pub max_edits: u32, + /// Whether the loop starts from the photographer's own style. + pub from_style: bool, +} + +/// The system prompt. +pub fn system_prompt(setup: &LoopSetup) -> String { + let mut out = String::from( + "You are a senior photo retoucher working in Adobe Lightroom Classic. You edit one \ +photograph by setting develop sliders, then look at what Lightroom renders and refine it — the \ +way a professional works: look, adjust, look again. + +How the session works: +- You see the photo as it is now, with measurements of its tones and the slider values it \ +starts from. Each time you call apply_edit, Lightroom applies your settings and you get back the \ +new render with fresh measurements. +- Every apply_edit is the COMPLETE edit, measured from the starting point — not a change on top \ +of your previous one. A slider you leave out goes back to its starting value. So when you refine, \ +repeat every value you want to keep and change only what the render shows is wrong. +- Values are absolute Lightroom slider positions, as the Develop panel shows them. +- Trust the measurements over your impression for clipping, brightness levels and colour casts. \ +Trust your eye for mood, skin, subject emphasis and taste. +- Call finish when another round would not clearly improve the photo. If an earlier round looked \ +better than the latest one, name it in best_round. + +How to edit: +1. Foundation: white balance first (greys neutral and skin natural, unless the look asks for \ +warmth or coolness), then exposure, so the main subject sits right. +2. Tonal range: recover highlights, open shadows, then set whites and blacks so the tones use the \ +range without clipping detail that matters (small specular highlights may clip). Prefer \ +highlights/shadows and the tone curve to brute contrast. +3. Presence and colour: texture and clarity sparingly, dehaze only for real haze, vibrance \ +before saturation, HSL for specific colours (skin is orange and red, sky blue and aqua, foliage \ +yellow and green), colour grading for the look. +4. Effects last: a subtle vignette when it helps the eye to the subject, grain only for a film \ +look. + +Typical sizes: exposure ±0.1–1.5, highlights −20 to −80, shadows +10 to +50, whites and blacks \ +±5–30, texture and clarity ±5–25, vibrance +5–25, saturation −10 to +10. Go further only when the \ +measurements call for it. In later rounds change a few things by moderate amounts; if your last \ +change made things worse, go back. + +Never: clip large areas, halos from clarity or dehaze, orange or magenta skin, crushed shadows \ +with no detail, an HDR look, or a white balance that swings between rounds.\n", + ); + + if setup.masks { + out.push_str( + "\nMasks: Lightroom's AI masks find their region themselves; you name the target and \ +its adjustments. subject = the main subject; sky; background = everything but the subject; \ +people parts (face_skin, body_skin, hair, lips, teeth, iris_and_pupil, eye_sclera, eyebrows, \ +facial_hair, clothes) cover every person in the photo; landscape classes (architecture, \ +mountains, vegetation, water, natural_ground, artificial_ground, snow). Use masks for what global \ +sliders cannot do without hurting the rest — typically: sky darker and richer (exposure −0.2 to \ +−0.6, highlights −20 to −50, saturation +5 to +15), subject lifted (exposure +0.1 to +0.4, \ +shadows +10 to +25), background calmed (exposure −0.1 to −0.4, saturation −5 to −15), skin \ +smoothed (face_skin: texture −10 to −25, clarity −5 to −10), eyes brighter (iris_and_pupil: \ +exposure +0.2, clarity +10). Mask exposure is in EV. At most four masks, each with a clear \ +purpose, and only for things the photo shows. A mask repeated in a later round replaces the \ +earlier one.\n", + ); + } + + let look = look(&setup.look); + out.push_str(&format!( + "\nThe look asked for: {} — {}\n", + look.label, look.brief + )); + if let Some(words) = setup + .instructions + .as_deref() + .map(str::trim) + .filter(|w| !w.is_empty()) + { + out.push_str(&format!( + "The photographer's own instructions, which win over the look where they differ: \ +{words}\n" + )); + } + if setup.from_style { + out.push_str( + "\nThe photo already carries the photographer's own style: the blend of their saved \ +edits on similar photos, shown to you as round 0 with its values. Keep that style — its colour, \ +contrast and choices — and fix only what does not suit this frame: exposure, white balance, \ +clipping, emphasis, local work. Your edits are measured from the style, not from the photo \ +before it: a slider you leave out keeps the style's value, so send only what you change. Stay \ +near their values unless the frame clearly needs otherwise.\n", + ); + } + out.push_str(&format!( + "\nYou may apply at most {} edits. Write `assessment` and `verdict` in {}.", + setup.max_edits.max(1), + if setup.language.trim().is_empty() { + "English" + } else { + setup.language.trim() + } + )); + out +} + +fn number(min: f64, max: f64) -> Value { + json!({"type": "number", "minimum": min, "maximum": max}) +} + +/// Inserts `leaf` at the dotted `path` below `root`, creating objects on the +/// way. +fn insert_path(root: &mut Map, path: &str, leaf: Value) { + let mut parts: Vec<&str> = path.split('.').collect(); + let last = parts.pop().expect("a path has a leaf"); + let mut node = root; + for part in parts { + let entry = node + .entry(part.to_string()) + .or_insert_with(|| json!({"type": "object", "properties": {}})); + node = entry + .get_mut("properties") + .and_then(Value::as_object_mut) + .expect("an object schema has properties"); + } + node.insert(last.to_string(), leaf); +} + +fn global_schema(setup: &LoopSetup) -> Value { + let mut properties = Map::new(); + for (path, spec) in recipe_globals() { + if !OFFERED_GLOBALS.contains(&path) { + continue; + } + let Some((min, max)) = ui_range(spec) else { + continue; + }; + insert_path(&mut properties, path, number(min, max)); + } + let white_balance = match setup.is_raw { + Some(true) => Some(("Temperature", "Tint", "Kelvin, e.g. 5500 for daylight")), + Some(false) => Some(( + "IncrementalTemperature", + "IncrementalTint", + "a relative offset; 0 keeps the photo's own white balance, negative is cooler", + )), + None => None, + }; + if let Some((temperature, tint, unit)) = white_balance { + for (field, key, description) in [ + ("temperature", temperature, unit), + ("tint", tint, "negative is greener, positive more magenta"), + ] { + let spec = lookup(Level::Global, key) + .expect("a white-balance key of the registry") + .spec(); + if let Some((min, max)) = ui_range(spec) { + let mut leaf = number(min, max); + leaf["description"] = json!(description); + properties.insert(field.to_string(), leaf); + } + } + } + properties.insert( + "treatment".to_string(), + json!({"type": "string", "enum": ["color", "black_and_white"]}), + ); + json!({ + "type": "object", + "description": "Global sliders. Leave out what stays at its starting value.", + "properties": properties, + }) +} + +fn mask_schema() -> Value { + let mut adjustments = Map::new(); + for (name, key) in LOCAL_ADJUSTMENTS { + let spec = lookup(Level::Correction, key) + .expect("a correction key of the registry") + .spec(); + if let Some((min, max)) = ui_range(spec) { + adjustments.insert((*name).to_string(), number(min, max)); + } + } + let targets: Vec<&str> = MASK_TARGETS.iter().map(|(name, _)| *name).collect(); + json!({ + "type": "array", + "maxItems": MAX_MASKS, + "description": "Local corrections through Lightroom's AI masks, one per target.", + "items": { + "type": "object", + "properties": { + "target": {"type": "string", "enum": targets}, + "adjustments": {"type": "object", "properties": adjustments}, + }, + "required": ["target", "adjustments"], + }, + }) +} + +/// The tool the model edits with. +pub fn apply_edit_tool(setup: &LoopSetup) -> ToolSpec { + let mut properties = Map::new(); + properties.insert( + "assessment".to_string(), + json!({ + "type": "string", + "description": "Two or three sentences: what you see in the latest image and what \ + this edit changes.", + }), + ); + properties.insert("global".to_string(), global_schema(setup)); + if setup.masks { + properties.insert("masks".to_string(), mask_schema()); + } + ToolSpec { + name: APPLY_EDIT.to_string(), + description: "Apply the complete edit, measured from the photo's starting point, and \ + get back Lightroom's render of it." + .to_string(), + input_schema: json!({ + "type": "object", + "properties": properties, + "required": ["assessment", "global"], + }), + } +} + +/// The tool that ends the loop. +pub fn finish_tool() -> ToolSpec { + ToolSpec { + name: FINISH.to_string(), + description: "End the session: the photo is done.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "verdict": { + "type": "string", + "description": "One or two sentences on the finished edit.", + }, + "best_round": { + "type": "integer", + "minimum": 1, + "description": "Only when an earlier round looked better than the latest: \ + its number.", + }, + }, + "required": ["verdict"], + }), + } +} + +/// An `apply_edit` call's arguments, split up. +#[derive(Clone, Debug, PartialEq)] +pub struct EditArguments { + /// `{"global": {...}, "masks": [...]}`. + pub recipe: Value, + /// What the model saw and changed. + pub assessment: Option, + /// What was left out of the call before it reached the recipe builder. + pub notes: Vec, +} + +/// An `apply_edit` call's arguments, split into the recipe and the model's +/// assessment. +/// +/// Only `global` and `masks` reach the recipe; anything else the model sent +/// is dropped here, and the recipe builder reports what it cannot use. +/// A model that wrote the global sliders flat, beside `assessment`, has its +/// numbers picked up as globals rather than lost. +pub fn recipe_from_arguments(arguments: &Value) -> EditArguments { + let assessment = arguments + .get("assessment") + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string); + let mut global = arguments + .get("global") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + if global.is_empty() { + if let Some(object) = arguments.as_object() { + for (key, value) in object { + if !matches!(key.as_str(), "assessment" | "masks" | "global") { + global.insert(key.clone(), value.clone()); + } + } + } + } + let all_masks = arguments + .get("masks") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let mut notes = Vec::new(); + if all_masks.len() > MAX_MASKS { + notes.push(format!( + "Only the first {MAX_MASKS} of {} masks were applied: each AI mask costs Lightroom \ + time on every render.", + all_masks.len() + )); + } + let masks: Vec = all_masks.into_iter().take(MAX_MASKS).collect(); + EditArguments { + recipe: json!({"global": global, "masks": masks}), + assessment, + notes, + } +} + +fn format_value(path: &str, v: f64) -> String { + if path == "exposure" { + format!("{v:+.2}") + } else if v.fract() == 0.0 { + format!("{v:.0}") + } else { + format!("{v:.1}") + } +} + +/// A photo's starting values for the opening message: the basic sliders on +/// one line, the rest (`tone_curve.lights 10`, ...) on the next, and how many +/// masks of its own it has. +pub fn starting_text(settings: &DevelopSettings) -> String { + let mut basics = Vec::new(); + let mut rest = Vec::new(); + for (path, v) in starting_values(settings) { + if path == "treatment_black_and_white" { + rest.push("treatment black_and_white".to_string()); + continue; + } + let item = format!("{path} {}", format_value(&path, v)); + if path.contains('.') { + rest.push(item); + } else { + basics.push(item); + } + } + let mut out = basics.join(", "); + if !rest.is_empty() { + out.push('\n'); + out.push_str(&rest.join(", ")); + } + if !settings.corrections.is_empty() { + out.push_str(&format!( + "\nThe photo already has {} mask(s) of its own; they stay as they are.", + settings.corrections.len() + )); + } + out +} + +fn delta(label: &str, before: f64, after: f64, digits: usize, unit: &str) -> Option { + let (b, a) = ( + format!("{before:.digits$}{unit}"), + format!("{after:.digits$}{unit}"), + ); + (b != a).then(|| format!("{label} {b} → {a}")) +} + +/// How a render's tones moved from the original's: only the measurements +/// that changed at the precision they are shown with. +pub fn against_original(original: &ToneStats, render: &ToneStats) -> String { + let pct = |v: f64| v * 100.0; + let parts: Vec = [ + delta( + "median", + pct(original.percentiles[3]), + pct(render.percentiles[3]), + 0, + "", + ), + delta( + "p99", + pct(original.percentiles[6]), + pct(render.percentiles[6]), + 0, + "", + ), + delta( + "p1", + pct(original.percentiles[0]), + pct(render.percentiles[0]), + 0, + "", + ), + delta( + "clipped highlights", + pct(original.clipped_highlights), + pct(render.clipped_highlights), + 1, + "%", + ), + delta( + "black", + pct(original.clipped_shadows), + pct(render.clipped_shadows), + 1, + "%", + ), + delta("warmth", original.warmth, render.warmth, 1, ""), + delta("tint", original.tint, render.tint, 1, ""), + delta( + "midtone saturation", + pct(original.midtone_saturation), + pct(render.midtone_saturation), + 0, + "%", + ), + ] + .into_iter() + .flatten() + .collect(); + parts.join(", ") +} + +/// The first message: the photo as it is, and what the model works from. +/// +/// `starting_values` are the photo's current sliders, one per line; +/// `measurements` come from `lrg_imaging::tone`; `frame_notes` are the +/// guardrails' explanations of what the frame can take. +pub fn opening_text( + starting_values: &str, + measurements: &str, + frame_notes: &[String], + setup: &LoopSetup, +) -> String { + let mut out = String::from("This is the photo to edit, as it is now.\n"); + out.push_str(match setup.is_raw { + Some(true) => "It is a raw file.\n", + Some(false) => { + "It is not a raw file (JPEG, TIFF or similar): white balance is an offset.\n" + } + None => "Whether it is a raw file is unknown, so white balance cannot be set.\n", + }); + out.push_str("\nStarting values (anything not listed is at its default):\n"); + out.push_str(if starting_values.trim().is_empty() { + "(all at their defaults)" + } else { + starting_values + }); + out.push_str("\n\nMeasurements of this image:\n"); + out.push_str(measurements); + if !frame_notes.is_empty() { + out.push_str("\n\nWhat this frame can take (the edit is held to it):\n- "); + out.push_str(&frame_notes.join("\n- ")); + } + out.push_str(if setup.from_style { + "\n\nThe photographer's style has been applied to it; its render follows." + } else { + "\n\nCall apply_edit with your first complete edit." + }); + out +} + +/// What the model is told about a render. +pub fn render_text( + edit_number: u32, + applied: &str, + measurements: &str, + against_original: &str, + notes: &[String], + edits_left: u32, +) -> String { + let mut out = if edit_number == 0 { + "The photographer's own style, applied and rendered by Lightroom: round 0. Your edits \ + start from it." + .to_string() + } else { + format!("Round {edit_number}, rendered by Lightroom.") + }; + if !applied.is_empty() { + out.push_str("\n\nThe edit this render shows:\n"); + out.push_str(applied); + } + out.push_str("\n\nMeasurements of this render:\n"); + out.push_str(measurements); + if !against_original.is_empty() { + out.push_str("\n\nAgainst the original: "); + out.push_str(against_original); + } + if !notes.is_empty() { + out.push_str("\n\nWhat Lightroom could not take as given:\n- "); + out.push_str(¬es.join("\n- ")); + } + out.push_str(&if edits_left == 0 { + "\n\nNo edits are left: call finish.".to_string() + } else { + format!( + "\n\n{edits_left} more edit{} allowed. Call apply_edit with the complete corrected \ + edit, or finish if the photo is done.", + if edits_left == 1 { "" } else { "s" } + ) + }); + out +} + +fn flatten(prefix: &str, value: &Value, out: &mut Vec) { + match value { + Value::Object(map) => { + for (key, v) in map { + let path = if prefix.is_empty() { + key.clone() + } else { + format!("{prefix}.{key}") + }; + flatten(&path, v, out); + } + } + Value::Number(n) => { + let v = n.as_f64().unwrap_or(0.0); + if v != 0.0 { + out.push(format!("{prefix} {}", format_value(prefix, v))); + } + } + Value::String(text) => out.push(format!("{prefix} {text}")), + _ => {} + } +} + +/// A recipe as the model reads it back: every global that is not 0, in +/// recipe paths, then one line per mask. +/// +/// Told with every render, so a model refining an edit has the numbers in +/// front of it instead of having to recall its own earlier call — small +/// local models otherwise drop the values they meant to keep. +pub fn recipe_text(recipe: &Value) -> String { + let mut globals = Vec::new(); + if let Some(global) = recipe.get("global") { + flatten("", global, &mut globals); + } + let mut out = if globals.is_empty() { + "(no global changes)".to_string() + } else { + globals.join(", ") + }; + for mask in recipe + .get("masks") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let target = mask.get("target").and_then(Value::as_str).unwrap_or("?"); + let mut adjustments = Vec::new(); + if let Some(a) = mask.get("adjustments") { + flatten("", a, &mut adjustments); + } + out.push_str(&format!("\nmask {target}: {}", adjustments.join(", "))); + } + out +} + +/// `overlay` on top of `base`: the globals merged leaf by leaf (an overlay +/// value wins, a base value the overlay does not mention stays), the masks +/// the overlay's alone. +/// +/// How a retoucher session started from the photographer's style turns the +/// model's edit into the whole edit: the model sends what it changes, the +/// style supplies the rest. +pub fn merge_onto(base: &Value, overlay: &Value) -> Value { + fn merge(base: &Value, overlay: &Value) -> Value { + match (base, overlay) { + (Value::Object(b), Value::Object(o)) => { + let mut out = b.clone(); + for (key, value) in o { + let merged = match out.get(key) { + Some(existing) => merge(existing, value), + None => value.clone(), + }; + out.insert(key.clone(), merged); + } + Value::Object(out) + } + (_, overlay) => overlay.clone(), + } + } + let empty = json!({}); + let global = merge( + base.get("global").unwrap_or(&empty), + overlay.get("global").unwrap_or(&empty), + ); + json!({ + "global": global, + "masks": overlay.get("masks").cloned().unwrap_or_else(|| json!([])), + }) +} + +/// Said when the model answered without calling a tool. +pub const NUDGE: &str = "Please answer by calling apply_edit with the complete edit, or finish."; + +#[cfg(test)] +mod tests { + use super::*; + + fn setup(is_raw: Option, masks: bool) -> LoopSetup { + LoopSetup { + is_raw, + masks, + language: "German".into(), + look: "cinematic".into(), + instructions: Some("keep the skin warm".into()), + max_edits: 3, + from_style: false, + } + } + + #[test] + fn every_offered_global_is_in_the_schema() { + let tool = apply_edit_tool(&setup(Some(true), true)); + let global = &tool.input_schema["properties"]["global"]["properties"]; + for path in OFFERED_GLOBALS { + let mut node = global; + let mut parts = path.split('.').peekable(); + while let Some(part) = parts.next() { + node = &node[part]; + if parts.peek().is_some() { + node = &node["properties"]; + } + } + assert_eq!(node["type"], "number", "{path}"); + } + assert_eq!(global["exposure"]["minimum"], json!(-5.0)); + assert_eq!(global["contrast"]["maximum"], json!(100.0)); + } + + #[test] + fn the_white_balance_offered_is_the_photos_own_scale() { + let raw = apply_edit_tool(&setup(Some(true), false)); + let t = &raw.input_schema["properties"]["global"]["properties"]["temperature"]; + assert!(t["maximum"].as_f64().unwrap() > 10_000.0); + + let jpeg = apply_edit_tool(&setup(Some(false), false)); + let t = &jpeg.input_schema["properties"]["global"]["properties"]["temperature"]; + assert_eq!(t["maximum"], json!(100.0)); + + let unknown = apply_edit_tool(&setup(None, false)); + assert!( + unknown.input_schema["properties"]["global"]["properties"]["temperature"].is_null() + ); + } + + #[test] + fn masks_are_offered_only_when_the_plugin_can_apply_them() { + let with = apply_edit_tool(&setup(Some(true), true)); + let targets = + &with.input_schema["properties"]["masks"]["items"]["properties"]["target"]["enum"]; + assert!(targets.as_array().unwrap().contains(&json!("face_skin"))); + let exposure = &with.input_schema["properties"]["masks"]["items"]["properties"] + ["adjustments"]["properties"]["exposure"]; + assert_eq!(exposure["maximum"], json!(4.0)); + + let without = apply_edit_tool(&setup(Some(true), false)); + assert!(without.input_schema["properties"]["masks"].is_null()); + assert!(!system_prompt(&setup(Some(true), false)).contains("Masks:")); + } + + #[test] + fn the_prompt_carries_the_look_the_words_and_the_language() { + let prompt = system_prompt(&setup(Some(true), true)); + assert!(prompt.contains("Cinematic")); + assert!(prompt.contains("keep the skin warm")); + assert!(prompt.contains("in German")); + assert!(prompt.contains("at most 3 edits")); + assert_eq!(look("nonsense").id, "natural"); + } + + #[test] + fn arguments_split_into_recipe_and_assessment() { + let EditArguments { + recipe, assessment, .. + } = recipe_from_arguments(&json!({ + "assessment": " Too dark. ", + "global": {"exposure": 0.5}, + "masks": [{"target": "sky", "adjustments": {"exposure": -0.3}}], + "mood": "nice" + })); + assert_eq!(assessment.as_deref(), Some("Too dark.")); + assert_eq!(recipe["global"]["exposure"], json!(0.5)); + assert_eq!(recipe["masks"][0]["target"], json!("sky")); + assert!(recipe.get("mood").is_none()); + } + + #[test] + fn flat_sliders_are_read_as_globals() { + let args = recipe_from_arguments(&json!({"assessment": "x", "exposure": 0.3})); + assert_eq!(args.recipe["global"]["exposure"], json!(0.3)); + } + + #[test] + fn the_masks_are_capped() { + let many: Vec = (0..10) + .map(|_| json!({"target": "sky", "adjustments": {"exposure": -0.1}})) + .collect(); + let args = recipe_from_arguments(&json!({"global": {}, "masks": many})); + assert_eq!(args.recipe["masks"].as_array().unwrap().len(), MAX_MASKS); + assert_eq!(args.notes.len(), 1, "the left-out masks are reported"); + } + + fn tones(median: f64, clipped: f64, warmth: f64) -> ToneStats { + ToneStats { + percentiles: [0.02, 0.05, 0.2, median, 0.7, 0.9, 0.97], + bands: [0.1, 0.2, 0.4, 0.2, 0.1], + clipped_highlights: clipped, + clipped_shadows: 0.01, + midtone_saturation: 0.3, + warmth, + tint: 0.0, + cast_from_neutrals: true, + contrast: 0.2, + } + } + + #[test] + fn the_comparison_names_only_what_moved() { + let text = against_original(&tones(0.38, 0.004, 3.0), &tones(0.47, 0.004, 0.4)); + assert!(text.contains("median 38 → 47"), "{text}"); + assert!(text.contains("warmth 3.0 → 0.4"), "{text}"); + assert!(!text.contains("clipped highlights"), "{text}"); + assert!(!text.contains("p99"), "{text}"); + } + + #[test] + fn starting_text_lists_the_basics_and_the_rest() { + let (settings, _) = lrg_develop::lua::from_lua_value( + &json!({"ProcessVersion": "15.4", "Exposure2012": 0.7, "ParametricLights": 10}), + lrg_develop::FileKindHint::Raw, + ) + .unwrap(); + let text = starting_text(&settings); + assert!(text.starts_with("exposure +0.70, contrast 0"), "{text}"); + assert!(text.contains("tone_curve.lights 10"), "{text}"); + } + + #[test] + fn the_render_text_counts_down_and_says_when_to_stop() { + let text = render_text( + 2, + "exposure +0.30", + "m", + "median 30 → 41", + &["note".into()], + 1, + ); + assert!(text.contains("Round 2")); + assert!(text.contains("The edit this render shows:\nexposure +0.30")); + assert!(text.contains("1 more edit allowed")); + assert!(text.contains("- note")); + assert!(render_text(3, "", "m", "", &[], 0).contains("call finish")); + assert!(render_text(0, "", "m", "", &[], 2).contains("round 0")); + } + + #[test] + fn a_recipe_reads_back_as_its_non_zero_values() { + let text = recipe_text(&json!({ + "global": {"exposure": 0.3, "contrast": 0, "hsl": {"green": {"saturation": 15}}, + "treatment": "black_and_white"}, + "masks": [{"target": "sky", "adjustments": {"exposure": -0.3, "saturation": 0}}] + })); + assert!(text.contains("exposure +0.30"), "{text}"); + assert!(!text.contains("contrast"), "{text}"); + assert!(text.contains("hsl.green.saturation 15"), "{text}"); + assert!(text.contains("treatment black_and_white"), "{text}"); + assert!(text.contains("mask sky: exposure -0.3"), "{text}"); + assert_eq!(recipe_text(&json!({"global": {}})), "(no global changes)"); + } + + #[test] + fn an_edit_merges_onto_the_style() { + let style = json!({"global": {"exposure": 0.9, "hsl": {"green": {"saturation": 10, "hue": 4}}, + "vibrance": 12}, + "masks": []}); + let edit = json!({"global": {"exposure": 0.6, "hsl": {"green": {"saturation": 15}}}, + "masks": [{"target": "sky", "adjustments": {"exposure": -0.2}}]}); + let merged = merge_onto(&style, &edit); + assert_eq!(merged["global"]["exposure"], json!(0.6)); + assert_eq!( + merged["global"]["vibrance"], + json!(12), + "the style's own value stays" + ); + assert_eq!(merged["global"]["hsl"]["green"]["saturation"], json!(15)); + assert_eq!(merged["global"]["hsl"]["green"]["hue"], json!(4)); + assert_eq!(merged["masks"][0]["target"], json!("sky")); + } +} diff --git a/server-rs/crates/lrg-providers/src/edit_recipe.rs b/server-rs/crates/lrg-providers/src/edit_recipe.rs index 39f5587e..fb43663e 100644 --- a/server-rs/crates/lrg-providers/src/edit_recipe.rs +++ b/server-rs/crates/lrg-providers/src/edit_recipe.rs @@ -83,33 +83,11 @@ fn normalize_temperature(value: Option<&Value>, is_raw: Option) -> Option< Some(clamped) } -/// Apply [`normalize_temperature`] to an already-built recipe's `global` block. -/// -/// For recipes that never went through [`normalize_edit_recipe`] — the style -/// engine interpolates stored develop settings directly, and those carry -/// whatever scale the training photos used. Returns true when the value was -/// dropped, so the caller can say so. -pub fn sanitize_recipe_temperature(recipe: &mut Value, is_raw: Option) -> bool { - let Some(global) = recipe.get_mut("global").and_then(Value::as_object_mut) else { - return false; - }; - if !global.contains_key("temperature") { - return false; - } - match normalize_temperature(global.get("temperature"), is_raw) { - Some(t) => { - global.insert("temperature".into(), json!(t)); - false - } - None => { - global.remove("temperature"); - true - } - } -} - +/// Local (mask) adjustment ranges in Lightroom's slider units. Exposure is +/// ±4 EV, not the global ±5: a mask stores `LocalExposure2012` as EV / 4 on +/// -1..1 (the registry test below keeps these in step with `lrg-develop`). const MASK_ADJUSTMENT_RANGES: &[(&str, (f64, f64))] = &[ - ("exposure", (-5.0, 5.0)), + ("exposure", (-4.0, 4.0)), ("contrast", (-100.0, 100.0)), ("highlights", (-100.0, 100.0)), ("shadows", (-100.0, 100.0)), @@ -1376,15 +1354,94 @@ mod tests { assert_eq!(relative["maximum"], 100.0); } + /// Every range the LLM schema declares for a key the develop registry + /// knows must be the registry's UI range. The registry is the one place + /// the ranges are established; a drift here means the model is asked for + /// values Lightroom does not take (or never offered ones it does). #[test] - fn the_style_engine_recipe_is_sanitized_in_place() { - let mut recipe = json!({"global": {"temperature": 5600, "contrast": 20}}); - assert!(sanitize_recipe_temperature(&mut recipe, Some(false))); - assert!(recipe["global"].get("temperature").is_none()); - assert_eq!(recipe["global"]["contrast"], 20, "other fields untouched"); + fn schema_ranges_match_the_develop_registry() { + use lrg_develop::registry::{self, to_ui, Level}; + + fn ui_range(level: Level, name: &str) -> (f64, f64) { + let spec = registry::lookup(level, name) + .unwrap_or_else(|| panic!("{name} is not in the registry")) + .spec(); + let (lo, hi) = spec.range.expect("a slider has a range"); + ( + to_ui(spec, lo).unwrap().get(), + to_ui(spec, hi).unwrap().get(), + ) + } + fn declared(schema: &Value) -> (f64, f64) { + ( + schema["minimum"].as_f64().unwrap(), + schema["maximum"].as_f64().unwrap(), + ) + } - let mut raw_recipe = json!({"global": {"temperature": 5600}}); - assert!(!sanitize_recipe_temperature(&mut raw_recipe, Some(true))); - assert_eq!(raw_recipe["global"]["temperature"], 5600.0); + let raw = openai_edit_recipe_schema(Some(true)); + let global = &raw["properties"]["global"]["properties"]; + let mut checked = 0; + for (_, spec) in registry::iter() { + let Some(path) = spec.recipe_alias.and_then(|a| a.strip_prefix("global.")) else { + continue; + }; + // `global.tone_curve.shadows` → properties.tone_curve.properties.shadows + let (parents, leaf) = path.rsplit_once('.').unwrap_or(("", path)); + let parent = parents + .split('.') + .filter(|seg| !seg.is_empty()) + .fold(global, |node, seg| &node[seg]["properties"]); + assert_eq!( + declared(&parent[leaf]), + ui_range(Level::Global, spec.name), + "global.{path} ({})", + spec.name + ); + checked += 1; + } + assert!(checked >= 20, "only {checked} aliases checked"); + + // White balance: the schema's scale follows the target's file kind. + let relative = openai_edit_recipe_schema(Some(false)); + let rel_global = &relative["properties"]["global"]["properties"]; + assert_eq!( + declared(&global["temperature"]), + ui_range(Level::Global, "Temperature") + ); + assert_eq!(declared(&global["tint"]), ui_range(Level::Global, "Tint")); + assert_eq!( + declared(&rel_global["temperature"]), + ui_range(Level::Global, "IncrementalTemperature") + ); + + // Masks: every local adjustment the schema offers. + let local_key = |field: &str| match field { + "exposure" => "LocalExposure2012", + "contrast" => "LocalContrast2012", + "highlights" => "LocalHighlights2012", + "shadows" => "LocalShadows2012", + "whites" => "LocalWhites2012", + "blacks" => "LocalBlacks2012", + "temperature" => "LocalTemperature", + "tint" => "LocalTint", + "texture" => "LocalTexture", + "clarity" => "LocalClarity2012", + "dehaze" => "LocalDehaze", + "saturation" => "LocalSaturation", + "sharpness" => "LocalSharpness", + "noise" => "LocalLuminanceNoise", + "moire" => "LocalMoire", + other => panic!("mask field {other} has no registry key in this test"), + }; + let adjustments = + &raw["properties"]["masks"]["items"]["properties"]["adjustments"]["properties"]; + for &(field, _) in MASK_ADJUSTMENT_RANGES { + assert_eq!( + declared(&adjustments[field]), + ui_range(Level::Correction, local_key(field)), + "masks[].adjustments.{field}" + ); + } } } diff --git a/server-rs/crates/lrg-providers/src/lib.rs b/server-rs/crates/lrg-providers/src/lib.rs index cad66ce6..099453f5 100644 --- a/server-rs/crates/lrg-providers/src/lib.rs +++ b/server-rs/crates/lrg-providers/src/lib.rs @@ -5,6 +5,7 @@ pub mod anthropic; pub mod chat; pub mod cloud_chat; +pub mod edit_loop; pub mod edit_recipe; pub mod gemini; pub mod gemini_schema; diff --git a/server-rs/crates/lrg-providers/src/prompts.rs b/server-rs/crates/lrg-providers/src/prompts.rs index c0e0a6c4..ea60948d 100644 --- a/server-rs/crates/lrg-providers/src/prompts.rs +++ b/server-rs/crates/lrg-providers/src/prompts.rs @@ -506,8 +506,10 @@ const TRAINING_CANONICAL_KEYS: &[&str] = &[ "Shadows2012", "Whites2012", "Blacks2012", - "Temp", + "Temperature", "Tint", + "IncrementalTemperature", + "IncrementalTint", "Texture", "Clarity2012", "Dehaze", diff --git a/server-rs/scripts/develop_registry/.gitignore b/server-rs/scripts/develop_registry/.gitignore new file mode 100644 index 00000000..cf8f5af4 --- /dev/null +++ b/server-rs/scripts/develop_registry/.gitignore @@ -0,0 +1,2 @@ +out/ +*.json diff --git a/server-rs/scripts/develop_registry/README.md b/server-rs/scripts/develop_registry/README.md new file mode 100644 index 00000000..9a5d8387 --- /dev/null +++ b/server-rs/scripts/develop_registry/README.md @@ -0,0 +1,195 @@ +# develop_registry — research and fixture tools for `lrg-develop` + +Build-time tools behind the backend's native Lightroom develop model (the +`lrg-develop` crate, step 1 of "AI Edit via XMP"). They answer three questions: + +1. **Which `crs:` keys exist, and how does Lightroom write them?** — an inventory + over Lightroom Classic's bundled presets/profiles and a sample of real + sidecars (`parse_xmp.py`, `scan_corpus.py`, `sample.py`, `structform.py`). +2. **What goes into the key registry?** — `spec.py` holds the hand-maintained + rows (type, range, UI scaling, defaults, policy class) and `gen.py` renders + them as Markdown with observed statistics. `gen_table_rs.py` seeded the + static Rust table from them once; after that the Rust table is the source + of truth. +3. **What do real `getDevelopSettings()` values look like?** — `dump_training.py` + reads the backend's training table and `extract_fixtures.py` turns a few rows + into scrubbed test fixtures under `server-rs/testdata/develop/lua/`. + +Nothing here ships with the backend. The tools are stdlib-only Python ≥ 3.12; +only `dump_training.py` needs `pylance`, in the opt-in `lance` group. + +> **Outputs derived from your own catalog, sidecars or training table are +> private and must never be committed.** That covers `corpus_meta.json`, +> `corpus_keyfreq.json`, `sample_files.json`, `inventory*.json`, the registry +> Markdown and the training dump — they contain paths, file names, photo ids +> and capture data. The **only** exceptions are what `extract_fixtures.py` writes +> (it scrubs its output, checks it in a private temporary folder and only then +> replaces the fixtures it owns; if the check fails the fixture folder is left +> untouched) and `server-rs/testdata/develop/defaults/`, Lightroom's own +> defaults read back from an unedited photo, reduced to key -> value plus the +> Lightroom version, process version and file kind (no file name, path, id +> or date; `orientation` is dropped. The Upright solver state stays in: the +> registry keeps it `NoDefault` and a test relies on that, and +> `UprightFocalLength35mm` can reflect the photo's focal length, as in the +> fixtures. `HDRMaxValue` stays in too, held back from the defaults as +> possibly per image. The hygiene checks cover it). +> Write every other output to a folder outside the repository. + +The tools enforce this: they refuse an output path inside the repository +(`outpath.py`), except `extract_fixtures.py` into `server-rs/testdata/develop/`. +A `.gitignore` here also keeps any `*.json` and `out/` in this folder out of +git. + +## Inputs + +| Variable | Meaning | +|---|---| +| `LRG_LRC_PRESETS_DIR` | Lightroom Classic's bundled settings, e.g. `/Applications/Adobe Lightroom Classic/Adobe Lightroom Classic.app/Contents/Resources/Settings` | +| `LRG_XMP_CORPUS_DIR` | root of a folder tree with Lightroom `.xmp` sidecars | +| `LRG_ACR_PRESETS_DIR` | optional: Camera Raw's settings (`/Library/Application Support/Adobe/CameraRaw/Settings`) | + +Every tool also takes these as arguments (`--lrc-presets`, `--corpus`, ...). +Sidecar rules everywhere: `* [conflicted].xmp` copies are skipped, and only +files containing `crs:ProcessVersion` count as Lightroom sidecars. + +## Tools + +Run from this directory; `OUT` is any folder **outside** the repository. + +### `scan_corpus.py` — pass 1 over the sidecars + +Byte-level scan: strata (top-level folder, writer version, process version, +element vs attribute form) and feature flags per sidecar, plus how many files +mention each `crs:` name. + +```bash +uv run scan_corpus.py --corpus "$LRG_XMP_CORPUS_DIR" --out-dir "$OUT" +``` + +### `sample.py` — stratified sample + +Guarantees the rare shapes (element form, older writers, snapshots, retouch, +filter lists, crops), then fills proportionally by (folder, process version, +masks). Fixed seed, so the same corpus gives the same sample. + +```bash +uv run sample.py --meta "$OUT/corpus_meta.json" --size 3000 --out-dir "$OUT" +``` + +### `parse_xmp.py` — the crs reader and the inventory + +A library (`Inventory`, `iter_xmp_files`, `is_lrc_sidecar`, ...) and a CLI. +Handles attribute and element form, `rdf:Seq`/`Bag`/`Alt`, nested +`rdf:Description` and `rdf:parseType="Resource"`. It does not inventory +`crs:Preset/Parameters`, `crs:Look/Parameters` or `crss:SavedSettings` (they are +not the photo's own settings); each skip is counted under `excluded`. + +```bash +uv run parse_xmp.py inventory --lrc-presets "$LRG_LRC_PRESETS_DIR" \ + --sidecar-list "$OUT/sample_files.json" --out "$OUT/inventory.json" --strict +# whole corpus, for the coverage check in gen.py: +uv run parse_xmp.py inventory --lrc-presets "$LRG_LRC_PRESETS_DIR" \ + --sidecar-list "$OUT/corpus_meta.json" --out "$OUT/inventory_full.json" +uv run parse_xmp.py show "$OUT/inventory.json" --levels global,correction,mask-tool +``` + +### `structform.py` — how nested structs are spelled + +Counts `parseType=Resource` vs `rdf:Description` child vs attributes on the +element, per struct key. A parser must accept every form listed. + +```bash +uv run structform.py --lrc-presets "$LRG_LRC_PRESETS_DIR" --sidecar-list "$OUT/sample_files.json" +``` + +### `gen.py` + `spec.py` — the registry as Markdown + +`spec.py` is the hand-written table; `gen.py` renders it with the "Observed" +column filled from the inventory and prints every observed key that no row +covers (`--strict` makes that fatal). Rows come from local observation only; +nothing is taken from ExifTool or darktable, and keys known only from ExifTool +were deliberately left out. Key names known from Camera Raw but never observed +in Lightroom Classic's output are marked as such and carry no value semantics. + +The "Observed" column shows raw string values only for Adobe enum keys +(`SAMPLE_KEYS`); other string keys show "N distinct values", because their +values can be file names, lens or camera models or your own mask names. +`--private-samples` shows everything, for your eyes only. When step 1a seeds +`registry/table.rs`, it imports `spec.py`'s rows, never the rendered Markdown. + +```bash +uv run gen.py --inventory "$OUT/inventory.json" --full-inventory "$OUT/inventory_full.json" \ + --out-dir "$OUT" --strict +``` + +### `gen_table_rs.py` — seed the Rust registry table + +Turns `spec.py`'s rows into `crates/lrg-develop/src/registry/table.rs`: group +rows are expanded (`HueAdjustment*`, `SDR*`, ...), the families that stay +patterns (`Table_`, `pm_*`, the FilterList payload, +`UprightTransform_N`, `UprightFourSegments_N`) are left to +`registry/patterns.rs`, and the decisions the free text cannot carry (value +kind of "mixed" rows, gates, frame scope, presence, number format, recipe +aliases) are the tables at the top of the script. It imports `spec` and +reads one committed data file, +`server-rs/testdata/develop/defaults/non_raw_lrc15.6.json` (experiment E13's +readback of an unedited non-raw photo): every global row whose non-raw +default the text leaves unverified gets the value read back, if the readback +has the key and it is not held back in `NON_RAW_READBACK_NOT_CONSTANT` +(`--counts` prints how many, which keys kept `NoDefault` and which were held +back). A readback that contradicts a non-raw default the text states stops +the run. +No inventory, no training dump. The `Temp` row is not seeded: +it documents the plugin bug, it is not a key. + +The Rust table is the source of truth; running the script again overwrites +hand edits to `table.rs`, so mirror them in `spec.py` (as of October 2026 the +script reproduces `table.rs` exactly). Refresh the review snapshot afterwards: + +```bash +python3 gen_table_rs.py --out ../../crates/lrg-develop/src/registry/table.rs --counts +LRG_BLESS=1 cargo test -p lrg-develop --test registry_snapshot +``` + +### `dump_training.py` — the training table as JSON + +Reads `edit_training.lance` from the backend's Lance store +(`/lrgenius-lance/` next to `--db-path /lrgenius.db`). + +```bash +OUT=$(mktemp -d) +uv run --group lance dump_training.py ~/path/to/lrgenius-lance/edit_training.lance "$OUT/training_rows.json" +``` + +### `extract_fixtures.py` — scrubbed fixtures for the repository + +Picks a small set of rows (currently 16 plus a synthetic top-level `[]`) that +together cover the shapes the Lua/JSON reader must handle (every mask kind, +intersect/subtract, local curves, `PointColors`, `AILook`, Adobe, adaptive and +camera-restricted Looks, retouch `pm_*` keys, `LensBlur` and `FilterList` as +`[]` and as objects, process versions 11.0 and 15.4, ...), scrubs them and writes +one file per shape plus `manifest.json`. Numbers keep the exact token +`JSON.lua` wrote. The scrubbing rules and the hygiene check are described in the +module docstring and in `server-rs/testdata/develop/README.md`. Hand-written +fixtures live next to the generated ones as `hand_*.json` and are never +touched by a run. + +```bash +uv run extract_fixtures.py "$OUT/training_rows.json" --out-dir ../../testdata/develop/lua +# the check alone (stdlib only, also what pre-commit and CI run): +python3 extract_fixtures.py --check-only --out-dir ../../testdata/develop/lua --root ../../testdata/develop +# the check's own test: crafted files on a copy of the tree, the same cases as +# crates/lrg-develop/tests/fixture_hygiene.rs (also in pre-commit and CI): +python3 extract_fixtures.py --self-test --out-dir ../../testdata/develop/lua --root ../../testdata/develop +``` + +## Lint + +The pre-commit hooks `develop-fixture-hygiene` and +`develop-fixture-hygiene-self-test` and the docs-check job in +`lint-format.yml` run the fixture check and its self-test. No hook runs ruff yet; keep the code +clean by hand: + +```bash +ruff format . && ruff check . +``` diff --git a/server-rs/scripts/develop_registry/dump_training.py b/server-rs/scripts/develop_registry/dump_training.py new file mode 100644 index 00000000..23897291 --- /dev/null +++ b/server-rs/scripts/develop_registry/dump_training.py @@ -0,0 +1,67 @@ +"""Dump the backend's ``edit_training`` LanceDB table to JSON. + +The table lives in the backend's Lance store next to the database: for +``--db-path /lrgenius.db`` that is ``/lrgenius-lance/edit_training.lance``. +It holds one row per "Train from edits" example: the photo's full +``getDevelopSettings()`` blob (as the JSON string the plugin's ``JSON.lua`` +produced) plus photo metadata. ``extract_fixtures.py`` reads this dump. + +Needs the optional ``lance`` dependency group (``pylance``):: + + uv run --group lance dump_training.py /lrgenius-lance/edit_training.lance "$OUT/training_rows.json" + +``$OUT`` is a folder outside the repository; the tool refuses to write inside it +and creates the file readable by you only (mode 0600). + +The output contains photo ids, file names, capture times and camera data from +your catalog. **It is private - never commit it.** Only the scrubbed files that +``extract_fixtures.py`` writes may go into the repository. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from pathlib import Path + +from outpath import require_outside_repo + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description="Dump the edit_training LanceDB table to JSON.") + ap.add_argument("dataset", help="path to the edit_training.lance dataset directory") + ap.add_argument("out", help="output JSON file outside the repository (a list of row objects)") + ap.add_argument("--show-schema", action="store_true", help="print the table schema") + args = ap.parse_args(argv) + require_outside_repo(args.out) + + try: + import lance # imported here so the rest of the project stays stdlib-only + except ImportError: + print("pylance is not installed: run with `uv run --group lance dump_training.py ...`", file=sys.stderr) + return 2 + + if not Path(args.dataset).is_dir(): + print(f"not a dataset directory: {args.dataset}", file=sys.stderr) + return 2 + ds = lance.dataset(args.dataset) + if args.show_schema: + print(ds.schema) + # The embedding column is large and not needed for fixtures. + cols = [f.name for f in ds.schema if f.name != "vector"] + rows = ds.to_table(columns=cols).to_pylist() + out = Path(args.out) + out.parent.mkdir(parents=True, exist_ok=True) + # Private data: create the file readable by the owner only. + fd = os.open(out, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + os.fchmod(fd, 0o600) # an existing file keeps its old mode otherwise + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(json.dumps(rows, default=str)) + print(f"{len(rows)} rows -> {args.out}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/server-rs/scripts/develop_registry/extract_fixtures.py b/server-rs/scripts/develop_registry/extract_fixtures.py new file mode 100644 index 00000000..8434383a --- /dev/null +++ b/server-rs/scripts/develop_registry/extract_fixtures.py @@ -0,0 +1,1325 @@ +"""Extract a small, scrubbed set of ``getDevelopSettings()`` fixtures from a training dump. + +Input is the JSON written by ``dump_training.py``: a list of rows whose +``metadata`` field is a JSON string holding, among photo metadata, the +``develop_settings`` blob - itself a JSON string produced by the plugin's +``JSON.lua`` from ``photo:getDevelopSettings()``. + +The tool picks a handful of rows that together cover the shapes the Lua/JSON +reader of ``lrg-develop`` must handle, scrubs them, and writes one file per +shape plus ``manifest.json`` (what each file covers) to ``--out-dir``; the +repository keeps them in ``server-rs/testdata/develop/lua/``. For each shape it +takes the smallest row that has it, avoiding rows already taken; shapes that +need no file of their own are satisfied by re-picking an existing file or, only +if that fails, by one more file. + +**Only this tool's output may be committed.** The dump itself is private. + +What is written +--------------- +Only the decoded ``develop_settings`` value. Every other metadata field (photo +id, file name, capture time, camera, EXIF, scene tags, canonical settings) is +dropped. Numbers are written with the exact token ``JSON.lua`` produced, so int +vs float, ``true``/``false`` vs ``0``/``1`` and ``[]`` for empty tables survive +unchanged. + +How it is scrubbed (the repository is public) +--------------------------------------------- +* ``Look.Parameters`` (Adobe's or a third party's profile definition) becomes + a stub holding only the original ``ConvertToGrayscale`` and ``ProcessVersion`` + (when present). ``Look.Name``/``UUID`` are kept only for the Adobe profiles in + ``ADOBE_LOOKS`` - UUIDs verified to be ``crs:UUID`` of a profile in Lightroom + Classic's bundled ``Adobe/Profiles``. Any other profile's name and UUID are + replaced, whatever its copyright text says; ``--stub-look-uuid`` replaces the + Adobe ones too. Copyright, group, sort name, cluster and camera restriction + always become generic text. +* Keys ``Table_``, ``BrushTable_`` (and the ``MaskBrushTable`` + reference to them), ``LookTable*`` and ``RGBTable*`` are removed everywhere. +* Every 32-hex digest/ID (``MaskDigest``, ``InputDigest``, ``CorrectionSyncID``, + ``LensProfileDigest``, ``AILookData``, ``pm_patch``, ...) becomes ``0`` x 20 + + 12 hex digits of a counter, every GUID (``CorrectionID``, ``MaskID``) becomes + ``00000000-0000-4000-8000-`` + 12 hex digits. The mapping is consistent across + all files, so equal IDs stay equal but nothing links back to a catalog. +* ``GrainSeed`` (a random per-photo value) becomes the constant 4000000001. +* Free text becomes type-neutral generic English: ``CorrectionName`` is + "Correction N", ``MaskName`` "Mask N" (LrC's own defaults are type-neutral too, + so a reader cannot cheat by classifying masks by name); lens profile names + and file names, model versions and time stamps get fixed synthetic values. +* Every other string must satisfy the value rule of its key (``STRING_RULES``) + or be a number list or brush-dab string; anything else becomes + "Synthetic Text" and is reported. The scrubber and the hygiene check share + these rules. +* Numeric values are otherwise unmodified; they can reveal the camera class + (image size, black levels) and focal length. + +Safety +------ +The run writes into a private temporary folder first, checks it there and only +then replaces the files it owns in ``--out-dir`` (those listed in the previous +``manifest.json``). If the hygiene check or the shape check fails, ``--out-dir`` +is left untouched and the rejected output is deleted (``--keep-rejected`` keeps +it for inspection, in a folder only you can read). Hand-written fixtures in +``--out-dir`` must be named ``hand_*.json``; the tool never deletes them and +checks them with the relaxed rules below. Inside the repository ``--out-dir`` +must be below ``server-rs/testdata/develop/``. + +Hygiene check +------------- +Hard failures in every checked file (text level): ``md5p:``, ``file:``, user, +volume, home, ``/private/``, ``/tmp/``, ``/var/``, ``/Library/``, drive and UNC +paths, raw/image file names, ``Table_``, ``LookTable``, ``RGBTable`` (the +preset-header key ``RequiresRGBTables``, as a whole word, is not table content +and is exempt). +Structural rules for every JSON file: keys are identifiers (language tags only +under ``Group``/``SortName``); ``Look.Parameters`` is the stub; ``Look.UUID``/ +``Name`` are an ``ADOBE_LOOKS`` pair or synthetic; every 32-hex value and GUID is +synthetic; no dates; no number or numeric string >= 1e9 except version keys +and the synthetic ``GrainSeed``. Generated files additionally require every +string to satisfy its key's rule. An XMP file must be well-formed XML with an +``rdf:RDF``; its data model (``xmp_as_tree``: every ``rdf:Description`` as an +object, properties by local name, ``rdf:Seq``/``rdf:Bag``/``rdf:Alt`` as lists, +``True``/``False`` as booleans, other values as text) gets the same structural +rules as a hand-written JSON file, and every ``rdf:Alt`` item needs a valid +``xml:lang``. ``--check-only`` runs just the check; with +``--root`` it also walks every ``*.json``/``*.xmp`` below that folder. + +Example (``$OUT`` is a folder outside the repository):: + + uv run extract_fixtures.py "$OUT/training_rows.json" --out-dir ../../testdata/develop/lua + python3 extract_fixtures.py --check-only --out-dir ../../testdata/develop/lua --root ../../testdata/develop +""" + +from __future__ import annotations + +import argparse +import json +import re +import shutil +import sys +import tempfile +import xml.etree.ElementTree as ET +from collections import Counter +from collections.abc import Callable +from pathlib import Path + +from outpath import output_problem + +# --------------------------------------------------------------------------- +# Exact-token JSON: numbers keep the text JSON.lua wrote +# --------------------------------------------------------------------------- + + +class RawInt(int): + """An int that remembers its JSON token.""" + + raw: str + + def __new__(cls, text: str) -> RawInt: + obj = super().__new__(cls, int(text)) + obj.raw = text + return obj + + +class RawFloat(float): + """A float that remembers its JSON token (``1e-05`` stays ``1e-05``).""" + + raw: str + + def __new__(cls, text: str) -> RawFloat: + obj = super().__new__(cls, float(text)) + obj.raw = text + return obj + + +def loads_exact(text: str): + return json.loads(text, parse_int=RawInt, parse_float=RawFloat) + + +def dumps_exact(value, indent: int = 2, level: int = 0) -> str: + """Deterministic JSON writer that emits numbers with their original token.""" + pad = " " * (indent * (level + 1)) + end = " " * (indent * level) + if value is True: + return "true" + if value is False: + return "false" + if value is None: + return "null" + if isinstance(value, (RawInt, RawFloat)): + return value.raw + if isinstance(value, (int, float)): + return json.dumps(value) + if isinstance(value, str): + return json.dumps(value, ensure_ascii=False) + if isinstance(value, list): + if not value: + return "[]" + return "[\n" + ",\n".join(pad + dumps_exact(v, indent, level + 1) for v in value) + "\n" + end + "]" + if isinstance(value, dict): + if not value: + return "{}" + items = ( + pad + json.dumps(k, ensure_ascii=False) + ": " + dumps_exact(v, indent, level + 1) for k, v in value.items() + ) + return "{\n" + ",\n".join(items) + "\n" + end + "}" + raise TypeError(f"cannot encode {type(value).__name__}") + + +# --------------------------------------------------------------------------- +# Adobe profiles whose name and UUID may be published +# --------------------------------------------------------------------------- + +# UUID -> Name of profiles verified to exist as crs:UUID in Lightroom Classic's +# bundled Settings/Adobe/Profiles (LrC 15.5.1). Extend deliberately, only after +# checking the bundle; never derive Adobe-ness from a Look's own text. +ADOBE_LOOKS = { + "B952C231111CD8E0ECCF14B86BAA7077": "Adobe Color", + "6F9C877E84273F4E8271E6B91BEB36A1": "Adobe Landscape", + "0CFE8F8AB5F63B2A73CE0B0077D20817": "Adobe Monochrome", + "D6496412E06A83789C499DF9540AA616": "Adobe Portrait", + "EA1DE074F188405965EF399C72C221D9": "Adobe Vivid", + "226E6263ED94405B973B41F5A6E67BE9": "Adaptive Color", + "4B2C86502E5F2637A81F96F8D96AC1F9": "Artistic 04", + "ADB81986EAF84E0999C0112BDE1F93D1": "B&W 03", + "9D1ED025F3DE4C94BC78481AA67B3C89": "B&W 11", + "8FD143CED5B04137ACE4B5A63962293F": "B&W 12", + "7B79093CEC7726D67B352CAD78D1601F": "Modern 07", + "DA1C3775662D6B6A75F8BC2CEEB3724A": "Modern 08", + "97291D549FC232787BDFD3353151BB62": "Vintage 01", +} + + +def is_adobe_look(look: dict) -> bool: + uuid = look.get("UUID") + return isinstance(uuid, str) and ADOBE_LOOKS.get(uuid.upper()) == look.get("Name") + + +# --------------------------------------------------------------------------- +# Shapes +# --------------------------------------------------------------------------- + +PEOPLE_PARTS = {2, 3, 4, 5, 6, 7, 8, 9, 11, 12} +LANDSCAPE_CLASSES = {50001, 50002, 50003, 50004, 50005, 50006, 50007, 50008} +BACKGROUND_CATEGORY = 22 +LEGACY_LOCAL_KEYS = ("LocalExposure", "LocalContrast", "LocalClarity") + + +def gesture_kind(mask: dict) -> str | None: + for g in mask.get("Gesture") or []: + if isinstance(g, dict): + return g.get("What") + return None + + +def mask_shape(mask: dict) -> str | None: + """Fixture shape name for one ``CorrectionMasks`` item.""" + what = mask.get("What") + if what == "Mask/Image": + sub = mask.get("MaskSubType") + cat = mask.get("MaskSubCategoryID") + if sub == 1: + return "mask_ai_subject" + if sub == 2: + return "mask_ai_sky" + if sub == 3 and cat in PEOPLE_PARTS: + return "mask_ai_people_part_all" + if sub == 0: + if gesture_kind(mask) == "Mask/Polygon": + return "mask_select_object_polygon" + if gesture_kind(mask) == "Mask/Paint": + return "mask_select_object_brush" + if cat == BACKGROUND_CATEGORY: + return "mask_ai_background" + if cat in PEOPLE_PARTS: + return "mask_ai_people_part_person" + if cat in LANDSCAPE_CLASSES: + return "mask_ai_landscape" + return "mask_ai_other" + if what == "Mask/Gradient": + return "mask_linear_gradient" + if what == "Mask/CircularGradient": + return "mask_radial_gradient" + if what == "Mask/Aggregate": + return "mask_brush_aggregate" + if what == "Mask/RangeMask": + crm = mask.get("CorrectionRangeMask") or {} + return {1: "mask_range_color", 2: "mask_range_luminance"}.get(crm.get("Type"), "mask_range_other") + return None + + +def _has_exponent_token(v) -> bool: + if isinstance(v, RawFloat): + return "e" in v.raw or "E" in v.raw + if isinstance(v, dict): + return any(_has_exponent_token(x) for x in v.values()) + if isinstance(v, list): + return any(_has_exponent_token(x) for x in v) + return False + + +def shapes_of(d) -> set[str]: + """Every fixture shape a decoded ``develop_settings`` value exhibits.""" + if isinstance(d, list): + return {"top_level_empty"} if not d else set() + s: set[str] = set() + pv = d.get("ProcessVersion") + if isinstance(pv, str): + s.add("pv_" + pv.replace(".", "_")) + look = d.get("Look") + if isinstance(look, dict) and look: + s.add("look") + if look.get("CameraModelRestriction"): + s.add("look_camera_restricted") + if "isAdobeAdaptive" in look: + s.add("look_adaptive") + if isinstance(look.get("Amount"), RawFloat): + s.add("look_amount_float") + elif "Look" not in d: + s.add("no_look") + lb = d.get("LensBlur") + if lb == []: + s.add("lensblur_empty") + elif isinstance(lb, dict): + s.add("lensblur_object") + fl = d.get("FilterList") + if fl == []: + s.add("filterlist_empty") + elif isinstance(fl, dict) and fl.get("Filters"): + s.add("filterlist_filters") + if isinstance(d.get("AILook"), dict) and d["AILook"]: + s.add("ailook_object") + if isinstance(d.get("PointColors"), list) and d["PointColors"]: + s.add("point_colors") + ra = d.get("RetouchAreas") + if isinstance(ra, list) and any(isinstance(a, dict) and any(k.startswith("pm_") for k in a) for a in ra): + s.add("retouch_areas") + if isinstance(d.get("RemoveAreas"), list) and d["RemoveAreas"]: + s.add("remove_areas") + if isinstance(d.get("RetouchInfo"), (dict, list)) and d["RetouchInfo"]: + s.add("retouch_info") + if any(k.startswith("UprightTransform_") for k in d): + s.add("upright_transform") + if isinstance(d.get("DepthMapInfo"), dict) and d["DepthMapInfo"]: + s.add("depth_map_info") + if d.get("WhiteBalance") == "Auto" and "AutoWhiteVersion" in d: + s.add("wb_auto") + if isinstance(d.get("GrainSeed"), int): + s.add("grain_seed") + if isinstance(d.get("PerspectiveRotate"), RawFloat): + s.add("perspective_rotate_float") + if _has_exponent_token(d): + s.add("number_exponent") + for corr in d.get("MaskGroupBasedCorrections") or []: + if not isinstance(corr, dict): + continue + if isinstance(corr.get("MainCurve"), list) and corr["MainCurve"]: + s.add("local_curve") + if any(isinstance(corr.get(k), RawFloat) for k in LEGACY_LOCAL_KEYS): + s.add("local_legacy_float") + for m in corr.get("CorrectionMasks") or []: + if not isinstance(m, dict): + continue + shape = mask_shape(m) + if shape: + s.add(shape) + crm = m.get("CorrectionRangeMask") + if shape == "mask_range_color" and isinstance(crm, dict) and crm.get("AreaModels"): + s.add("mask_range_color_area") + if m.get("MaskBlendMode") == 1: + s.add("mask_intersect" if m.get("MaskInverted") is True else "mask_subtract") + return s + + +# Shapes that get a file of their own, in selection order (plan step 1, 7.2). +PRIMARY_SHAPES = [ + "mask_ai_subject", + "mask_ai_sky", + "mask_ai_background", + "mask_ai_people_part_all", + "mask_ai_people_part_person", + "mask_ai_landscape", + "mask_select_object_polygon", + "mask_linear_gradient", + "mask_radial_gradient", + "mask_brush_aggregate", + "mask_range_luminance", + "mask_range_color", + "mask_range_color_area", + "lensblur_object", + "filterlist_filters", + "no_look", +] +# Shapes some file must cover; satisfied by re-picking a file where possible. +INCIDENTAL_SHAPES = [ + "look", + "lensblur_empty", + "filterlist_empty", + "pv_11_0", + "pv_15_4", + "mask_intersect", + "mask_subtract", + "local_curve", + "point_colors", + "ailook_object", + "look_camera_restricted", + "look_adaptive", + "retouch_areas", + "remove_areas", + "retouch_info", + "wb_auto", + "number_exponent", + "upright_transform", + "depth_map_info", + "look_amount_float", + "perspective_rotate_float", + "local_legacy_float", + "grain_seed", +] +# Shapes we only want when present; missing is reported but not an error. +OPTIONAL_SHAPES = { + "mask_ai_people_part_all", + "mask_ai_people_part_person", + "mask_ai_landscape", + "mask_range_color_area", + "no_look", + "mask_subtract", + "look_adaptive", + "remove_areas", + "retouch_info", + "upright_transform", + "depth_map_info", + "look_amount_float", + "perspective_rotate_float", + "local_legacy_float", + "grain_seed", +} +WANTED_SHAPES = set(PRIMARY_SHAPES) | set(INCIDENTAL_SHAPES) + +# --------------------------------------------------------------------------- +# Value rules (shared by the scrubber and the hygiene check) +# --------------------------------------------------------------------------- + +# Anchored patterns end in \Z, not $ ($ also matches before a trailing +# newline), and digits are ASCII (re.ASCII), so the Rust mirror +# (crates/lrg-develop/tests/fixture_hygiene.rs, `regex` crate: $ = end of +# text, [0-9]) accepts exactly the same strings. +HEX32_RE = re.compile(r"^[0-9A-Fa-f]{32}\Z") +HEX32_ANY_RE = re.compile(r"(? or RequiresRGBTablesX still fail. Same +# pattern as ALLOWED_HEADER_KEYS in crates/lrg-develop/tests/fixture_hygiene.rs. +ALLOWED_HEADER_KEYS_RE = re.compile(r"\bRequiresRGBTables\b", re.ASCII) +# Files directly in --root that hold no settings, only the registry's own +# description (generated by lrg-develop's tests/registry_snapshot.rs). They +# name every key, the table keys included, so they get the text rules without +# the table-key names, the 32-hex/GUID rule, and no fixture tree rules. +REGISTRY_FILES = frozenset({"registry_snapshot.json"}) + +SYNTHETIC_DATE = "2000-01-01T00:00:00+00:00" +SYNTHETIC_TEXT = "Synthetic Text" +SYNTHETIC_PROFILE = "Synthetic Profile" +SYNTHETIC_GRAIN_SEED = "4000000001" # > 2^31, so the u32 path stays covered +STUB_KEYS = frozenset({"ConvertToGrayscale", "ProcessVersion"}) +# Keys whose numbers may legitimately be >= 1e9 (packed engine versions). +BIG_NUMBER_KEYS = frozenset( + {"UprightVersion", "ModelVersion", "MinEditVersion", "MinDisplayVersion", "CompatibleVersion", "AutoWhiteVersion"} +) + +CAMERA_PROFILES = frozenset( + { + "Adobe Standard", + "Adobe Standard v2", + "Camera Standard", + "Camera Standard v2", + "Camera Neutral", + "Camera Neutral v2", + "Camera Landscape", + "Camera Landscape v2", + "Camera Faithful", + "Camera Faithful v2", + "Camera Portrait", + "Camera Portrait v2", + "Camera Fine Detail", + "Camera Monochrome", + } +) +TONE_CURVE_NAMES = frozenset({"Linear", "Medium Contrast", "Strong Contrast", "Custom", "Matter Kontrast"}) + +# Values the scrubber writes in place of free text; the check accepts exactly these. +FIXED_VALUES: dict[str, str] = { + "LensProfileName": "Adobe (Synthetic Lens)", + "LensProfileFilename": "Synthetic Lens - RAW.lcp", + "pm_clio_model_version": "synthetic-model-version", + "Copyright": "Synthetic copyright notice", + "Cluster": "Synthetic", + "CameraModelRestriction": "Synthetic Camera", +} +FIXED_IN_PARENT: dict[tuple[str, str], str] = { + ("GenAIInfo", "Name"): "Synthetic Generative Model", + ("GenAIInfo", "SoftwareAgent"): "Synthetic Agent", +} +LOOK_ALT_FIELDS = {"Group": "Synthetic Group", "SortName": "Synthetic"} + + +def _one_of(values) -> Callable[[str], bool]: + allowed = frozenset(values) + return lambda v: v in allowed + + +def _match(rx: str) -> Callable[[str], bool]: + pattern = re.compile(rx, re.ASCII) + return lambda v: bool(pattern.fullmatch(v)) + + +# Per-key value rules. A string under one of these keys must satisfy the rule. +STRING_RULES: dict[str, Callable[[str], bool]] = { + "What": _match(r"^(Correction|Mask/[A-Za-z]+)$"), + "WhiteBalance": _one_of( + {"As Shot", "Auto", "Custom", "Daylight", "Cloudy", "Shade", "Tungsten", "Fluorescent", "Flash"} + ), + "ProcessVersion": VERSION_RE.match, + "Version": VERSION_RE.match, + "orientation": _match(r"^[A-D]{2}$"), + "LensProfileSetup": _one_of({"LensDefaults", "Custom", "Auto"}), + "CameraProfile": _one_of(CAMERA_PROFILES), + "ToneCurveName": _one_of(TONE_CURVE_NAMES), + "ToneCurveName2012": _one_of(TONE_CURVE_NAMES), + "Method": _one_of({"poisson", "gaussian", "clone"}), + "SourceState": _one_of({"sourceAutoComputed", "sourceSetExplicitly"}), + "sourceState": _one_of({"sourceAutoComputed", "sourceSetExplicitly"}), + "SpotType": _one_of({"heal", "clone", "heal_patchmatch"}), + "spotType": _one_of({"heal", "clone", "heal_patchmatch"}), + "fill_method": _one_of({"firefly"}), + "pm_source_type": _one_of({"full"}), + "CorrectionName": _match(r"^Correction \d+$"), + "MaskName": _match(r"^Mask \d+$"), + **{k: _one_of({v}) for k, v in FIXED_VALUES.items()}, +} +STRING_RULES_IN_PARENT: dict[tuple[str, str], Callable[[str], bool]] = { + ("Filters", "Name"): _one_of( + { + "Enhance", + "Denoise", + "Raw Details", + "Super Resolution", + "Dust Removal", + "Distracting People Removal", + "Reflection Removal", + } + ), + ("Filters", "Title"): _match(r"^\$\$\$/CRaw/Filter/[A-Za-z/]+=[A-Za-z ]+$"), + **{k: _one_of({v}) for k, v in FIXED_IN_PARENT.items()}, +} + + +def numeric_text_ok(v: str) -> bool: + """A number list without a lone huge integer (serial numbers, Unix times).""" + if not NUMERIC_TEXT_RE.match(v): + return False + tokens = [t for t in re.split(r"[ ,/]+", v) if t] + return not (len(tokens) == 1 and "." not in v and abs(float(v)) >= BIG_NUMBER) + + +def generic_string_ok(v: str) -> bool: + """Strings that carry no identifying content, whatever the key.""" + return ( + v in ("", SYNTHETIC_TEXT, SYNTHETIC_DATE) + or bool(SYNTH_HEX_RE.match(v) or SYNTH_GUID_RE.match(v)) + or numeric_text_ok(v) + or bool(DAB_RE.match(v)) + ) + + +def string_ok(v: str, key: str, parent: str) -> bool: + """The value rule for ``key`` (under ``parent``), or the generic rule.""" + if v == SYNTHETIC_TEXT or SYNTH_HEX_RE.match(v) or SYNTH_GUID_RE.match(v): + return True + rule = STRING_RULES_IN_PARENT.get((parent, key)) or STRING_RULES.get(key) + if rule is not None: + return bool(rule(v)) + return generic_string_ok(v) + + +def is_dropped_key(key: str) -> bool: + return bool(TABLE_KEY_RE.match(key)) or key.startswith(("LookTable", "RGBTable")) or key == "MaskBrushTable" + + +# --------------------------------------------------------------------------- +# Scrubbing +# --------------------------------------------------------------------------- + + +class Scrubber: + """Stateful so that IDs map consistently across every file of one run.""" + + def __init__(self, stub_look_uuid: bool) -> None: + self.stub_look_uuid = stub_look_uuid + self.hex_map: dict[str, str] = {} + self.guid_map: dict[str, str] = {} + self.report: Counter[str] = Counter() + + def hex32(self, value: str) -> str: + key = value.upper() + if key not in self.hex_map: + self.hex_map[key] = "%032X" % (len(self.hex_map) + 1) + out = self.hex_map[key] + return out.lower() if value.islower() else out + + def guid(self, value: str) -> str: + key = value.upper() + if key not in self.guid_map: + self.guid_map[key] = "00000000-0000-4000-8000-%012X" % (len(self.guid_map) + 1) + out = self.guid_map[key] + return out.lower() if value.islower() else out + + def _drop(self, key: str) -> None: + if "Table_" in key: + self.report["dropped md5 lookup-table key"] += 1 + elif key == "MaskBrushTable": + self.report["dropped brush-table reference"] += 1 + else: + self.report["dropped profile table key"] += 1 + + def scrub_settings(self, d): + if isinstance(d, list): # top-level [] (empty table) + return [self._value(v, "", "") for v in d] + self._mask_n = 0 + self._corr_n = 0 + out = {} + for k, v in d.items(): + if is_dropped_key(k): + self._drop(k) + continue + if k == "Look" and isinstance(v, dict): + out[k] = self._look(v) + else: + out[k] = self._value(v, k, "") + return out + + def _look(self, look: dict) -> dict: + adobe = is_adobe_look(look) and not self.stub_look_uuid + out = {} + for k, v in look.items(): + if is_dropped_key(k): + self._drop(k) + continue + if k == "Parameters": + out[k] = self._look_stub(v) + elif k == "Name": + out[k] = v if adobe else SYNTHETIC_PROFILE + elif k == "UUID" and isinstance(v, str): + out[k] = v if adobe else self.hex32(v) + elif k in LOOK_ALT_FIELDS and isinstance(v, dict): + out[k] = {lang: LOOK_ALT_FIELDS[k] for lang in v} + else: + out[k] = self._value(v, k, "Look") + if not adobe and "Name" in look: + self.report["Look name/UUID replaced (not a verified Adobe profile)"] += 1 + return out + + def _look_stub(self, params): + """Only the two keys a reader needs; [] (JSON.lua's empty table) when neither exists.""" + self.report["Look.Parameters replaced by stub"] += 1 + params = params if isinstance(params, dict) else {} + stub = {k: params[k] for k in sorted(STUB_KEYS) if k in params} + if isinstance(stub.get("ProcessVersion"), str) and not VERSION_RE.match(stub["ProcessVersion"]): + del stub["ProcessVersion"] + if "ConvertToGrayscale" in stub and not isinstance(stub["ConvertToGrayscale"], bool): + del stub["ConvertToGrayscale"] + return stub or [] + + def _value(self, v, key: str, parent: str): + if isinstance(v, dict): + out = {} + for k, vv in v.items(): + if is_dropped_key(k): + self._drop(k) + continue + if not KEY_RE.match(k) and not (key in LOOK_ALT_FIELDS and LANG_RE.match(k)): + self.report["dropped key with an unexpected name"] += 1 + continue + if k == "MaskName" and isinstance(vv, str): + self._mask_n += 1 + out[k] = f"Mask {self._mask_n}" + elif k == "CorrectionName" and isinstance(vv, str): + self._corr_n += 1 + out[k] = f"Correction {self._corr_n}" + else: + out[k] = self._value(vv, k, key) + return out + if isinstance(v, list): + return [self._value(x, key, parent) for x in v] + if isinstance(v, str): + return self._string(v, key, parent) + if key == "GrainSeed" and isinstance(v, int) and not isinstance(v, bool): + self.report["GrainSeed replaced by a synthetic constant"] += 1 + return RawInt(SYNTHETIC_GRAIN_SEED) + return v + + def _string(self, v: str, key: str, parent: str) -> str: + if HEX32_RE.match(v): + return self.hex32(v) + if GUID_RE.match(v): + return self.guid(v) + fixed = FIXED_IN_PARENT.get((parent, key)) or FIXED_VALUES.get(key) + if fixed is not None: + return fixed + if DATE_RE.search(v): + self.report[f"date replaced ({key})"] += 1 + return SYNTHETIC_DATE + if key == "CameraProfile" and v not in CAMERA_PROFILES: + return "Adobe Standard" + if key in ("ToneCurveName", "ToneCurveName2012") and v not in TONE_CURVE_NAMES: + return "Custom" + if string_ok(v, key, parent): + return v + self.report[f"free text replaced ({parent}.{key})"] += 1 + return SYNTHETIC_TEXT + + +# --------------------------------------------------------------------------- +# Selection +# --------------------------------------------------------------------------- + + +def load_rows(path: Path) -> list[tuple[str, object]]: + """``[(develop_settings text, decoded value)]`` for every usable row.""" + rows = json.loads(path.read_text(encoding="utf-8")) + out = [] + for r in rows: + meta = r.get("metadata") + meta = json.loads(meta) if isinstance(meta, str) else (meta or {}) + ds = meta.get("develop_settings") + if not isinstance(ds, str) or not ds.strip(): + continue + try: + out.append((ds, loads_exact(ds))) + except json.JSONDecodeError: + continue + return out + + +def row_cost(text: str, value) -> int: + """Smaller is better: prefer small rows without unrelated heavy payloads.""" + cost = len(text) + if isinstance(value, dict): + if "BrushTable_" in text or "MaskBrushTable" in text: + cost += 10_000_000 # its brush bitmap would be dropped, leaving a dangling reference + for heavy in ("RetouchAreas", "RemoveAreas", "GenAIInfo", "DepthMapInfo", "UprightTransform_"): + if heavy in text: + cost += 500_000 # unrelated payloads: keep each fixture about one thing + look = value.get("Look") + if isinstance(look, dict) and not is_adobe_look(look): + cost += 1_000_000 # prefer a verified Adobe profile; shapes that need another still find one + return cost + + +def select(rows: list[tuple[str, object]]) -> tuple[list[tuple[str, int]], list[str]]: + """Pick ``(file shape, row index)`` pairs covering every wanted shape.""" + shapes = [shapes_of(v) for _, v in rows] + costs = [row_cost(t, v) for t, v in rows] + taken: set[int] = set() + picks: list[tuple[str, int]] = [] + missing: list[str] = [] + + def best(pred: Callable[[int], bool]) -> int | None: + cands = [i for i in range(len(rows)) if i not in taken and pred(i)] + return min(cands, key=lambda i: costs[i]) if cands else None + + def covered(p: list[tuple[str, int]]) -> set[str]: + return set().union(*(shapes[i] for _, i in p)) & WANTED_SHAPES if p else set() + + for shape in PRIMARY_SHAPES: + i = best(lambda i, s=shape: s in shapes[i]) + if i is None: + missing.append(shape) + continue + taken.add(i) + picks.append((shape, i)) + + for need in INCIDENTAL_SHAPES: + have = covered(picks) + if need in have: + continue + done = False + # Re-pick one file within its own shape, without losing anything covered so far. + for n, (shape, old) in enumerate(picks): + taken.discard(old) + others = picks[:n] + picks[n + 1 :] + rest = covered(others) + i = best( + lambda i, s=shape, nd=need, hv=have, rs=rest: ( + s in shapes[i] and nd in shapes[i] and hv <= rs | shapes[i] + ) + ) + if i is not None: + taken.add(i) + picks[n] = (shape, i) + done = True + break + taken.add(old) + if not done: + i = best(lambda i, s=need: s in shapes[i]) + if i is None: + missing.append(need) + else: + taken.add(i) + picks.append((need, i)) + return picks, missing + + +# --------------------------------------------------------------------------- +# Hygiene +# --------------------------------------------------------------------------- + + +def hard_text_problems(text: str, name: str) -> list[str]: + text = ALLOWED_HEADER_KEYS_RE.sub("", text) + return [f"{name}: forbidden text matching {rx.pattern!r}" for rx in HARD_TEXT_RES if rx.search(text)] + + +def check_json_tree(data, name: str, strict: bool) -> list[str]: + """Structural rules for one fixture; ``strict`` adds the per-key value rules.""" + problems: list[str] = [] + + def number(v, path: str, key: str) -> None: + if isinstance(v, bool) or not isinstance(v, (int, float)) or abs(v) < BIG_NUMBER: + return + if key in BIG_NUMBER_KEYS or (key == "GrainSeed" and str(v) == SYNTHETIC_GRAIN_SEED): + return + problems.append(f"{name}: number >= 1e9 at {path} (per-photo value?)") + + def string(v: str, path: str, key: str, parent: str, value_rule: bool = strict) -> None: + for h in HEX32_ANY_RE.findall(v): + if not SYNTH_HEX_RE.match(h): + problems.append(f"{name}: 32-hex value {h[:6]}... at {path} is not synthetic") + for g in GUID_ANY_RE.findall(v): + if not SYNTH_GUID_RE.match(g): + problems.append(f"{name}: GUID {g[:8]}... at {path} is not synthetic") + if DATE_RE.search(v) and v != SYNTHETIC_DATE: + problems.append(f"{name}: date-like text at {path}") + # Numbers are text in XMP, so the version keys and the synthetic + # GrainSeed are exempt as numeric strings too. + big_ok = key in BIG_NUMBER_KEYS or (key == "GrainSeed" and v == SYNTHETIC_GRAIN_SEED) + if NUMERIC_TEXT_RE.match(v) and not numeric_text_ok(v) and not big_ok: + problems.append(f"{name}: numeric string >= 1e9 at {path}") + if value_rule and not string_ok(v, key, parent): + problems.append(f"{name}: unexplained string at {path}: {v[:30]!r}") + + def look(node: dict, path: str) -> None: + params = node.get("Parameters") + if "Parameters" in node: + if params != [] and not ( + isinstance(params, dict) + and params + and set(params) <= STUB_KEYS + and isinstance(params.get("ConvertToGrayscale", False), bool) + and VERSION_RE.match(str(params.get("ProcessVersion", "1"))) + ): + problems.append(f"{name}: {path}.Parameters is not the stub (keys within {sorted(STUB_KEYS)})") + uuid, lname = node.get("UUID"), node.get("Name") + if isinstance(uuid, str) and uuid.upper() in ADOBE_LOOKS: + if lname is not None and lname != ADOBE_LOOKS[uuid.upper()]: + problems.append(f"{name}: {path}.Name does not match its Adobe UUID") + elif isinstance(uuid, str) and not SYNTH_HEX_RE.match(uuid): + problems.append(f"{name}: {path}.UUID is neither a verified Adobe profile nor synthetic") + elif lname is not None and lname != SYNTHETIC_PROFILE: + problems.append(f"{name}: {path}.Name {str(lname)[:30]!r} without a verified Adobe UUID") + for k, v in node.items(): + if k in ("Parameters", "UUID", "Name"): + continue + if k in LOOK_ALT_FIELDS and isinstance(v, dict): + for lang, text in v.items(): + if not LANG_RE.match(lang): + problems.append(f"{name}: bad language tag at {path}.{k}") + if strict and text != LOOK_ALT_FIELDS[k]: + problems.append(f"{name}: unexplained string at {path}.{k}.{lang}") + if isinstance(text, str): + string(text, f"{path}.{k}.{lang}", lang, k, value_rule=False) + continue + walk(v, f"{path}.{k}", k, "Look") + + def walk(node, path: str, key: str, parent: str) -> None: + if isinstance(node, dict): + for k, v in node.items(): + p = f"{path}.{k}" if path else k + if not KEY_RE.match(k): + problems.append(f"{name}: key {p[:60]!r} is not an identifier") + if is_dropped_key(k) or k.startswith(("Table_", "BrushTable_")): + problems.append(f"{name}: forbidden key {p[:60]}") + if k == "Look" and isinstance(v, dict): + look(v, p) + continue + walk(v, p, k, key) + elif isinstance(node, list): + for v in node: + walk(v, path + "[]", key, parent) + elif isinstance(node, str): + string(node, path, key, parent) + else: + number(node, path, key) + + walk(data, "", "", "") + return problems + + +RDF_NS = "http://www.w3.org/1999/02/22-rdf-syntax-ns#" +XML_NS = "http://www.w3.org/XML/1998/namespace" +XMP_BOOLS = {"True": True, "true": True, "False": False, "false": False} + + +def _split_tag(tag: str) -> tuple[str, str]: + """``'{ns}local'`` -> ``('ns', 'local')``.""" + if tag.startswith("{"): + ns, local = tag[1:].split("}", 1) + return ns, local + return "", tag + + +def xmp_as_tree(data: bytes, name: str) -> tuple[object, list[str]]: + """An XMP file's data model as a JSON-like tree for ``check_json_tree``. + + Every ``rdf:Description`` under ``rdf:RDF`` becomes an object (the tree is + the list of them); properties are keyed by local name whatever their + namespace; attributes in ``rdf:``/``xml:`` are skipped. A property element + whose first container child is ``rdf:Seq``/``rdf:Bag``/``rdf:Alt`` is a + list of its ``rdf:li`` items; else one with an ``rdf:Description`` child + is that object; else one with ``rdf:parseType="Resource"``, child elements + or field attributes is an object of those; else its text. ``True``/ + ``False`` (either case) become booleans. Every ``rdf:Alt`` item needs a + valid ``xml:lang``. Mirrored by ``xmp_as_tree`` in + ``crates/lrg-develop/tests/fixture_hygiene.rs``. + """ + problems: list[str] = [] + try: + root = ET.fromstring(data) + except ET.ParseError as e: + return None, [f"{name}: not well-formed XML ({e})"] + rdf_tag = f"{{{RDF_NS}}}RDF" + rdf = root if root.tag == rdf_tag else next(root.iter(rdf_tag), None) + if rdf is None: + return None, [f"{name}: no rdf:RDF element"] + + def scalar(v: str): + return XMP_BOOLS.get(v, v) + + def is_field_attr(k: str) -> bool: + return _split_tag(k)[0] not in (RDF_NS, XML_NS) + + def fields(node: ET.Element, path: str) -> dict: + d: dict = {} + for k, v in node.attrib.items(): + if is_field_attr(k): + d[_split_tag(k)[1]] = scalar(v) + for c in node: + local = _split_tag(c.tag)[1] + d[local] = value(c, f"{path}.{local}" if path else local) + return d + + def value(el: ET.Element, path: str): + kids = list(el) + for c in kids: + ns, local = _split_tag(c.tag) + if ns == RDF_NS and local in ("Seq", "Bag", "Alt"): + items = [li for li in c if _split_tag(li.tag) == (RDF_NS, "li")] + if local == "Alt": + for li in items: + lang = li.get(f"{{{XML_NS}}}lang") + if lang is None or not LANG_RE.match(lang): + problems.append(f"{name}: bad language tag at {path}") + return [value(li, f"{path}[]") for li in items] + for c in kids: + if _split_tag(c.tag) == (RDF_NS, "Description"): + return fields(c, path) + if el.get(f"{{{RDF_NS}}}parseType") == "Resource" or kids or any(is_field_attr(k) for k in el.attrib): + return fields(el, path) + return scalar(el.text or "") + + tree = [fields(d, "") for d in rdf if _split_tag(d.tag) == (RDF_NS, "Description")] + return tree, problems + + +def check_manifest(data, name: str) -> list[str]: + problems: list[str] = [] + + def walk(node) -> None: + if isinstance(node, dict): + for k, v in node.items(): + walk(k) + walk(v) + elif isinstance(node, list): + for v in node: + walk(v) + elif isinstance(node, str): + for h in HEX32_ANY_RE.findall(node): + if not SYNTH_HEX_RE.match(h): + problems.append(f"{name}: 32-hex value {h[:6]}... is not synthetic") + for g in GUID_ANY_RE.findall(node): + if not SYNTH_GUID_RE.match(g): + problems.append(f"{name}: GUID {g[:8]}... is not synthetic") + + walk(data) + return problems + + +def manifest_files(out_dir: Path) -> set[str] | None: + """File names the manifest in ``out_dir`` owns, or ``None`` without a manifest.""" + path = out_dir / "manifest.json" + if not path.is_file(): + return None + data = json.loads(path.read_text(encoding="utf-8")) + return {e["file"] for e in data.get("files", []) if isinstance(e, dict) and isinstance(e.get("file"), str)} + + +def hygiene_check(out_dir: Path, root: Path | None = None) -> list[str]: + """Problems in ``out_dir`` (and every ``*.json``/``*.xmp`` below ``root``); empty means clean. + + Files the manifest owns get the strict per-key rules; ``hand_*.json`` and + files outside ``out_dir`` get the relaxed rules. Any other ``*.json`` in + ``out_dir`` is itself a problem. + """ + problems: list[str] = [] + owned = manifest_files(out_dir) or set() + files = sorted(out_dir.glob("*.json")) + sorted(out_dir.glob("*.xmp")) + if root is not None: + out_resolved = out_dir.resolve() + files += sorted( + p for p in root.rglob("*") if p.suffix in (".json", ".xmp") and p.parent.resolve() != out_resolved + ) + for f in files: + name = str(f) + text = f.read_text(encoding="utf-8") + if root is not None and f.name in REGISTRY_FILES and f.parent.resolve() == root.resolve(): + unmasked = ALLOWED_HEADER_KEYS_RE.sub("", text) + problems += [ + f"{name}: forbidden text matching {rx.pattern!r}" + for rx in HARD_TEXT_RES + if rx not in TABLE_NAME_RES and rx.search(unmasked) + ] + try: + problems += check_manifest(json.loads(text), name) + except json.JSONDecodeError as e: + problems.append(f"{name}: not valid JSON ({e})") + continue + problems += hard_text_problems(text, name) + if f.suffix == ".xmp": + for h in HEX32_ANY_RE.findall(text): + if not SYNTH_HEX_RE.match(h) and h.upper() not in ADOBE_LOOKS: + problems.append(f"{name}: 32-hex value {h[:6]}... is not synthetic") + tree, xml_problems = xmp_as_tree(f.read_bytes(), name) + problems += xml_problems + if tree is not None: + problems += check_json_tree(tree, name, strict=False) + continue + try: + data = json.loads(text) + except json.JSONDecodeError as e: + problems.append(f"{name}: not valid JSON ({e})") + continue + in_out_dir = f.parent.resolve() == out_dir.resolve() + if in_out_dir and f.name == "manifest.json": + problems += check_manifest(data, name) + continue + if in_out_dir and f.name not in owned and not f.name.startswith("hand_"): + problems.append(f"{name}: not listed in manifest.json; hand-written fixtures must be named hand_*.json") + problems += check_json_tree(data, name, strict=in_out_dir and f.name in owned) + return problems + + +def report_hygiene(problems: list[str]) -> int: + if problems: + print(f"HYGIENE CHECK FAILED ({len(problems)} problems):", file=sys.stderr) + for p in problems[:50]: + print(" " + p, file=sys.stderr) + if len(problems) > 50: + print(f" ... and {len(problems) - 50} more", file=sys.stderr) + return 1 + print("hygiene check: OK") + return 0 + + +def crafted_xmp(attrs: str, body: str = "") -> str: + """A minimal well-formed XMP file for the self-test (same text as ``crafted_xmp`` in the Rust test).""" + return ( + '' + f'{body}' + "" + ) + + +def self_test(out_dir: Path, root: Path) -> int: + """Runs ``hygiene_check`` on a copy of ``root`` with one crafted file per rule. + + The same cases as ``dispatch_rules_flag_exactly_the_crafted_files`` in + ``crates/lrg-develop/tests/fixture_hygiene.rs``: the file-level rules + (scope, manifest ownership, the registry exemption, XMP ids) and the + inputs the two checks once judged differently. Returns 0 when exactly the + crafted files that should fail are flagged. + """ + foreign_hex = "0123456789ABCDEF0123456789ABCDEF" + masked = '{"MaskGroupBasedCorrections": [{"CorrectionMasks": [{"MaskName": "Himmel"}]}]}' + crafted = [ + # (file relative to root, content, flagged) + ("lua/unlisted.json", '{"Exposure2012": 0.5}', True), + ("lua/registry_snapshot.json", '{"rows": ["LookTable"]}', True), + ("xmp/foreign_id.xmp", crafted_xmp(f'crs:MaskDigest="{foreign_hex}"'), True), + ( + "xmp/adobe_look.xmp", + crafted_xmp("", ''), + False, + ), + ( + "xmp/look_parameters.xmp", + crafted_xmp( + "", '' + ), + True, + ), + ("xmp/not_xml.xmp", "Synthetic Text'), + True, + ), + ("lua/owned_crafted.json", masked, True), + ("lua/hand_localised.json", masked, False), + ("lua/stray.xmp", '', True), + # Inputs the Python and Rust checks used to judge differently. + ("lua/hand_newline_number.json", '{"Foo": "5000000000\\n"}', False), + ("lua/hand_arabic_digits.json", '{"Foo": "' + "\u0665" + "\u0660" * 9 + '"}', False), + ("lua/hand_newline_key.json", '{"Exposure\\n": 1}', True), + ("lua/hand_newline_version.json", '{"Look": {"Parameters": {"ProcessVersion": "15.4\\n"}}}', True), + ("lua/hand_null_name.json", '{"Look": {"Name": null}}', False), + # The preset-header key is allowed as a whole word only. + ("xmp/requires_rgb_tables.xmp", crafted_xmp('crs:RequiresRGBTables="False"'), False), + ("xmp/requires_rgb_tables_suffix.xmp", crafted_xmp('crs:RequiresRGBTablesX="False"'), True), + ("xmp/rgb_table_key.xmp", crafted_xmp('crs:RequiresRGBTables="False" crs:RGBTable="x"'), True), + ] + with tempfile.TemporaryDirectory(prefix="lrg-hygiene-self-test-") as tmp: + copy = Path(tmp) / "develop" + shutil.copytree(root, copy) + out_copy = copy / out_dir.resolve().relative_to(root.resolve()) + manifest_path = out_copy / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["files"].append({"file": "owned_crafted.json", "covers": []}) + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + (copy / "xmp").mkdir(exist_ok=True) + for rel, content, _ in crafted: + (copy / rel).write_text(content, encoding="utf-8") + problems = hygiene_check(out_copy, copy) + + def flagged(rel: str) -> bool: + return any(p.startswith(f"{copy / rel}: ") for p in problems) + + wrong = [f"{rel}: expected flagged={want}" for rel, _, want in crafted if flagged(rel) != want] + prefixes = tuple(f"{copy / rel}: " for rel, _, _ in crafted) + other = [p for p in problems if not p.startswith(prefixes)] + for w in wrong: + print("SELF-TEST: " + w, file=sys.stderr) + for o in other: + print("SELF-TEST: real file flagged: " + o, file=sys.stderr) + if wrong or other: + return 1 + print(f"hygiene self-test: OK ({len(crafted)} crafted files)") + return 0 + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +TOP_LEVEL_EMPTY_NOTE = ( + "Synthetic: JSON.lua encodes an empty getDevelopSettings() table as []; the reader must treat it " + "as 'no settings'. No such row exists in the source dump, so this file was written by the tool." +) + + +def build(rows: list[tuple[str, object]], picks: list[tuple[str, int]], missing: list[str], stub_look_uuid: bool): + """``{file name: text}`` for every generated file, including ``manifest.json``.""" + scrubber = Scrubber(stub_look_uuid=stub_look_uuid) + files: dict[str, str] = {} + entries = [] + covered: set[str] = set() + for shape, i in picks: + _, value = rows[i] + fname = f"{shape}.json" + files[fname] = dumps_exact(scrubber.scrub_settings(value)) + "\n" + sh = sorted(shapes_of(value) & (WANTED_SHAPES | {"mask_select_object_brush"})) + covered.update(sh) + entries.append( + { + "file": fname, + "primary_shape": shape, + "covers": sh, + "process_version": value.get("ProcessVersion") if isinstance(value, dict) else None, + "synthetic": False, + } + ) + + # Top-level "[]": taken from the dump if present, synthesized otherwise. + empty_i = next((i for i, (_, v) in enumerate(rows) if v == []), None) + files["top_level_empty.json"] = "[]\n" + entries.append( + { + "file": "top_level_empty.json", + "primary_shape": "top_level_empty", + "covers": ["top_level_empty"], + "process_version": None, + "synthetic": empty_i is None, + **({"note": TOP_LEVEL_EMPTY_NOTE} if empty_i is None else {}), + } + ) + covered.add("top_level_empty") + + manifest = { + "description": ( + "Scrubbed photo:getDevelopSettings() values (as JSON.lua encodes them) for the lrg-develop " + "Lua/JSON reader. Generated by server-rs/scripts/develop_registry/extract_fixtures.py; " + "see ../README.md for provenance, scrubbing and the hygiene rules. Files listed here are " + "owned by the generator and replaced on every run; hand-written fixtures are named hand_*.json." + ), + "look_parameters_stub": { + "allowed_keys": sorted(STUB_KEYS), + "meaning": "Look.Parameters reduced to these keys (or [] when neither exists); not Adobe data.", + }, + "synthetic_values": { + "hex32": "20 zeros + 12 hex digits, e.g. 00000000000000000000000000000001", + "guid": "00000000-0000-4000-8000-<12 hex digits>", + "date": SYNTHETIC_DATE, + "GrainSeed": int(SYNTHETIC_GRAIN_SEED), + "names": "CorrectionName 'Correction N', MaskName 'Mask N' (numbered per file)", + "free_text": SYNTHETIC_TEXT, + }, + "files": entries, + "shapes_covered": sorted(covered), + "shapes_missing": missing, + "scrub_report": dict(sorted(scrubber.report.items())), + } + files["manifest.json"] = json.dumps(manifest, indent=2) + "\n" + return files, entries, scrubber.report + + +def install(staging: Path, out_dir: Path, new_files: set[str]) -> None: + """Replace the generated files in ``out_dir``; hand-written ones are never touched.""" + out_dir.mkdir(parents=True, exist_ok=True) + old = manifest_files(out_dir) or set() + for name in sorted(old - new_files): + if not name.startswith("hand_") and (out_dir / name).is_file(): + (out_dir / name).unlink() + for name in sorted(new_files): + shutil.copy2(staging / name, out_dir / name) + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description="Extract scrubbed getDevelopSettings() fixtures.") + ap.add_argument("dump", nargs="?", help="training dump from dump_training.py (private, never committed)") + ap.add_argument("--out-dir", required=True, help="fixture folder, e.g. server-rs/testdata/develop/lua") + ap.add_argument( + "--stub-look-uuid", action="store_true", help="also replace Adobe profile names/UUIDs with synthetic ones" + ) + ap.add_argument("--check-only", action="store_true", help="only run the hygiene check on --out-dir") + ap.add_argument("--root", help="with --check-only: also check every *.json/*.xmp below this folder") + ap.add_argument( + "--self-test", + action="store_true", + help="with --root: run the hygiene check on a copy of --root with crafted files and verify what it flags", + ) + ap.add_argument( + "--keep-rejected", action="store_true", help="keep the private staging folder when a run is rejected" + ) + args = ap.parse_args(argv) + out_dir = Path(args.out_dir) + + if args.self_test: + if not args.root: + ap.error("--self-test needs --root") + return self_test(out_dir, Path(args.root)) + if args.check_only: + if manifest_files(out_dir) is None: + print(f"no manifest.json in {out_dir}", file=sys.stderr) + return 2 + return report_hygiene(hygiene_check(out_dir, Path(args.root) if args.root else None)) + if not args.dump: + ap.error("the training dump is required unless --check-only is given") + problem = output_problem(out_dir, allow_fixtures=True) + if problem: + print(problem, file=sys.stderr) + return 2 + + rows = load_rows(Path(args.dump)) + print(f"{len(rows)} rows with develop_settings") + picks, missing = select(rows) + for m in missing: + print(f"shape not present in the dump: {m}" + ("" if m in OPTIONAL_SHAPES else " (REQUIRED)")) + fatal = [m for m in missing if m not in OPTIONAL_SHAPES] + if fatal: + print(f"FAILED: required shapes missing: {fatal}; {out_dir} left untouched", file=sys.stderr) + return 1 + + files, entries, report = build(rows, picks, missing, args.stub_look_uuid) + staging = Path(tempfile.mkdtemp(prefix="lrg-fixtures-")) # mode 0700 + try: + for name, text in files.items(): + (staging / name).write_text(text, encoding="utf-8") + for e in entries: + print(f" {e['file']:<36} PV {e['process_version']!s:<5} covers {', '.join(e['covers'])}") + print("scrub:", dict(sorted(report.items()))) + if report_hygiene(hygiene_check(staging)): + if args.keep_rejected: + print(f"rejected output kept in {staging} (private; delete it when done)", file=sys.stderr) + print(f"{out_dir} left untouched", file=sys.stderr) + return 1 + install(staging, out_dir, set(files)) + finally: + if not args.keep_rejected: + shutil.rmtree(staging, ignore_errors=True) + print(f"{len(files)} files written to {out_dir}") + return report_hygiene(hygiene_check(out_dir)) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/server-rs/scripts/develop_registry/gen.py b/server-rs/scripts/develop_registry/gen.py new file mode 100644 index 00000000..e000a32f --- /dev/null +++ b/server-rs/scripts/develop_registry/gen.py @@ -0,0 +1,343 @@ +"""Render the crs key registry (``spec.py``) as Markdown, with observed statistics. + +Reads one or two inventories from ``parse_xmp.py inventory``: + +* ``--inventory`` - the stratified sample (plus presets); it supplies the + "Observed" column (n, min / p5 / p95 / max, bool spelling, container shape). +* ``--full-inventory`` - optional, the whole corpus; used only where the sample + has no data (marked "full corpus only") and for the coverage check. + +Writes to ``--out-dir``: + +* ``registry_tables.md`` - sections 3-7 of the registry (global keys, preset + header, correction, mask tool, nested structs). With ``--head``/``--tail`` the + given Markdown files are wrapped around the tables instead, as ``registry.md``. +* ``counts.json`` - keys and rows per policy class and level. + +The "Observed" column never shows raw string values by default: they can be file +names, lens or camera models or your own mask names. Only keys whose values are +Adobe enum labels (``SAMPLE_KEYS``) show their top values; every other string +key shows "N distinct values". ``--private-samples`` prints the raw top values +for all keys - for your own inspection only; such output must never be +committed or used to seed ``table.rs``. + +The public Rust table is seeded from ``spec.py``'s rows (``import spec``), never +from the rendered Markdown's "Observed" column. + +It then prints every ``(level, key)`` that the inventory saw but no ``spec.py`` row +covers. That list must be empty before the registry is used to seed the Rust +table; ``--strict`` turns a non-empty list into a failing exit code. + +Example:: + + uv run gen.py --inventory "$OUT/inventory.json" --out-dir "$OUT" + +``$OUT`` is a folder outside the repository; the tool refuses to write inside it. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from collections import Counter, defaultdict +from pathlib import Path + +import spec +from outpath import require_outside_repo + +FILTER_LEVELS = [ + "nested:Filters", + "nested:Images", + "nested:ImageGroup", + "nested:Alpha", + "nested:ColorVariations", + "nested:BlackLevels", + "nested:Linearization", + "nested:FilterList", +] +# String-valued keys whose values are Adobe enum labels, safe to show as samples. +SAMPLE_KEYS = frozenset( + { + "What", + "WhiteBalance", + "ProcessVersion", + "Version", + "ToneCurveName", + "ToneCurveName2012", + "LensProfileSetup", + "Method", + "SourceState", + "sourceState", + "SpotType", + "spotType", + "fill_method", + "pm_source_type", + "PresetType", + "ConvertToGrayscale", + "CropConstrainToWarp", + "Orientation", + "orientation", + } +) +COLUMNS = ( + "Key", + "Type", + "UI range / unit", + "Stored \u2194 UI", + "Default raw / non-raw", + "Observed: n; min / p5 / p95 / max", + "PV / version", + "Policy", + "Notes", +) + + +def index(inv: dict) -> dict[str, dict[str, dict]]: + out: defaultdict[str, dict[str, dict]] = defaultdict(dict) + for v in inv["keys"].values(): + out[v["level"]][v["key"]] = v + return out + + +def fmt(x: float | None) -> str: + if x is None: + return "" + if abs(x) >= 1e5: + return "%d" % x + return ("%.4f" % x).rstrip("0").rstrip(".") if x != int(x) else "%d" % x + + +class Generator: + def __init__(self, sample: dict, full: dict, private_samples: bool = False) -> None: + self.private_samples = private_samples + self.sources = ((index(sample), ""), (index(full), " (full corpus only)")) + self.full_index = self.sources[1][0] + self.policy_keys: Counter[str] = Counter() + self.policy_by_level: defaultdict[str, Counter[str]] = defaultdict(Counter) + self.policy_rows: Counter[str] = Counter() + self.gated = 0 + self.covered: defaultdict[str, set[str]] = defaultdict(set) + + # -- groups whose member lists come from the data ------------------------ + + def patched_nested(self) -> list[tuple]: + fi = self.full_index + filters_keys = sorted({k for lvl in FILTER_LEVELS for k in fi.get(lvl, {})} - {"GenAIInfo"}) + [ + "IsSignalForDelete" + ] + pm_levels = ("nested:RetouchArea", "nested:RemoveAreas", "nested:pm_patch_variations") + pm_keys = sorted({k for lvl in pm_levels for k in fi.get(lvl, {}) if k.startswith("pm_")}) + retouch_mask_keys = sorted(fi.get("mask-tool(retouch)", {}).keys()) + out = [] + for lvl, title, rows in spec.NESTED: + new = [] + for r in rows: + r = list(r) + if r[0].startswith("pm_*"): + r[0] = "pm_* (content-aware / generative patch state, %d keys)" % len(pm_keys) + r[8] = pm_keys + elif r[0].startswith("Filters payload"): + r[0] = "Filters payload (%d keys)" % len(filters_keys) + r[8] = filters_keys + r[7] = ", ".join(filters_keys) + ". IsSignalForDelete seen only inside applied-Preset/Parameters." + elif r[0].startswith("GenAIInfo"): + r[8] = ["GenAIInfo", "Name", "UUID", "SoftwareAgent", "Timestamp", "ParameterPairs"] + elif r[0].startswith("Mask/Ellipse"): + r[0] = "Masks items (Mask/Ellipse heal shapes, Mask/Paint strokes; %d keys)" % len( + retouch_mask_keys + ) + r[8] = retouch_mask_keys + r[7] = ", ".join(retouch_mask_keys) + "." + new.append(tuple(r)) + out.append((lvl, title, new)) + return out + + # -- the "Observed" column ------------------------------------------------- + + def observed(self, levels: list[str], members: list[str]) -> str: + """Observed statistics from the first inventory that has data.""" + for idx, tag in self.sources: + rows = [(lvl, idx.get(lvl, {}).get(m)) for lvl in levels for m in members] + rows = [(lvl, v) for lvl, v in rows if v] + if not rows: + continue + if len(members) > 1: + found = len({v["key"] for _, v in rows}) + nmax = max(v["n"] for _, v in rows) + mins = [v["min"] for _, v in rows if v["min"] is not None and not v["bools"]] + maxs = [v["max"] for _, v in rows if v["max"] is not None and not v["bools"]] + rng = "" + if mins and all(v["num_n"] for _, v in rows): + rng = "; range across keys %s .. %s" % (fmt(min(mins)), fmt(max(maxs))) + return "%d/%d keys seen, up to n=%d per key%s%s" % (found, len(members), nmax, rng, tag) + lvl, v = rows[0] + k = v["kinds"] + sc = k.get("sidecar", 0) + pr = sum(c for kk, c in k.items() if kk != "sidecar") + head = "n=%d sc + %d pr" % (sc, pr) + if lvl != levels[0]: + head += " [in %s]" % lvl.replace("nested:", "") + return "%s; %s%s" % (head, self._body(v), tag) + return "not observed" + + def _samples(self, key: str, values: list[str], width: int) -> str | None: + """Quoted sample values, or ``None`` when this key's strings must not be shown.""" + if not (self.private_samples or key in SAMPLE_KEYS): + return None + return "; ".join('"%s"' % t[:width] for t in values) + + def _body(self, v: dict) -> str: + if v["bools"]: + return ", ".join("%s %d" % (b, c) for b, c in sorted(v["bools"].items(), key=lambda x: -x[1])) + n_values = max(1, v["n"] - sum(v["containers"].values())) + if v["num_n"] and v["num_n"] >= 0.8 * n_values and not v["containers"]: + return "%s / %s / %s / %s" % (fmt(v["min"]), fmt(v["p5"]), fmt(v["p95"]), fmt(v["max"])) + if v["containers"] or v["item_fmt"]: + if "Alt" in v["containers"]: + tops = [t[0] for t in v["top"] if t[0]][:3] + if not tops: + return "Alt x-default (empty)" + shown = self._samples(v["key"], tops, 30) + return "Alt x-default: " + (shown if shown is not None else "%d distinct values" % v["distinct"]) + if v["item_fmt"]: + return "Seq; items %s" % ", ".join(v["item_fmt"]) + return "struct" if "struct" in v["forms"] else "Seq" + if "struct" in v["forms"]: + return "struct" + tops = [t[0] for t in v["top"] if not t[0].startswith("[item]")][:3] + non_empty = [t for t in tops if t] + if not non_empty: + return 'always ""' + if v["key"] in ("UUID", "CorrectionSyncID", "MaskSyncID") or all( + re.match(r"^[0-9A-F]{32}$", t) for t in non_empty + ): + return "32-hex, %d distinct" % v["distinct"] + shown = self._samples(v["key"], tops, 34) + return shown if shown is not None else "string, %d distinct values" % v["distinct"] + + # -- tables --------------------------------------------------------------- + + def _count(self, level_name: str, policy: str, members: list[str]) -> None: + p = policy.replace("\u2020", "") + n = max(1, len(members)) + self.policy_keys[p] += n + self.policy_by_level[level_name][p] += n + self.policy_rows[p] += 1 + if "\u2020" in policy: + self.gated += n + + def table(self, rows: list[tuple], levels: list[str], level_name: str) -> str: + out = ["| " + " | ".join(COLUMNS) + " |", "|" + "---|" * len(COLUMNS)] + for r in rows: + key, typ, ui, sc, dflt, pv, pol, notes = r[:8] + grouped = len(r) > 8 + members = r[8] if grouped else [key] + for m in members: + for lvl in levels: + self.covered[lvl].add(m) + self._count(level_name, pol, members) + obs = self.observed(levels, members) if members else "see global keys" + cells = [key if grouped else "`%s`" % key, typ, ui, sc, dflt, obs, pv, "**%s**" % pol, notes] + out.append("| " + " | ".join(c.replace("|", "\\|").replace("\n", " ") for c in cells) + " |") + return "\n".join(out) + + def render(self) -> str: + parts = ["## 3. Global keys (top level of the develop description)\n"] + for i, (title, rows) in enumerate(spec.G): + parts.append("### 3.%d %s\n" % (i + 1, title)) + parts.append(self.table(rows, ["global", "look-parameters"], "global") + "\n") + + parts.append("## 4. Preset header (preset and profile files only)\n") + parts.append(self.table(spec.HEADER, ["header", "header(look)", "global"], "header") + "\n") + + parts.append("## 5. Correction level (one `MaskGroupBasedCorrections` item)\n") + parts.append(self.table(spec.CORR, ["correction"], "correction") + "\n") + + parts.append("## 6. Mask-tool level (one `CorrectionMasks` item)\n") + for i, (title, rows) in enumerate(spec.MASK): + parts.append("### 6.%d %s\n" % (i + 1, title)) + parts.append(self.table(rows, ["mask-tool", "nested:Gesture"], "mask-tool") + "\n") + + parts.append("## 7. Nested structures\n") + extra_levels = { + "nested:RetouchArea": [ + "nested:RetouchArea", + "nested:RemoveAreas", + "mask-tool(retouch)", + "nested:pm_patch_variations", + ], + "nested:Filters": FILTER_LEVELS + ["nested:GenAIInfo"], + "nested:CorrectionRangeMask": ["nested:CorrectionRangeMask", "nested:AreaModels"], + "nested:Gesture": ["nested:Gesture", "nested:Points"], + } + for i, (lvl, title, rows) in enumerate(self.patched_nested()): + parts.append("### 7.%d %s\n" % (i + 1, title)) + levels = extra_levels.get(lvl, [lvl]) + parts.append(self.table(rows, levels, lvl.replace("nested:", "struct:")) + "\n") + return "\n".join(parts) + + def uncovered(self) -> list[tuple[str, str]]: + """Keys the full inventory saw that no spec row covers.""" + skip_levels = {"header(look)", "look-parameters"} + out = [] + for lvl, keys in self.full_index.items(): + if lvl in skip_levels: + continue + out.extend((lvl, k) for k in keys if k not in self.covered.get(lvl, set())) + return sorted(out) + + def counts(self) -> dict: + return { + "keys": dict(self.policy_keys), + "rows": dict(self.policy_rows), + "by_level": {lvl: dict(c) for lvl, c in self.policy_by_level.items()}, + "gated": self.gated, + } + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description="Render the crs key registry as Markdown.") + ap.add_argument("--inventory", required=True, help="inventory.json of the sample (+ presets)") + ap.add_argument("--full-inventory", help="inventory of the whole corpus (default: --inventory)") + ap.add_argument("--out-dir", required=True) + ap.add_argument("--head", help="Markdown to put before the tables (writes registry.md)") + ap.add_argument("--tail", help="Markdown to put after the tables (writes registry.md)") + ap.add_argument("--strict", action="store_true", help="exit 1 when an observed key has no spec row") + ap.add_argument( + "--private-samples", + action="store_true", + help="show raw top string values for every key (private output: file names, lenses, cameras, mask names)", + ) + args = ap.parse_args(argv) + require_outside_repo(args.out_dir) + + sample = json.loads(Path(args.inventory).read_text(encoding="utf-8")) + full = json.loads(Path(args.full_inventory).read_text(encoding="utf-8")) if args.full_inventory else sample + gen = Generator(sample, full, private_samples=args.private_samples) + tables = gen.render() + + out = Path(args.out_dir) + out.mkdir(parents=True, exist_ok=True) + if args.head or args.tail: + head = Path(args.head).read_text(encoding="utf-8") if args.head else "" + tail = Path(args.tail).read_text(encoding="utf-8") if args.tail else "" + (out / "registry.md").write_text("\n".join(p for p in (head, tables, tail) if p), encoding="utf-8") + else: + (out / "registry_tables.md").write_text(tables, encoding="utf-8") + counts = gen.counts() + (out / "counts.json").write_text(json.dumps(counts, indent=1), encoding="utf-8") + + unc = gen.uncovered() + print("UNCOVERED", unc) + print("POLICY (keys)", counts["keys"], "total", sum(counts["keys"].values())) + print("POLICY (rows)", counts["rows"], "total", sum(counts["rows"].values())) + print("GATED", counts["gated"]) + for lvl, c in counts["by_level"].items(): + print(" ", lvl, c) + return 1 if unc and args.strict else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/server-rs/scripts/develop_registry/gen_table_rs.py b/server-rs/scripts/develop_registry/gen_table_rs.py new file mode 100644 index 00000000..612eb5a5 --- /dev/null +++ b/server-rs/scripts/develop_registry/gen_table_rs.py @@ -0,0 +1,1123 @@ +"""Seed ``lrg-develop``'s key registry (``registry/table.rs``) from ``spec.py``. + +``spec.py`` holds the maintainer's rows as free text (type, UI range, stored +unit, defaults, policy, notes). This script turns them into typed +``KeySpec`` literals: + +* group rows are expanded into one row per member (``HueAdjustment*``, + ``SDR*``, ...); +* families that stay patterns (``Table_``, ``pm_*``, the FilterList + payload, ``UprightTransform_N``, ``UprightFourSegments_N``) are skipped here + and live in ``registry/patterns.rs``; +* the per-key decisions the text cannot express (value kind of "mixed" rows, + gates, frame scope, presence, number format, recipe aliases) are the tables + at the top of this file. + +It imports ``spec`` and reads one committed data file, +``server-rs/testdata/develop/defaults/non_raw_lrc15.6.json`` (experiment E13's +readback of a non-raw photo without develop edits: key -> value plus the +Lightroom version, process version and file kind it was read on). That file +fills the non-raw default of every global row the text leaves ``Unverified`` +(see ``non_raw_readback``). No inventory, no training dump, no ExifTool data, +and it never reads the rendered Markdown or its "Observed" column. The output +depends only on committed files. + +The Rust table is the source of truth once seeded (docs/wiki/Dev-Develop-Model.md). +Re-running this script overwrites ``table.rs``, including hand edits made +since; use it for the initial seed or when ``spec.py`` changed and the diff is +reviewed. Afterwards regenerate the snapshot:: + + python3 gen_table_rs.py --out ../../crates/lrg-develop/src/registry/table.rs + LRG_BLESS=1 cargo test -p lrg-develop --test registry_snapshot + +``rustfmt`` formats the output when it is on ``PATH``. +""" + +from __future__ import annotations + +import argparse +import json +import re +import shutil +import subprocess +import sys +from dataclasses import dataclass, field +from pathlib import Path + +import spec + +HSL = spec.HSL + +# --------------------------------------------------------------------------- +# Rows that are not seeded, with the reason. +# --------------------------------------------------------------------------- + +SKIP_ROWS = { + # Not a develop key at all: the plugin's white-balance bug (fixed in 1d). + # A registry row would make readers accept it as known, and the planned + # grep invariant forbids the literal in backend sources. + ("Global", "Temp"), +} +# Row labels (prefix match) that stay pattern families (registry/patterns.rs). +PATTERN_ROWS = ( + "Table_", + "pm_* (", + "Filters payload", + "GenAIInfo (", +) +# Members of a group row that are patterns; the rest of the row is seeded. +PATTERN_MEMBERS = re.compile(r"^(UprightTransform_\d+|UprightFourSegments_\d+)$") + +# --------------------------------------------------------------------------- +# Levels +# --------------------------------------------------------------------------- + +NESTED_LEVEL = { + "nested:Look": "Struct(StructKind::Look)", + "nested:LensBlur": "Struct(StructKind::LensBlur)", + "nested:CorrectionRangeMask": "Struct(StructKind::CorrectionRangeMask)", + "nested:Gesture": "Struct(StructKind::Gesture)", + "nested:AILook": "Struct(StructKind::AiLook)", + "nested:RangeMaskMapInfo": "Struct(StructKind::RangeMaskMapInfo)", + "nested:DepthMapInfo": "Struct(StructKind::DepthMapInfo)", + "nested:ISODependent": "Struct(StructKind::IsoDependent)", + "nested:Preset": "Struct(StructKind::Preset)", + "nested:RetouchArea": "Struct(StructKind::RetouchArea)", + "nested:RetouchInfo": "Struct(StructKind::RetouchInfo)", + "nested:PointColors": "Struct(StructKind::PointColor)", + "nested:PointColorRange": "Struct(StructKind::PointColorRange)", + # Look.Parameters holds global keys; ValueKind::Settings says so. + "look-parameters": None, + # Everything below FilterList is the filter_list_payload pattern. + "nested:Filters": None, +} +# Members of a nested row that live one level further down. +SUB_LEVEL = { + ("nested:Gesture", "X"): "Struct(StructKind::GesturePoint)", + ("nested:Gesture", "Y"): "Struct(StructKind::GesturePoint)", + ("nested:CorrectionRangeMask", "AreaComponents"): "Struct(StructKind::AreaModel)", + ("nested:CorrectionRangeMask", "ColorRangeMaskAreaSampleInfo"): "Struct(StructKind::AreaModel)", +} +# The heal-spot shape row of a retouch area describes mask components. +MASKTOOL_ROWS = ("Mask/Ellipse",) + +# --------------------------------------------------------------------------- +# Value kinds +# --------------------------------------------------------------------------- + +B_TITLE = "Bool(BoolStyle::TitleCase)" +B_LOWER = "Bool(BoolStyle::Lower)" + +# Explicit kinds, by (level, name) or name alone (checked in that order). +KIND = { + # white balance + "AutoWhiteVersion": "VersionU32", + # tone + ("Global", "Exposure"): "Real", + ("Global", "Contrast"): "Int", + ("Global", "Brightness"): "Int", + ("Global", "Shadows"): "Int", + ("Global", "FillLight"): "Int", + ("Global", "HighlightRecovery"): "Int", + ("Global", "Clarity"): "Int", + "ToneMapStrength": "Any", + "ExtendedToneCurvePV2012": "Curve(CurveKind::Global)", + "ExtendedToneCurvePV2012Red": "Curve(CurveKind::Global)", + "ExtendedToneCurvePV2012Green": "Curve(CurveKind::Global)", + "ExtendedToneCurvePV2012Blue": "Curve(CurveKind::Global)", + "ExtendedToneCurveName2012": "Any", + "ToneCurve": "Curve(CurveKind::Global)", + "ToneCurveRed": "Curve(CurveKind::Global)", + "ToneCurveGreen": "Curve(CurveKind::Global)", + "ToneCurveBlue": "Curve(CurveKind::Global)", + "ToneCurveName": "Str", + "ToneCurveName2012": "Str", + # upright solver state + "UprightVersion": "VersionU32", + "UprightTransformCount": "Int", + "UprightCenterMode": "Int", + "UprightCenterNormX": "Real", + "UprightCenterNormY": "Real", + "UprightFocalMode": "Int", + "UprightFocalLength35mm": "Real", + "UprightPreview": B_TITLE, + "UprightDependentDigest": "Hex32", + "UprightGuidedDependentDigest": "Hex32", + "UprightFourSegmentsCount": "Int", + # crop / effects / profile (names only, never observed) + "CropWidth": "Any", + "CropHeight": "Any", + "CropUnit": "Any", + "CropUnits": "Any", + "GlowRange": "Any", + "GlowWarmth": "Any", + "GlowSpread": "Any", + "GlowStyle": "Any", + "ReshapeAmount": "Any", + "GrainSeed": "Int", + "LookTable": "Hex32", + "RGBTable": "Hex32", + "LookTableAmount": "Any", + "RGBTableAmount": "Any", + "RGBTables": "Any", + "ProfileGainTableMap": "Any", + "ProfileGainTableMap2": "Any", + "ProfileToneCurve": "Any", + "MissingCameraProfile": "Any", + "MissingCameraProfileDigest": "Hex32", + "SourceAutoExposure2012": "Any", + "SourceAutoContrast2012": "Any", + "SourceAutoHighlights2012": "Any", + "SourceAutoShadows2012": "Any", + "SourceAutoWhites2012": "Any", + "SourceAutoBlacks2012": "Any", + "SourceAutoSaturation": "Any", + "SourceAutoVibrance": "Any", + "ToggleStyleAmount": "Real", + # structures at the top level + "Look": "Struct(StructKind::Look)", + ("Global", "LensBlur"): "Struct(StructKind::LensBlur)", + "AILook": "Struct(StructKind::AiLook)", + "ISODependent": "StructSeq(StructKind::IsoDependent)", + "RetouchAreas": "StructSeq(StructKind::RetouchArea)", + "RemoveAreas": "StructSeq(StructKind::RetouchArea)", + "RetouchInfo": "PerFormat { lua: &ValueKind::StructSeq(StructKind::RetouchInfo), xmp: &ValueKind::StrSeq }", + "PointColors": "PerFormat { lua: &ValueKind::StructSeq(StructKind::PointColor), xmp: &ValueKind::StrSeq }", + "FilterList": "Struct(StructKind::FilterList)", + "AllowFilters": "Int", + "DepthMapInfo": "Struct(StructKind::DepthMapInfo)", + ("Global", "RangeMaskMapInfo"): "Struct(StructKind::RangeMaskMapInfo)", + "DepthBasedCorrections": "CorrectionSeq", + "MaskGroupBasedCorrections": "CorrectionSeq", + "GradientBasedCorrections": "CorrectionSeq", + "CircularGradientBasedCorrections": "CorrectionSeq", + "PaintBasedCorrections": "CorrectionSeq", + ("Global", "Preset"): "Struct(StructKind::Preset)", + "AsShotTemperature": "Int", + "AsShotTint": "Int", + "ProcessVersion": "VersionStr", + ("Global", "Version"): "VersionStr", + "CompatibleVersion": "VersionU32", + "RenderVersion": "VersionU32", + "orientation": "Str", + # header + "Baseline": 'Enum(&["Adobe Default", "Camera Settings"])', + # correction + ("Correction", "What"): 'Enum(&["Correction"])', + "CorrectionMasks": "ComponentSeq", + "CorrectionID": "Str", + "CorrectionReferenceX": "Real", + "CorrectionReferenceY": "Real", + # mask components + ("MaskTool", "MaskID"): "Str", + "MaskDigest": "Hex32", + ("MaskTool", "InputDigest"): "Hex32", + ("MaskTool", "InputDigestVersion"): "Int", + "LocalInputDigest": "Hex32", + "LocalInputDigestVersion": "Int", + ("MaskTool", "ModelVersion"): "VersionU32", + "WholeImageArea": "Str", + "Origin": "Str", + "FullMaskSize": "Str", + "DidOverrideInputDigestMismatch": B_LOWER, + "MaskSubCategoryID": "Int", + ("MaskTool", "Gesture"): "StructSeq(StructKind::Gesture)", + ("MaskTool", "Masks"): "ComponentSeq", + ("MaskTool", "CorrectionRangeMask"): "Struct(StructKind::CorrectionRangeMask)", + "MaskBrushTable": "Hex32", + "MaskBrushUncompressedBytes": "Int", + ("MaskTool", "Alpha"): "Real", + ("MaskTool", "CenterValue"): "Real", + ("MaskTool", "PerimeterValue"): "Real", + ("MaskTool", "X"): "Real", + ("MaskTool", "Y"): "Real", + ("MaskTool", "SizeX"): "Real", + ("MaskTool", "SizeY"): "Real", + ("MaskTool", "Radius"): "Real", + ("MaskTool", "Flow"): "Real", + ("MaskTool", "CenterWeight"): "Real", + ("MaskTool", "Midpoint"): "Int", + ("MaskTool", "Roundness"): "Int", + # Look + ("Struct(StructKind::Look)", "Parameters"): "Settings", + ("Struct(StructKind::Look)", "Cluster"): "Str", + ("Struct(StructKind::Look)", "Copyright"): "Str", + # LensBlur + ("Struct(StructKind::LensBlur)", "SampledArea"): "Str", + ("Struct(StructKind::LensBlur)", "SampledRange"): "Str", + ("Struct(StructKind::LensBlur)", "SubjectRange"): "Str", + ("Struct(StructKind::LensBlur)", "ImageOrientation"): "Int", + ("Struct(StructKind::LensBlur)", "FocalRangeSource"): "Int", + ("Struct(StructKind::LensBlur)", "BokehShape"): "Int", + # CorrectionRangeMask + ("Struct(StructKind::CorrectionRangeMask)", "AreaModels"): "StructSeq(StructKind::AreaModel)", + ("Struct(StructKind::AreaModel)", "AreaComponents"): "StrSeq", + ("Struct(StructKind::AreaModel)", "ColorRangeMaskAreaSampleInfo"): "Str", + ("Struct(StructKind::CorrectionRangeMask)", "LumMin"): "Any", + ("Struct(StructKind::CorrectionRangeMask)", "LumMax"): "Any", + ("Struct(StructKind::CorrectionRangeMask)", "LumFeather"): "Any", + ("Struct(StructKind::CorrectionRangeMask)", "DepthMin"): "Any", + ("Struct(StructKind::CorrectionRangeMask)", "DepthMax"): "Any", + ("Struct(StructKind::CorrectionRangeMask)", "DepthFeather"): "Any", + ("Struct(StructKind::CorrectionRangeMask)", "DepthRange"): "Any", + # Gesture + ("Struct(StructKind::Gesture)", "Points"): "StructSeq(StructKind::GesturePoint)", + ("Struct(StructKind::GesturePoint)", "X"): "Real", + ("Struct(StructKind::GesturePoint)", "Y"): "Real", + ("Struct(StructKind::Gesture)", "Dabs"): "StrSeq", + ("Struct(StructKind::Gesture)", "Radius"): "Real", + ("Struct(StructKind::Gesture)", "Flow"): "Real", + ("Struct(StructKind::Gesture)", "CenterWeight"): "Real", + ("Struct(StructKind::Gesture)", "BrushGestureInterpretation"): "Int", + ("Struct(StructKind::Gesture)", "MaskActive"): B_LOWER, + ("Struct(StructKind::Gesture)", "MaskBlendMode"): "EnumInt(&[0, 1])", + ("Struct(StructKind::Gesture)", "MaskInverted"): B_LOWER, + ("Struct(StructKind::Gesture)", "MaskSyncID"): "Hex32", + ("Struct(StructKind::Gesture)", "MaskValue"): "Real", + ("Struct(StructKind::Gesture)", "MaskID"): "Str", + # AILook + ("Struct(StructKind::AiLook)", "Active"): B_LOWER, + ("Struct(StructKind::AiLook)", "AILookData"): "Str", + ("Struct(StructKind::AiLook)", "InputDigest"): "Hex32", + ("Struct(StructKind::AiLook)", "InputDigestVersion"): "Int", + ("Struct(StructKind::AiLook)", "ModelVersion"): "VersionU32", + ("Struct(StructKind::AiLook)", "Version"): "VersionU32", + # RangeMaskMapInfo + ("Struct(StructKind::RangeMaskMapInfo)", "RangeMaskMapInfo"): "Struct(StructKind::RangeMaskMapInfo)", + ("Struct(StructKind::RangeMaskMapInfo)", "RGBMin"): "Str", + ("Struct(StructKind::RangeMaskMapInfo)", "RGBMax"): "Str", + ("Struct(StructKind::RangeMaskMapInfo)", "LabMin"): "Str", + ("Struct(StructKind::RangeMaskMapInfo)", "LabMax"): "Str", + ("Struct(StructKind::RangeMaskMapInfo)", "LumEq"): "StrSeq", + # DepthMapInfo + ("Struct(StructKind::DepthMapInfo)", "DepthSource"): "Int", + ("Struct(StructKind::DepthMapInfo)", "BaseRawDepthTable"): "Hex32", + ("Struct(StructKind::DepthMapInfo)", "BaseRawDepthInputDigest"): "Hex32", + ("Struct(StructKind::DepthMapInfo)", "BaseRawDepthVersion"): "Int", + ("Struct(StructKind::DepthMapInfo)", "BaseLayeredDepthTable"): "Hex32", + ("Struct(StructKind::DepthMapInfo)", "BaseLayeredDepthInputDigest"): "Hex32", + ("Struct(StructKind::DepthMapInfo)", "BaseLayeredDepthVersion"): "Int", + ("Struct(StructKind::DepthMapInfo)", "BaseHighlightGuideTable"): "Hex32", + ("Struct(StructKind::DepthMapInfo)", "BaseHighlightGuideInputDigest"): "Hex32", + ("Struct(StructKind::DepthMapInfo)", "BaseHighlightGuideVersion"): "Int", + # Preset record + ("Struct(StructKind::Preset)", "Name"): "Str", + ("Struct(StructKind::Preset)", "UUID"): "Hex32", + ("Struct(StructKind::Preset)", "Amount"): "Real", + ("Struct(StructKind::Preset)", "LookAmount"): "Real", + ("Struct(StructKind::Preset)", "Group"): "LangAlt", + ("Struct(StructKind::Preset)", "Cluster"): "Str", + ("Struct(StructKind::Preset)", "Baseline"): "Str", + ("Struct(StructKind::Preset)", "SupportsAmount"): B_LOWER, + ("Struct(StructKind::Preset)", "SupportsMonochrome"): B_LOWER, + ("Struct(StructKind::Preset)", "SupportsOutputReferred"): B_LOWER, + ("Struct(StructKind::Preset)", "Parameters"): "Settings", + # retouch spots + ("Struct(StructKind::RetouchArea)", "SpotType"): "Str", + ("Struct(StructKind::RetouchArea)", "Method"): "Str", + ("Struct(StructKind::RetouchArea)", "SourceState"): "Str", + ("Struct(StructKind::RetouchArea)", "Opacity"): "Real", + ("Struct(StructKind::RetouchArea)", "Feather"): "Real", + ("Struct(StructKind::RetouchArea)", "Seed"): "Int", + ("Struct(StructKind::RetouchArea)", "OffsetY"): "Real", + ("Struct(StructKind::RetouchArea)", "SourceX"): "Real", + ("Struct(StructKind::RetouchArea)", "HealVersion"): "Int", + ("Struct(StructKind::RetouchArea)", "IngestInfo"): "Str", + ("Struct(StructKind::RetouchArea)", "fill_method"): "Str", + ("Struct(StructKind::RetouchArea)", "Masks"): "ComponentSeq", + ("Struct(StructKind::PointColor)", "HueRange"): "Struct(StructKind::PointColorRange)", + ("Struct(StructKind::PointColor)", "SatRange"): "Struct(StructKind::PointColorRange)", + ("Struct(StructKind::PointColor)", "LumRange"): "Struct(StructKind::PointColorRange)", + # correction extras never observed + "LocalColorGradeShadowHue": "Any", + "DepthRange": "Any", + ("MaskTool", "TargetImageArea"): "Any", + ("MaskTool", "EdgeShift"): "Any", + ("MaskTool", "Bidirectional"): "Any", + ("MaskTool", "Zero2X"): "Any", + ("MaskTool", "Zero2Y"): "Any", + ("MaskTool", "ZeroFeather"): "Any", + ("MaskTool", "Zero2Feather"): "Any", + ("MaskTool", "FullPointDistance"): "Any", +} +# Lower-case legacy spot fields (retouch areas and RetouchInfo items). +for _lvl in ("Struct(StructKind::RetouchArea)", "Struct(StructKind::RetouchInfo)"): + for _n, _k in ( + ("centerX", "Real"), + ("centerY", "Real"), + ("opacity", "Real"), + ("radius", "Real"), + ("seed", "Int"), + ("sourceState", "Str"), + ("sourceX", "Real"), + ("sourceY", "Real"), + ("spotType", "Str"), + ("Method", "Str"), + ): + KIND[(_lvl, _n)] = _k +# Everything else in a "mixed"/"number" row that is only known by name. +ANY_TYPES = {"mixed", "number", "?"} + +ENUMS = { + "WhiteBalance": ( + 'Enum(&["As Shot", "Auto", "Custom", "Daylight", "Cloudy", "Shade", "Tungsten", "Fluorescent", "Flash"])' + ), + "LensProfileSetup": 'Enum(&["LensDefaults", "Auto", "Custom"])', + "PresetType": 'Enum(&["Normal", "Look"])', + ("MaskTool", "What"): ( + 'Enum(&["Mask/Image", "Mask/Gradient", "Mask/CircularGradient", "Mask/RangeMask", ' + '"Mask/Aggregate", "Mask/Paint", "Mask/Polygon", "Mask/Ellipse", "Mask/FlattenedGroup", ' + '"Mask/Clip", "Mask/Combine"])' + ), + ("Struct(StructKind::Gesture)", "What"): 'Enum(&["Mask/Polygon", "Mask/Paint"])', + "PerspectiveUpright": "EnumInt(&[0, 1, 2, 3, 4, 5])", + "PostCropVignetteStyle": "EnumInt(&[1, 2, 3])", + ("MaskTool", "MaskBlendMode"): "EnumInt(&[0, 1])", + "MaskSubType": "EnumInt(&[0, 1, 2, 3])", + "ErrorReason": "EnumInt(&[0, 1])", + ("Struct(StructKind::CorrectionRangeMask)", "Type"): "EnumInt(&[1, 2, 3])", + ("Struct(StructKind::CorrectionRangeMask)", "SampleType"): "EnumInt(&[0, 1])", +} + +SIMPLE_TYPES = { + "int": "Int", + "real": "Real", + "int (0/1)": "IntFlag", + "string": "Str", + "hex32": "Hex32", + "uint": "Int", + "uint32": "Int", + "string[]": "StrSeq", + "lang-alt": "LangAlt", + "runtime": "Str", +} + +# --------------------------------------------------------------------------- +# Policies, gates, scopes +# --------------------------------------------------------------------------- + +POLICY = { + "LEARN": "Learn", + "PHOTO": "Photo", + "COMPUTED": "Computed", + "NEVER": "Never", + "META": "Meta", + "UNKNOWN": "Unknown", +} +# The gate of every LEARN† key (by name, or (level, name)). +GATE = { + "Temperature": "RawOnly", + "Tint": "RawOnly", + "IncrementalTemperature": "NonRawOnly", + "IncrementalTint": "NonRawOnly", + "ConvertToGrayscale": "Categorical", + **{"GrayMixer" + c: 'DependsOn("ConvertToGrayscale")' for c in HSL}, + "Sharpness": "FileKindDefault", + "ColorNoiseReduction": "FileKindDefault", + "LensProfileSetup": 'OnlyValues(&["LensDefaults", "Auto"])', + "LensProfileDistortionScale": 'DependsOn("LensProfileEnable")', + "LensProfileVignettingScale": 'DependsOn("LensProfileEnable")', + ("Global", "LensBlur"): "NeedsAiUpdate", + "CameraProfile": "CameraRestricted", + "Look": "CameraRestricted", + "MaskGroupBasedCorrections": "ByMaskType", + "CorrectionMasks": "ByMaskType", + ("MaskTool", "What"): "ByMaskType", + ("MaskTool", "CorrectionRangeMask"): "ByMaskType", + ("Struct(StructKind::CorrectionRangeMask)", "Type"): "ByMaskType", +} +# LEARN keys the registry gates although spec.py lists them as plain LEARN: +# hue angles are averaged on the circle, weighted by the saturation of the +# same zone (a hue at saturation 0 is invisible, spec note "weight by +# saturation"); the vignette style is a category (spec note "Categorical +# (majority)"). +EXTRA_GATE = { + "SplitToningShadowHue": 'CircularHue("SplitToningShadowSaturation")', + "SplitToningHighlightHue": 'CircularHue("SplitToningHighlightSaturation")', + "ColorGradeMidtoneHue": 'CircularHue("ColorGradeMidtoneSat")', + "ColorGradeGlobalHue": 'CircularHue("ColorGradeGlobalSat")', + ("Correction", "LocalToningHue"): 'CircularHue("LocalToningSaturation")', + "PostCropVignetteStyle": "Categorical", +} + +# Adjusted per frame: exposure, white balance and the keys the edit guardrails +# scale per frame (lrg-api/src/edit_budget.rs: contrast, clarity, dehaze, the +# parametric curve, shadows, whites). +PER_FRAME_ADJUSTED = { + "Exposure2012", + "Temperature", + "Tint", + "IncrementalTemperature", + "IncrementalTint", + "WhiteBalance", + "Contrast2012", + "Clarity2012", + "Dehaze", + "Shadows2012", + "Whites2012", + "ParametricHighlights", + "ParametricLights", + "ParametricDarks", + "ParametricShadows", +} + +FILE_SCOPE = { + "Temperature": "RawOnly", + "Tint": "RawOnly", + "IncrementalTemperature": "NonRawOnly", + "IncrementalTint": "NonRawOnly", +} + +LUA_ONLY = { + *( + "Enable" + p + for p in ( + "Calibration", + "ColorAdjustments", + "Detail", + "Effects", + "GrayscaleMix", + "LensCorrections", + "RedEye", + "Retouch", + "SplitToning", + "ToneCurve", + "Transform", + "MaskGroupBasedCorrections", + "GradientBasedCorrections", + "DistractionRemoval", + ) + ), + "orientation", + "CropConstrainAspectRatio", + "Defringe", + "ChromaticAberrationR", + "ChromaticAberrationB", + ("Correction", "CorrectionID"), + ("Correction", "CorrectionReferenceX"), + ("Correction", "CorrectionReferenceY"), + ("MaskTool", "MaskID"), + ("Struct(StructKind::Gesture)", "MaskID"), + ("Struct(StructKind::Look)", "isAdobeAdaptive"), +} +LUA_ONLY_LEVELS = { + "Struct(StructKind::RetouchInfo)", + "Struct(StructKind::PointColor)", + "Struct(StructKind::PointColorRange)", +} +LUA_ONLY_RETOUCH = {"centerX", "centerY", "opacity", "radius", "seed", "sourceState", "sourceX", "sourceY", "spotType"} +XMP_ONLY = {"HasCrop", "HasSettings", "AlreadyApplied", "RawFileName", ("Global", "Preset")} +# Names from "never observed" rows that were seen after all (getDevelopSettings()). +OBSERVED_AFTER_ALL = { + "ExtendedToneCurvePV2012", + "ExtendedToneCurvePV2012Red", + "ExtendedToneCurvePV2012Green", + "ExtendedToneCurvePV2012Blue", +} + +# Keys whose meaning depends on process version 2012 or later. +PV2012_NOTE = re.compile(r"PV2012\+") + +# Number formats beyond the kind's default. +FIXED = { + "Exposure2012": 2, + "HDRMaxValue": 2, + "PerspectiveX": 2, + "PerspectiveY": 2, + "SharpenRadius": 1, + "PerspectiveRotate": 1, + "ProcessVersion": 1, +} +COMPOUND = { + ("MaskTool", "ReferencePoint"), + ("Struct(StructKind::CorrectionRangeMask)", "LumRange"), + ("Struct(StructKind::CorrectionRangeMask)", "LuminanceDepthSampleInfo"), + ("Struct(StructKind::CorrectionRangeMask)", "PointModels"), + ("Global", "PointColors"), + ("Global", "ColorVariance"), + ("Correction", "LocalPointColors"), + ("Correction", "LocalColorVariance"), + ("MaskTool", "Dabs"), + ("Struct(StructKind::Gesture)", "Dabs"), + ("Struct(StructKind::RangeMaskMapInfo)", "RGBMin"), + ("Struct(StructKind::RangeMaskMapInfo)", "RGBMax"), + ("Struct(StructKind::RangeMaskMapInfo)", "LabMin"), + ("Struct(StructKind::RangeMaskMapInfo)", "LabMax"), + ("Struct(StructKind::RangeMaskMapInfo)", "LumEq"), +} +# "+" is written although the value is never negative. +ALWAYS_PLUS = {"SharpenRadius", "HDRMaxValue"} +NEVER_PLUS = {"CropTop", "CropLeft", "CropBottom", "CropRight", "CropAngle", "Temperature"} + +# Ranges the UI-range text does not give in a parsable form (stored unit). +RANGE = { + "LensProfileEnable": (0, 1), + "AutoLateralCA": (0, 1), + "CropConstrainToWarp": (0, 1), + "HDREditMode": (0, 1), + ("Struct(StructKind::CorrectionRangeMask)", "ColorAmount"): (0, 1), +} +NO_RANGE = { + # observed spreads, not limits + ("MaskTool", "Angle"), + ("Struct(StructKind::Look)", "Amount"), # set below with the scale +} +UI_DIV = {("Struct(StructKind::CorrectionRangeMask)", "ColorAmount"): 100.0} + +# Recipe aliases: the recipe fields the style engine blends and sends +# (lrg-analysis/src/training.rs `canonical_keys`), each one a field the +# installed plugin already applies (DevelopEditManager.lua: GLOBAL_KEY_MAP, +# buildHslDevelopSettings, buildColorGradingDevelopSettings, +# buildToneCurveSettings). White balance is left out on purpose: step 1d moves +# it out of the generic blend into its own policy. Not aliased although the +# plugin reads a recipe field for them: colour grading midtones/global/ +# luminance/blending (the installed plugin warns and drops them) and the point +# curves (averaging curves is step 3). +ALIAS = { + "Exposure2012": "global.exposure", + "Contrast2012": "global.contrast", + "Highlights2012": "global.highlights", + "Shadows2012": "global.shadows", + "Whites2012": "global.whites", + "Blacks2012": "global.blacks", + "Texture": "global.texture", + "Clarity2012": "global.clarity", + "Dehaze": "global.dehaze", + "Vibrance": "global.vibrance", + "Saturation": "global.saturation", + "Sharpness": "global.sharpening", + "SharpenRadius": "global.sharpen_radius", + "SharpenDetail": "global.sharpen_detail", + "SharpenEdgeMasking": "global.sharpen_masking", + "LuminanceSmoothing": "global.noise_reduction", + "LuminanceNoiseReductionDetail": "global.noise_reduction_detail", + "LuminanceNoiseReductionContrast": "global.noise_reduction_contrast", + "ColorNoiseReduction": "global.color_noise_reduction", + "ColorNoiseReductionDetail": "global.color_noise_reduction_detail", + "ColorNoiseReductionSmoothness": "global.color_noise_reduction_smoothness", + "PostCropVignetteAmount": "global.vignette", + "PostCropVignetteMidpoint": "global.vignette_midpoint", + "PostCropVignetteRoundness": "global.vignette_roundness", + "PostCropVignetteFeather": "global.vignette_feather", + "PostCropVignetteHighlightContrast": "global.vignette_highlights", + "GrainAmount": "global.grain", + "GrainSize": "global.grain_size", + "GrainFrequency": "global.grain_roughness", + "ParametricHighlights": "global.tone_curve.highlights", + "ParametricLights": "global.tone_curve.lights", + "ParametricDarks": "global.tone_curve.darks", + "ParametricShadows": "global.tone_curve.shadows", + "ParametricShadowSplit": "global.tone_curve.shadow_split", + "ParametricMidtoneSplit": "global.tone_curve.midtone_split", + "ParametricHighlightSplit": "global.tone_curve.highlight_split", + **{f"{slider}Adjustment{c}": f"global.hsl.{c.lower()}.{field}" + for c in HSL + for slider, field in (("Hue", "hue"), ("Saturation", "saturation"), ("Luminance", "luminance"))}, + "SplitToningShadowHue": "global.color_grading.shadows.hue", + "SplitToningShadowSaturation": "global.color_grading.shadows.saturation", + "SplitToningHighlightHue": "global.color_grading.highlights.hue", + "SplitToningHighlightSaturation": "global.color_grading.highlights.saturation", + "SplitToningBalance": "global.color_grading.balance", +} + +# Default literals the free text does not give in parsable form. +STR_DEFAULT = { + "ToneCurveName2012": "Linear", + "CameraProfile": "Adobe Standard", + ("Header", "CameraModelRestriction"): "", +} +NO_DEFAULT = {"Look", ("Global", "LensBlur"), "PointColors", "ColorVariance", "RedEyeInfo"} + +# --------------------------------------------------------------------------- + + +def pick(table: dict, level: str, name: str): + if (level, name) in table: + return table[(level, name)] + return table.get(name) + + +def has(s: set, level: str, name: str) -> bool: + return (level, name) in s or name in s + + +@dataclass +class Row: + level: str + name: str + typ: str + ui_range: str + stored: str + default: str + pv: str + policy: str + note: str + out: dict = field(default_factory=dict) + + +def rows() -> list[Row]: + out: list[Row] = [] + + def add(level: str, r: tuple) -> None: + key, typ, ui, st, dflt, pv, pol, notes = r[:8] + if any(key.startswith(p) for p in PATTERN_ROWS): + return + members = r[8] if len(r) > 8 else [key] + for m in members: + if PATTERN_MEMBERS.match(m) or (level, m) in SKIP_ROWS: + continue + out.append(Row(level, m, typ, ui, st, dflt, pv, pol, notes)) + + for _title, rs in spec.G: + for r in rs: + add("Global", r) + for r in spec.HEADER: + add("Header", r) + for r in spec.CORR: + add("Correction", r) + for _title, rs in spec.MASK: + for r in rs: + add("MaskTool", r) + for lvl, _title, rs in spec.NESTED: + if lvl not in NESTED_LEVEL: + raise SystemExit(f"no level mapping for {lvl}") + level = NESTED_LEVEL[lvl] + if level is None: + continue + for r in rs: + if r[0].startswith(MASKTOOL_ROWS): + add("MaskTool", r) + continue + before = len(out) + add(level, r) + for row in out[before:]: + row.level = SUB_LEVEL.get((lvl, row.name), row.level) + return out + + +NUM = r"[+-]?\d+(?:\.\d+)?" +RANGE_RE = re.compile(rf"^\s*({NUM})\.\.({NUM})(?!\?)(?![\d.])") + + +def kind_of(r: Row) -> str: + k = pick(ENUMS, r.level, r.name) or pick(KIND, r.level, r.name) + if k: + return k + t = r.typ.strip() + if t == "bool": + return B_TITLE if r.level in ("Global", "Header") else B_LOWER + if t == "curve": + return "Curve(CurveKind::Local)" if r.level == "Correction" else "Curve(CurveKind::Global)" + if t in SIMPLE_TYPES: + return SIMPLE_TYPES[t] + if t in ANY_TYPES: + return "Any" + raise SystemExit(f"no value kind for {r.level}/{r.name} (type {t!r})") + + +def policy_of(r: Row) -> str: + p = r.policy + if p.endswith("†"): + g = pick(GATE, r.level, r.name) + if not g: + raise SystemExit(f"LEARN† without a gate: {r.level}/{r.name}") + return f"LearnGated(Gate::{g})" + if p == "LEARN": + g = pick(EXTRA_GATE, r.level, r.name) + if g: + return f"LearnGated(Gate::{g})" + return POLICY[p] + + +def presence_of(r: Row) -> str: + if has(LUA_ONLY, r.level, r.name) or r.level in LUA_ONLY_LEVELS: + return "LuaOnly" + if r.level == "Struct(StructKind::RetouchArea)" and r.name in LUA_ONLY_RETOUCH: + return "LuaOnly" + if r.level == "Header" or has(XMP_ONLY, r.level, r.name): + return "XmpOnly" + if "never observed in LrC output" in r.pv and r.name not in OBSERVED_AFTER_ALL: + return "Unobserved" + if "never observed in XMP or getDevelopSettings" in r.pv: + return "Unobserved" + return "Both" + + +def frame_of(r: Row, policy: str) -> str: + if policy in ("Computed", "Meta", "Unknown"): + return "NotApplicable" + if policy == "Never": + return "PerFrameOnly" + if r.level == "Global" and r.name in PER_FRAME_ADJUSTED: + return "PerFrameAdjusted" + if policy == "Photo": + return "PerFrameOnly" + return "Shareable" + + +def scale_of(r: Row, kind: str) -> tuple[str, float]: + """UiScale literal and the divisor applied to the UI range.""" + div = pick(UI_DIV, r.level, r.name) + if div: + return f"UiScale::Div({div:.1f})", 1.0 # its range text is already stored + if kind not in ("Int", "Real", "IntFlag") and not kind.startswith("EnumInt") and kind != "VersionU32": + return "UiScale::Identity", 1.0 + st = r.stored + if "?" in st or (r.policy == "UNKNOWN" and "?" in r.ui_range): + return "UiScale::Unknown", 1.0 + if "EV/4" in st: + return "UiScale::Div(4.0)", 4.0 + if "UI/100" in st or "/100" in st: + return "UiScale::Div(100.0)", 100.0 + return "UiScale::Identity", 1.0 + + +def range_of(r: Row, kind: str, div: float) -> tuple[float, float] | None: + if kind not in ("Int", "Real", "IntFlag"): + return None + if has(NO_RANGE, r.level, r.name): + return None + explicit = pick(RANGE, r.level, r.name) + if explicit: + return explicit + m = RANGE_RE.match(r.ui_range) + if not m: + return None + lo, hi = float(m.group(1)), float(m.group(2)) + return lo / div, hi / div + + +def parse_lit(kind: str, text: str): + t = text.strip().strip('"') + if kind in ("Int", "IntFlag", "VersionU32") or kind.startswith("EnumInt"): + if re.fullmatch(r"[+-]?\d+", t): + return ("Int", int(t)) + return None + if kind == "Real": + if re.fullmatch(NUM, t): + return ("Real", float(t)) + return None + if kind.startswith("Bool"): + if t in ("True", "true"): + return ("Bool", True) + if t in ("False", "false"): + return ("Bool", False) + return None + if kind.startswith("Enum("): + values = re.findall(r'"([^"]*)"', kind) + return ("Str", t) if t in values else None + if kind == "Curve(CurveKind::Global)": + m = re.fullmatch(r"\[([\d, ]+)\]", t) + return ("IntList", [int(x) for x in m.group(1).split(",")]) if m else None + return None + + +def default_of(r: Row, kind: str, policy: str) -> tuple: + """(raw, non_raw) as ("Value", lit) | ("Absent",) | ("Unverified",) | ("NoDefault",).""" + name = r.name + if name in ("Temperature", "Tint"): + return ("NoDefault",), ("Absent",) + if name in ("IncrementalTemperature", "IncrementalTint"): + return ("Absent",), ("Unverified",) + s = pick(STR_DEFAULT, r.level, name) + if s is not None: + return ("Value", ("Str", s)), ("Unverified",) + text = r.default.strip() + if has(NO_DEFAULT, r.level, name) or not text: + if not text and (policy == "Unknown" or "?" in r.typ): + return ("Unverified",), ("Unverified",) + return ("NoDefault",), ("NoDefault",) + if text == "?": + return ("Unverified",), ("Unverified",) + left = text.split(" / ")[0] + left = re.split(r"\s+\(|\s+in preset form|\s+in presets", left)[0] + lit = parse_lit(kind, left) or parse_lit(kind, left.split()[0] if left.split() else "") + if lit is None: + return ("NoDefault",), ("NoDefault",) + return ("Value", lit), ("Unverified",) + + +# Lightroom's own non-raw defaults, read back (experiment E13). Committed, +# scrubbed: key -> value only, plus where it was read (Lightroom version, +# process version, file kind). Values for keys absent from the readback are +# not invented: absence can mean "written only when used". +NON_RAW_READBACK = Path(__file__).resolve().parents[2] / "testdata/develop/defaults/non_raw_lrc15.6.json" + + +# Readback keys whose value is not taken as Lightroom's non-raw default: the +# readback is one photo, and these may be per image. They stay ``Unverified`` +# and are listed by ``--counts``; ``registry/tests.rs`` +# (``non_raw_defaults_come_from_the_readback``) holds the same set. +NON_RAW_READBACK_NOT_CONSTANT = { + "HDRMaxValue": "one photo (a virtual copy of a TIFF) read 2.3; may be per image", +} + + +def load_non_raw_readback(path: Path = NON_RAW_READBACK) -> dict: + doc = json.loads(path.read_text(encoding="utf-8")) + prov = doc.get("provenance", {}) + for k in ("lightroom", "process_version", "file_kind"): + if not isinstance(prov.get(k), str) or not prov[k]: + raise SystemExit(f"{path.name}: provenance.{k} missing") + if not isinstance(doc.get("settings"), dict) or not doc["settings"]: + raise SystemExit(f"{path.name}: no settings") + return doc["settings"] + + +def readback_lit(kind: str, v): + """The readback value ``v`` as a literal of ``kind``; None when the kind + cannot carry a default (``Any``, ``Hex32``, ...) or the value does not fit.""" + if isinstance(v, bool): + return ("Bool", v) if kind.startswith("Bool") else None + if isinstance(v, int): + if kind in ("Int", "VersionU32") or kind.startswith("EnumInt"): + return ("Int", v) + if kind == "IntFlag": + return ("Int", v) if v in (0, 1) else None + if kind == "Real": + return ("Real", float(v)) + return None + if isinstance(v, float): + return ("Real", v) if kind == "Real" else None + if isinstance(v, str): + if kind == "Str": + return ("Str", v) + if kind.startswith("Enum("): + return ("Str", v) if v in re.findall(r'"([^"]*)"', kind) else None + return None + if isinstance(v, list) and kind == "Curve(CurveKind::Global)": + if all(isinstance(x, int) and not isinstance(x, bool) for x in v) and len(v) >= 4 and len(v) % 2 == 0: + return ("IntList", v) + return None + + +def stated_non_raw(r: Row, kind: str): + """The non-raw half of the row's default text ("raw / non-raw") as a + literal of ``kind``; None when the text states none ("?", prose, or no + second half).""" + text = r.default.strip() + if " / " not in text: + return None + right = text.split(" / ", 1)[1] + right = re.split(r"\s+\(|\s+in preset form|\s+in presets", right)[0] + return parse_lit(kind, right) or parse_lit(kind, right.split()[0] if right.split() else "") + + +def non_raw_readback(r: Row, kind: str, non_raw: tuple, readback: dict, report: dict) -> tuple: + """The non-raw default after the readback: an ``Unverified`` global row + whose key Lightroom read back gets that value. ``NoDefault`` and + ``Absent`` stay (a per-photo value, or a key of the other file kind), + and so do the keys in ``NON_RAW_READBACK_NOT_CONSTANT``. Two + contradictions stop the run: a readback of a key the text calls absent + for non-raw files, and a readback that differs from the non-raw default + the text states (unless the key is held back).""" + if r.level != "Global" or r.name not in readback: + return non_raw + report["seen"].add(r.name) + if non_raw == ("Absent",): + raise SystemExit(f"{r.name}: non-raw readback has the key, the row says absent for non-raw files") + stated = stated_non_raw(r, kind) + read = readback_lit(kind, readback[r.name]) + if stated is not None and read is not None and stated != read and r.name not in NON_RAW_READBACK_NOT_CONSTANT: + raise SystemExit( + f"{r.name}: the text states the non-raw default {stated[1]!r}, the readback has {read[1]!r};" + " correct spec.py or hold the key back in NON_RAW_READBACK_NOT_CONSTANT" + ) + if r.name in NON_RAW_READBACK_NOT_CONSTANT: + report["held_back"].append(f"{r.name} ({NON_RAW_READBACK_NOT_CONSTANT[r.name]})") + return non_raw + if non_raw != ("Unverified",): + report["kept"].append(f"{r.name} ({non_raw[0]})") + return non_raw + lit = read + if lit is None: + report["no_literal"].append(f"{r.name} ({kind})") + return non_raw + report["filled"].append(r.name) + return ("Value", lit) + + +def fmt_of(r: Row, kind: str, presence: str) -> str: + if presence == "LuaOnly": + return "NumFmt::Text" + if r.name in FIXED and r.level == "Global": + return f"NumFmt::Fixed({FIXED[r.name]})" + if (r.level, r.name) in COMPOUND: + return "NumFmt::CompoundFixed6" + if kind in ("Int", "IntFlag", "VersionU32") or kind.startswith("EnumInt") or kind.startswith("Curve"): + return "NumFmt::Int" + if kind == "Real": + return "NumFmt::Trim6" + return "NumFmt::Text" + + +def plus_of(r: Row, kind: str, rng, presence: str) -> bool: + if r.level != "Global" or presence == "LuaOnly" or r.name in NEVER_PLUS: + return False + if r.name in ALWAYS_PLUS: + return True + return kind in ("Int", "Real") and rng is not None and rng[0] < 0 + + +# --------------------------------------------------------------------------- +# Rust rendering +# --------------------------------------------------------------------------- + + +def rs_str(s: str) -> str: + return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"' + + +def rs_f(x: float) -> str: + return f"Finite::new_const({x!r})" if not float(x).is_integer() else f"Finite::new_const({float(x):.1f})" + + +def rs_def(d: tuple) -> str: + if d[0] != "Value": + return f"Def::{d[0]}" + tag, v = d[1] + if tag == "Int": + return f"Def::Value(Lit::Int({v}))" + if tag == "Real": + return f"Def::Value(Lit::Real({rs_f(v)}))" + if tag == "Bool": + return f"Def::Value(Lit::Bool({'true' if v else 'false'}))" + if tag == "Str": + return f"Def::Value(Lit::Str({rs_str(v)}))" + if tag == "IntList": + return f"Def::Value(Lit::IntList(&[{', '.join(map(str, v))}]))" + raise AssertionError(tag) + + +def note_of(r: Row) -> str: + note, pv = r.note.strip(), r.pv.strip() + text = f"{note} [{pv}]" if note and pv else note or (f"[{pv}]" if pv else "") + text = text.replace("**", "") + if '"Temp"' in text: + raise SystemExit('a note must not contain the literal "Temp" (backend sources must not hold it)') + return text + + +def render(all_rows: list[Row], readback: dict | None = None) -> tuple[str, dict]: + readback = load_non_raw_readback() if readback is None else readback + report: dict = {"seen": set(), "filled": [], "kept": [], "held_back": [], "no_literal": []} + seen: set[tuple[str, str]] = set() + lines = [] + counts: dict = {} + for r in all_rows: + if (r.level, r.name) in seen: + raise SystemExit(f"duplicate row {r.level}/{r.name}") + seen.add((r.level, r.name)) + kind = kind_of(r) + policy = policy_of(r) + presence = presence_of(r) + ui, div = scale_of(r, kind) + rng = range_of(r, kind, div) + if r.level == "Struct(StructKind::Look)" and r.name == "Amount": + ui, rng = "UiScale::Div(100.0)", (0.0, 2.0) + raw, non_raw = default_of(r, kind, policy.split("(")[0]) + non_raw = non_raw_readback(r, kind, non_raw, readback, report) + fmt = fmt_of(r, kind, presence) + plus = plus_of(r, kind, rng, presence) + frame = frame_of(r, policy.split("(")[0]) + file_scope = FILE_SCOPE.get(r.name, "Both") if r.level == "Global" else "Both" + min_pv = "Some(ProcessVersion::PV2012)" if PV2012_NOTE.search(r.pv) else "None" + alias = ALIAS.get(r.name) if r.level == "Global" else None + for d in (raw, non_raw): + if d[0] == "Value" and rng is not None and d[1][0] in ("Int", "Real"): + v = d[1][1] + if not rng[0] <= v <= rng[1]: + raise SystemExit(f"default {v} outside range {rng} for {r.level}/{r.name}") + rng_rs = "None" if rng is None else f"Some(({rs_f(rng[0])}, {rs_f(rng[1])}))" + lines.append( + " KeySpec {\n" + f" name: {rs_str(r.name)},\n" + f" level: Level::{r.level},\n" + f" kind: ValueKind::{kind},\n" + f" range: {rng_rs},\n" + f" ui: {ui},\n" + f" fmt: {fmt},\n" + f" plus_sign: {'true' if plus else 'false'},\n" + f" default: DefaultSpec {{ raw: {rs_def(raw)}, non_raw: {rs_def(non_raw)} }},\n" + f" policy: Policy::{policy},\n" + f" frame: FrameScope::{frame},\n" + f" file_kind: FileScope::{file_scope},\n" + f" presence: Presence::{presence},\n" + f" min_pv: {min_pv},\n" + f" recipe_alias: {'None' if alias is None else f'Some({rs_str(alias)})'},\n" + f" note: {rs_str(note_of(r))},\n" + " }," + ) + lvl = r.level.replace("Struct(StructKind::", "struct:").rstrip(")") + cls = policy.split("(")[0] + counts.setdefault(lvl, {}).setdefault(cls, 0) + counts[lvl][cls] += 1 + unknown = sorted(set(readback) - report["seen"]) + stale = sorted(set(NON_RAW_READBACK_NOT_CONSTANT) - report["seen"]) + if stale: + raise SystemExit(f"NON_RAW_READBACK_NOT_CONSTANT names keys the readback lacks: {stale}") + if unknown: + raise SystemExit(f"non-raw readback keys without a global row: {unknown}") + counts["non-raw readback"] = { + "filled": len(report["filled"]), + "kept (NoDefault)": len(report["kept"]), + "held back (not constant)": len(report["held_back"]), + "no literal (kind)": len(report["no_literal"]), + } + counts["non-raw readback: kept"] = report["kept"] + counts["non-raw readback: held back"] = report["held_back"] + counts["non-raw readback: no literal"] = report["no_literal"] + missing_alias = set(ALIAS) - {r.name for r in all_rows if r.level == "Global"} + if missing_alias: + raise SystemExit(f"aliases for unknown keys: {sorted(missing_alias)}") + head = ( + "//! The key registry table: one [`KeySpec`] per develop key.\n" + "//!\n" + "//! Seeded from `server-rs/scripts/develop_registry/spec.py` by\n" + "//! `gen_table_rs.py` in the same folder, and the source of truth since:\n" + "//! edit this file by hand, then refresh the review snapshot with\n" + "//! `LRG_BLESS=1 cargo test -p lrg-develop --test registry_snapshot`.\n" + "//! Re-running the generator overwrites hand edits.\n" + "//!\n" + "//! Order is registry order (the sections of the registry: global keys by\n" + "//! panel, header, correction, mask tool, nested structures); [`KeyId`]s\n" + "//! are indices into this slice.\n" + "//!\n" + "//! [`KeyId`]: super::KeyId\n\n" + "use super::{\n" + " BoolStyle, CurveKind, Def, DefaultSpec, Finite, FileScope, FrameScope, Gate, KeySpec, Level, Lit,\n" + " NumFmt, Policy, Presence, ProcessVersion, StructKind, UiScale, ValueKind,\n" + "};\n\n" + f"/// The {len(lines)} registry rows.\n" + "pub(crate) static TABLE: &[KeySpec] = &[\n" + ) + return head + "\n".join(lines) + "\n];\n", counts + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description="Seed registry/table.rs from spec.py.") + ap.add_argument("--out", required=True, help="path of registry/table.rs") + ap.add_argument("--counts", action="store_true", help="print row counts per level and policy class") + args = ap.parse_args(argv) + text, counts = render(rows()) + out = Path(args.out) + out.write_text(text, encoding="utf-8") + rustfmt = shutil.which("rustfmt") + if rustfmt: + subprocess.run([rustfmt, "--edition", "2021", str(out)], check=True) + else: + print("rustfmt not found; run cargo fmt", file=sys.stderr) + if args.counts: + total = 0 + for lvl, c in counts.items(): + if lvl.startswith("non-raw readback"): + print(f"{lvl:28} {c}") + continue + print(f"{lvl:28} {dict(sorted(c.items()))}") + total += sum(c.values()) + print("total rows", total) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/server-rs/scripts/develop_registry/outpath.py b/server-rs/scripts/develop_registry/outpath.py new file mode 100644 index 00000000..f635545f --- /dev/null +++ b/server-rs/scripts/develop_registry/outpath.py @@ -0,0 +1,55 @@ +"""Output-path guard shared by the tools in this directory. + +Everything these tools write from your own catalog, sidecars or training table +is private (paths, file names, photo ids, capture data). The repository is +public, so the tools refuse to write such output anywhere inside the +repository's working tree. The one exception is ``extract_fixtures.py``, whose +scrubbed output may go to ``server-rs/testdata/develop/``. +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +# Relative to the repository root; the only place scrubbed fixtures may land. +FIXTURE_ROOT = Path("server-rs") / "testdata" / "develop" + + +def repo_root() -> Path | None: + """Root of the working tree this file lives in (``.git`` may be a file in a worktree).""" + for parent in Path(__file__).resolve().parents: + if (parent / ".git").exists(): + return parent + return None + + +def _inside(path: Path, root: Path) -> bool: + try: + path.relative_to(root) + except ValueError: + return False + return True + + +def output_problem(path: str | os.PathLike, *, allow_fixtures: bool = False) -> str | None: + """Why ``path`` must not receive output, or ``None`` when it may.""" + root = repo_root() + if root is None: + return None + target = Path(path).expanduser().resolve() + if not _inside(target, root): + return None + if allow_fixtures and _inside(target, root / FIXTURE_ROOT): + return None + where = f"{FIXTURE_ROOT}/" if allow_fixtures else "a folder outside the repository" + return f"refusing to write into the repository ({target}); use {where}" + + +def require_outside_repo(path: str | os.PathLike, *, allow_fixtures: bool = False) -> None: + """Exit with status 2 when ``path`` is inside the repository (see ``output_problem``).""" + problem = output_problem(path, allow_fixtures=allow_fixtures) + if problem: + print(problem, file=sys.stderr) + sys.exit(2) diff --git a/server-rs/scripts/develop_registry/parse_xmp.py b/server-rs/scripts/develop_registry/parse_xmp.py new file mode 100644 index 00000000..4c757533 --- /dev/null +++ b/server-rs/scripts/develop_registry/parse_xmp.py @@ -0,0 +1,626 @@ +"""Generic reader for Camera Raw settings (``crs:``) in XMP files, and the key inventory. + +Two uses: + +* **Library.** ``iter_xmp_files``, ``is_lrc_sidecar`` and ``Inventory`` are imported + by the other tools in this directory. +* **CLI.** ``inventory`` walks Lightroom Classic's bundled presets/profiles, optional + ACR and user preset folders and a list of sidecars, and writes ``inventory.json``: + per ``(level, key)`` statistics (value range, number formatting, bool spelling, + container types, which files/kinds it occurs in) plus serialization observations. + ``show`` prints one line per key of an existing inventory for a quick look. + +The walker understands everything Lightroom writes: + +* attribute form (``crs:Exposure2012="+0.50"``) and element form + (``+0.50``), mixed freely; +* ``rdf:Seq`` / ``rdf:Bag`` / ``rdf:Alt`` containers (``Alt`` reports ``x-default``); +* nested structs written as an ``rdf:Description`` child, as attributes on the + property element, or with ``rdf:parseType="Resource"``; +* several top-level ``rdf:Description`` elements in one file. + +Three subtrees are deliberately *not* inventoried, because they are not the +photo's own settings: + +* ``crs:Preset/Parameters`` (a copy of the last applied preset) - only the + ``Preset`` record's own direct fields are counted; +* ``crs:Look/Parameters`` in a sidecar (Adobe's profile definition, filled in by + Lightroom from its profile library); +* ``crss:SavedSettings`` (snapshots). + +Each skip is counted under ``excluded`` in the output. A profile file's own +top-level keys *are* a profile definition; they are recorded under the level +``look-parameters`` so they never mix with a photo's global keys. + +Sidecar rules: ``* [conflicted].xmp`` files (sync-conflict copies) are skipped, +and only files containing ``crs:ProcessVersion`` count as Lightroom sidecars. + +Example:: + + uv run parse_xmp.py inventory --lrc-presets "$LRG_LRC_PRESETS_DIR" \\ + --sidecar-list "$OUT/sample_files.json" --out "$OUT/inventory.json" + uv run parse_xmp.py show "$OUT/inventory.json" --levels global,correction + +``$OUT`` is a folder outside the repository; the tool refuses to write inside it. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +import xml.etree.ElementTree as ET +from collections import Counter, defaultdict +from collections.abc import Iterable, Iterator +from pathlib import Path + +from outpath import require_outside_repo + +RDF = "http://www.w3.org/1999/02/22-rdf-syntax-ns#" +CRS = "http://ns.adobe.com/camera-raw-settings/1.0/" +CRSS = "http://ns.adobe.com/camera-raw-saved-settings/1.0/" +XML = "http://www.w3.org/XML/1998/namespace" + +#: Keys that belong to a preset/profile file's header rather than to the settings. +HEADER_KEYS = frozenset( + { + "PresetType", + "Cluster", + "UUID", + "SupportsAmount", + "SupportsAmount2", + "SupportsColor", + "SupportsMonochrome", + "SupportsHighDynamicRange", + "SupportsNormalDynamicRange", + "SupportsSceneReferred", + "SupportsOutputReferred", + "RequiresRGBTables", + "CameraModelRestriction", + "Copyright", + "ContactInfo", + "Name", + "ShortName", + "SortName", + "Group", + "Description", + "ShowInPresets", + "ShowInQuickActions", + "PresetSubtype", + "Stubbed", + } +) + +NUM_RE = re.compile(r"^[+-]?(\d+\.?\d*|\.\d+)$") +TABLE_RE = re.compile(r"^Table_[0-9A-Fa-f]{32}$") +CURVE_KEYS = frozenset({"MainCurve", "RedCurve", "GreenCurve", "BlueCurve"}) + +# --------------------------------------------------------------------------- +# File discovery +# --------------------------------------------------------------------------- + + +def iter_xmp_files(root: str | os.PathLike[str]) -> Iterator[Path]: + """Yield every ``*.xmp`` below ``root`` in a stable (sorted) order.""" + for dirpath, dirnames, filenames in os.walk(root): + dirnames.sort() + for name in sorted(filenames): + if name.lower().endswith(".xmp") and not name.startswith("._"): + yield Path(dirpath) / name + + +def is_conflicted(path: str | os.PathLike[str]) -> bool: + """True for sync-conflict copies such as ``IMG_0001 [conflicted].xmp``.""" + return "[conflicted]" in Path(path).name + + +def is_lrc_sidecar(data: bytes) -> bool: + """True when the file carries develop settings (it has ``crs:ProcessVersion``).""" + return b"crs:ProcessVersion" in data + + +def split_tag(tag: str) -> tuple[str, str]: + """``'{ns}local'`` -> ``('ns', 'local')``.""" + if tag.startswith("{"): + ns, local = tag[1:].split("}", 1) + return ns, local + return "", tag + + +def has_struct_content(el: ET.Element) -> bool: + """True when a property element holds a struct rather than a plain value.""" + return ( + any(split_tag(a)[0] == CRS for a in el.attrib) + or len(el) > 0 + or el.attrib.get(f"{{{RDF}}}parseType") == "Resource" + ) + + +# --------------------------------------------------------------------------- +# Value classification +# --------------------------------------------------------------------------- + + +def classify_value(v: str) -> str: + if v in ("True", "False", "true", "false"): + return "bool" + if NUM_RE.match(v): + return "num" + return "str" + + +def item_format(s: str) -> str: + """Coarse shape of a Seq item (curve points, float lists, dab strings, ...).""" + s = s.strip() + if re.match(r"^-?\d+, -?\d+$", s): + return "int, int" + if re.match(r"^-?\d+(\.\d+)?, -?\d+(\.\d+)?$", s): + return "num, num" + if re.match(r"^(-?\d+\.\d+, ){5,}-?\d+\.\d+$", s): + return "float-list(%d)" % (s.count(",") + 1) + if re.match(r"^-?\d+\.\d+$", s): + return "float" + if re.match(r"^[a-zA-Z] ", s): + return "token-string" + return "other" + + +# --------------------------------------------------------------------------- +# Inventory +# --------------------------------------------------------------------------- + + +class Stat: + """Statistics for one ``(level, key)``.""" + + __slots__ = ( + "n", + "files", + "lastfile", + "forms", + "kinds", + "nums", + "strs", + "plus", + "pos_noplus", + "decimals", + "bools", + "whats", + "containers", + "items", + "item_fmt", + "pv", + "intish", + ) + + def __init__(self) -> None: + self.n = 0 + self.files = 0 + self.lastfile: str | None = None + self.forms: Counter[str] = Counter() + self.kinds: Counter[str] = Counter() + self.nums: list[float] = [] + self.strs: Counter[str] = Counter() + self.plus = 0 + self.pos_noplus = 0 + self.decimals: Counter[int] = Counter() + self.bools: Counter[str] = Counter() + self.whats: Counter[str] = Counter() + self.containers: Counter[str] = Counter() + self.items: Counter[int] = Counter() + self.item_fmt: Counter[str] = Counter() + self.pv: Counter[str | None] = Counter() + self.intish = 0 + + def to_json(self, level: str, key: str) -> dict: + nums = sorted(self.nums) + + def pct(p: float) -> float | None: + if not nums: + return None + i = min(len(nums) - 1, max(0, int(round(p * (len(nums) - 1))))) + return nums[i] + + return { + "level": level, + "key": key, + "n": self.n, + "files": self.files, + "forms": dict(self.forms), + "kinds": dict(self.kinds), + "num_n": len(nums), + "min": pct(0), + "p5": pct(0.05), + "p50": pct(0.5), + "p95": pct(0.95), + "max": pct(1), + "plus": self.plus, + "pos_noplus": self.pos_noplus, + "decimals": {str(k): v for k, v in self.decimals.items()}, + "bools": dict(self.bools), + "intish": self.intish, + "whats": dict(self.whats.most_common(12)), + "containers": dict(self.containers), + "items": {str(k): v for k, v in self.items.items()}, + "item_fmt": dict(self.item_fmt), + "top": self.strs.most_common(12), + "distinct": len(self.strs), + "pv": {str(k): v for k, v in self.pv.items()}, + } + + +def child_level(level: str, key: str) -> str: + """Level name for the items/struct under ``key`` found at ``level``.""" + if key == "MaskGroupBasedCorrections": + return "correction" + if key in ( + "GradientBasedCorrections", + "CircularGradientBasedCorrections", + "PaintBasedCorrections", + ): + return "correction-legacy" + if key == "DepthBasedCorrections": + return "correction-depth" + if key == "CorrectionMasks": + return "mask-tool" + if key == "Masks": + return "mask-tool(retouch)" if level.startswith("nested:RetouchArea") else "mask-tool" + if key == "RetouchAreas": + return "nested:RetouchArea" + return "nested:" + key + + +class Inventory: + """Accumulates per-key statistics over many XMP files.""" + + def __init__(self) -> None: + self.stats: defaultdict[tuple[str, str], Stat] = defaultdict(Stat) + self.ser: defaultdict[tuple, Counter] = defaultdict(Counter) + self.excluded: Counter[str] = Counter() + self.files: Counter[str] = Counter() + self.skipped: Counter[str] = Counter() + self.errors: list[tuple[str, str]] = [] + self.what_keys: defaultdict[str, Counter[str]] = defaultdict(Counter) + self.file_forms: Counter[tuple[str, str]] = Counter() + + # -- recording --------------------------------------------------------- + + def _rec(self, level: str, key: str, value: str | None, form: str, ctx: dict) -> None: + if TABLE_RE.match(key): + key = "Table_" + if value is not None and len(value) > 60: + value = "" % (len(value) // 1000 * 1000) + st = self.stats[(level, key)] + st.n += 1 + if st.lastfile != ctx["file"]: + st.files += 1 + st.lastfile = ctx["file"] + st.forms[form] += 1 + file_form = "elemfile" if ctx.get("elemform") else "attrfile" + self.ser[("form", level, key, file_form)][form] += 1 + if value is not None and NUM_RE.match(value) and float(value) == 0: + if value.startswith("+0"): + self.ser[("plus-zero", level, key)][value] += 1 + if value.startswith("-0"): + self.ser[("minus-zero", level, key)][value] += 1 + st.kinds[ctx["kind"]] += 1 + st.pv[ctx.get("pv")] += 1 + if ctx.get("what"): + st.whats[ctx["what"]] += 1 + if value is None: + return + c = classify_value(value) + if c == "bool": + st.bools[value] += 1 + self.ser[("bool", level, ctx["kind"])][value] += 1 + elif c == "num": + f = float(value) + st.nums.append(f) + if "." in value: + st.decimals[len(value.split(".", 1)[1])] += 1 + else: + st.decimals[0] += 1 + st.intish += 1 + if f > 0: + if value.startswith("+"): + st.plus += 1 + else: + st.pos_noplus += 1 + if len(st.strs) < 400 or value in st.strs: + st.strs[value[:120]] += 1 + + @staticmethod + def _level_for(level: str, key: str, ctx: dict) -> str: + if level != "global": + return level + if ctx["kind"].endswith("profile"): + return "header(look)" if key in HEADER_KEYS else "look-parameters" + return "header" if key in HEADER_KEYS else "global" + + # -- walking ----------------------------------------------------------- + + @staticmethod + def _what(node: ET.Element) -> str | None: + w = node.attrib.get(f"{{{CRS}}}What") + if w is None: + e = node.find(f"{{{CRS}}}What") + if e is not None: + w = (e.text or "").strip() + if w is None: + d = node.find(f"{{{RDF}}}Description") + if d is not None: + return Inventory._what(d) + return w + + def _walk_desc(self, node: ET.Element, level: str, ctx: dict) -> None: + what = self._what(node) + if what is not None: + ctx = dict(ctx, what=what) + for k, v in node.attrib.items(): + ns, local = split_tag(k) + if ns != CRS: + continue + lvl = self._level_for(level, local, ctx) + self._rec(lvl, local, v, "attr", ctx) + if lvl.startswith("mask-tool") and what: + self.what_keys[what][local] += 1 + for ch in node: + ns, local = split_tag(ch.tag) + if ns == RDF and local == "Description": + self._walk_desc(ch, level, ctx) + elif ns == CRSS: + self.excluded["crss:" + local] += 1 + elif ns == CRS: + self._handle_prop(local, ch, level, ctx) + + def _handle_preset_record(self, el: ET.Element, ctx: dict) -> None: + """``crs:Preset``: count its own direct fields, never its ``Parameters``.""" + self.excluded["crs:Preset/Parameters"] += 1 + d = el.find(f"{{{RDF}}}Description") + if d is None: + d = el + for k, v in d.attrib.items(): + ns, local = split_tag(k) + if ns == CRS: + self._rec("nested:Preset", local, v, "attr", ctx) + for c in d: + ns, local = split_tag(c.tag) + if ns != CRS or local == "Parameters": + continue + if c.find(f"{{{RDF}}}Alt") is not None: + self._rec("nested:Preset", local, None, "alt", ctx) + elif len(c) == 0: + self._rec("nested:Preset", local, (c.text or "").strip(), "elem", ctx) + + def _handle_prop(self, key: str, el: ET.Element, level: str, ctx: dict) -> None: + lvl = self._level_for(level, key, ctx) + if level == "global" and key == "Preset": + self._handle_preset_record(el, ctx) + return + if level == "nested:Look" and key == "Parameters": + self.excluded["crs:Look/Parameters"] += 1 + return + conts = [c for c in el if split_tag(c.tag)[0] == RDF and split_tag(c.tag)[1] in ("Seq", "Bag", "Alt")] + if conts: + self._handle_container(key, conts[0], lvl, ctx) + return + if has_struct_content(el): + self._rec(lvl, key, None, "struct", ctx) + self._walk_desc(el, child_level(lvl, key), dict(ctx, parent=key)) + return + self._rec(lvl, key, (el.text or "").strip(), "elem", ctx) + + def _handle_container(self, key: str, cont: ET.Element, lvl: str, ctx: dict) -> None: + ctype = split_tag(cont.tag)[1] + items = [li for li in cont if split_tag(li.tag) == (RDF, "li")] + st = self.stats[(lvl, key)] + st.containers[ctype] += 1 + self.ser[("container", key)][ctype] += 1 + if ctype == "Alt": + val = None + langs = [] + for li in items: + lang = li.attrib.get(f"{{{XML}}}lang", "?") + langs.append(lang) + if lang == "x-default": + val = li.text or "" + self.ser[("alt-lang", key)][tuple(sorted(langs))] += 1 + self._rec(lvl, key, val if val is not None else "", "alt", ctx) + return + self._rec(lvl, key, None, "seq" if ctype == "Seq" else "bag", ctx) + sub = child_level(lvl, key) + for li in items: + if has_struct_content(li): + self._walk_desc(li, sub, dict(ctx, parent=key)) + continue + t = li.text or "" + st.item_fmt[item_format(t)] += 1 + if len(st.strs) < 400: + st.strs["[item] " + t[:100]] += 1 + self.ser[("item", key)][item_format(t)] += 1 + if key.startswith("ToneCurve") or key in CURVE_KEYS: + spacing = "comma-space" if ", " in t else ("comma" if "," in t else "other") + self.ser[("curve-item-spacing", key)][spacing] += 1 + st.items[len(items)] += 1 + + # -- public API ---------------------------------------------------------- + + def add_file(self, path: str | os.PathLike[str], kind: str) -> bool: + """Parse one file. ``kind`` is ``lrc``, ``acr``, ``user`` or ``sidecar``. + + Returns False when the file was skipped (conflict copy, not a sidecar, + unreadable or unparsable). + """ + path = str(path) + if kind == "sidecar" and is_conflicted(path): + self.skipped["conflicted"] += 1 + return False + try: + raw = Path(path).read_bytes() + except OSError as e: + self.errors.append((path, str(e)[:100])) + return False + if kind == "sidecar" and not is_lrc_sidecar(raw): + self.skipped["no-ProcessVersion"] += 1 + return False + try: + root = ET.fromstring(raw) + except ET.ParseError as e: + self.errors.append((path, str(e)[:100])) + return False + rdf = root if split_tag(root.tag) == (RDF, "RDF") else root.find(f"{{{RDF}}}RDF") + if rdf is None: + self.errors.append((path, "no rdf:RDF")) + return False + ptype = re.search(rb'crs:PresetType(?:="|>)([A-Za-z]+)', raw) + if kind != "sidecar" and ptype and ptype.group(1) == b"Look": + kind = kind + "-profile" + pvm = re.search(rb'crs:ProcessVersion(?:="|>)([0-9.]+)', raw) + elemform = b"" in raw or b"" in raw + self.file_forms[(kind, "element-form" if elemform else "attribute-form")] += 1 + self.files[kind] += 1 + ctx = { + "file": path, + "kind": kind, + "pv": pvm.group(1).decode() if pvm else None, + "elemform": elemform, + } + for d in rdf.findall(f"{{{RDF}}}Description"): + self._walk_desc(d, "global", ctx) + return True + + def add_files(self, paths: Iterable[str | os.PathLike[str]], kind: str) -> None: + for p in paths: + self.add_file(p, kind) + + def to_json(self) -> dict: + return { + "files": dict(self.files), + "skipped": dict(self.skipped), + "errors": self.errors, + "excluded": dict(self.excluded), + "file_forms": {"%s|%s" % k: v for k, v in self.file_forms.items()}, + "keys": {"%s|%s" % (lvl, key): st.to_json(lvl, key) for (lvl, key), st in self.stats.items()}, + "ser": {"|".join(map(str, k)): {str(kk): v for kk, v in c.items()} for k, c in self.ser.items()}, + "what_keys": {w: dict(c) for w, c in self.what_keys.items()}, + } + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def load_sidecar_list(path: str | os.PathLike[str]) -> list[str]: + """Accept either ``sample.py`` output (a list of paths) or ``scan_corpus.py``'s + ``corpus_meta.json`` (a list of objects with a ``p`` field).""" + data = json.loads(Path(path).read_text(encoding="utf-8")) + return [d["p"] if isinstance(d, dict) else d for d in data] + + +def cmd_inventory(args: argparse.Namespace) -> int: + require_outside_repo(args.out) + inv = Inventory() + sources = [ + (args.lrc_presets, "lrc"), + (args.acr_presets, "acr"), + (args.user_presets, "user"), + ] + if not any(d for d, _ in sources) and not args.sidecar_list and not args.sidecars_dir: + print("nothing to scan: give --lrc-presets and/or --sidecar-list/--sidecars-dir", file=sys.stderr) + return 2 + for directory, kind in sources: + if not directory: + continue + if not Path(directory).is_dir(): + print(f"not a directory: {directory}", file=sys.stderr) + return 2 + inv.add_files(iter_xmp_files(directory), kind) + if args.sidecar_list: + inv.add_files(load_sidecar_list(args.sidecar_list), "sidecar") + if args.sidecars_dir: + inv.add_files(iter_xmp_files(args.sidecars_dir), "sidecar") + out = inv.to_json() + out_path = Path(args.out) + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(json.dumps(out, indent=0), encoding="utf-8") + print(f"files {out['files']}; {len(out['keys'])} (level, key) rows; errors {len(out['errors'])}") + print(f"skipped {out['skipped']}") + print(f"excluded {out['excluded']}") + print(f"forms {out['file_forms']}") + return 1 if out["errors"] and args.strict else 0 + + +def cmd_show(args: argparse.Namespace) -> int: + d = json.loads(Path(args.inventory).read_text(encoding="utf-8")) + levels = set(args.levels.split(",")) if args.levels else None + + def f(x: float | None) -> str: + return "" if x is None else "%g" % x + + for v in sorted(d["keys"].values(), key=lambda v: (v["level"], v["key"])): + if levels and v["level"] not in levels: + continue + k = v["kinds"] + presets = sum(c for kk, c in k.items() if kk != "sidecar") + print( + "%-24s %-30s n=%-5d sidecar=%-5d preset=%-5d | %s | %s %s %s %s %s | +%d/%d dec=%s b=%s | %s %s | top=%s" + % ( + v["level"][:24], + v["key"], + v["n"], + k.get("sidecar", 0), + presets, + ",".join(v["forms"]), + f(v["min"]), + f(v["p5"]), + f(v["p50"]), + f(v["p95"]), + f(v["max"]), + v["plus"], + v["pos_noplus"], + v["decimals"] or "", + v["bools"] or "", + v["containers"] or "", + v["item_fmt"] or "", + [t[0][:36] for t in v["top"][:6]], + ) + ) + return 0 + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description="Inventory crs: keys in XMP presets, profiles and sidecars.") + sub = ap.add_subparsers(dest="cmd", required=True) + + inv = sub.add_parser("inventory", help="build inventory.json") + inv.add_argument( + "--lrc-presets", + default=os.environ.get("LRG_LRC_PRESETS_DIR"), + help="LrC's bundled Settings folder (default: $LRG_LRC_PRESETS_DIR)", + ) + inv.add_argument( + "--acr-presets", + default=os.environ.get("LRG_ACR_PRESETS_DIR"), + help="Camera Raw's Settings folder (default: $LRG_ACR_PRESETS_DIR)", + ) + inv.add_argument("--user-presets", help="a folder of your own imported presets/profiles") + inv.add_argument("--sidecar-list", help="JSON list of sidecar paths (sample.py or scan_corpus.py output)") + inv.add_argument("--sidecars-dir", help="scan every sidecar below this folder instead of a list") + inv.add_argument("--out", required=True, help="output file outside the repository, e.g. $OUT/inventory.json") + inv.add_argument("--strict", action="store_true", help="exit non-zero if any file failed to parse") + inv.set_defaults(func=cmd_inventory) + + show = sub.add_parser("show", help="print an inventory as one line per key") + show.add_argument("inventory") + show.add_argument("--levels", help="comma-separated levels, e.g. global,correction,mask-tool") + show.set_defaults(func=cmd_show) + + args = ap.parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/server-rs/scripts/develop_registry/pyproject.toml b/server-rs/scripts/develop_registry/pyproject.toml new file mode 100644 index 00000000..03d9a4fe --- /dev/null +++ b/server-rs/scripts/develop_registry/pyproject.toml @@ -0,0 +1,28 @@ +[project] +name = "lrg-develop-registry" +version = "0.1.0" +description = "Research and fixture tools for the native Lightroom develop model (lrg-develop): crs XMP inventory, key-registry generator, scrubbed test fixtures. Build-time tooling only, not part of any shipped backend." +requires-python = ">=3.12" +# The tools are stdlib-only. Only dump_training.py needs LanceDB's Python +# package; it lives in an opt-in group so a plain `uv run` never installs it: +# uv run --group lance dump_training.py ... +dependencies = [] + +[dependency-groups] +lance = [ + "pylance>=12.0.0", +] + +[tool.uv] +package = false + +[tool.ruff] +line-length = 120 +target-version = "py312" + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "B"] + +[tool.ruff.lint.per-file-ignores] +# spec.py is a data table kept one row per line (see its docstring). +"spec.py" = ["E501"] diff --git a/server-rs/scripts/develop_registry/sample.py b/server-rs/scripts/develop_registry/sample.py new file mode 100644 index 00000000..4c6f159e --- /dev/null +++ b/server-rs/scripts/develop_registry/sample.py @@ -0,0 +1,99 @@ +"""Stratified, reproducible sample of a sidecar corpus (input: ``scan_corpus.py`` output). + +The full corpus is large and dominated by a few folders and one process version. +The structural inventory (``parse_xmp.py inventory``) runs on a sample instead, +built in two steps: + +1. **Guaranteed rare shapes** first: element-form files, files written by an + older Camera Raw major version than the newest one seen, snapshots + (``crss:SavedSettings``), retouch, filter lists (Denoise and friends), crops. +2. **Proportional fill** by ``(group, process version, has masks)`` with a + minimum per stratum, so small strata are still represented. + +Selection is random with a fixed seed over a sorted list, so the same corpus +always yields the same sample. + +Output: ``sample_files.json`` in ``--out-dir``, a sorted list of paths. It holds +paths from your own disk. **Never commit it.** + +Example:: + + uv run sample.py --meta "$OUT/corpus_meta.json" --size 3000 --out-dir "$OUT" + +``$OUT`` is a folder outside the repository; the tool refuses to write inside it. +""" + +from __future__ import annotations + +import argparse +import json +import random +import sys +from collections import Counter, defaultdict +from pathlib import Path + +from outpath import require_outside_repo + + +def major(version: str) -> int: + try: + return int(version.split(".")[0]) + except ValueError: + return -1 + + +def stratified_sample(meta: list[dict], size: int, seed: int, min_per_stratum: int) -> list[str]: + rng = random.Random(seed) + meta = sorted(meta, key=lambda m: m["p"]) + chosen: dict[str, dict] = {} + + def take(pool: list[dict], n: int) -> None: + pool = [m for m in pool if m["p"] not in chosen] + for m in rng.sample(pool, min(n, len(pool))): + chosen[m["p"]] = m + + newest = max((major(m["ver"]) for m in meta), default=-1) + take([m for m in meta if m["elem"]], 10) + take([m for m in meta if major(m["ver"]) != newest], 200) + take([m for m in meta if m["saved"]], 30) + take([m for m in meta if m["retouch"]], 80) + take([m for m in meta if m["filterlist"]], 80) + take([m for m in meta if m["hascrop"]], 100) + + strata: defaultdict[tuple, list[dict]] = defaultdict(list) + for m in meta: + strata[(m["group"], m["pv"], m["masks"])].append(m) + budget = max(0, size - len(chosen)) + total = max(1, len(meta)) + for _key, pool in sorted(strata.items()): + take(pool, max(min_per_stratum, round(budget * len(pool) / total))) + return sorted(chosen) + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description="Stratified sidecar sample.") + ap.add_argument("--meta", required=True, help="corpus_meta.json from scan_corpus.py") + ap.add_argument("--size", type=int, default=3000, help="target sample size (default 3000)") + ap.add_argument("--seed", type=int, default=42, help="random seed (default 42)") + ap.add_argument("--min-per-stratum", type=int, default=15, help="minimum files per stratum (default 15)") + ap.add_argument("--out-dir", required=True) + args = ap.parse_args(argv) + require_outside_repo(args.out_dir) + + meta = json.loads(Path(args.meta).read_text(encoding="utf-8")) + files = stratified_sample(meta, args.size, args.seed, args.min_per_stratum) + by_path = {m["p"]: m for m in meta} + picked = [by_path[p] for p in files] + out = Path(args.out_dir) + out.mkdir(parents=True, exist_ok=True) + (out / "sample_files.json").write_text(json.dumps(files, indent=0), encoding="utf-8") + + print(f"{len(files)} files sampled from {len(meta)}") + groups = Counter(m["group"] for m in picked) + print(f"{len(groups)} groups; largest", dict(groups.most_common(10))) + print("masks", sum(m["masks"] for m in picked), "pv", dict(Counter(m["pv"] for m in picked))) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/server-rs/scripts/develop_registry/scan_corpus.py b/server-rs/scripts/develop_registry/scan_corpus.py new file mode 100644 index 00000000..99e8184d --- /dev/null +++ b/server-rs/scripts/develop_registry/scan_corpus.py @@ -0,0 +1,128 @@ +"""Pass 1 over a sidecar corpus: a cheap byte-level scan of every Lightroom sidecar. + +For each ``*.xmp`` below the corpus root that is a Lightroom sidecar (contains +``crs:ProcessVersion``; ``* [conflicted].xmp`` copies are skipped) it records the +strata ``sample.py`` needs - top-level folder, writer version, process version, +element vs attribute form, size - and which notable features are present (masks, +retouch, lens blur, point colour, applied-preset record, snapshots, crop, filter +list, legacy local corrections). + +It also counts, per ``crs:`` name, how many files mention it anywhere. That is +presence only (a regex over the bytes, nesting ignored); ``parse_xmp.py`` does +the structural inventory. + +Outputs (in ``--out-dir``): + +* ``corpus_meta.json`` - one object per sidecar (``p`` is its absolute path) +* ``corpus_keyfreq.json`` - ``{crs name: number of files}`` + +These contain paths from your own disk. **Never commit them.** + +Example:: + + uv run scan_corpus.py --corpus "$LRG_XMP_CORPUS_DIR" --out-dir "$OUT" + +``$OUT`` is a folder outside the repository; the tool refuses to write inside it. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +from collections import Counter +from pathlib import Path + +from outpath import require_outside_repo +from parse_xmp import is_conflicted, is_lrc_sidecar, iter_xmp_files + +KEY_RE = re.compile(rb"crs:([A-Za-z_][A-Za-z0-9_]*)") +VERSION_RE = re.compile(rb'crs:Version(?:="|>)([0-9.]+)') +PV_RE = re.compile(rb'crs:ProcessVersion(?:="|>)([0-9.]+)') + + +def describe(path: Path, root: Path, data: bytes) -> dict: + """Strata and feature flags for one sidecar.""" + rel = path.relative_to(root) + m = VERSION_RE.search(data) + pv = PV_RE.search(data) + return { + "p": str(path), + # First folder below the corpus root (for a year-per-folder layout this is the year). + "group": rel.parts[0] if len(rel.parts) > 1 else ".", + "ver": m.group(1).decode() if m else "?", + "pv": pv.group(1).decode() if pv else "?", + "masks": b"MaskGroupBasedCorrections" in data, + "retouch": b"RetouchAreas" in data, + "lensblur": b"crs:LensBlur" in data, + "pointcolors": b"crs:PointColors" in data, + "preset": b"" in data, + "size": len(data), + "hascrop": b'crs:HasCrop="True"' in data or b"True" in data, + "filterlist": b"crs:FilterList" in data, + "legacy": ( + b"PaintBasedCorrections" in data + or b"GradientBasedCorrections>" in data + or b"CircularGradientBasedCorrections" in data + ), + } + + +def scan(root: Path) -> tuple[list[dict], dict[str, int], Counter[str]]: + meta: list[dict] = [] + keyfreq: Counter[str] = Counter() + skipped: Counter[str] = Counter() + for p in iter_xmp_files(root): + if is_conflicted(p): + skipped["conflicted"] += 1 + continue + try: + data = p.read_bytes() + except OSError: + skipped["unreadable"] += 1 + continue + if not is_lrc_sidecar(data): + skipped["no-ProcessVersion"] += 1 + continue + meta.append(describe(p, root, data)) + for k in set(KEY_RE.findall(data)): + keyfreq[k.decode()] += 1 + return meta, dict(sorted(keyfreq.items())), skipped + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description="Scan a sidecar corpus (presence + strata).") + ap.add_argument( + "--corpus", + default=os.environ.get("LRG_XMP_CORPUS_DIR"), + help="root folder of the sidecars (default: $LRG_XMP_CORPUS_DIR)", + ) + ap.add_argument("--out-dir", required=True, help="where to write corpus_meta.json and corpus_keyfreq.json") + args = ap.parse_args(argv) + if not args.corpus or not Path(args.corpus).is_dir(): + print("give --corpus DIR or set LRG_XMP_CORPUS_DIR", file=sys.stderr) + return 2 + require_outside_repo(args.out_dir) + root = Path(args.corpus).resolve() + meta, keyfreq, skipped = scan(root) + out = Path(args.out_dir) + out.mkdir(parents=True, exist_ok=True) + (out / "corpus_meta.json").write_text(json.dumps(meta), encoding="utf-8") + (out / "corpus_keyfreq.json").write_text(json.dumps(keyfreq, indent=0), encoding="utf-8") + + print(f"{len(meta)} LrC sidecars; {len(keyfreq)} distinct crs names; skipped {dict(skipped)}") + groups = Counter(m["group"] for m in meta) + print(f"{len(groups)} groups; largest", dict(groups.most_common(10))) + print("writer major", dict(Counter(m["ver"].split(".")[0] for m in meta))) + print("process version", dict(Counter(m["pv"] for m in meta))) + flags = ("masks", "retouch", "lensblur", "pointcolors", "preset", "saved", "elem", "legacy", "filterlist") + print(", ".join(f"{f} {sum(m[f] for m in meta)}" for f in flags)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/server-rs/scripts/develop_registry/spec.py b/server-rs/scripts/develop_registry/spec.py new file mode 100644 index 00000000..a0c77dfc --- /dev/null +++ b/server-rs/scripts/develop_registry/spec.py @@ -0,0 +1,519 @@ +"""Hand-maintained registry specification: one entry per ``crs`` key or collapsed key group. + +``gen.py`` renders these rows as Markdown tables and fills the "Observed" column +from ``inventory.json``. The rows are the maintainer's own notes, based on local +observation only: Lightroom Classic's bundled presets and profiles, the +maintainer's own sidecar corpus and the Lightroom SDK reference. A few rows are +key names known from Camera Raw that were never observed in Lightroom Classic's +output; they are marked "known from Camera Raw; never observed in LrC output", +carry no value semantics and are UNKNOWN or COMPUTED. No row is copied from +ExifTool or darktable; keys known only from ExifTool's tag tables and never +observed were deliberately left out (see the step-1 plan, "Lizenz/Herkunft"). + +Row tuple:: + + (key_or_label, type, ui_range_unit, stored_vs_ui, default_raw_nonraw, + pv_notes, policy, notes[, members]) + +``members`` (optional) lists the real key names a collapsed row stands for. +``policy`` is one of LEARN, LEARN\u2020 (gated), PHOTO, COMPUTED, NEVER, META, +UNKNOWN. "research \u00a7N" and "E" refer to the maintainer's research notes and +the develop-settings experiments; the public summary is the wiki page +``Dev-AI-Edit-XMP-Findings.md``. + +The table is kept in its compact one-row-per-line layout on purpose, so the +formatter is switched off below. +""" + +# fmt: off + +HSL = ['Red', 'Orange', 'Yellow', 'Green', 'Aqua', 'Blue', 'Purple', 'Magenta'] + +G = [] # (section, [rows]) for level=global + + +def sec(title, rows): + G.append((title, rows)) + + +sec('White balance', [ + ('WhiteBalance', 'enum', 'As Shot, Auto, Custom, Daylight, Cloudy, Shade, Tungsten, Fluorescent, Flash (non-raw: As Shot, Auto, Custom)', 'string = UI label (English, not localised)', 'As Shot / As Shot', 'all PV', 'PHOTO', + 'Never average. The preset writer derives `Custom` whenever it writes Temperature/Tint, which a preset carries only when this is `Custom` or absent: next to any other mode they are that photo\'s resolved values (SkipReason::WhiteBalanceMode). `Auto` is resolved per photo by LrC (see AutoWhiteVersion). Raw and non-raw have different preset lists, so a mixed batch must not share one value.'), + ('Temperature', 'int', '2000..50000 K', 'stored = UI (Kelvin)', 'as shot (camera) / key absent', 'all PV; raw only', 'LEARN†', + 'Raw-only key. Gate: drop from presets that can hit non-raw targets; learn relative to the example\'s as-shot value, not as absolute Kelvin (lighting differs per scene). **Bug (f), fixed in PR 1d: the style engine read `Temp`; it now reads the white balance typed (`WbSetting`).**'), + ('Tint', 'int', '-150..+150', 'stored = UI', 'as shot / key absent', 'all PV; raw only', 'LEARN†', + 'Raw-only, same gate as Temperature. **Bug (f), fixed in PR 1d: non-raw must use IncrementalTint; the style engine blended `Tint` for both.**'), + ('IncrementalTemperature', 'int', '-100..+100 (relative)', 'stored = UI', 'n/a / 0', 'all PV; non-raw only', 'LEARN†', + 'Non-raw counterpart of Temperature. Not in this raw-only corpus; seen in 5 profile Look.Parameters and (research) in every non-raw catalog row checked.'), + ('IncrementalTint', 'int', '-100..+100 (relative)', 'stored = UI', 'n/a / 0', 'all PV; non-raw only', 'LEARN†', 'Non-raw counterpart of Tint.'), + ('AutoWhiteVersion', 'uint', 'engine version', 'major<<24|minor<<16 (134348800 = 8.2)', 'absent', '', 'COMPUTED', 'Written by LrC when WhiteBalance=Auto was resolved.'), + ('AsShotTemperature, AsShotTint', 'int', 'K / -150..150', '', '', 'seen only inside applied-Preset/Parameters (a few sidecars)', 'UNKNOWN', + 'Not a photo setting in XMP. Meaning inside a preset record unknown (research §8 open point).', ['AsShotTemperature', 'AsShotTint']), +]) + +sec('Basic tone and presence', [ + ('Exposure2012', 'real', '-5.00..+5.00 EV', 'stored = UI (EV), fixed 2 decimals', '0.00 / 0.00', 'PV2012+ (PV ≥ 2012; legacy `Exposure` for PV2003/2010)', 'LEARN', ''), + ('Contrast2012', 'int', '-100..+100', 'stored = UI', '0 / 0', 'PV2012+', 'LEARN', ''), + ('Highlights2012', 'int', '-100..+100', 'stored = UI', '0 / 0', 'PV2012+', 'LEARN', ''), + ('Shadows2012', 'int', '-100..+100', 'stored = UI', '0 / 0', 'PV2012+', 'LEARN', ''), + ('Whites2012', 'int', '-100..+100', 'stored = UI', '0 / 0', 'PV2012+', 'LEARN', ''), + ('Blacks2012', 'int', '-100..+100', 'stored = UI', '0 / 0', 'PV2012+', 'LEARN', ''), + ('Texture', 'int', '-100..+100', 'stored = UI', '0 / 0', 'LrC 8.3+', 'LEARN', ''), + ('Clarity2012', 'int', '-100..+100', 'stored = UI', '0 / 0', 'PV2012+', 'LEARN', ''), + ('Dehaze', 'int', '-100..+100', 'stored = UI', '0 / 0', 'PV2012+', 'LEARN', ''), + ('Vibrance', 'int', '-100..+100', 'stored = UI', '0 / 0', 'all PV', 'LEARN', ''), + ('Saturation', 'int', '-100..+100', 'stored = UI', '0 / 0', 'all PV', 'LEARN', ''), + ('ToneMapStrength', 'real', '?', '?', '?', 'known from Camera Raw; never observed in LrC output', 'UNKNOWN', 'Not in LrC 15.5.1 UI as far as observed; do not write.'), + ('Exposure, Contrast, Brightness, Shadows, FillLight, HighlightRecovery, Clarity (legacy PV2003/PV2010 tone)', 'real / int', '', '', '', 'PV2003/PV2010; a few bundled presets and every getDevelopSettings() table (as defaults)', 'COMPUTED', + 'Superseded by the *2012 keys. Read for fidelity, never learned or written; a PV2012+ photo ignores them.', + ['Exposure', 'Contrast', 'Brightness', 'Shadows', 'FillLight', 'HighlightRecovery', 'Clarity']), +]) + +sec('Tone curve', [ + ('ToneCurveName2012', 'string', 'Linear, Medium Contrast, Strong Contrast, Custom (+ localised names, e.g. "Matter Kontrast")', 'label', 'Linear / Linear', 'PV2012+', 'META', + 'Display label, sometimes localised. Serializer derives: `Linear` for the identity curve, else `Custom`. Never learn from it.'), + ('ToneCurvePV2012', 'curve', 'points (x,y), ints 0..255, x strictly increasing, ≥ 2 points', 'XMP Seq "x, y"; Lua flat array {x1,y1,x2,y2,...}', '[0,0,255,255]', 'PV2012+', 'LEARN', 'Learn by resampling to a fixed x grid; write back as a small point set.'), + ('ToneCurvePV2012Red', 'curve', 'as above', 'as above', '[0,0,255,255]', 'PV2012+', 'LEARN', ''), + ('ToneCurvePV2012Green', 'curve', 'as above', 'as above', '[0,0,255,255]', 'PV2012+', 'LEARN', ''), + ('ToneCurvePV2012Blue', 'curve', 'as above', 'as above', '[0,0,255,255]', 'PV2012+', 'LEARN', ''), + ('ParametricShadows', 'int', '-100..+100', 'stored = UI', '0 / 0', '', 'LEARN', ''), + ('ParametricDarks', 'int', '-100..+100', 'stored = UI', '0 / 0', '', 'LEARN', ''), + ('ParametricLights', 'int', '-100..+100', 'stored = UI', '0 / 0', '', 'LEARN', ''), + ('ParametricHighlights', 'int', '-100..+100', 'stored = UI', '0 / 0', '', 'LEARN', ''), + ('ParametricShadowSplit', 'int', '0..100, must stay < MidtoneSplit', 'stored = UI', '25 / 25', '', 'LEARN', 'Keep the ordering Shadow < Midtone < Highlight when averaging.'), + ('ParametricMidtoneSplit', 'int', '0..100', 'stored = UI', '50 / 50', '', 'LEARN', ''), + ('ParametricHighlightSplit', 'int', '0..100, must stay > MidtoneSplit', 'stored = UI', '75 / 75', '', 'LEARN', ''), + ('CurveRefineSaturation', 'int', '0..100', 'stored = UI', '100 / 100', 'written by recent LrC only (about a fifth of the sampled sidecars)', 'LEARN', 'Low priority.'), + ('ExtendedToneCurvePV2012, ExtendedToneCurvePV2012Red, ExtendedToneCurvePV2012Green, ExtendedToneCurvePV2012Blue', 'curve', '?', 'Lua: flat number array like ToneCurvePV2012', '', 'in a few getDevelopSettings() tables (3 of 1,294 training rows); never observed in XMP', 'UNKNOWN', 'Probably HDR-extended curves. Research: no XMP counterpart found.', + ['ExtendedToneCurvePV2012', 'ExtendedToneCurvePV2012Red', 'ExtendedToneCurvePV2012Green', 'ExtendedToneCurvePV2012Blue']), + ('ExtendedToneCurveName2012', 'string', '?', '?', '', 'known from Camera Raw; never observed in LrC output', 'UNKNOWN', 'Name for the extended curves above.'), + ('ToneCurve, ToneCurveRed, ToneCurveGreen, ToneCurveBlue, ToneCurveName', 'curve / string', '', '', '', 'legacy PV2003/2010 (11 Classic presets)', 'COMPUTED', 'Legacy. Never write; the parser keeps them for round-trip only.', ['ToneCurve', 'ToneCurveRed', 'ToneCurveGreen', 'ToneCurveBlue', 'ToneCurveName']), +]) + +sec('Colour mixer (HSL) and B&W', [ + ('HueAdjustment{8}', 'int', '-100..+100', 'stored = UI', '0 / 0', '', 'LEARN', '8 keys: ' + ', '.join('HueAdjustment' + c for c in HSL), ['HueAdjustment' + c for c in HSL]), + ('SaturationAdjustment{8}', 'int', '-100..+100', 'stored = UI', '0 / 0', '', 'LEARN', '8 keys.', ['SaturationAdjustment' + c for c in HSL]), + ('LuminanceAdjustment{8}', 'int', '-100..+100', 'stored = UI', '0 / 0', '', 'LEARN', '8 keys.', ['LuminanceAdjustment' + c for c in HSL]), + ('ConvertToGrayscale', 'bool', 'True/False', '"True"/"False"', 'False / False', '', 'LEARN†', + 'Categorical: weighted majority, never a mean. Gate: B&W and colour examples must not be averaged into one recipe (today they are, research §2).'), + ('GrayMixer{8}', 'int', '-100..+100', 'stored = UI', 'auto-mix computed on conversion / 0 in presets', '', 'LEARN†', 'Only meaningful with ConvertToGrayscale=True. 8 keys.', ['GrayMixer' + c for c in HSL]), + ('AutoGrayscaleMix', 'bool', 'True', '"True"', 'absent', 'presets only (13)', 'UNKNOWN', 'Preset instruction "compute auto B&W mix on apply". Behaviour through applyDevelopSettings unverified.'), +]) + +sec('Colour grading (split toning keys are shared)', [ + ('SplitToningShadowHue', 'int', '0..360 deg (Colour Grading "Shadows" hue)', 'stored = UI', '0 / 0', 'LrC 10+ reuses the split-toning keys', 'LEARN', 'Circular mean; weight by saturation.'), + ('SplitToningShadowSaturation', 'int', '0..100', 'stored = UI', '0 / 0', '', 'LEARN', ''), + ('SplitToningHighlightHue', 'int', '0..360 deg', 'stored = UI', '0 / 0', '', 'LEARN', 'Circular mean.'), + ('SplitToningHighlightSaturation', 'int', '0..100', 'stored = UI', '0 / 0', '', 'LEARN', ''), + ('SplitToningBalance', 'int', '-100..+100', 'stored = UI', '0 / 0', '', 'LEARN', ''), + ('ColorGradeShadowLum', 'int', '-100..+100', 'stored = UI', '0 / 0', 'LrC 10+', 'LEARN', '**Bug (f): plugin warns "not supported by Lightroom" and drops it.**'), + ('ColorGradeHighlightLum', 'int', '-100..+100', 'stored = UI', '0 / 0', 'LrC 10+', 'LEARN', '**Bug (f): same false warning.**'), + ('ColorGradeMidtoneHue', 'int', '0..360 deg', 'stored = UI', '0 / 0', 'LrC 10+', 'LEARN', 'Circular mean.'), + ('ColorGradeMidtoneSat', 'int', '0..100', 'stored = UI', '0 / 0', 'LrC 10+', 'LEARN', ''), + ('ColorGradeMidtoneLum', 'int', '-100..+100', 'stored = UI', '0 / 0', 'LrC 10+', 'LEARN', ''), + ('ColorGradeGlobalHue', 'int', '0..360 deg', 'stored = UI', '0 / 0', 'LrC 10+', 'LEARN', 'Circular mean.'), + ('ColorGradeGlobalSat', 'int', '0..100', 'stored = UI', '0 / 0', 'LrC 10+', 'LEARN', ''), + ('ColorGradeGlobalLum', 'int', '-100..+100', 'stored = UI', '0 / 0', 'LrC 10+', 'LEARN', 'Missing from the LLM schema today.'), + ('ColorGradeBlending', 'int', '0..100', 'stored = UI', '50 / 50', 'LrC 10+', 'LEARN', 'Non-zero default.'), +]) + +sec('Detail', [ + ('Sharpness', 'int', '0..150', 'stored = UI', '40 / 0', '', 'LEARN†', 'Default differs raw/non-raw: learn per file type (or as delta to the type default).'), + ('SharpenRadius', 'real', '0.5..3.0 px', 'stored = UI, fixed 1 decimal, always "+" ("+1.0")', '+1.0 / +1.0', '', 'LEARN', ''), + ('SharpenDetail', 'int', '0..100', 'stored = UI', '25 / 25', '', 'LEARN', 'Non-zero default.'), + ('SharpenEdgeMasking', 'int', '0..100', 'stored = UI', '0 / 0', '', 'LEARN', ''), + ('LuminanceSmoothing', 'int', '0..100 (UI "Noise Reduction")', 'stored = UI', '0 / 0', '', 'LEARN', ''), + ('LuminanceNoiseReductionDetail', 'int', '0..100', 'stored = UI', '50 / 50', 'written only when LuminanceSmoothing was touched', 'LEARN', ''), + ('LuminanceNoiseReductionContrast', 'int', '0..100', 'stored = UI', '0 / 0', 'as above', 'LEARN', ''), + ('ColorNoiseReduction', 'int', '0..100', 'stored = UI', '25 / 0', '', 'LEARN†', 'Default differs raw/non-raw.'), + ('ColorNoiseReductionDetail', 'int', '0..100', 'stored = UI', '50 / 50', '', 'LEARN', ''), + ('ColorNoiseReductionSmoothness', 'int', '0..100', 'stored = UI', '50 / 50', '', 'LEARN', ''), + ('ISODependent', 'struct[]', 'Seq of {ISO, ColorNoiseReduction, LuminanceSmoothing}', 'ints', 'absent', 'Adobe ISO-adaptive presets only (8)', 'UNKNOWN', 'Preset-only table. Whether applyDevelopSettings/plugin presets accept it is unverified.'), +]) + +sec('Lens corrections', [ + ('LensProfileEnable', 'int (0/1)', '0 = off, 1 = on', 'integer, not bool', '0 / 0 (Adobe); 1 = this user\'s default', '', 'LEARN', + '**Bug (f): plugin writes `EnableLensCorrections` (panel switch) instead.**'), + ('LensProfileSetup', 'enum', 'LensDefaults, Auto, Custom', 'string', 'LensDefaults', '', 'LEARN†', 'Gate: only `LensDefaults`/`Auto` are transferable; `Custom` pins a specific profile (PHOTO).'), + ('LensProfileName', 'string', 'profile display name', '', 'filled by LrC', '', 'PHOTO', 'Lens identity. LrC fills it itself for LensDefaults/Auto.'), + ('LensProfileFilename', 'string', '.lcp file name', '', 'filled by LrC', '', 'PHOTO', 'Lens identity.'), + ('LensProfileDigest', 'hex32', '', '', 'filled by LrC', '', 'PHOTO', 'Lens identity digest.'), + ('LensProfileIsEmbedded', 'bool', 'True/False', '"True"/"False"', 'filled by LrC', '', 'PHOTO', 'True for in-camera (embedded) profiles.'), + ('LensProfileDistortionScale', 'int', '0..200', 'stored = UI', '100 / 100', '', 'LEARN†', 'Only meaningful with LensProfileEnable=1.'), + ('LensProfileVignettingScale', 'int', '0..200', 'stored = UI', '100 / 100', '', 'LEARN†', 'Only meaningful with LensProfileEnable=1 (0 seen = profile vignetting off).'), + ('AutoLateralCA', 'int (0/1)', '0/1', 'integer', '0 / 0 (Adobe); 1 = this user\'s default', '', 'LEARN', ''), + ('LensManualDistortionAmount', 'int', '-100..+100', 'stored = UI', '0 / 0', '', 'PHOTO', 'Geometry: disables geometric masks (research §4.3).'), + ('VignetteAmount', 'int', '-100..+100 (lens vignetting)', 'stored = UI', '0 / 0', '', 'LEARN', 'Low priority.'), + ('VignetteMidpoint', 'int', '0..100', 'stored = UI', '50 / 50', 'written only when VignetteAmount ≠ 0 (2 sidecars in full corpus)', 'LEARN', ''), + ('DefringePurpleAmount', 'int', '0..20', 'stored = UI', '0 / 0', '', 'LEARN', 'Low priority, lens-dependent.'), + ('DefringePurpleHueLo', 'int', '0..100, gap ≥ 10 to Hi', 'stored = UI', '30 / 30', '', 'LEARN', ''), + ('DefringePurpleHueHi', 'int', '0..100', 'stored = UI', '70 / 70', '', 'LEARN', ''), + ('DefringeGreenAmount', 'int', '0..20', 'stored = UI', '0 / 0', '', 'LEARN', ''), + ('DefringeGreenHueLo', 'int', '0..100', 'stored = UI', '40 / 40', '', 'LEARN', ''), + ('DefringeGreenHueHi', 'int', '0..100', 'stored = UI', '60 / 60', '', 'LEARN', ''), + ('Defringe, ChromaticAberrationR, ChromaticAberrationB', 'int', '', '', '', 'legacy; in every getDevelopSettings() table (Lua) and the Zeroed template, never observed in XMP', 'COMPUTED', 'Legacy CA controls. Never write.', + ['Defringe', 'ChromaticAberrationR', 'ChromaticAberrationB']), + ('LensProfileChromaticAberrationScale', 'int', '', '', '', 'legacy (Zeroed template only; never observed in XMP or getDevelopSettings())', 'COMPUTED', 'Legacy CA control. Never write.'), +]) + +sec('Transform / Upright', [ + ('PerspectiveUpright', 'enum int', '0 Off, 1 Auto, 2 Full, 3 Level, 4 Vertical, 5 Guided', 'integer', '0 / 0', '', 'PHOTO', 'Write 1–4 only, never 5 and never the UprightTransform_* matrices; LrC recomputes. Disables geometric masks.'), + ('PerspectiveVertical', 'int', '-100..+100', 'stored = UI', '0 / 0', '', 'PHOTO', 'Geometry.'), + ('PerspectiveHorizontal', 'int', '-100..+100', 'stored = UI', '0 / 0', '', 'PHOTO', 'Geometry.'), + ('PerspectiveRotate', 'real', '-10.0..+10.0 deg', 'fixed 1 decimal ("0.0", "+0.8")', '0.0 / 0.0', '', 'PHOTO', 'Geometry.'), + ('PerspectiveScale', 'int', '50..150', 'stored = UI', '100 / 100', '', 'PHOTO', 'Geometry.'), + ('PerspectiveAspect', 'int', '-100..+100', 'stored = UI', '0 / 0', '', 'PHOTO', 'Geometry.'), + ('PerspectiveX', 'real', '-100..+100 (UI "Offset X")', 'fixed 2 decimals ("0.00")', '0.00 / 0.00', '', 'PHOTO', 'Geometry.'), + ('PerspectiveY', 'real', '-100..+100', 'fixed 2 decimals', '0.00 / 0.00', '', 'PHOTO', 'Geometry.'), + ('Upright solver state (17 keys)', 'mixed', '', '', 'absent', 'written when PerspectiveUpright ≠ 0', 'COMPUTED', + 'UprightVersion, UprightTransformCount, UprightTransform_0..5 (9-float matrices), UprightCenterMode, UprightCenterNormX/Y, UprightFocalMode, UprightFocalLength35mm, UprightPreview, UprightDependentDigest, UprightGuidedDependentDigest, UprightFourSegmentsCount.', + ['UprightVersion', 'UprightTransformCount', 'UprightTransform_0', 'UprightTransform_1', 'UprightTransform_2', 'UprightTransform_3', 'UprightTransform_4', 'UprightTransform_5', 'UprightCenterMode', 'UprightCenterNormX', 'UprightCenterNormY', 'UprightFocalMode', 'UprightFocalLength35mm', 'UprightPreview', 'UprightDependentDigest', 'UprightGuidedDependentDigest', 'UprightFourSegmentsCount']), + ('UprightFourSegments_0..3', 'string', '4 floats per guide', '', 'absent', 'Guided Upright only', 'NEVER', 'User-drawn guide lines (per-photo strokes).', ['UprightFourSegments_0', 'UprightFourSegments_1', 'UprightFourSegments_2', 'UprightFourSegments_3']), +]) + +sec('Crop', [ + ('CropTop', 'real', '0..1', 'normalised, uncropped sensor frame; up to 6 decimals trimmed', '0 / 0', '', 'PHOTO', 'Crop corners are opposite corners of a rectangle rotated by CropAngle (research §4).'), + ('CropLeft', 'real', '0..1', 'as above', '0 / 0', '', 'PHOTO', ''), + ('CropBottom', 'real', '0..1', 'as above', '1 / 1', '', 'PHOTO', ''), + ('CropRight', 'real', '0..1', 'as above', '1 / 1', '', 'PHOTO', ''), + ('CropAngle', 'real', '-45..+45 deg', 'no "+" sign, up to 6 decimals', '0 / 0', '', 'PHOTO', ''), + ('CropConstrainToWarp', 'int (0/1)', '0/1', 'integer', '0 / 0', '', 'PHOTO', ''), + ('CropConstrainToUnitSquare', 'int', '1', 'integer', '1 / 1', '', 'PHOTO', 'Always 1 when written.'), + ('HasCrop', 'bool', 'True', '"True"', 'absent', 'XMP only (not in the Lua table)', 'META', 'Derived: written iff the crop differs from the full frame.'), + ('CropConstrainAspectRatio', 'bool', 'true/false (Lua)', 'Lua boolean', 'absent', 'Lua only (getDevelopSettings(), 66 of 1,294 training rows); never observed in XMP', 'PHOTO', 'Aspect-ratio lock of the crop tool: per-photo UI state.'), + ('CropWidth, CropHeight, CropUnit, CropUnits', 'real / enum', 'output size; units unknown', '', '', 'known from Camera Raw; never observed in LrC output', 'UNKNOWN', 'ACR crop-to-size settings; not an LrC feature as far as observed.', ['CropWidth', 'CropHeight', 'CropUnit', 'CropUnits']), +]) + +sec('Effects', [ + ('PostCropVignetteAmount', 'int', '-100..+100', 'stored = UI', '0 / 0', '', 'LEARN', ''), + ('PostCropVignetteMidpoint', 'int', '0..100', 'stored = UI', '50 / 50', '', 'LEARN', ''), + ('PostCropVignetteFeather', 'int', '0..100', 'stored = UI', '50 / 50', '', 'LEARN', ''), + ('PostCropVignetteRoundness', 'int', '-100..+100', 'stored = UI', '0 / 0', '', 'LEARN', ''), + ('PostCropVignetteStyle', 'enum int', '1 Highlight Priority, 2 Color Priority, 3 Paint Overlay', 'integer', '1 / 1', '', 'LEARN', 'Categorical (majority).'), + ('PostCropVignetteHighlightContrast', 'int', '0..100', 'stored = UI', '0 / 0', '', 'LEARN', ''), + ('OverrideLookVignette', 'bool', 'True/False', '"True"/"False"', 'False / False', '', 'UNKNOWN', 'Set by LrC when the user vignette overrides a vignette built into the Look; whether we must set it when writing PostCropVignette* is unverified.'), + ('GrainAmount', 'int', '0..100', 'stored = UI', '0 / 0', '', 'LEARN', ''), + ('GrainSize', 'int', '0..100', 'stored = UI', '25 / 25', '', 'LEARN', 'Zeroed template uses 50; the Adobe default and the corpus mode are 25.'), + ('GrainFrequency', 'int', '0..100 (UI "Roughness")', 'stored = UI', '50 / 50', '', 'LEARN', ''), + ('GrainSeed', 'uint32', 'random seed', '', 'absent', 'recent LrC', 'COMPUTED', 'Grain pattern seed chosen by LrC.'), + ('Glow', '?', '?', '?', '', 'one sidecar of the local corpus, Camera Raw 18.7 engine (October 2026); never observed in LrC 15.5.1 output', 'UNKNOWN', 'Newer engine feature (the Glow panel, with GlowRange/GlowWarmth/GlowSpread/GlowStyle); type, range and default not established.'), + ('GlowRange, GlowWarmth, GlowSpread, GlowStyle, ReshapeAmount', 'number', '?', '?', '', 'known from Camera Raw 18.6; never observed in LrC output', 'UNKNOWN', 'Newer engine features not seen in LrC 15.5.1 output.', ['GlowRange', 'GlowWarmth', 'GlowSpread', 'GlowStyle', 'ReshapeAmount']), +]) + +sec('Lens blur', [ + ('LensBlur', 'struct', 'see sub-table 7.3', '', 'Active=false / same', 'LrC 13+ (Adobe ships lens-blur-only presets)', 'LEARN\u2020', + 'Gate: needs a depth map, i.e. an AI update after applying; FocalRange semantics are UNKNOWN, so v1 writes only Active + the look sliders.'), +]) + +sec('Calibration', [ + ('ShadowTint', 'int', '-100..+100', 'stored = UI', '0 / 0', '', 'LEARN', ''), + ('RedHue', 'int', '-100..+100', 'stored = UI', '0 / 0', '', 'LEARN', ''), + ('RedSaturation', 'int', '-100..+100', 'stored = UI', '0 / 0', '', 'LEARN', ''), + ('GreenHue', 'int', '-100..+100', 'stored = UI', '0 / 0', '', 'LEARN', ''), + ('GreenSaturation', 'int', '-100..+100', 'stored = UI', '0 / 0', '', 'LEARN', ''), + ('BlueHue', 'int', '-100..+100', 'stored = UI', '0 / 0', '', 'LEARN', ''), + ('BlueSaturation', 'int', '-100..+100', 'stored = UI', '0 / 0', '', 'LEARN', '`BluePrimarySaturation` does not exist (silently dropped by LrC, research §7).'), +]) + +sec('Profile', [ + ('CameraProfile', 'string', 'Adobe Standard, Adobe Standard v2, symbolic Default Color / Default Monochrome / Default Profile, camera-specific DCP names (Camera Standard, Camera PROVIA/Standard, ...), Embedded (non-raw)', 'string', 'Adobe Standard / Embedded', '', 'LEARN†', + 'Gate: transfer only Adobe Standard and the symbolic Default* values; camera-specific names only to the same make. The creative profile is the `Look`. **Bug (f): dead `profile` branch writes `CameraProfile="Adobe Color"`, which is a Look name and is silently dropped.**'), + ('CameraProfileDigest', 'hex32', '', '', 'filled by LrC', '', 'COMPUTED', ''), + ('Look', 'struct', 'see sub-table', '', 'raw: Adobe Color (UUID B952C231111CD8E0ECCF14B86BAA7077) / none', 'LrC 7.3+', 'LEARN†', 'Identity = Look.Name + Look.UUID, strength = Look.Amount. Gate: camera-restricted Looks only to the same camera; Adaptive looks need an AI update (AILook).'), + ('LookTable, RGBTable', 'hex32', 'reference to a Table_ blob', '', '', '', 'COMPUTED', 'Profile lookup-table references.', ['LookTable', 'RGBTable']), + ('Table_', 'blob', 'base85 + zlib', '', '', 'one attribute per referenced table, name carries the MD5', 'COMPUTED', 'Profile LUTs, legacy inline mask bitmaps (crs:Version 16), Denoise/Enhance payloads. Decodable (research §11) but never written by us.'), + ('LookTableAmount, RGBTableAmount, RGBTables, ProfileGainTableMap, ProfileGainTableMap2, ProfileToneCurve, MissingCameraProfile, MissingCameraProfileDigest', 'mixed', '', '', '', 'profile-definition fields (Look.Parameters) / known from Camera Raw', 'COMPUTED', 'Part of a profile definition, not a photo setting.', + ['LookTableAmount', 'RGBTableAmount', 'RGBTables', 'ProfileGainTableMap', 'ProfileGainTableMap2', 'ProfileToneCurve', 'MissingCameraProfile', 'MissingCameraProfileDigest']), + ('AILook', 'struct', 'see sub-table', '', 'absent', 'Adaptive Color profile', 'COMPUTED', 'Computed state of an adaptive profile.'), +]) + +sec('Point colour', [ + ('PointColors', 'string[] (XMP) / struct[] (Lua)', 'XMP: Seq of swatches, 19 floats each "%.6f, ..."; Lua: one table per swatch (see PointColors items)', 'sentinel swatch of 19 × -1.000000 = none', 'one sentinel swatch', 'LrC 13+', 'UNKNOWN', + 'Layout only partly decoded; values are sampled from one photo. Round-trip only.'), + ('ColorVariance', 'string[]', 'Seq of floats "%.6f"', '', '[-50.000000]', 'LrC 13+', 'UNKNOWN', 'Meaning of -50 unknown (research §8).'), +]) + +sec('HDR', [ + ('HDREditMode', 'int (0/1)', '0/1', 'integer', '0 / 0', 'LrC 12+', 'PHOTO', 'Never learn: the 8-bit JPEG export cannot show HDR. Keep the photo\'s value.'), + ('HDRMaxValue', 'real', 'EV headroom', 'fixed 2 decimals, always "+" ("+4.00")', '+4.00 / ?', 'LrC 12+', 'PHOTO', 'Non-raw default not established: the one non-raw readback (E13, a virtual copy of a TIFF, LrC 15.6) has 2.3, which may be per image, so the generator holds it back (NON_RAW_READBACK_NOT_CONSTANT).'), + ('SDRBrightness, SDRContrast, SDRClarity, SDRHighlights, SDRShadows, SDRWhites, SDRBlend', 'int', '-100..+100 (SDR rendition sliders)', 'stored = UI', '0 / 0', 'LrC 12+, only with HDREditMode=1', 'PHOTO', '', ['SDRBrightness', 'SDRContrast', 'SDRClarity', 'SDRHighlights', 'SDRShadows', 'SDRWhites', 'SDRBlend']), +]) + +sec('Auto and style state', [ + ('AutoTone', 'bool', 'True', '"True"', 'absent', 'presets (23) + 1 sidecar', 'UNKNOWN', 'Preset instruction "run Auto Tone on apply"; collides with explicit tone values; applyDevelopSettings has its own `flattenAutoNow` flag. Not for v1.'), + ('AutoToneDigest, AutoToneDigestNoSat, AutoToneDigestPV2', 'hex32', '', '', '', '', 'COMPUTED', 'Cache keys of the last auto-tone evaluation.', ['AutoToneDigest', 'AutoToneDigestNoSat', 'AutoToneDigestPV2']), + ('SourceAuto* (8 keys)', 'number', '', '', '', 'known from Camera Raw; never observed in LrC output', 'COMPUTED', 'SourceAutoExposure2012 ... SourceAutoVibrance.', + ['SourceAutoExposure2012', 'SourceAutoContrast2012', 'SourceAutoHighlights2012', 'SourceAutoShadows2012', 'SourceAutoWhites2012', 'SourceAutoBlacks2012', 'SourceAutoSaturation', 'SourceAutoVibrance']), + ('AutoExposure, AutoShadows, AutoBrightness, AutoContrast', 'bool', '', '', 'false (Zeroed)', 'legacy PV2003/2010', 'COMPUTED', 'Legacy auto flags.', ['AutoExposure', 'AutoShadows', 'AutoBrightness', 'AutoContrast']), + ('ToggleStyleDigest', 'hex32', '', '', 'absent', 'recent LrC (a few sampled sidecars)', 'UNKNOWN', 'Probably identifies an applied "style"/preset for toggling; meaning unverified.'), + ('ToggleStyleAmount', 'real', '0..1?', '', 'absent', 'as above', 'UNKNOWN', 'Observed 0.67..1.'), +]) + +sec('Retouch, remove, red eye, AI filters', [ + ('RetouchAreas', 'struct[]', 'see sub-table', '', 'absent', '', 'NEVER', 'Heal/clone/content-aware spots.'), + ('RemoveAreas', 'struct[]', 'see sub-table', '', 'absent', 'LrC 13+', 'NEVER', 'Remove tool / generative remove.'), + ('RetouchInfo', 'string[] (XMP) / struct[] (Lua)', 'XMP: "centerX = ..., centerY = ..." items; Lua: one table per spot (see RetouchInfo items)', '', 'absent', 'legacy spot removal', 'NEVER', ''), + ('RedEyeInfo', 'string[]', '', '', 'empty', 'legacy', 'NEVER', 'Present (empty) in the Zeroed Lua template.'), + ('FilterList', 'struct', 'see sub-table', '', 'absent', 'LrC 12.3+ (Denoise, Raw Details, Super Resolution)', 'COMPUTED', 'Enhance/AI filter payloads; FilterIDs 3/4/6 are remove/heal/distraction removal (NEVER).'), + ('AllowFilters', 'int', '1', '', 'absent', 'with FilterList', 'COMPUTED', ''), + ('DepthMapInfo', 'struct', 'see sub-table', '', 'absent', 'lens blur / depth', 'COMPUTED', ''), + ('RangeMaskMapInfo', 'struct', 'see sub-table', '', 'absent', 'range masks', 'COMPUTED', 'Per-photo image statistics for range masks.'), + ('DepthBasedCorrections', 'struct[]', 'Correction struct + LocalCorrectedDepth', '', 'absent', 'known from Camera Raw; never observed in LrC output', 'UNKNOWN', 'Old depth-based local corrections.'), +]) + +sec('Masks (container) and legacy local containers', [ + ('MaskGroupBasedCorrections', 'struct[]', 'Seq of Correction structs (one per mask in the Masks panel), order = panel order', '', 'absent', 'crs:Version ≥ 14 (LrC 11+)', 'LEARN†', + 'Per-item policy follows the mask components (sub-tables below): semantic AI components are LEARN, geometry PHOTO, brush NEVER.'), + ('GradientBasedCorrections, CircularGradientBasedCorrections, PaintBasedCorrections', 'struct[]', 'pre-LrC 11 containers', '', 'absent', 'legacy; 0 in corpus', 'NEVER', 'Never write (no AI masks, no add/subtract). A parser may migrate them read-only.', + ['GradientBasedCorrections', 'CircularGradientBasedCorrections', 'PaintBasedCorrections']), +]) + +sec('Versioning and bookkeeping', [ + ('ProcessVersion', 'string', '"5.0" PV2003, "5.7" PV2010, "6.7" PV2012, "10.0" PV4, "11.0" PV5, "15.4" PV6', 'one decimal', 'current: 15.4', '', 'META', 'Write only when switching PV on purpose; omitting it keeps the photo\'s PV. Learning must refuse to mix PV2003/2010 examples.'), + ('Version', 'string', 'writer engine version "18.5", "18.5.1"', '', '', '', 'META', 'Must be ≤ the engine of the target LrC (LrC 15.5.1 = 18.5.1).'), + ('CompatibleVersion', 'uint', 'minimum reader version', 'major<<24 | minor<<16 (285212672 = 17.0)', 'absent', '', 'META', 'Feature → version mapping unknown; omit unless copying Adobe\'s value for the same feature.'), + ('HasSettings', 'bool', 'True', '"True"', 'True', 'XMP only', 'META', 'Always True.'), + ('AlreadyApplied', 'bool', 'False', '"False"', 'False', 'sidecars only', 'META', ''), + ('RawFileName', 'string', 'file name of the raw', '', '', 'sidecars only', 'META', 'Photo identity; set from the target file.'), + ('RenderVersion', 'uint', '', '', '', 'known from Camera Raw; never observed in LrC output', 'COMPUTED', ''), + ('Preset', 'struct', 'record of the last applied preset (see 7.10)', '', 'absent', 'sidecars only (about a tenth of them)', 'COMPUTED', 'Written by LrC; its Parameters subtree is a preset, not the photo\'s settings.'), +]) + +sec('Lua-table-only keys (getDevelopSettings / applyDevelopSettings, never in XMP)', [ + ('Enable* panel switches (14)', 'bool', 'true/false (Lua)', 'Lua boolean', 'true', 'in Zeroed.lrtemplate and the SDK table; 0 occurrences in the sidecar corpus or the bundled preset/profile files', 'META', + 'EnableCalibration, EnableColorAdjustments, EnableDetail, EnableEffects, EnableGrayscaleMix, EnableLensCorrections, EnableRedEye, EnableRetouch, EnableSplitToning, EnableToneCurve, EnableTransform, EnableMaskGroupBasedCorrections, EnableGradientBasedCorrections, EnableDistractionRemoval. Never write `false`; write `true` only for panels we set (panel switches, not features).', + ['EnableCalibration', 'EnableColorAdjustments', 'EnableDetail', 'EnableEffects', 'EnableGrayscaleMix', 'EnableLensCorrections', 'EnableRedEye', 'EnableRetouch', 'EnableSplitToning', 'EnableToneCurve', 'EnableTransform', 'EnableMaskGroupBasedCorrections', 'EnableGradientBasedCorrections', 'EnableDistractionRemoval']), + ('orientation', 'enum string', 'AB, BC, CD, DA, CB, ... (catalog codes)', '', '', 'catalog / Lua only', 'PHOTO', 'Read-only input for the coordinate transform (E13 decides the runtime format). Never write.'), + ('Temp', 'n/a', '', '', '', 'not a crs key (in no catalog row and no XMP file checked)', 'NEVER', '**Bug (f)**: written/trained today. E1 decides whether LrC rejects or ignores it. Map to Temperature (raw) / IncrementalTemperature (non-raw).'), +]) + +# Preset header (META) +HEADER = [ + ('PresetType', 'enum', 'Normal (preset), Look (profile)', '', '', '', 'META', ''), + ('UUID', 'hex32', '32 uppercase hex, no dashes', '', '', '', 'META', 'Stable per generated preset; LrDevelopPreset:getUuid() differs from it (research §5).'), + ('Cluster', 'string', 'e.g. "Adobe", "Classic", "Premium 1.50", ""', '', '', '', 'META', ''), + ('Name', 'lang-alt', 'x-default only', '', '', '', 'META', ''), + ('ShortName', 'lang-alt', '', '', '', '', 'META', 'Empty Alt allowed.'), + ('SortName', 'lang-alt', '', '', '', '', 'META', ''), + ('Group', 'lang-alt', 'preset group shown in the panel', '', '', '', 'META', 'Use "LrGeniusAI".'), + ('Description', 'lang-alt', '', '', '', '', 'META', ''), + ('Copyright', 'string', '', '', '', '', 'META', ''), + ('ContactInfo', 'string', '', '', '', '', 'META', ''), + ('CameraModelRestriction', 'string', '"" or camera model', '', '""', '', 'META', 'Set to the model when a camera-restricted Look is included.'), + ('SupportsAmount', 'bool', 'True/False', '"True"/"False"', '', '', 'META', 'Enables the amount slider (0..200 %).'), + ('SupportsAmount2', 'bool', 'True/False', '', '', 'newer presets (330)', 'META', 'Meaning vs SupportsAmount unverified (E4 compares).'), + ('SupportsColor', 'bool', 'True', '', '', '', 'META', ''), + ('SupportsMonochrome', 'bool', 'True/False', '', '', '', 'META', 'False when the preset forces colour/B&W semantics.'), + ('SupportsHighDynamicRange', 'bool', 'True', '', '', '', 'META', ''), + ('SupportsNormalDynamicRange', 'bool', 'True', '', '', '', 'META', ''), + ('SupportsSceneReferred', 'bool', 'True', '', '', '', 'META', ''), + ('SupportsOutputReferred', 'bool', 'True/False', '', '', '', 'META', ''), + ('RequiresRGBTables', 'bool', 'False', '', '', 'newer presets', 'META', ''), + ('ShowInPresets', 'bool', 'True', '', '', 'few presets', 'META', ''), + ('ShowInQuickActions', 'bool', 'False', '', '', 'few presets', 'META', ''), + ('Baseline', 'enum', 'Adobe Default, Camera Settings', '', 'absent', 'presets (20) and applied-Preset records', 'UNKNOWN', 'Probably the reference state for the amount slider (amount 0 % = baseline). Unverified.'), +] + +# Correction level (one MaskGroupBasedCorrections item) +CORR = [ + ('What', 'const', '"Correction"', '', '', '', 'META', ''), + ('CorrectionAmount', 'real', '0..200 % (mask "Amount")', 'UI/100 → 0..2', '1', '', 'LEARN', 'Research: observed 0.21–2.'), + ('CorrectionActive', 'bool', 'true/false', '"true"/"false"', 'true', '', 'META', 'Always true when we write.'), + ('CorrectionName', 'string', 'free text, localised by LrC', '', '', '', 'META', 'We write "LrGenius · ". Never infer semantics from names.'), + ('CorrectionSyncID', 'hex32', '', '', '', '', 'META', 'Stable, role-derived id; whether an equal id replaces or duplicates is E5.'), + ('CorrectionMasks', 'struct[]', 'Seq of mask components, evaluation order', '', '', '', 'LEARN†', 'Policy per component (mask-tool table).'), + ('LocalExposure2012', 'real', '-4..+4 EV', '**EV/4** → -1..+1', '0', 'PV2012+', 'LEARN', 'Dodge 0.0625 = +0.25 EV (Adobe local presets).'), + ('LocalContrast2012', 'real', '-100..+100', 'UI/100', '0', 'PV2012+', 'LEARN', ''), + ('LocalHighlights2012', 'real', '-100..+100', 'UI/100', '0', 'PV2012+', 'LEARN', ''), + ('LocalShadows2012', 'real', '-100..+100', 'UI/100', '0', 'PV2012+', 'LEARN', ''), + ('LocalWhites2012', 'real', '-100..+100', 'UI/100', '0', 'PV2012+', 'LEARN', ''), + ('LocalBlacks2012', 'real', '-100..+100', 'UI/100', '0', 'PV2012+', 'LEARN', ''), + ('LocalClarity2012', 'real', '-100..+100', 'UI/100', '0', 'PV2012+', 'LEARN', ''), + ('LocalDehaze', 'real', '-100..+100', 'UI/100', '0', 'no "2012" suffix', 'LEARN', ''), + ('LocalTexture', 'real', '-100..+100', 'UI/100', '0', 'no suffix', 'LEARN', ''), + ('LocalSaturation', 'real', '-100..+100', 'UI/100', '0', 'no suffix', 'LEARN', ''), + ('LocalSharpness', 'real', '-100..+100', 'UI/100', '0', '', 'LEARN', ''), + ('LocalTemperature', 'real', '-100..+100 (relative)', 'UI/100', '0', 'no suffix', 'LEARN', 'The LLM schema range is right in UI units; the serializer must divide by 100.'), + ('LocalTint', 'real', '-100..+100 (relative)', 'UI/100', '0', 'no suffix', 'LEARN', ''), + ('LocalLuminanceNoise', 'real', '-100..+100 (UI "Noise")', 'UI/100', '0', '', 'LEARN', 'UI range from knowledge; only 0..1 observed. The plugin\'s `local_Noise` must be `local_LuminanceNoise`.'), + ('LocalMoire', 'real', '-100..+100', 'UI/100', '0', '', 'LEARN', ''), + ('LocalDefringe', 'real', '-100..+100', 'UI/100', '0', '', 'LEARN', ''), + ('LocalHue', 'real', '-180..+180? deg', 'UI/180? (one third-party measurement)', '0', '', 'UNKNOWN', 'Scale unverified; 2 non-zero values in the sample.'), + ('LocalToningHue', 'real', '0..360 deg (mask "Color" swatch)', 'degrees, raw', '0 (Adobe local presets use 240 with saturation 0)', '', 'LEARN', 'Circular; ignore when LocalToningSaturation = 0.'), + ('LocalToningSaturation', 'real', '0..100', 'UI/100', '0', '', 'LEARN', ''), + ('LocalCurveRefineSaturation', 'real', '0..100', 'stored = UI', '100', 'LrC 13+', 'LEARN', 'Only 100 and 30 observed.'), + ('LocalGrain', 'real', '0..100?', 'UI/100?', '0', 'LrC 13+', 'UNKNOWN', 'Only 0 and 1 observed; scale unverified.'), + ('MainCurve, RedCurve, GreenCurve, BlueCurve', 'curve', 'points 0..255', 'XMP Seq "x,y" (**no space**); Lua list of "x,y" strings (research)', 'absent', 'LrC 13+', 'LEARN', 'Low priority.', ['MainCurve', 'RedCurve', 'GreenCurve', 'BlueCurve']), + ('LocalPointColors', 'string[]', 'as PointColors', '', 'absent', 'LrC 13+', 'UNKNOWN', 'As global PointColors.'), + ('LocalColorVariance', 'string[]', 'as ColorVariance', '', 'absent', 'LrC 13+', 'UNKNOWN', ''), + ('LocalExposure, LocalContrast, LocalClarity, LocalBrightness', 'real', '', '', '0', 'legacy PV2010 local keys', 'COMPUTED', 'Adobe presets write them as 0; the serializer may emit 0 for fidelity, never other values. (LocalClarity 0.655 appears only in legacy local presets.)', + ['LocalExposure', 'LocalContrast', 'LocalClarity', 'LocalBrightness']), + ('LocalCorrectedDepth', 'real', '', '', '0', 'depth corrections', 'COMPUTED', 'Always 0.'), + ('LocalColorGrade* (14), LocalToningLuminance, LocalGlow, CorrectionColorTable, CorrectionLUTName, CorrectionLUTAmount', 'mixed', '', '', '', 'known from Camera Raw 18.6; never observed in LrC output', 'UNKNOWN', 'Do not use (research §3.4).', + ['LocalColorGradeShadowHue', 'LocalColorGradeShadowSat', 'LocalColorGradeShadowLum', 'LocalColorGradeHighlightHue', 'LocalColorGradeHighlightSat', 'LocalColorGradeHighlightLum', 'LocalColorGradeMidtoneHue', 'LocalColorGradeMidtoneSat', 'LocalColorGradeMidtoneLum', 'LocalColorGradeGlobalHue', 'LocalColorGradeGlobalSat', 'LocalColorGradeGlobalLum', 'LocalColorGradeBalance', 'LocalColorGradeBlending', 'LocalToningLuminance', 'LocalGlow', 'CorrectionColorTable', 'CorrectionLUTName', 'CorrectionLUTAmount']), + ('CorrectionID, CorrectionReferenceX, CorrectionReferenceY', 'runtime', '', '', '', 'Lua/catalog only', 'COMPUTED', 'Runtime ids; never write.', ['CorrectionID', 'CorrectionReferenceX', 'CorrectionReferenceY']), +] + +# Mask-tool level: (What, [rows]) +MASK = [ + ('All component types', [ + ('What', 'enum', 'Mask/Image, Mask/Gradient, Mask/CircularGradient, Mask/RangeMask, Mask/Aggregate, Mask/Paint (inside Aggregate/Gesture), Mask/Polygon (Gesture), Mask/Ellipse (retouch); Camera Raw also knows Mask/FlattenedGroup, Mask/Clip, Mask/Combine', '', '', '', 'LEARN†', 'Policy follows the type: Image = LEARN, Gradient/CircularGradient/Polygon = PHOTO, Paint/Aggregate = NEVER.'), + ('MaskActive', 'bool', 'true/false', '', 'true', '', 'META', ''), + ('MaskName', 'string', 'localised, version-dependent ("Radialverlauf" vs "Radialer Verlauf")', '', '', '', 'META', 'Never key on it.'), + ('MaskBlendMode', 'enum int', '0 add, 1 subtract/intersect', '', '0', '', 'LEARN', 'Intersect = 1 + MaskValue 0 + MaskInverted true (105 corpus cases).'), + ('MaskInverted', 'bool', 'true/false', '', 'false', '', 'LEARN', ''), + ('MaskSyncID', 'hex32', '', '', '', '', 'META', ''), + ('MaskValue', 'real', '0..1', '', '1', '', 'LEARN', '0 iff MaskBlendMode = 1 (235/235).'), + ('MaskID', 'runtime', '', '', '', 'Lua/catalog only', 'COMPUTED', 'Runtime id; never write.'), + ]), + ('Mask/Image (AI, semantic)', [ + ('MaskVersion', 'int', '1', '', '1', '', 'META', ''), + ('MaskSubType', 'enum int', '0 category/object/person part, 1 Subject, 2 Sky, 3 people part (preset form)', 'XMP: integer string; Lua: number (every mask in the training dump)', '', 'LrC 11+', 'LEARN', 'Subtype 3 = "all people" is unconfirmed; 0 + part id = one specific person.'), + ('MaskSubCategoryID', 'enum int', '22 Background; 2–9, 11, 12 people parts; 20036 whole person; 50001–50008 landscape classes', '', 'absent for subtype 1/2', 'landscape classes LrC 13+', 'LEARN', 'Full tables in Dev-AI-Edit-XMP-Findings.md. 20036 generic form unknown.'), + ('ReferencePoint', 'string', '"x y", "%.6f %.6f", sensor frame', '', '"0.500000 0.500000" in preset form', '', 'PHOTO', 'Write the constant 0.5 0.5 for preset-form semantic masks (META); a real point selects a person/object (PHOTO).'), + ('ErrorReason', 'enum int', '0 ok / not computed, 1 computed but nothing found', '', '0 in preset form', '', 'COMPUTED', 'Adobe presets write 0; read back after an AI update and surface 1 as a warning.'), + ('Gesture', 'struct[]', 'hint geometry: Mask/Polygon (4 points) or Mask/Paint strokes', '', 'absent', '"Select Object"', 'PHOTO', 'Frame-dependent (research §4.2). Polygon form only; never paint strokes.'), + ('MaskDigest, InputDigest, InputDigestVersion, LocalInputDigest, LocalInputDigestVersion, ModelVersion, WholeImageArea, Origin, FullMaskSize, DidOverrideInputDigestMismatch', 'mixed', '', '', 'absent in preset form', '', 'COMPUTED', + 'Computed-mask bookkeeping (bitmap lives in .acr / catalog). FullMaskSize ~ "2880,1920"; WholeImageArea rationals.', + ['MaskDigest', 'InputDigest', 'InputDigestVersion', 'LocalInputDigest', 'LocalInputDigestVersion', 'ModelVersion', 'WholeImageArea', 'Origin', 'FullMaskSize', 'DidOverrideInputDigestMismatch']), + ('TargetImageArea, EdgeShift', 'mixed', '', '', '', 'known from Camera Raw; never observed in LrC output', 'COMPUTED', '', ['TargetImageArea', 'EdgeShift']), + ('SemanticObjectPrompt, ExtendedSemanticName, ExtendedInstanceID, InstanceIDs, InstanceID, InstanceBounds', 'mixed', '', '', '', 'known from Camera Raw 18.6; never observed in LrC output', 'UNKNOWN', + 'SemanticObjectPrompt suggests text-prompted object masks; relevant for step 4 (dodge & burn tier 2).', + ['SemanticObjectPrompt', 'ExtendedSemanticName', 'ExtendedInstanceID', 'InstanceIDs', 'InstanceID', 'InstanceBounds']), + ]), + ('Mask/Gradient (linear)', [ + ('ZeroX', 'real', 'normalised sensor frame, may leave [0,1]', 'up to 6 decimals', '', '', 'PHOTO', '0 % point.'), + ('ZeroY', 'real', 'as above', '', '', '', 'PHOTO', ''), + ('FullX', 'real', 'as above', '', '', '', 'PHOTO', '100 % point.'), + ('FullY', 'real', 'as above', '', '', '', 'PHOTO', 'Direction/orthogonality in pixel space is derived, not measured.'), + ('Bidirectional, Zero2X, Zero2Y, ZeroFeather, Zero2Feather, FullPointDistance', 'mixed', '', '', '', 'known from Camera Raw; never observed in LrC output', 'UNKNOWN', 'Newer gradient variants.', + ['Bidirectional', 'Zero2X', 'Zero2Y', 'ZeroFeather', 'Zero2Feather', 'FullPointDistance']), + ]), + ('Mask/CircularGradient (radial)', [ + ('Top', 'real', 'normalised sensor frame, may leave [0,1]', '', '', '', 'PHOTO', ''), + ('Left', 'real', 'as above', '', '', '', 'PHOTO', ''), + ('Bottom', 'real', 'as above', '', '', '', 'PHOTO', ''), + ('Right', 'real', 'as above', '', '', '', 'PHOTO', ''), + ('Angle', 'real', '-45..+45 deg observed', '', '0', '', 'PHOTO', 'Convention (sign, rotated corners) uncalibrated: write 0 only.'), + ('Midpoint', 'int', '50', '', '50', '', 'PHOTO', 'Always 50.'), + ('Roundness', 'int', '0 observed', '', '0', '', 'PHOTO', ''), + ('Feather', 'int', '0..100', 'stored = UI', '50', '', 'PHOTO', ''), + ('Flipped', 'bool', 'true/false', '', '', '', 'PHOTO', 'Always = !MaskInverted (276 + 186 corpus radials).'), + ('Version', 'int', '2', '', '2', '', 'META', ''), + ]), + ('Mask/RangeMask', [ + ('CorrectionRangeMask', 'struct', 'see sub-table', '', '', '', 'LEARN†', 'Luminance (Type 2) is LEARN, colour (Type 1) PHOTO.'), + ]), + ('Mask/Aggregate and Mask/Paint (brush)', [ + ('Masks', 'struct[]', 'Seq of Mask/Paint strokes', '', '', '', 'NEVER', ''), + ('Dabs', 'string[]', 'tokens "M x y" (stroke start), "d x y" (dab), "r R", "f F", "h H"', '', '', '', 'NEVER', 'Up to 218 KB per sidecar.'), + ('Radius', 'real', 'normalised by sensor width', '', '', '', 'NEVER', ''), + ('Flow', 'real', '0..1', '', '', '', 'NEVER', ''), + ('CenterWeight', 'real', '0', '', '', '', 'NEVER', ''), + ('MaskBrushTable, MaskBrushUncompressedBytes', 'hex32 / int', '', '', '', 'strokes moved to .acr/catalog', 'COMPUTED', '', ['MaskBrushTable', 'MaskBrushUncompressedBytes']), + ]), +] + +# Nested structs +NESTED = [ + ('nested:Look', 'Look (profile reference)', [ + ('Name', 'string', 'profile name (plain attribute, not Alt)', '', '', '', 'LEARN', ''), + ('UUID', 'hex32', '', '', '', '', 'LEARN', 'Must match an installed profile.'), + ('Amount', 'real', '0..200 %', 'UI/100 → 0..2 ("1", "1.19", "1.000000")', '1', '', 'LEARN', ''), + ('Stubbed', 'bool', 'true', '', 'presets only', '', 'META', 'Presets carry a stub (no Parameters); whether applyDevelopSettings accepts a stub is E11.'), + ('Parameters', 'struct', 'the profile definition (see Look.Parameters)', '', '', 'sidecars only', 'COMPUTED', 'LrC fills it from its profile library.'), + ('Group', 'lang-alt', '', '', '', '', 'COMPUTED', 'Copied from the profile.'), + ('SortName', 'lang-alt', '', '', '', '', 'COMPUTED', ''), + ('Cluster, Copyright', 'string', '', '', '', '', 'COMPUTED', '', ['Cluster', 'Copyright']), + ('CameraModelRestriction', 'string', 'e.g. "Nikon Z 9" (bundled camera-matching profiles)', '', '', '', 'COMPUTED', 'Also the learning gate: never transfer such a Look to another camera.'), + ('isAdobeAdaptive', 'bool', 'true', 'Lua boolean', '', 'Lua only (getDevelopSettings() of adaptive profiles); never observed in XMP', 'COMPUTED', 'Marks an Adobe Adaptive profile (needs an AI update, see AILook).'), + ('SupportsAmount, SupportsMonochrome, SupportsOutputReferred', 'bool', '', '', '', '', 'COMPUTED', '', ['SupportsAmount', 'SupportsMonochrome', 'SupportsOutputReferred']), + ]), + ('look-parameters', 'Look.Parameters (profile definition; also the top level of a profile .xmp)', [ + ('all keys (profile definition)', 'mixed', '', '', '', '', 'COMPUTED', + 'Observed: CameraProfile, ConvertToGrayscale, LookTable, RGBTable, RGBTables, RGBTableAmount, ProfileGainTableMap, ProfileToneCurve, Table_, ToneCurvePV2012*, ProcessVersion, Version, CompatibleVersion, HasSettings, Clarity2012, Highlights2012, Shadows2012, Contrast2012, Exposure2012, Whites2012, Blacks2012, Saturation, Vibrance, HSL, Parametric*, SplitToning*, ColorGrade*, GrayMixer*, PostCropVignette*, IncrementalTemperature/Tint, CurveRefineSaturation, PointColors, ColorVariance, LensBlur. Same types and serialization as the global keys, but never merged into the photo\'s own settings.', []), + ]), + ('nested:LensBlur', 'LensBlur', [ + ('Active', 'bool', 'true/false', '', 'false', 'LrC 13+', 'LEARN', 'Needs a depth map (AI update).'), + ('BlurAmount', 'int', '0..100', '', '50', '', 'LEARN', ''), + ('BokehShape', 'enum int', '0.. (0 circle; others unverified)', '', '0', '', 'LEARN', ''), + ('BokehShapeDetail', 'int', '0..?', '', '0', '', 'LEARN', ''), + ('BokehAspect', 'int', '-100..+100', '', '0', '', 'LEARN', ''), + ('BokehRotation', 'int', '?', '', '0', '', 'LEARN', ''), + ('CatEyeAmount', 'int', '0..100', '', '0', '', 'LEARN', ''), + ('CatEyeScale', 'int', '100', '', '100', '', 'LEARN', ''), + ('HighlightsBoost', 'int', '0..100', '', '50', '', 'LEARN', ''), + ('HighlightsThreshold', 'int', '0..100', '', '50', '', 'LEARN', ''), + ('SphericalAberration', 'int', '0..100?', '', '0', '', 'LEARN', ''), + ('Version', 'int', '1', '', '1', '', 'META', ''), + ('FocalRange', 'string', '4 ints "a b c d" (depth 0..100, may exceed)', '', '"0 0 100 100" in presets', '', 'UNKNOWN', 'Preset value vs per-photo focus range; whether LrC re-focuses on the subject when applied from a preset is unverified.'), + ('FocalRangeSource', 'enum int', '1 observed', '', '', '', 'UNKNOWN', ''), + ('SampledArea, SampledRange, SubjectRange, ImageOrientation', 'string / int', '', '', '', '', 'COMPUTED', 'Per-photo focus sampling.', ['SampledArea', 'SampledRange', 'SubjectRange', 'ImageOrientation']), + ]), + ('nested:CorrectionRangeMask', 'CorrectionRangeMask (inside a Mask/RangeMask component)', [ + ('Version', 'int', '3', '', '3', '', 'META', ''), + ('Type', 'enum int', '1 colour, 2 luminance, 3 depth (unobserved)', '', '', '', 'LEARN†', 'Type 2 is LEARN; Type 1 is PHOTO (photo-specific samples).'), + ('Invert', 'bool', 'true/false', '', 'false', '', 'LEARN', ''), + ('SampleType', 'enum int', '0 / 1', '', '0', '', 'UNKNOWN', 'Meaning unverified.'), + ('LumRange', 'string', '4 floats "%.6f": feather-low, low, high, feather-high (0..1)', '', '', '', 'LEARN', 'Threshold-only, frame-independent.'), + ('LuminanceDepthSampleInfo', 'string', '"0 x y" eyedropper sample', '', '', '', 'PHOTO', ''), + ('ColorAmount', 'real', '0..1 (UI "Refine" /100)', '', '', 'colour ranges', 'PHOTO', ''), + ('PointModels', 'string[]', 'Lab sample + tolerances', '', '', 'colour ranges', 'PHOTO', ''), + ('AreaModels', 'struct[]', '{AreaComponents: string[], ColorRangeMaskAreaSampleInfo}', '', '', 'colour ranges', 'PHOTO', ''), + ('AreaComponents, ColorRangeMaskAreaSampleInfo', 'string', '', '', '', 'inside AreaModels', 'PHOTO', '', ['AreaComponents', 'ColorRangeMaskAreaSampleInfo']), + ('LumMin, LumMax, LumFeather, DepthMin, DepthMax, DepthFeather, DepthRange', 'real', '', '', '', 'known from Camera Raw; never observed in LrC output (legacy LrC 7 range masks)', 'UNKNOWN', '', ['LumMin', 'LumMax', 'LumFeather', 'DepthMin', 'DepthMax', 'DepthFeather', 'DepthRange']), + ]), + ('nested:Gesture', 'Gesture (hint for Mask/Image subtype 0 "Select Object")', [ + ('What', 'enum', 'Mask/Polygon, Mask/Paint', '', '', '', 'PHOTO', ''), + ('Points', 'struct[]', 'Seq of {X, Y}, normalised sensor frame; order TL, TR, BL, BR (display order)', '', '', '', 'PHOTO', 'The only gesture form we may write (a box as prompt).'), + ('X, Y', 'real', '', '', '', 'inside Points', 'PHOTO', '', ['X', 'Y']), + ('Dabs, Radius, Flow, CenterWeight, BrushGestureInterpretation', 'mixed', '', '', '', 'paint-stroke gestures', 'NEVER', '', ['Dabs', 'Radius', 'Flow', 'CenterWeight', 'BrushGestureInterpretation']), + ('MaskActive, MaskBlendMode, MaskInverted, MaskSyncID, MaskValue', 'mixed', 'as mask components', '', '', '', 'META', '', ['MaskActive', 'MaskBlendMode', 'MaskInverted', 'MaskSyncID', 'MaskValue']), + ('MaskID', 'runtime', '', '', '', 'Lua/catalog only', 'COMPUTED', 'Runtime id; never write.'), + ]), + ('nested:AILook', 'AILook (Adaptive Color state)', [ + ('Active, AILookData, InputDigest, InputDigestVersion, ModelVersion, Version', 'mixed', '', '', '', '', 'COMPUTED', 'Produced by the AI update.', ['Active', 'AILookData', 'InputDigest', 'InputDigestVersion', 'ModelVersion', 'Version']), + ]), + ('nested:RangeMaskMapInfo', 'RangeMaskMapInfo', [ + ('RangeMaskMapInfo (inner wrapper), RGBMin, RGBMax, LabMin, LabMax, LumEq', 'string / string[]', 'image statistics', '', '', '', 'COMPUTED', 'Written as rdf:parseType="Resource" wrapper around an inner RangeMaskMapInfo struct.', ['RangeMaskMapInfo', 'RGBMin', 'RGBMax', 'LabMin', 'LabMax', 'LumEq']), + ]), + ('nested:DepthMapInfo', 'DepthMapInfo', [ + ('DepthSource, Base{RawDepth,LayeredDepth,HighlightGuide}{Table,InputDigest,Version}', 'mixed', '', '', '', '', 'COMPUTED', '10 keys.', + ['DepthSource', 'BaseRawDepthTable', 'BaseRawDepthInputDigest', 'BaseRawDepthVersion', 'BaseLayeredDepthTable', 'BaseLayeredDepthInputDigest', 'BaseLayeredDepthVersion', 'BaseHighlightGuideTable', 'BaseHighlightGuideInputDigest', 'BaseHighlightGuideVersion']), + ]), + ('nested:ISODependent', 'ISODependent items (Adobe ISO-adaptive presets)', [ + ('ISO, ColorNoiseReduction, LuminanceSmoothing', 'int', 'ISO breakpoint + NR values', '', '', 'presets only', 'UNKNOWN', 'Interpolated by ISO on apply (inferred). Not for v1.', ['ISO', 'ColorNoiseReduction', 'LuminanceSmoothing']), + ]), + ('nested:Preset', 'Preset (record of the last applied preset, sidecars only)', [ + ('Parameters', 'struct', 'the applied preset\'s own settings (same keys as the top level)', '', '', 'sidecars only', 'COMPUTED', 'Kept opaque; never merged into the photo\'s settings.'), + ('Name, UUID, Amount, LookAmount, Group, Cluster, Baseline, SupportsAmount, SupportsMonochrome, SupportsOutputReferred', 'mixed', '', '', '', 'about a tenth of the sidecars; Parameters subtree excluded from this inventory', 'COMPUTED', + 'Written by LrC. Useful as a learning signal ("which preset did the user start from"), never written by us.', + ['Name', 'UUID', 'Amount', 'LookAmount', 'Group', 'Cluster', 'Baseline', 'SupportsAmount', 'SupportsMonochrome', 'SupportsOutputReferred']), + ]), + ('nested:RetouchArea', 'RetouchAreas / RemoveAreas items', [ + ('SpotType, Method, SourceState, Opacity, Feather, Seed, OffsetY, SourceX, HealVersion, IngestInfo, fill_method, Masks', 'mixed', '', '', '', '', 'NEVER', 'Heal/clone/remove spots (Masks = Mask/Ellipse or Mask/Paint).', + ['SpotType', 'Method', 'SourceState', 'Opacity', 'Feather', 'Seed', 'OffsetY', 'SourceX', 'HealVersion', 'IngestInfo', 'fill_method', 'Masks']), + ('centerX, centerY, opacity, radius, seed, sourceState, sourceX, sourceY, spotType', 'mixed', '', '', '', 'Lua only: legacy spot fields next to the new ones in a few getDevelopSettings() retouch areas', 'NEVER', 'Same fields as a RetouchInfo item.', + ['centerX', 'centerY', 'opacity', 'radius', 'seed', 'sourceState', 'sourceX', 'sourceY', 'spotType']), + ('pm_* (content-aware / generative patch state, 38 keys)', 'mixed', '', '', '', '', 'NEVER', 'pm_patch, pm_patch_mask, pm_patch_variations, pm_remap_info_*, pm_search_*, pm_target_*, pm_whole_image_*, pm_full_image_*, pm_input_digest, ...', ['pm_%d' % i for i in range(38)]), + ('Mask/Ellipse: X, Y, SizeX, SizeY, Alpha, CenterValue, PerimeterValue', 'real', '', '', '', 'heal spot shape', 'NEVER', '', ['X', 'Y', 'SizeX', 'SizeY', 'Alpha', 'CenterValue', 'PerimeterValue']), + ]), + ('nested:RetouchInfo', 'RetouchInfo items (Lua table form of the legacy spot list)', [ + ('Method, centerX, centerY, opacity, radius, sourceState, sourceX, sourceY, spotType', 'mixed', '', '', '', 'Lua only (the XMP form is one string per spot)', 'NEVER', 'Legacy heal/clone spot.', + ['Method', 'centerX', 'centerY', 'opacity', 'radius', 'sourceState', 'sourceX', 'sourceY', 'spotType']), + ]), + ('nested:PointColors', 'PointColors items (Lua table form of one swatch)', [ + ('SrcHue, SrcSat, SrcLum, HueShift, SatScale, LumScale, RangeAmount, Variance', 'real', '', '', '', 'Lua only (the XMP form is one string of 19 floats per swatch)', 'UNKNOWN', 'Sampled colour and its shift; values come from one photo. Round-trip only.', + ['SrcHue', 'SrcSat', 'SrcLum', 'HueShift', 'SatScale', 'LumScale', 'RangeAmount', 'Variance']), + ('HueRange, SatRange, LumRange', 'struct', 'see PointColors ranges', '', '', 'Lua only', 'UNKNOWN', '', ['HueRange', 'SatRange', 'LumRange']), + ]), + ('nested:PointColorRange', 'HueRange / SatRange / LumRange inside a PointColors item', [ + ('LowerNone, LowerFull, UpperFull, UpperNone', 'real', '', '', '', 'Lua only', 'UNKNOWN', 'Range with feathered edges.', ['LowerNone', 'LowerFull', 'UpperFull', 'UpperNone']), + ]), + ('nested:Filters', 'FilterList.Filters (Denoise / Raw Details / Super Resolution / generative)', [ + ('Filters payload (Filters, Images, ImageGroup, Alpha, ColorVariations, BlackLevels, Linearization, ...)', 'mixed', '', '', '', '', 'COMPUTED', + 'FilterID, Name, Title, CompressedSettings, Src/Dst bounds, MinEditVersion, OrderIndex*, ShouldDeleteImagesDueToUpstream, IsSignalForDelete, Images[] (BlendType, Matrix*, Reference*, GlobalInputDigest, ...), ImageGroup[] (GroupDigest, PixelType, Planes, Size*, Stage3*), BlackLevels, MapPolynomial. About 60 keys.', ['f%d' % i for i in range(60)]), + ('GenAIInfo (Name, UUID, SoftwareAgent, Timestamp, ParameterPairs)', 'struct[]', '', '', '', 'generative provenance', 'NEVER', '', ['GName', 'GUUID', 'GSoftwareAgent', 'GTimestamp', 'GParameterPairs']), + ]), +] +# fmt: on diff --git a/server-rs/scripts/develop_registry/structform.py b/server-rs/scripts/develop_registry/structform.py new file mode 100644 index 00000000..b60394f6 --- /dev/null +++ b/server-rs/scripts/develop_registry/structform.py @@ -0,0 +1,137 @@ +"""Count how nested ``crs:`` structs are spelled in XMP. + +The same struct can be serialized three ways, and a parser has to accept all of +them: + +* ``parseType=Resource`` - ``..`` +* ``Description-child`` - ```` +* ``attrs-on-element`` - ```` (only when no field is itself complex) + +For every struct-valued property (``Key``) and every struct item in a Seq +(``Key[]``) this prints the form and how often it occurs, and whether the struct +has complex children (``complex=True``). Alt containers (localised strings) are +not structs and are skipped. + +Inputs are any mix of folders (scanned recursively) and ``--sidecar-list`` files. +Sidecar rules apply to the list: ``[conflicted]`` copies and files without +``crs:ProcessVersion`` are skipped. + +Example:: + + uv run structform.py --lrc-presets "$LRG_LRC_PRESETS_DIR" --sidecar-list "$OUT/sample_files.json" + +``$OUT`` is a folder outside the repository; ``--out`` refuses a path inside it. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import xml.etree.ElementTree as ET +from collections import Counter +from pathlib import Path + +from outpath import require_outside_repo +from parse_xmp import CRS, RDF, is_conflicted, is_lrc_sidecar, iter_xmp_files, load_sidecar_list + +CRS_T = "{%s}" % CRS +RDF_T = "{%s}" % RDF + + +def _complex_children(node: ET.Element) -> list[ET.Element]: + return [c for c in node if c.tag.startswith(CRS_T)] + + +def form_of(el: ET.Element) -> str: + """Serialization form of a struct held by a property element or ``rdf:li``.""" + if el.attrib.get(RDF_T + "parseType") == "Resource": + return "parseType=Resource" + d = el.find(RDF_T + "Description") + if d is not None: + return "Description-child(complex=%s)" % bool(_complex_children(d)) + if any(k.startswith(CRS_T) for k in el.attrib): + return "attrs-on-element(complex=%s)" % bool(_complex_children(el)) + return "other" + + +def count_forms(files: list[Path | str], counter: Counter, errors: list[str] | None = None) -> int: + """Count struct forms into ``counter``; returns files parsed, appends failures to ``errors``.""" + parsed = 0 + for p in files: + try: + root = ET.parse(p).getroot() + except (ET.ParseError, OSError) as e: + if errors is not None: + errors.append(f"{p}: {e}") + continue + parsed += 1 + for el in root.iter(): + if not el.tag.startswith(CRS_T): + continue + key = el.tag[len(CRS_T) :] + seq = el.find(RDF_T + "Seq") + if seq is not None: + for li in seq.findall(RDF_T + "li"): + if len(li) or any(k.startswith(CRS_T) for k in li.attrib): + counter[(key + "[]", form_of(li))] += 1 + elif (len(el) or any(k.startswith(CRS_T) for k in el.attrib)) and el.find(RDF_T + "Alt") is None: + counter[(key, form_of(el))] += 1 + return parsed + + +def sidecars_from_list(path: str) -> list[str]: + out = [] + for p in load_sidecar_list(path): + if is_conflicted(p): + continue + try: + if is_lrc_sidecar(Path(p).read_bytes()): + out.append(p) + except OSError: + continue + return out + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description="Count struct serialization forms in XMP files.") + ap.add_argument( + "--lrc-presets", + default=os.environ.get("LRG_LRC_PRESETS_DIR"), + help="LrC's bundled Settings folder (default: $LRG_LRC_PRESETS_DIR)", + ) + ap.add_argument("--dir", action="append", default=[], help="further folder to scan (repeatable)") + ap.add_argument("--sidecar-list", help="JSON list of sidecar paths (sample.py output)") + ap.add_argument("--out", help="also write the counts as JSON to this file") + args = ap.parse_args(argv) + if args.out: + require_outside_repo(args.out) + + files: list[Path | str] = [] + for d in ([args.lrc_presets] if args.lrc_presets else []) + args.dir: + files.extend(iter_xmp_files(d)) + if args.sidecar_list: + files.extend(sidecars_from_list(args.sidecar_list)) + if not files: + print("no input: give --lrc-presets, --dir or --sidecar-list", file=sys.stderr) + return 2 + + counter: Counter = Counter() + errors: list[str] = [] + parsed = count_forms(files, counter, errors) + for (k, f), n in sorted(counter.items()): + print("%-32s %-40s %d" % (k, f, n)) + print(f"-- {parsed} files parsed, {len(errors)} could not be parsed") + for e in errors[:20]: + print(" parse error: " + e, file=sys.stderr) + if len(errors) > 20: + print(f" ... and {len(errors) - 20} more", file=sys.stderr) + if args.out: + rows = [{"key": k, "form": f, "n": n} for (k, f), n in sorted(counter.items())] + Path(args.out).write_text(json.dumps(rows, indent=1), encoding="utf-8") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/server-rs/scripts/develop_registry/uv.lock b/server-rs/scripts/develop_registry/uv.lock new file mode 100644 index 00000000..76c68130 --- /dev/null +++ b/server-rs/scripts/develop_registry/uv.lock @@ -0,0 +1,322 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "lance-namespace" +version = "0.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lance-namespace-urllib3-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bf/93/da5f7fcac690db9b282a3439ed9e34960c147619a0d6e1f4eb8cd240e7a5/lance_namespace-0.11.1.tar.gz", hash = "sha256:f67cfbbe0647b7cb42f23b673e7edf8a75b7d8a047265a916492f8d247ee1bc2", size = 11631, upload-time = "2026-08-18T17:40:06.294Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/bc/601f2b3cc4cfa0070d858a33223bc823fffdd7981a25c45984a5216ca952/lance_namespace-0.11.1-py3-none-any.whl", hash = "sha256:07643fce9a42ad4d58cc8bf91e3f592bc7f4cbd8d0ad5233223506debf67551c", size = 13507, upload-time = "2026-08-18T17:40:03.561Z" }, +] + +[[package]] +name = "lance-namespace-urllib3-client" +version = "0.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "typing-extensions" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/c5/2bdd0ff98b469894c8a73be809d26ffdad5402517b0e5f9e758026cba29e/lance_namespace_urllib3_client-0.11.1.tar.gz", hash = "sha256:145a9e9424d7597487249b5b95ee274423bf2910e1a9160b6a07b676b61ea46a", size = 237345, upload-time = "2026-08-18T17:40:07.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/2a/eaaefd55d1190291207049fedc6b3eb22b506e57d6de91bae46bbaaa9c60/lance_namespace_urllib3_client-0.11.1-py3-none-any.whl", hash = "sha256:36537f529294da6d884ba0fe783704483f0a75463497c7705fd083a4d0257990", size = 406311, upload-time = "2026-08-18T17:40:04.842Z" }, +] + +[[package]] +name = "lrg-develop-registry" +version = "0.1.0" +source = { virtual = "." } + +[package.dev-dependencies] +lance = [ + { name = "pylance" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +lance = [{ name = "pylance", specifier = ">=12.0.0" }] + +[[package]] +name = "numpy" +version = "2.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/01/11703282db468b85f6f7b8c7f22d058de5970d5c7e60a3a8aaa313c3de36/numpy-2.5.3.tar.gz", hash = "sha256:df2d5874ff183595a4ba404edd04f6bd9b5505c1d7708573f6a6c17489a67563", size = 20791231, upload-time = "2026-09-06T16:27:47.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/50/8fdbb16af64895706a45f06a4068e29db732ec180f3c1375f14123359138/numpy-2.5.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb189f09db39283b26bfd061ec16189e14f71c6755207f72a0f7540867afe5b9", size = 16994982, upload-time = "2026-09-06T16:24:29.244Z" }, + { url = "https://files.pythonhosted.org/packages/60/39/789131c1188c078dcb3a1692e72e1e050c68b88ffe72c9ccaac9bcd7a9cd/numpy-2.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f59a878c33d6b88122d80d239bb3b845d58708750b0cb06a09aebb9b18ec696c", size = 12009327, upload-time = "2026-09-06T16:24:32.491Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/a312e95696e5f601914dd8b6dd844692ba61670807417e24b68e337b5c70/numpy-2.5.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a72f874bc9e10e4b8f80426fb49716d5141f64442a0c8418065093ec8017fbb0", size = 5445405, upload-time = "2026-09-06T16:24:35.071Z" }, + { url = "https://files.pythonhosted.org/packages/30/d0/5623a1707ed4fe16e3909fe3cf5ee3da004ae677ad23d83bbf3adf1a6faf/numpy-2.5.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:fc36dc566135b5eceec4cf89758fcb719266a019ef07dae1754ae7c9f617ef3e", size = 6783213, upload-time = "2026-09-06T16:24:37.253Z" }, + { url = "https://files.pythonhosted.org/packages/f1/32/84146fc020ad3c25f805f70ab60da46fe3c540a21369754a7e4369754b6f/numpy-2.5.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76c2c1e6bfa5c84adc6434dfbf013aa92096a7985221762c8f11fedfd20fff58", size = 15687872, upload-time = "2026-09-06T16:24:39.751Z" }, + { url = "https://files.pythonhosted.org/packages/65/af/aa78d1a88805456e212b65461354cd943197fb9acecc4c90fd12295123a3/numpy-2.5.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7e18c623bb5c95acb3b3328861272816ba199fb531921c5d6d0b675f1fde9e3", size = 16717410, upload-time = "2026-09-06T16:24:42.745Z" }, + { url = "https://files.pythonhosted.org/packages/3b/24/faa79d865e69a97ba17473b23a1b74094b2259c03e820c70297293b9ea49/numpy-2.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4f8929ee6c96bfbd7b4ed2032e0c03af86fe1826740ab61ddabf9072d06e57ff", size = 17040975, upload-time = "2026-09-06T16:24:45.961Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/8877e629445a7176297dffcaf9c485faa96a95d81728a62521ad55bd4c0f/numpy-2.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b5d93cf48f687479941d12b69c873ad2cc76bbd487f0091c2200636497f34034", size = 18476479, upload-time = "2026-09-06T16:24:49.35Z" }, + { url = "https://files.pythonhosted.org/packages/c8/db/35e1c2d38b04cbd5b731f9d71495e055e813197669d22b612f11748d2ff9/numpy-2.5.3-cp312-cp312-win32.whl", hash = "sha256:bf63afbe037eb5d2fe87fbcc7778e61da53ebaf21d938a4515aa73b62532a5d4", size = 6133378, upload-time = "2026-09-06T16:24:51.915Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/accf6d4f0c80c5d9ba9735d6b1550e444180599f34dec69ca01360f717ad/numpy-2.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:0a59a421a32580a009e8a1751345bf829631b990dc1794b80514ab722b435def", size = 12567828, upload-time = "2026-09-06T16:24:54.255Z" }, + { url = "https://files.pythonhosted.org/packages/22/43/1764aff32e4652526ae2f71fa8b3efd8d25c8a3d6926914454e47138ed1e/numpy-2.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:ccb32e0525d29e8b0572eb84c9a57af0e7a4e615726927506f55063c62414034", size = 10485432, upload-time = "2026-09-06T16:24:57.278Z" }, + { url = "https://files.pythonhosted.org/packages/79/e5/8fb89cd46d14e35699d13bf943a5f5f441ecee8667120a1f6105ab89e349/numpy-2.5.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:66a78fe4556c60aceda5916f9eacd638b18e9e681016ec302dcb4682d6d4d034", size = 16991061, upload-time = "2026-09-06T16:25:00.411Z" }, + { url = "https://files.pythonhosted.org/packages/2f/06/9dc9e48b5e5e941c8b10350c5ff2d721da42a20517d911d15544246775ff/numpy-2.5.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92f30e89b8ee0ecf363033576c422b2f58fed6a80bed0aa48dff6d14c654663e", size = 12003676, upload-time = "2026-09-06T16:25:03.475Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2a/98282aa5b8f58b1157d440bb6282eed47e3632a5de53a714fbab17e659fe/numpy-2.5.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f9a2353b37a1a9e78fd82b27ad7e2a32a2d036604d18f02b05e3136c62ca3b09", size = 5439695, upload-time = "2026-09-06T16:25:05.978Z" }, + { url = "https://files.pythonhosted.org/packages/a1/f9/b6533d777be9d6ffd29dc1be0867e563e6e8cc9a220ff1b716adc317f060/numpy-2.5.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:ccbc4665079665c3cf3bab4db9f6b095370cd6437d66be549b6c2a1fd19e1958", size = 6779395, upload-time = "2026-09-06T16:25:08.599Z" }, + { url = "https://files.pythonhosted.org/packages/73/85/735720d04ec197c5dcfacdfc9922667c7f1f5f496a279b7ba4d7c74c4cc7/numpy-2.5.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c76d5dde9f445058f83d0c02af00557a4db91de9a9a57c0df87d1535001d654b", size = 15681750, upload-time = "2026-09-06T16:25:11.173Z" }, + { url = "https://files.pythonhosted.org/packages/3a/1b/3b16a9bc514a440a7a0883684111dcb1ef1aee960af2ca95da8fc775f124/numpy-2.5.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5fa86b80fd24bcd1aff83ad23be44ea323de3f787be8f8b15d4a65621e25321", size = 16708577, upload-time = "2026-09-06T16:25:14.171Z" }, + { url = "https://files.pythonhosted.org/packages/69/c4/386f397831b07328b639c96c5b62719346cf4baf07c68d927239752b1534/numpy-2.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd4cb9ad3c7889b9b3fe0a9a9fb5d2ed26f9879bff2608d9f01aed147a20d231", size = 17042047, upload-time = "2026-09-06T16:25:17.582Z" }, + { url = "https://files.pythonhosted.org/packages/5f/3e/a700ecbf36e85ae8328fd3b0e12eeddc22ed6358a64cb2bd913e0d195d65/numpy-2.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1302b90c0e52281681b2975adfe8a860cb7b12216a27b4b0b4207c44bf7bccf0", size = 18465724, upload-time = "2026-09-06T16:25:20.949Z" }, + { url = "https://files.pythonhosted.org/packages/41/ee/38e785e88a4045f6ad1d1f2808dcdfafdca48c760260c0587bf171e29fc9/numpy-2.5.3-cp313-cp313-win32.whl", hash = "sha256:1c80eabb4035ecf4ca9cd49cde8a9fdd69a729e63e6474887d1523ade7aa277f", size = 6129003, upload-time = "2026-09-06T16:25:23.664Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ec/100f2b1794ede74a9b3d7ec6b9736927f56713414c1dfe19ab6c383494bf/numpy-2.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:71cad2b2a7451ab79d8f5e71b453485b6775963d5cf794179144a7463fe6e8ec", size = 12560965, upload-time = "2026-09-06T16:25:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/80/b1/7dc825ca94c12acebbce4c37caa5e198695eb31424bc579679f32b1bb49d/numpy-2.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:8e4dd766076855b5ff7ea52fa5f07ce26286726e0f8bff446b7739d02e6ea204", size = 10482343, upload-time = "2026-09-06T16:25:29.772Z" }, + { url = "https://files.pythonhosted.org/packages/70/78/cf416f15dc29375a229d9dfebf8db6e313f291580b39fa1a568b6052bb07/numpy-2.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:350ba9783ce969cf9f7ce6e6a9a58e1a6e2a19ca025b7ee448c4db727706212a", size = 16998686, upload-time = "2026-09-06T16:25:33.171Z" }, + { url = "https://files.pythonhosted.org/packages/9e/59/abcc2d8def4fd60eec7d87f92d27c13448ffd9ab14339bcc63a0d7a2fdea/numpy-2.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:012e66aca395d795496446e52aeeb5866312a5d4d3f27da270e5a0b43f70dc5c", size = 12013862, upload-time = "2026-09-06T16:25:36.748Z" }, + { url = "https://files.pythonhosted.org/packages/94/75/4640d2d6e4b64a049e48425a82728a41ef4adb61332d2cba68055774878b/numpy-2.5.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:adc1ada2662f8a5f960b8a10d9986897e7499ef07e06d4cfe7197f8cce923c07", size = 5449793, upload-time = "2026-09-06T16:25:39.476Z" }, + { url = "https://files.pythonhosted.org/packages/96/cd/625b57ae33d4ca560f32cc0b47b4a5922146d9beb998ddf773900d440a73/numpy-2.5.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:54a115e5a73b8fc44f0cebef486365a1894b5c9760685d4558b72b7c3eb846e0", size = 6785176, upload-time = "2026-09-06T16:25:42.069Z" }, + { url = "https://files.pythonhosted.org/packages/9c/72/12918652e7912ef9751e8694c88820fcd1908e0618cb23f5f3caa6004b7b/numpy-2.5.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be5a8381859b6da607c84f4f7d6847725f1cf1853ef8a2c9e115b7d58bef47dc", size = 15703377, upload-time = "2026-09-06T16:25:45.135Z" }, + { url = "https://files.pythonhosted.org/packages/45/8f/9beacf79ca7c650688ad0baa80931adb988fe6e6e5d5903c23cc3dbd70eb/numpy-2.5.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b0521d0f4aebb6e06189451025fa17a913287b13c03d5fe05c017333b654ea5b", size = 16711928, upload-time = "2026-09-06T16:25:48.461Z" }, + { url = "https://files.pythonhosted.org/packages/09/8d/41d0a56e1ac4c87495c897a211b1368691b7237aadabec8b3b8f3a74d48f/numpy-2.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9deb49575e5b0b94ed72c8a64ec4d033381adc27e9060ae842971f697ba96104", size = 17059507, upload-time = "2026-09-06T16:25:51.873Z" }, + { url = "https://files.pythonhosted.org/packages/08/1e/0dfbc5cc251d54e2af790f254d24ec38637fa97ec7d5d11de7ffed787098/numpy-2.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b00eefbcf0f292945c4b4dec2ae845389ef5bcdcd596e6e4328051db5b5ba694", size = 18471002, upload-time = "2026-09-06T16:25:55.233Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2c/dfa40f6991f8185c8c30ffd023dfcbb11888e823cfab9557b920f3bb7bed/numpy-2.5.3-cp314-cp314-win32.whl", hash = "sha256:c2381f82999704f818e2c987a865050e285ec3621262c66d40f5a96c8f899f8e", size = 6180485, upload-time = "2026-09-06T16:25:58.157Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/d2c08231e4fde7e415501fd02c715d96e98599b2d8384445933944152984/numpy-2.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:2c25dfa72943e4336ddb6b0ee4277b47a0c85bede0807530ec68103bf58e2c10", size = 12698179, upload-time = "2026-09-06T16:26:00.789Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e9/dcdcc9b95cf5f49815055573aee1b11cfbf5299f38a180e437ded050810f/numpy-2.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:15aa985ac73a8db02db7663381aa109510449d3819d37206caed27b33a65a8a6", size = 10769383, upload-time = "2026-09-06T16:26:04.011Z" }, + { url = "https://files.pythonhosted.org/packages/49/c4/af8bc08a7ef4e1529a7c0cf24969accce316b783999802089a581ec99272/numpy-2.5.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ac7bb1c52d445bd4f8f7f97fefe6abc3a084dc4d63df50d79b17fa2b78e89297", size = 12132668, upload-time = "2026-09-06T16:26:07.138Z" }, + { url = "https://files.pythonhosted.org/packages/c5/ae/0f15eb56d4ec5e13c1f7ff04ff407f997d1acbadb45d3e1f2e2645a8f43c/numpy-2.5.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e6ab667ba76450084eb64013762c438ea76d9d29cc676dcd6c2e9892ba37f841", size = 5568580, upload-time = "2026-09-06T16:26:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/23/fb/c72a8f25d4b6e96c354e7ab45ace3b27dc11e5d6a13b6c7d0cd6b08bf112/numpy-2.5.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:f7fabeb6cea87d65f3b926de33d03fb016cfdc29314c90974383b5582ae72891", size = 6882634, upload-time = "2026-09-06T16:26:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/07/a9/968c90ed2ab15060c338e8137f1215b5a60756ae07328e0a60d1c6734df4/numpy-2.5.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fb6f8fb9ff0b3a69f52c66ce397b0246583e9f28616231b0e32ca49259a5fa6", size = 15748923, upload-time = "2026-09-06T16:26:15.092Z" }, + { url = "https://files.pythonhosted.org/packages/59/08/9df04103947b95e3b6b1f2ed1a70521f325647a31b82da6a2aae3a485508/numpy-2.5.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93e1f5447e2b1e479d7bd74701e84746b86450cff1fc368b132d195e2b8f8211", size = 16746748, upload-time = "2026-09-06T16:26:18.43Z" }, + { url = "https://files.pythonhosted.org/packages/41/a0/14c8d5fe5b53a334aabb653deb391c0fef49558f491880ea300ed6785224/numpy-2.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c00abe94c1a69d75d827dcf1c025b25c8a45d230b3bcd77a9020883a1b047653", size = 17111561, upload-time = "2026-09-06T16:26:22.113Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a6/d7e96e42f01522e154c32489640f16dfc4f6181d165d05fc3bec8c2c4999/numpy-2.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:536f963710a4e63934d80ac0dc4f478804a83e9a84b6828018f25d09953ada33", size = 18513945, upload-time = "2026-09-06T16:26:25.401Z" }, + { url = "https://files.pythonhosted.org/packages/25/39/3453afb7119d0449ef11c886874120ff180e2c337760e0e2d88f70f1a945/numpy-2.5.3-cp314-cp314t-win32.whl", hash = "sha256:4c8a6d2ebce6305fd82fbefca827775437147052a976ee7c94b36a0c1b52ac6c", size = 6335421, upload-time = "2026-09-06T16:26:28.175Z" }, + { url = "https://files.pythonhosted.org/packages/99/01/22815d2b19a1a746b1d45205cffebb3fe511a18acb75fba6c88491fc9894/numpy-2.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9a37475425b431b4d060f23b4f52cd2f3aef6bc7c654bd760adf0040eec9d435", size = 12896420, upload-time = "2026-09-06T16:26:31.265Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ee/a7cbba67eeaff038dc29ca8b98a88396c8b0cc9c89d4924f4a27a5c9150b/numpy-2.5.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2d8240cb4c16fd831074aa2b2cf9fc54664d826341d61c372245b96a74a49a9a", size = 10857177, upload-time = "2026-09-06T16:26:34.167Z" }, + { url = "https://files.pythonhosted.org/packages/45/56/78194492883ff5eec90423fe56a3a44b154da047d88a6307f629713c584f/numpy-2.5.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a6391fafaba97500887132cd582abc6e19452b1ac775a47caa7b24490e152058", size = 16996531, upload-time = "2026-09-06T16:26:37.287Z" }, + { url = "https://files.pythonhosted.org/packages/11/39/dd55c0af90bbab564b09ae3b0aa60ec5c02b900fa4f1ba23440525c8b32d/numpy-2.5.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:09d5a423c71ad5feb5625844ad58050e35df43871004b52ac9c0ad44a56775be", size = 12012569, upload-time = "2026-09-06T16:26:40.707Z" }, + { url = "https://files.pythonhosted.org/packages/b6/51/04f67d32e4862b281b1cb84ceeaed3421189a84fb6fb51a391cd6d5009f7/numpy-2.5.3-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:f9579f383d1bf9df80081e72760e84960a7fd4f88cf0c9e535a8597c9bb646f5", size = 5448498, upload-time = "2026-09-06T16:26:43.435Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c9/25b4dc0dd1344ec26c7319e84fd4e9809d2b5628f4e12decd618036e5178/numpy-2.5.3-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:86bff898a431c0fb71f7610b75726e75a54d47b37edc9d537f48de63bb3c0b90", size = 6783026, upload-time = "2026-09-06T16:26:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c7/29285be1e5232a6e7ee3268a33c85843f5a8ee93350c6465cddd66ebbf76/numpy-2.5.3-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f3ed25271581281f2fccb1adcedfcde4c07362eec69189b50baf6f90e3ae159", size = 15697322, upload-time = "2026-09-06T16:26:49.415Z" }, + { url = "https://files.pythonhosted.org/packages/55/49/bbad5335fb4996a16881f853ff3e0ba582f01720e55c89b1c06b8fc42a90/numpy-2.5.3-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffdc76bfcae6b255dff75202c5e7feaf95b40246bc0a17944facc1fecf9f79ab", size = 16708995, upload-time = "2026-09-06T16:26:53.127Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e9/1df35483760b04a65ea44669f89dc64f30e5aca098b48ceb8b1310b0e0fe/numpy-2.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:116f96cadd935c6122e9228d676fe7ede19e741f5c8bb1c3cddbe0c51ccebea2", size = 17052508, upload-time = "2026-09-06T16:26:56.464Z" }, + { url = "https://files.pythonhosted.org/packages/b8/99/66e54da8265cc8be8a7382bf96edce17aaa2837d6f484432025932a3caa5/numpy-2.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:09ffa5d903faeaa5c4dd05009cf81c8bab9f2cb37c548b8d39b65b4cfa7c97f7", size = 18468224, upload-time = "2026-09-06T16:26:59.966Z" }, + { url = "https://files.pythonhosted.org/packages/01/bc/b5e90a91c115168d793dfd2ad9c69c438c2fe7a13a437e770bc5b078e732/numpy-2.5.3-cp315-cp315-win32.whl", hash = "sha256:e01c918ac3d48e18a927cf7b14a26a3e29ff2bdf2eacb976da0aecd6a43ed034", size = 6179919, upload-time = "2026-09-06T16:27:03.166Z" }, + { url = "https://files.pythonhosted.org/packages/37/ea/780748fd3985109075514ef8fc64cd25f943e40dde13a6d59141eb268fc8/numpy-2.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:e931e4f499e0dc7ef29d269a8e5b35dd722e5d14be07df6240166ea7c6532fae", size = 12697656, upload-time = "2026-09-06T16:27:06.153Z" }, + { url = "https://files.pythonhosted.org/packages/b3/16/407be69a2a87c8cab64d95975a8977a426a29e138f07e276ec258f0fe4e5/numpy-2.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:26e15e4aecd8617dfbaecb37d223e365d7b39411fba20454be2670a96aa74cb5", size = 10767601, upload-time = "2026-09-06T16:27:09.297Z" }, + { url = "https://files.pythonhosted.org/packages/44/bf/a97ffb01e41d50a32a9177aef942a4d0e389a3daf451d04e5f38ef6afb87/numpy-2.5.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:6cef4bb1706dfec49243c05d921eefb4e190d41e2528b30d8035ea1f36b4c24a", size = 17090092, upload-time = "2026-09-06T16:27:12.907Z" }, + { url = "https://files.pythonhosted.org/packages/d1/24/136c02f2c2af9a067a84d0c3aa10c99012c0476fa5066732fa4a4202557d/numpy-2.5.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:d1c89973648c85069c5046ad460f7b8a00218b29a2e42359ac8cc63e9ab94832", size = 12129429, upload-time = "2026-09-06T16:27:16.089Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6c/b47582d6597789bf946d5efbeb6b9e56fd8bcbd5efc6fbf51dbe1ea31eb3/numpy-2.5.3-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:214045a5bf00113a146ab9ee9730c44501af6723cdf1f6830932f7b5ef2e7af0", size = 5565452, upload-time = "2026-09-06T16:27:19.868Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/ef3cc6da73774202d4deae16bb321fd8298a4e0561e3539f8c4be237d916/numpy-2.5.3-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:8617bbfae4486cf99c9f899966699428d19da931d06ca94ad3da986c76e15997", size = 6876736, upload-time = "2026-09-06T16:27:22.232Z" }, + { url = "https://files.pythonhosted.org/packages/9e/24/e3813329498596cb842703dcacac1741612ed9fb9c4e6a3e0c7e2ebbc597/numpy-2.5.3-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:595d020938c84e320bcf40ad71089e108eac0d377cd018e14a8c094f39e98d85", size = 15745777, upload-time = "2026-09-06T16:27:25.181Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9e/4e7a07fd0776dc2210cdacf2010be8665194d094defc10c419d7dea794cc/numpy-2.5.3-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f24021b9f22bc6301c37b196974a92c1c18dccedb6fef3dd252e95f2d6adbe4", size = 16746949, upload-time = "2026-09-06T16:27:28.576Z" }, + { url = "https://files.pythonhosted.org/packages/91/db/01674c0e20335057813a00c2ebd546ed25bff9ed7914f9bced00f8c55d94/numpy-2.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:71b39d9f935b6ec0f8753e3e2afb51e3efba6f2e05b68b32a40754d24bcd4a3c", size = 17108994, upload-time = "2026-09-06T16:27:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/45/7a/584c5e71f8d378e57cac0b033891ed65c683ef90573ba4854e8c28203db0/numpy-2.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:6b05c171afb3aa07adbd20abc00aea86fe375beb0fdb9ef780ec5b7f63bab1c0", size = 18512266, upload-time = "2026-09-06T16:27:35.196Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d2/4e1014173aa3c55e6a756e0e567290743a6ab33a288460374d7ef6bcd239/numpy-2.5.3-cp315-cp315t-win32.whl", hash = "sha256:f54660b0eb6b0b9f36e7fe1cdfdff472028dd0d14acd9b9b65098efbad059469", size = 6330292, upload-time = "2026-09-06T16:27:38.149Z" }, + { url = "https://files.pythonhosted.org/packages/6c/b0/ff5658a58199b7bcaad87bf260eef6713d9d42cca4e028f935b4fc5fbac6/numpy-2.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:1aad64d99730d013cfc6debafed22783b4fc5a7f4b8bc744d2d8cf7dcc880551", size = 12884918, upload-time = "2026-09-06T16:27:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/fb/0b/b12a2df5d1b774bd9007a6fdff9381145b6223d37f11afc9c37ab0efd9a1/numpy-2.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:befa1ae5bd6030b3f512b43ff3fa5290bbed6b84411a44244b14adf835f5b89d", size = 10850807, upload-time = "2026-09-06T16:27:43.868Z" }, +] + +[[package]] +name = "pyarrow" +version = "25.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/e3/27f57f80141379d60defe6703eb50a707325706f07fedfd1312c7a751995/pyarrow-25.0.1.tar.gz", hash = "sha256:9150a83248bfed9813ea3c3af74c3856c1984d444aa28e58bf7733b9750ddf6a", size = 1201653, upload-time = "2026-08-10T12:40:53.904Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/e2/9ab15b88cbfac28e16419ce5439ec29234c5172cb8259301b4ba639bdec0/pyarrow-25.0.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:df961f2e7ae9cf496459259d798652c70625f6c080650d6952f8c04053c58ee9", size = 35861559, upload-time = "2026-08-10T12:38:02.567Z" }, + { url = "https://files.pythonhosted.org/packages/58/79/a0036dbe1eabe1f73127427342f1d99982584c4a2cde2651d6c93499c6f6/pyarrow-25.0.1-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:cc4aa407fde9fc660be3939e49ea31f50f3e9fec17c0ec63159f7711edd3efc9", size = 37628383, upload-time = "2026-08-10T12:38:09.083Z" }, + { url = "https://files.pythonhosted.org/packages/13/49/d93a57d375f4bf0cf82913dd6bb54acafde83dd993be2282c81ac5616cad/pyarrow-25.0.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:4340f0ba6c1d2e13f21658de1d7c662ca2545018568d0030a1e9afca159d87e3", size = 46820190, upload-time = "2026-08-10T12:38:15.458Z" }, + { url = "https://files.pythonhosted.org/packages/60/c9/711ca85d79f1ec98f29a5eae2b051e25b4ecec5de3e3c0e2d5c5dcb15664/pyarrow-25.0.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:5389cdf79447ed1515c9e31620e6e1e2302249564d603f2ad727d4f6d313e4c3", size = 50102437, upload-time = "2026-08-10T12:38:22.487Z" }, + { url = "https://files.pythonhosted.org/packages/80/53/8fb8359ff17cfb6263a1cf3ebf7caec9fe197de118719e84fcb1d0618026/pyarrow-25.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d51592cb7561e87877c506113e7adbf1342ab579e6c21f0ef44b8ba41cb74c80", size = 49942424, upload-time = "2026-08-10T12:38:28.755Z" }, + { url = "https://files.pythonhosted.org/packages/e8/83/4e5ae02a9341571b18a6fca380ac7a58ce6ddae7ab3c060208c0a1e79f02/pyarrow-25.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6109c94d8b9f3b17a041daca16cacb2f651ad8f1ef70a4232c2c0f37a23da2a8", size = 53144206, upload-time = "2026-08-10T12:38:34.862Z" }, + { url = "https://files.pythonhosted.org/packages/65/ee/197cbf47e49f83e6ebeb946a5259a48a638dea27ac774db42fe78022179d/pyarrow-25.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:8858d7bfc22e3f51529aeaa4077225029724623e4595dc9eff8c793935c34140", size = 27953934, upload-time = "2026-08-10T12:38:39.808Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8d/8f271a7a034c834910ec925d56fa4b29733b1380f5289419f5aaa3b02777/pyarrow-25.0.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:c7c534ec03c358a76ea3e505e74c1b6aef290af90c444dfd092dbfe23e755b85", size = 35855328, upload-time = "2026-08-10T12:38:45.489Z" }, + { url = "https://files.pythonhosted.org/packages/d2/cd/5bac242f4e841b9971d5eb94fdfe2577e2b70be983e27401e72055786037/pyarrow-25.0.1-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:dda9470024204d7bbf2042b47c6e8a0e47a3eeb8e34405882dfaea6577e0c153", size = 37622415, upload-time = "2026-08-10T12:38:51.107Z" }, + { url = "https://files.pythonhosted.org/packages/63/1f/96d03b4e1506524f7087adb0fd6b2f69f0c9c7aaff1ec36d8030082e15a5/pyarrow-25.0.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:44a9120ce5bd81936b8ab9a88076e3fd47c2c6838e0e43630fed83626aca81d9", size = 46813813, upload-time = "2026-08-10T12:38:57.773Z" }, + { url = "https://files.pythonhosted.org/packages/98/d6/33a411115b61dbfc16ad6ad73e71730f6fea654ee3667673bc53ab0e2fe7/pyarrow-25.0.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:0befcf816e45a1af33ac775a9970b749e4868a230c7372f0ae5e932bee27039f", size = 50104452, upload-time = "2026-08-10T12:39:04.579Z" }, + { url = "https://files.pythonhosted.org/packages/33/ae/b1b97c9ca87f9f9ddbb5230c798df94eccce61bd79b9b45458c69a478588/pyarrow-25.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f89685964f46e4216103c75483aac0c0692a5f72212d7ca835adba5ede56ce3", size = 49951343, upload-time = "2026-08-10T12:39:11.8Z" }, + { url = "https://files.pythonhosted.org/packages/98/9e/a112df5cfd5a68cb1d9fc31cfe38c28d5aec9f10865ce37ecef2e4450873/pyarrow-25.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6943e2fe7954d29d84de45d29d34c8dc36ce96570e67d89aa9976e650a4a9138", size = 53144784, upload-time = "2026-08-10T12:39:20.503Z" }, + { url = "https://files.pythonhosted.org/packages/31/24/97e8bd98f1e3b07e2ba08bcdff690674fbe16d69a7d2712cc3884665e615/pyarrow-25.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:31e49a7888fcdf3a835da33ae777f6bb9a866334e5a789282fc26dcf426f7f15", size = 27870159, upload-time = "2026-08-10T12:39:26.161Z" }, + { url = "https://files.pythonhosted.org/packages/36/4c/b525824ad3094076919273cd97db61fb3d78252dee76fa3b8dc8f76774aa/pyarrow-25.0.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:bf0b672390cdcb640d7288f96b826d71ff4e9abb254a86c89890baf51a29cee6", size = 35885255, upload-time = "2026-08-10T12:39:32.366Z" }, + { url = "https://files.pythonhosted.org/packages/08/62/448bb0e940de41aec31d1a956e63ad9c54afdf122a103cc3ab20c2a3ce33/pyarrow-25.0.1-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:38a9a4b4b9613380e200641891495a56c3d5a98a092db4a870af9975e220471d", size = 37644461, upload-time = "2026-08-10T12:39:38.142Z" }, + { url = "https://files.pythonhosted.org/packages/6e/9a/13587e38bd4806fd218f50fd13b8903fab60588a699ff0c406372e5b4043/pyarrow-25.0.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0b726ad7e7b669be982b0c71c07fe4b037d654354130da79a7902a669e93a66b", size = 46877146, upload-time = "2026-08-10T12:39:43.722Z" }, + { url = "https://files.pythonhosted.org/packages/8d/61/1c5d1229fa21da4cff5365e41e57177aaac57c563c727f35419b8513d1c1/pyarrow-25.0.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:9171748cdf796972d85a4b60157c279913e242992e350c90c7450182a9838b2a", size = 50131616, upload-time = "2026-08-10T12:39:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/43/20/291e1d65cc0b09aa19f03cf25cf51a2f5fa94b5db315178f2d254ed5cad4/pyarrow-25.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b7a296aac7a71fa0886c08e155ddb6c636a50013f801f6178daafa0f9e726188", size = 50008879, upload-time = "2026-08-10T12:39:56.891Z" }, + { url = "https://files.pythonhosted.org/packages/8b/7c/1b7c9ec28e76576337e4f97b31141c9a181b89b6d1d6221e9d8205621a58/pyarrow-25.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0fe7c8b6c03969b49c8c66182e4a18e3819ab92d07cfab5d8370c531b9369ef0", size = 53170864, upload-time = "2026-08-10T12:40:04.918Z" }, + { url = "https://files.pythonhosted.org/packages/b7/75/f3d789dc06011a765d14d86bda799cf72ac1d715b6a6edecaa0d73d95062/pyarrow-25.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:f729cfdbd36fd99d543b67a914d2de044c84ebe45be8b34902b299b608c15c8f", size = 28620729, upload-time = "2026-08-10T12:40:51.41Z" }, + { url = "https://files.pythonhosted.org/packages/fc/05/647a8ee6f7c2662feb6921315617bc04dcd6034763fb61b1199720bf6162/pyarrow-25.0.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:59a2de54c0cbd954da861eee4d1d330f8e909c45b53455baef696380f2c55033", size = 36130288, upload-time = "2026-08-10T12:40:11.014Z" }, + { url = "https://files.pythonhosted.org/packages/93/f8/c9ee997554d7bea94520667dd1933f109ac1da3ee3556d2b49381e023484/pyarrow-25.0.1-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:35935cd5de130aa5cf4dea052a63e6bf2e17006c35c3a468194242b9b2bf5956", size = 37762187, upload-time = "2026-08-10T12:40:16.592Z" }, + { url = "https://files.pythonhosted.org/packages/a2/08/a28c01c7fe9e96e8233ce2d13df1d402f4f999f848f51d2daacd6bb4c036/pyarrow-25.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:f3831aaa25c67a99f99dc8b05873cb9d64560390372e2aa197ce9dd4a3f06a44", size = 46888003, upload-time = "2026-08-10T12:40:23.242Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b9/58612e977d28dc58c878448866838369ee8da2f1e7cc8ed2c84b952aafee/pyarrow-25.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6a1fdfc6659b6b19022f2e50627fb5cf7156a66c46bf4299379955cbe742382a", size = 50079036, upload-time = "2026-08-10T12:40:29.169Z" }, + { url = "https://files.pythonhosted.org/packages/72/13/66e1402dcc860e1dc2760b1e0292c9a569b62b3bccab69def1b3e907d006/pyarrow-25.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:169d3429d5be7c752125890620f75a60776d38b0035eddae939651640822332e", size = 50040226, upload-time = "2026-08-10T12:40:35.186Z" }, + { url = "https://files.pythonhosted.org/packages/78/10/3f1a5497a7ef732ab0f03ecca3e66d89d9c0f57fdc61b4794c456b781f01/pyarrow-25.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:119297a6dc197e45d9c6d4415f7814a67ffa36c180d26f68c154c58067ae782d", size = 53149035, upload-time = "2026-08-10T12:40:41.454Z" }, + { url = "https://files.pythonhosted.org/packages/93/c0/37d4a7e8e2f7a6076283673d5298018ca26478b934c6ee369e10505ab32c/pyarrow-25.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4288f27577352d608ca08553b0865e4a9b3aa14820c5d95b53337218d609835b", size = 28753071, upload-time = "2026-08-10T12:40:46.623Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, + { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, + { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, + { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, + { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, + { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, + { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, + { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, + { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, + { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, + { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, + { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, +] + +[[package]] +name = "pylance" +version = "12.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lance-namespace" }, + { name = "numpy" }, + { name = "pyarrow" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/e6/f95aaa102e141a133418ed247051df3077aacb1851ee3bad88f3045fb5fc/pylance-12.0.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:58583827b01ae14da220dcea2ea258b008e8d2d20915cf462dee18ddcea068a5", size = 72336247, upload-time = "2026-09-17T16:41:26.318Z" }, + { url = "https://files.pythonhosted.org/packages/4f/24/1f38770557db8189c63b0eeff497f0a6a9fc799ed2e3b2b023365b3a3ad9/pylance-12.0.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca5af2bf6e489b76768b56848b8e9c692ad42d7eedb31a8097fcb57e3146a211", size = 76717015, upload-time = "2026-09-17T16:44:55.744Z" }, + { url = "https://files.pythonhosted.org/packages/da/a4/4e87599ed6120bb6ae91b7611b9a1685759cc2e12898585c5ee347827168/pylance-12.0.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bac18e8f6fecb9c998c2a7301d782281bac075cf06bb0df5d89fc6fc4e8768e1", size = 80868316, upload-time = "2026-09-17T16:53:32.971Z" }, + { url = "https://files.pythonhosted.org/packages/c6/81/b1a578be4c3845183f84bcc19cc0e3bacff2052868faeec975e00e3b7bad/pylance-12.0.0-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:df80a7991fbeab9ca206da4c0110e611696e29fc7b79bc9a88309b1b42c5849f", size = 76735408, upload-time = "2026-09-17T16:45:07.137Z" }, + { url = "https://files.pythonhosted.org/packages/63/1c/2596ba2f0a7dedb28b7e4d07b6d1996365f501fb9329af422afd31a95179/pylance-12.0.0-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:d002980aa548336682f1445427ea89cf39e71a452297741ca97627192e4a1678", size = 80846740, upload-time = "2026-09-17T17:01:03.452Z" }, + { url = "https://files.pythonhosted.org/packages/2d/0c/69df299e26b5594e8b919bc16a6a951295da96f2237eadb2e2b52c2b0316/pylance-12.0.0-cp310-abi3-win_amd64.whl", hash = "sha256:0cd59044927c5ee5a6811ea28b6f4860b2d21932828e770d9e5b39522afcf19d", size = 87083663, upload-time = "2026-09-17T16:55:09.236Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "urllib3" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, +] diff --git a/server-rs/testdata/develop/README.md b/server-rs/testdata/develop/README.md new file mode 100644 index 00000000..b899e6fb --- /dev/null +++ b/server-rs/testdata/develop/README.md @@ -0,0 +1,285 @@ +# testdata/develop — fixtures for the native develop model (`lrg-develop`) + +## `registry_snapshot.json` — the key registry, for review + +Generated by `crates/lrg-develop/tests/registry_snapshot.rs` from the +registry (`registry/table.rs` and `registry/patterns.rs`) and compared on +every test run, so a registry change shows up as a readable diff. Do not edit +it; regenerate after an intended change with +`LRG_BLESS=1 cargo test -p lrg-develop --test registry_snapshot`. It holds no +settings, only key names and their metadata, so the hygiene check below +applies only its text rules to it (see "Hygiene rules"). + +## `defaults/` — Lightroom's own defaults, read back + +`non_raw_lrc15.6.json` is experiment E13's full `getDevelopSettings()` +readback of one non-raw photo without develop edits (Lightroom Classic 15.6, +process version 15.4, a virtual copy of a TIFF), reduced to key -> value: the +top-level scalars and the four global tone curves. Left out: the empty tables +(`AILook`, `FilterList`, `LensBlur`, `PointColors`, `RedEyeInfo`), +`orientation` (the photo's own, not a default) and `ProcessVersion` (in +`provenance`). Kept although they are not defaults: the Upright solver state +(`Upright*`, nine keys), which the registry keeps `NoDefault` (a test relies +on that) and whose `UprightFocalLength35mm` can reflect the photo's focal +length, as in the fixtures; and `HDRMaxValue` (2.3), which the generator +holds back as possibly per image (`NON_RAW_READBACK_NOT_CONSTANT`). One +virtual copy is thin evidence for a default table: the run report's own +check, a freshly imported JPEG (and raw) for the default tables, is still +open. +`provenance` holds only the Lightroom version, the process version and the +file kind; nothing else from the run's report (no file name, path, id or +date). `scripts/develop_registry/gen_table_rs.py` fills the registry's +non-raw defaults from it, and `registry/tests.rs` requires every non-raw +default to match it. Replace it only with another readback of an unedited +photo, then re-run the generator and re-bless the registry snapshot. The +hygiene checks cover it like every other file here. + +## `lua/` — `getDevelopSettings()` values + +Each `*.json` file is one `photo:getDevelopSettings()` table exactly as the +plugin's `JSON.lua` encodes it (the `develop_settings` blob of a training +example), decoded and pretty-printed. `manifest.json` lists every file the +generator owns, the shapes each one covers, its process version and whether +it is synthetic. + +Shapes covered: AI masks (subject, sky, background, a people part in preset +form and for one person, a landscape class), "Select Object" with a polygon +gesture, linear and radial gradients, a brush `Mask/Aggregate`, luminance and +colour range masks (point and area form), `MaskBlendMode` 1 as subtract +(`MaskInverted` false) and intersect (`MaskInverted` true), a local curve +(`MainCurve`), non-empty `PointColors`, `AILook` as an object, `Look` absent, +Adobe, adaptive (`isAdobeAdaptive`) and camera-restricted, float `Look.Amount`, +`PerspectiveRotate` and legacy `LocalExposure`/`LocalClarity`, retouch and +remove areas with their `pm_*` keys, `RetouchInfo`, `UprightTransform_N`, +`DepthMapInfo`, `WhiteBalance` Auto with `AutoWhiteVersion`, number tokens in +exponent form (`1e-05`), a `GrainSeed` above 2^31, `LensBlur` as `[]` and as an +object, `FilterList` as `[]` and with `Filters`, process versions 11.0 and 15.4, +and a top-level `[]` (the encoding of an empty table). + +Not in the source data (every training row is a raw file of process version +11.0 or 15.4), so hand-written as `hand_*.json` (see below); each has a test of +its own in `crates/lrg-develop/tests/lua_read.rs` with the warnings it must +produce: + +| File | Shape | +|---|---| +| `hand_bool_as_int.json` | bool keys sent as `0`/`1`, 0/1-flag keys sent as booleans | +| `hand_non_integer_on_int_key.json` | non-integers on Int keys (rounded half-to-even, with a warning) | +| `hand_null_in_array.json` | `null` inside arrays of known keys | +| `hand_mixed_array.json` | mixed arrays (numbers and strings, objects and strings) | +| `hand_non_raw_white_balance.json` | non-raw `IncrementalTemperature`/`IncrementalTint`; read with an `is_raw = true` hint it is also the "family contradicts the file kind" case | +| `hand_wb_both_families.json` | raw and non-raw white-balance keys together | +| `hand_pv_2010.json` | process version 5.7 (PV2010) with its legacy keys | + +### Generated and hand-written files + +- Files listed in `lua/manifest.json` are **owned by the generator** and + replaced on every run. +- **Hand-written fixtures go into `lua/` as `hand_*.json`.** The generator never + deletes or rewrites them, and the hygiene check applies its relaxed rules to + them (so, for example, a localised `MaskName` is fine). Any other `*.json` + in `lua/` that the manifest does not list fails the check. + +### Provenance + +Derived from the maintainer's own Lightroom Classic edits (the "Train from +edits" table of the maintainer's catalog), published with the maintainer's +consent. Produced by `server-rs/scripts/develop_registry/extract_fixtures.py` +and scrubbed: + +- **Only the develop settings.** Photo ids, file names, capture times, camera, + EXIF, scene tags and every other metadata field were dropped. +- **No Adobe or third-party profile content.** `Look.Parameters` (a profile + definition) is reduced to `ConvertToGrayscale` and `ProcessVersion`. Profile + lookup-table keys (named `Table` or `BrushTable` plus an underscore and an + md5, `LookTable*`, `RGBTable*`) are removed everywhere. `Look.Name`/`UUID` are + kept only for Adobe profiles whose UUID was verified against Lightroom + Classic's bundled profiles (`ADOBE_LOOKS` in the extractor); any other + profile's name and UUID are synthetic, and all other Look text is generic. +- **IDs do not link back to a catalog.** Every 32-hex digest/ID is replaced by + twenty zeros + 12 hex digits and every GUID by `00000000-0000-4000-8000-` + + 12 hex digits, consistently across files (equal IDs stay equal). `GrainSeed` + is the constant 4000000001. Names are type-neutral (`Correction N`, + `Mask N`); lens profile names/file names, model versions and time stamps are + fixed synthetic values. +- **Numeric values are otherwise unmodified.** Slider values, coordinates and + structure are exactly what Lightroom produced; numbers keep their original + JSON token, so int vs float, `true`/`false` vs `0`/`1` and `[]` for empty + tables are preserved. Because they are unmodified, some numbers can reveal + the camera class (image size, black levels in a Denoise payload) and the + focal length (`UprightFocalLength35mm`). + +`top_level_empty.json` is **synthetic**: no stored example had an empty table +(see its `manifest.json` entry). + +### Hygiene rules + +`extract_fixtures.py --check-only` enforces them over every `*.json` and +`*.xmp` below this folder; it runs in pre-commit (`develop-fixture-hygiene`) and +in CI (`lint-format.yml`, docs-check job). The Rust test +`crates/lrg-develop/tests/fixture_hygiene.rs` mirrors them and runs with +`cargo test`; change both sides together. Both match whole strings (a trailing +newline is not ignored) with ASCII digits, and both run the same crafted +files through the file-level rules (`extract_fixtures.py --self-test`, in +pre-commit and CI, and a test in `fixture_hygiene.rs`): + +- Scope: `lua/*.json`, `xmp/*.xmp` and `written/*.xmp` (plus any other + `*.json`/`*.xmp` here, `lua/*.xmp` included); + never this README. `registry_snapshot.json` (directly in this folder) is + the registry's own description: it gets the hard text rules except the + table-key names (which are registry keys there) and the 32-hex/GUID rule, + and none of the fixture tree rules. +- Hard failures anywhere (text level): the `md5p` photo-id prefix with its + colon, `file:`, user/volume/home paths, `/private/`, `/tmp/`, `/var/`, + `/Library/`, drive and UNC paths, raw or image file names (`.cr3`, `.nef`, + `.dng`, `.jpg`, ...), and the substrings of the dropped table keys. +- Keys are identifiers; language tags only under `Look.Group`/`Look.SortName`. +- `Look.Parameters` is `[]` or an object whose keys are within + `{ConvertToGrayscale, ProcessVersion}` (a boolean and a version string). +- `Look.UUID` is in the extractor's `ADOBE_LOOKS` constant (and `Look.Name` + matches it) or synthetic. The allowlist is that constant, never a list read + from `manifest.json`. +- Every 32-hex value and GUID is synthetic; no dates (ISO or EXIF form) other + than `2000-01-01T00:00:00+00:00`. +- No number or bare numeric string ≥ 1e9, except packed version keys + (`UprightVersion`, `ModelVersion`, `MinEditVersion`, `MinDisplayVersion`, + `CompatibleVersion`, `AutoWhiteVersion`) and the synthetic `GrainSeed`. +- Generated files only: every string satisfies its key's value rule + (`STRING_RULES` in the extractor) or is a number list, brush-dab string or + a synthetic value. +- XMP files: well-formed XML with an `rdf:RDF`, and every `rdf:Alt` item + carries a valid `xml:lang`. Their data model (`xmp_as_tree` in the + extractor and in `fixture_hygiene.rs`: every `rdf:Description` an object, + properties by local name in any namespace, `rdf:Seq`/`rdf:Bag`/`rdf:Alt` + as lists, `True`/`False` as booleans, everything else as text) gets the + same rules as a hand-written JSON file — so `Look.Parameters` is the stub, + `Look.UUID`/`Name` are an Adobe pair or synthetic, no dates, no 32-hex id + or GUID that is not synthetic. Numbers are text in XMP, so the version-key + and `GrainSeed` exemptions apply to numeric strings as well. +- One exception to the table-key substrings: the preset-header key + `RequiresRGBTables` (a preset-header key Adobe's newer presets — the 163 of + 446 bundled ones that carry `SupportsAmount2`, every adaptive preset + included — and every preset the writer produces carry) is removed as a whole ASCII word before the text rules + run, so `RGBTable_` or `RequiresRGBTablesX` still fail. + +Run it by hand with: + +```bash +python3 server-rs/scripts/develop_registry/extract_fixtures.py --check-only \ + --out-dir server-rs/testdata/develop/lua --root server-rs/testdata/develop +``` + +### Line endings + +Everything in this folder is checked out with LF endings on every platform +(`.gitattributes` at the repository root), so byte-for-byte goldens stay +byte-for-byte on a Windows checkout too. The registry snapshot test compares +content and would pass on CRLF as well. + +### Regenerating + +```bash +cd server-rs/scripts/develop_registry +OUT=$(mktemp -d) # private folder outside the repository +uv run --group lance dump_training.py /lrgenius-lance/edit_training.lance "$OUT/training_rows.json" +uv run extract_fixtures.py "$OUT/training_rows.json" --out-dir ../../testdata/develop/lua +rm -rf "$OUT" +``` + +The dump is private (`dump_training.py` creates it with mode 0600 and refuses a +path inside the repository). The extractor writes into a private temporary +folder first and replaces the files it owns in `lua/` only when the hygiene +and shape checks pass; on failure `lua/` is left untouched. Regenerating after +the training table has grown picks different rows and renumbers every synthetic +ID, so expect the whole generated set to change. + +### License + +The files contain no Adobe content (no presets, profiles, profile parameters or +lookup tables) — only the numeric settings of the maintainer's own edits, the +key names Lightroom uses for them and the names/UUIDs of a few profiles every +Lightroom Classic installation ships. They are covered by the repository's +license. + +## `xmp/` — XMP sidecars, presets and profiles + +Input for the XMP reader (`lrg_develop::xmp::parse`), tested by +`crates/lrg-develop/tests/xmp_fixtures.rs` (every file is listed there and +has a test of its own) and `tests/xmp_lua_equivalence.rs` (the same settings +written by hand in the Lua table form must read into an equal model). + +**Self-authored, one file per form.** No file is an Adobe preset or profile, +a copy of one, or an excerpt of a real sidecar: the key names are +Lightroom's, the values are synthetic or reused from the scrubbed `lua/` +fixtures (some mask coordinates, the rotated radial box and its `Angle`, the +brush radius and dab, the range `PointModels`/`ColorAmount`); none comes +from an Adobe file or a real sidecar (ids synthetic as above, names +`Synthetic Text`/`Synthetic Group`/`Synthetic Profile`, `Correction N`, +`Mask N`), the only real identifiers are one profile pair from `ADOBE_LOOKS`. +Each file uses only keys Lightroom writes to XMP (no Lua-only key; checked). + +| File | Form | +|---|---| +| `preset_header.xmp` | develop preset: full header (`RequiresRGBTables` included, as Adobe's newer presets have it), empty `rdf:Alt` items, empty `Cluster`/`CameraModelRestriction` | +| `number_formats.xmp` | `+1.35`, `+0.50`, signed integers (`+6`, `-12`, `+35`), `-1.5`, `0`, `+1.0`, `0.012345`, `True`/`False`, 0/1 flags, a packed version (`CompatibleVersion`), a struct boolean `true` | +| `curves.xmp` | global curves (`"x, y"`) and local curves (`"x,y"`) | +| `masks_ai.xmp` | subject, sky, background, people part (preset form), landscape class, person part at a reference point | +| `mask_linear.xmp` | linear gradient | +| `element_form.xmp` | `mask_linear.xmp` in element form (and an `xpacket` wrapper); reads into the same model | +| `mask_radial.xmp` | radial gradients with `Angle` 0 (typed) and ≠ 0 (opaque) | +| `mask_range.xmp` | luminance range (attributes on the property element) and colour range (nested `rdf:Description`) | +| `parse_type_resource.xmp` | `mask_range.xmp` with `rdf:parseType="Resource"` throughout; reads into the same model | +| `mask_combine.xmp` | add, subtract, intersect, inverted add | +| `mask_brush.xmp` | `Mask/Aggregate` with a nested `Mask/Paint` and its dabs | +| `look_stub.xmp` | `Look` with the stub `Parameters` | +| `preset_record.xmp` | `crs:Preset` with its `Parameters` (kept opaque, never evaluated) | +| `snapshot_crss.xmp` | a `crss:` snapshot (skipped entirely) | +| `multiple_descriptions.xmp` | three `rdf:Description`s, two of them `crs:`, one `dc:`/`xmp:` | +| `unknown_keys.xmp` | unknown keys at every level and a foreign namespace inside a correction (the only file with `UnknownKey` warnings) | +| `coercion_warnings.xmp` | wrong types, a rounded integer, a value outside its set, a short id, an unknown combination (the only other file with warnings) | +| `bag.xmp` | an `rdf:Bag` where the registry expects an `rdf:Seq` (kept whole) | +| `alt_second_language.xmp` | `rdf:Alt` with a second language (kept whole) | +| `bom.xmp` | a UTF-8 byte-order mark | +| `profile.xmp` | a profile (`PresetType="Look"`) | +| `not_develop.xmp` | no develop settings at all (not an error) | + +A new file needs an entry in `FIXTURES` in `tests/xmp_fixtures.rs` and a test +of its own. Every file here is also written back by the XMP writer and must +read into the same model (`tests/xmp_roundtrip.rs`). + +## `written/` — the XMP writer's byte goldens + +Output of the develop-preset writer (`lrg_develop::xmp::write`, preset mode), +compared byte for byte by `crates/lrg-develop/tests/builder_presets.rs`: +the two hand-test presets built with `lrg_develop::build`, with two changes +that keep them within the hygiene rules — synthetic ids (twenty zeros and +twelve hex digits) in place of the derived sync ids and preset UUID (a +SHA-256 id cannot be told from a catalog's), and the development toolkit +string `LrGeniusAI 0.0.0-dev` in place of a release version. Nothing in them +comes from Adobe or a photo. They are also round-tripped with the fixtures in +`tests/xmp_roundtrip.rs`. + +| File | Content | +|---|---| +| `handtest_subject_sky.xmp` | Contrast +15; a Subject correction (Exposure +0.30 EV); a Sky correction (Highlights −40, Dehaze +15) | +| `handtest_landscape_people.xmp` | a Vegetation correction (Saturation +20); an Iris and Pupil correction for all people (Saturation +30) | + +Do not edit them; after an intended change to the writer, regenerate with +`LRG_BLESS=1 cargo test -p lrg-develop --test builder_presets` and review the +diff. + +## `wire/` — the Lua writer's wire goldens + +Tables the Lua writer (`lrg_develop::lua::to_lua_value`, apply mode) hands +to the plugin, one per shape, read by +`crates/lrg-develop/tests/lua_wire_goldens.rs` (byte for byte) and +`tests/lua_roundtrip.rs`, and by the plugin spec +`plugin/spec/native_wire_format_spec.lua` (decoded with JSON.lua). +**Provisional**: they change when experiments E1, E2, E4 and E11 settle the +write-side forms; the list of cases, who reads them and how to re-bless are +in `wire/README.md`. Their inputs are hand-written tables, models built in +the test with synthetic sync ids, and the committed fixtures in `lua/`; +the hygiene rules above apply to them like to every other `*.json` here. +`wire/key_classes.txt` (key names and value classes from the registry, for +the plugin spec) is generated by the same run. +Regenerate with `LRG_BLESS=1 cargo test -p lrg-develop --test lua_wire_goldens`. diff --git a/server-rs/testdata/develop/defaults/non_raw_lrc15.6.json b/server-rs/testdata/develop/defaults/non_raw_lrc15.6.json new file mode 100644 index 00000000..81a3dcf0 --- /dev/null +++ b/server-rs/testdata/develop/defaults/non_raw_lrc15.6.json @@ -0,0 +1,194 @@ +{ + "provenance": { + "lightroom": "15.6", + "process_version": "15.4", + "file_kind": "non-raw (TIFF, a virtual copy without develop edits)" + }, + "settings": { + "AutoBrightness": false, + "AutoContrast": false, + "AutoExposure": false, + "AutoLateralCA": 0, + "AutoShadows": false, + "Blacks2012": 0, + "BlueHue": 0, + "BlueSaturation": 0, + "Brightness": 0, + "CameraProfile": "Embedded", + "ChromaticAberrationB": 0, + "ChromaticAberrationR": 0, + "Clarity": 0, + "Clarity2012": 0, + "ColorGradeBlending": 50, + "ColorGradeGlobalHue": 0, + "ColorGradeGlobalLum": 0, + "ColorGradeGlobalSat": 0, + "ColorGradeHighlightLum": 0, + "ColorGradeMidtoneHue": 0, + "ColorGradeMidtoneLum": 0, + "ColorGradeMidtoneSat": 0, + "ColorGradeShadowLum": 0, + "ColorNoiseReduction": 0, + "ColorNoiseReductionDetail": 50, + "ColorNoiseReductionSmoothness": 50, + "Contrast": 0, + "Contrast2012": 0, + "ConvertToGrayscale": false, + "CropAngle": 0, + "CropBottom": 1, + "CropConstrainToUnitSquare": 1, + "CropConstrainToWarp": 0, + "CropLeft": 0, + "CropRight": 1, + "CropTop": 0, + "CurveRefineSaturation": 100, + "Defringe": 0, + "DefringeGreenAmount": 0, + "DefringeGreenHueHi": 60, + "DefringeGreenHueLo": 40, + "DefringePurpleAmount": 0, + "DefringePurpleHueHi": 70, + "DefringePurpleHueLo": 30, + "Dehaze": 0, + "EnableCalibration": true, + "EnableColorAdjustments": true, + "EnableDetail": true, + "EnableDistractionRemoval": true, + "EnableEffects": true, + "EnableGrayscaleMix": true, + "EnableLensCorrections": true, + "EnableMaskGroupBasedCorrections": true, + "EnableRedEye": true, + "EnableRetouch": true, + "EnableSplitToning": true, + "EnableToneCurve": true, + "EnableTransform": true, + "Exposure": 0, + "Exposure2012": 0, + "FillLight": 0, + "GrainAmount": 0, + "GrainFrequency": 50, + "GrainSize": 25, + "GreenHue": 0, + "GreenSaturation": 0, + "HDREditMode": 0, + "HDRMaxValue": 2.3, + "HighlightRecovery": 0, + "Highlights2012": 0, + "HueAdjustmentAqua": 0, + "HueAdjustmentBlue": 0, + "HueAdjustmentGreen": 0, + "HueAdjustmentMagenta": 0, + "HueAdjustmentOrange": 0, + "HueAdjustmentPurple": 0, + "HueAdjustmentRed": 0, + "HueAdjustmentYellow": 0, + "IncrementalTemperature": 0, + "IncrementalTint": 0, + "LensManualDistortionAmount": 0, + "LensProfileEnable": 0, + "LensProfileSetup": "LensDefaults", + "LuminanceAdjustmentAqua": 0, + "LuminanceAdjustmentBlue": 0, + "LuminanceAdjustmentGreen": 0, + "LuminanceAdjustmentMagenta": 0, + "LuminanceAdjustmentOrange": 0, + "LuminanceAdjustmentPurple": 0, + "LuminanceAdjustmentRed": 0, + "LuminanceAdjustmentYellow": 0, + "LuminanceNoiseReductionContrast": 0, + "LuminanceNoiseReductionDetail": 50, + "LuminanceSmoothing": 0, + "ParametricDarks": 0, + "ParametricHighlightSplit": 75, + "ParametricHighlights": 0, + "ParametricLights": 0, + "ParametricMidtoneSplit": 50, + "ParametricShadowSplit": 25, + "ParametricShadows": 0, + "PerspectiveAspect": 0, + "PerspectiveHorizontal": 0, + "PerspectiveRotate": 0, + "PerspectiveScale": 100, + "PerspectiveUpright": 0, + "PerspectiveVertical": 0, + "PerspectiveX": 0, + "PerspectiveY": 0, + "PostCropVignetteAmount": 0, + "PostCropVignetteFeather": 50, + "PostCropVignetteHighlightContrast": 0, + "PostCropVignetteMidpoint": 50, + "PostCropVignetteRoundness": 0, + "PostCropVignetteStyle": 1, + "RedHue": 0, + "RedSaturation": 0, + "SDRBlend": 0, + "SDRBrightness": 0, + "SDRClarity": 0, + "SDRContrast": 0, + "SDRHighlights": 0, + "SDRShadows": 0, + "SDRWhites": 0, + "Saturation": 0, + "SaturationAdjustmentAqua": 0, + "SaturationAdjustmentBlue": 0, + "SaturationAdjustmentGreen": 0, + "SaturationAdjustmentMagenta": 0, + "SaturationAdjustmentOrange": 0, + "SaturationAdjustmentPurple": 0, + "SaturationAdjustmentRed": 0, + "SaturationAdjustmentYellow": 0, + "ShadowTint": 0, + "Shadows": 0, + "Shadows2012": 0, + "SharpenDetail": 25, + "SharpenEdgeMasking": 0, + "SharpenRadius": 1, + "Sharpness": 0, + "SplitToningBalance": 0, + "SplitToningHighlightHue": 0, + "SplitToningHighlightSaturation": 0, + "SplitToningShadowHue": 0, + "SplitToningShadowSaturation": 0, + "Texture": 0, + "ToneCurveName2012": "Linear", + "ToneCurvePV2012": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Blue": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Green": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Red": [ + 0, + 0, + 255, + 255 + ], + "UprightCenterMode": 0, + "UprightCenterNormX": 0.5, + "UprightCenterNormY": 0.5, + "UprightFocalLength35mm": 35, + "UprightFocalMode": 0, + "UprightFourSegmentsCount": 0, + "UprightPreview": false, + "UprightTransformCount": 6, + "UprightVersion": 151388160, + "Vibrance": 0, + "VignetteAmount": 0, + "VignetteMidpoint": 50, + "WhiteBalance": "As Shot", + "Whites2012": 0 + } +} diff --git a/server-rs/testdata/develop/lua/filterlist_filters.json b/server-rs/testdata/develop/lua/filterlist_filters.json new file mode 100644 index 00000000..246e4005 --- /dev/null +++ b/server-rs/testdata/develop/lua/filterlist_filters.json @@ -0,0 +1,316 @@ +{ + "AILook": [], + "AutoBrightness": false, + "AutoContrast": false, + "AutoExposure": false, + "AutoLateralCA": 1, + "AutoShadows": false, + "Blacks2012": -4, + "BlueHue": -24, + "BlueSaturation": -23, + "Brightness": 50, + "CameraProfile": "Camera Standard", + "ChromaticAberrationB": 0, + "ChromaticAberrationR": 0, + "Clarity": 0, + "Clarity2012": 0, + "ColorGradeBlending": 50, + "ColorGradeGlobalHue": 0, + "ColorGradeGlobalLum": 0, + "ColorGradeGlobalSat": 0, + "ColorGradeHighlightLum": 0, + "ColorGradeMidtoneHue": 218, + "ColorGradeMidtoneLum": 0, + "ColorGradeMidtoneSat": 29, + "ColorGradeShadowLum": 0, + "ColorNoiseReduction": 0, + "ColorNoiseReductionDetail": 50, + "ColorNoiseReductionSmoothness": 50, + "Contrast": 25, + "Contrast2012": 19, + "ConvertToGrayscale": false, + "CropAngle": 0, + "CropBottom": 1, + "CropConstrainToUnitSquare": 1, + "CropConstrainToWarp": 0, + "CropLeft": 0, + "CropRight": 1, + "CropTop": 0, + "CurveRefineSaturation": 100, + "Defringe": 0, + "DefringeGreenAmount": 0, + "DefringeGreenHueHi": 60, + "DefringeGreenHueLo": 40, + "DefringePurpleAmount": 0, + "DefringePurpleHueHi": 70, + "DefringePurpleHueLo": 30, + "Dehaze": 0, + "EnableCalibration": true, + "EnableColorAdjustments": true, + "EnableDetail": true, + "EnableDistractionRemoval": true, + "EnableEffects": true, + "EnableGrayscaleMix": true, + "EnableLensCorrections": true, + "EnableMaskGroupBasedCorrections": true, + "EnableRedEye": true, + "EnableRetouch": true, + "EnableSplitToning": true, + "EnableToneCurve": true, + "EnableTransform": true, + "Exposure": 0, + "Exposure2012": 0.35, + "FillLight": 0, + "FilterList": { + "Filters": [ + { + "CompressedSettings": "00000000000000000000000000000029", + "DstBoundsBottom": 5488, + "DstBoundsLeft": 0, + "DstBoundsRight": 8222, + "DstBoundsTop": 0, + "DstDefaultCropAreaBottom": 5478, + "DstDefaultCropAreaLeft": 14, + "DstDefaultCropAreaRight": 8206, + "DstDefaultCropAreaTop": 14, + "FilterID": 1, + "Images": [ + { + "BlendParams": [ + "751619277/1073741824", + "805306368/1073741824", + "32768/32768", + "32768/32768" + ], + "BlendType": 1, + "BlendWhiteBalanceMode": 2, + "ColorVariations": [ + { + "BlackLevels": [ + { + "Black": "0,1" + }, + { + "Black": "0,1" + }, + { + "Black": "0,1" + } + ], + "ImageGroup": [ + { + "GroupDigest": "000000000000000000000000000000BF", + "OriginalInstanceDigest": "000000000000000000000000000000C0", + "PixelType": 3, + "Planes": 3, + "SizeX": 8222, + "SizeY": 5488, + "Stage3X": 8192, + "Stage3Y": 5464 + } + ], + "Linearization": [ + { + "MapPolynomial": [ + "1589802/1073741824", + "27495956/1073741824", + "0/1", + "412439343/1073741824" + ] + }, + { + "MapPolynomial": [ + "3078126/1073741824", + "50151685/1073741824", + "0/1", + "752275281/1073741824" + ] + }, + { + "MapPolynomial": [ + "1099331/1073741824", + "30975761/1073741824", + "0/1", + "464636418/1073741824" + ] + } + ] + } + ], + "GlobalInputDigest": "0000000000000000000000000000002C", + "MatrixParamsA": [ + "25/100", + "25/100" + ], + "MatrixParamsB": [ + "25/100", + "25/100" + ], + "MatrixTypeA": 1, + "MatrixTypeB": 1, + "MatrixTypeC": 4, + "Opacity": "1,1", + "ReferenceDefaultCropAreaBottom": 5478, + "ReferenceDefaultCropAreaLeft": 14, + "ReferenceDefaultCropAreaRight": 8206, + "ReferenceDefaultCropAreaTop": 14, + "ReferenceImageAreaBottom": 5488, + "ReferenceImageAreaLeft": 0, + "ReferenceImageAreaRight": 8222, + "ReferenceImageAreaTop": 0 + } + ], + "MinDisplayVersion": 285212672, + "MinEditVersion": 285212672, + "Name": "Enhance", + "OrderIndexD": 1, + "OrderIndexN": 100000, + "ShouldDeleteImagesDueToUpstream": true, + "SrcBoundsBottom": 5488, + "SrcBoundsLeft": 0, + "SrcBoundsRight": 8222, + "SrcBoundsTop": 0, + "SrcDefaultCropAreaBottom": 5478, + "SrcDefaultCropAreaLeft": 14, + "SrcDefaultCropAreaRight": 8206, + "SrcDefaultCropAreaTop": 14, + "Title": "$$$/CRaw/Filter/Title/Denoise=Denoise" + } + ] + }, + "GrainAmount": 0, + "GrainFrequency": 50, + "GrainSize": 25, + "GreenHue": 9, + "GreenSaturation": 0, + "HDREditMode": 0, + "HDRMaxValue": 2.3, + "HighlightRecovery": 0, + "Highlights2012": -33, + "HueAdjustmentAqua": 0, + "HueAdjustmentBlue": 0, + "HueAdjustmentGreen": 0, + "HueAdjustmentMagenta": 0, + "HueAdjustmentOrange": 0, + "HueAdjustmentPurple": 0, + "HueAdjustmentRed": 0, + "HueAdjustmentYellow": 0, + "LensBlur": [], + "LensManualDistortionAmount": 0, + "LensProfileDigest": "000000000000000000000000000000BA", + "LensProfileDistortionScale": 100, + "LensProfileEnable": 1, + "LensProfileFilename": "Synthetic Lens - RAW.lcp", + "LensProfileIsEmbedded": false, + "LensProfileName": "Adobe (Synthetic Lens)", + "LensProfileSetup": "LensDefaults", + "LensProfileVignettingScale": 100, + "LuminanceAdjustmentAqua": 0, + "LuminanceAdjustmentBlue": 0, + "LuminanceAdjustmentGreen": -27, + "LuminanceAdjustmentMagenta": 0, + "LuminanceAdjustmentOrange": 0, + "LuminanceAdjustmentPurple": 0, + "LuminanceAdjustmentRed": 0, + "LuminanceAdjustmentYellow": 0, + "LuminanceNoiseReductionContrast": 0, + "LuminanceNoiseReductionDetail": 50, + "LuminanceSmoothing": 0, + "ParametricDarks": 0, + "ParametricHighlightSplit": 75, + "ParametricHighlights": 0, + "ParametricLights": 0, + "ParametricMidtoneSplit": 50, + "ParametricShadowSplit": 25, + "ParametricShadows": 0, + "PerspectiveAspect": 0, + "PerspectiveHorizontal": 0, + "PerspectiveRotate": 0, + "PerspectiveScale": 100, + "PerspectiveUpright": 0, + "PerspectiveVertical": 0, + "PerspectiveX": 0, + "PerspectiveY": 0, + "PointColors": [], + "PostCropVignetteAmount": -37, + "PostCropVignetteFeather": 50, + "PostCropVignetteHighlightContrast": 0, + "PostCropVignetteMidpoint": 50, + "PostCropVignetteRoundness": 0, + "PostCropVignetteStyle": 1, + "ProcessVersion": "15.4", + "RedEyeInfo": [], + "RedHue": -11, + "RedSaturation": 1, + "SDRBlend": 0, + "SDRBrightness": 0, + "SDRClarity": 0, + "SDRContrast": 0, + "SDRHighlights": 0, + "SDRShadows": 0, + "SDRWhites": 0, + "Saturation": 0, + "SaturationAdjustmentAqua": 0, + "SaturationAdjustmentBlue": 20, + "SaturationAdjustmentGreen": 0, + "SaturationAdjustmentMagenta": 29, + "SaturationAdjustmentOrange": 0, + "SaturationAdjustmentPurple": 21, + "SaturationAdjustmentRed": 0, + "SaturationAdjustmentYellow": -42, + "ShadowTint": 0, + "Shadows": 5, + "Shadows2012": 38, + "SharpenDetail": 25, + "SharpenEdgeMasking": 0, + "SharpenRadius": 1, + "Sharpness": 40, + "SplitToningBalance": 0, + "SplitToningHighlightHue": 37, + "SplitToningHighlightSaturation": 29, + "SplitToningShadowHue": 224, + "SplitToningShadowSaturation": 29, + "Temperature": 4850, + "Texture": 0, + "Tint": 20, + "ToneCurveName2012": "Linear", + "ToneCurvePV2012": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Blue": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Green": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Red": [ + 0, + 0, + 255, + 255 + ], + "UprightCenterMode": 0, + "UprightCenterNormX": 0.5, + "UprightCenterNormY": 0.5, + "UprightFocalLength35mm": 35, + "UprightFocalMode": 0, + "UprightFourSegmentsCount": 0, + "UprightPreview": false, + "UprightTransformCount": 6, + "UprightVersion": 151388160, + "Vibrance": 0, + "VignetteAmount": 0, + "VignetteMidpoint": 50, + "WhiteBalance": "As Shot", + "Whites2012": 26, + "orientation": "AB" +} diff --git a/server-rs/testdata/develop/lua/hand_bool_as_int.json b/server-rs/testdata/develop/lua/hand_bool_as_int.json new file mode 100644 index 00000000..e7df8ae9 --- /dev/null +++ b/server-rs/testdata/develop/lua/hand_bool_as_int.json @@ -0,0 +1,30 @@ +{ + "AutoLateralCA": true, + "ConvertToGrayscale": 1, + "EnableDetail": 0, + "LensProfileEnable": false, + "MaskGroupBasedCorrections": [ + { + "CorrectionActive": 1, + "CorrectionAmount": 1, + "CorrectionMasks": [ + { + "MaskActive": 1, + "MaskBlendMode": 0, + "MaskInverted": 0, + "MaskName": "Mask 1", + "MaskSubType": 2, + "MaskValue": 1, + "What": "Mask/Image" + } + ], + "CorrectionName": "Correction 1", + "LocalExposure2012": 0.1, + "What": "Correction" + } + ], + "ProcessVersion": "15.4", + "Temperature": 5200, + "Tint": 4, + "WhiteBalance": "Custom" +} diff --git a/server-rs/testdata/develop/lua/hand_mixed_array.json b/server-rs/testdata/develop/lua/hand_mixed_array.json new file mode 100644 index 00000000..10e0cc6f --- /dev/null +++ b/server-rs/testdata/develop/lua/hand_mixed_array.json @@ -0,0 +1,34 @@ +{ + "MaskGroupBasedCorrections": [ + { + "CorrectionAmount": 1, + "CorrectionMasks": [ + { + "MaskBlendMode": 0, + "MaskInverted": false, + "MaskSubType": 1, + "MaskValue": 1, + "What": "Mask/Image" + } + ], + "MainCurve": ["0,0", 128, "255,255"], + "What": "Correction" + } + ], + "ProcessVersion": "15.4", + "RetouchInfo": [ + { + "Method": "poisson", + "centerX": 0.5, + "centerY": 0.5, + "opacity": 1, + "radius": 0.01, + "sourceState": "sourceAutoComputed", + "sourceX": 0, + "sourceY": 0, + "spotType": "heal" + }, + "0.5 0.5" + ], + "ToneCurvePV2012Red": [0, 0, "128", 128, 255, 255] +} diff --git a/server-rs/testdata/develop/lua/hand_non_integer_on_int_key.json b/server-rs/testdata/develop/lua/hand_non_integer_on_int_key.json new file mode 100644 index 00000000..24a7d163 --- /dev/null +++ b/server-rs/testdata/develop/lua/hand_non_integer_on_int_key.json @@ -0,0 +1,9 @@ +{ + "Contrast2012": 12.5, + "Exposure2012": 1, + "ProcessVersion": "15.4", + "Temperature": 5650.4, + "Tint": -3, + "Vibrance": 13.5, + "WhiteBalance": "Custom" +} diff --git a/server-rs/testdata/develop/lua/hand_non_raw_white_balance.json b/server-rs/testdata/develop/lua/hand_non_raw_white_balance.json new file mode 100644 index 00000000..3e1e7f07 --- /dev/null +++ b/server-rs/testdata/develop/lua/hand_non_raw_white_balance.json @@ -0,0 +1,7 @@ +{ + "Exposure2012": 0.35, + "IncrementalTemperature": 12, + "IncrementalTint": -4, + "ProcessVersion": "15.4", + "WhiteBalance": "Custom" +} diff --git a/server-rs/testdata/develop/lua/hand_null_in_array.json b/server-rs/testdata/develop/lua/hand_null_in_array.json new file mode 100644 index 00000000..7e281fbd --- /dev/null +++ b/server-rs/testdata/develop/lua/hand_null_in_array.json @@ -0,0 +1,6 @@ +{ + "ProcessVersion": "15.4", + "RedEyeInfo": ["0.5 0.5", null], + "ToneCurvePV2012": [0, 0, null, 128, 255, 255], + "ToneCurvePV2012Blue": [0, 0, 255, 255] +} diff --git a/server-rs/testdata/develop/lua/hand_pv_2010.json b/server-rs/testdata/develop/lua/hand_pv_2010.json new file mode 100644 index 00000000..c654026f --- /dev/null +++ b/server-rs/testdata/develop/lua/hand_pv_2010.json @@ -0,0 +1,12 @@ +{ + "Brightness": 50, + "Contrast": 25, + "Exposure": 0.5, + "FillLight": 0, + "HighlightRecovery": 10, + "ProcessVersion": "5.7", + "Shadows": 5, + "Temperature": 5500, + "Tint": 0, + "WhiteBalance": "As Shot" +} diff --git a/server-rs/testdata/develop/lua/hand_wb_both_families.json b/server-rs/testdata/develop/lua/hand_wb_both_families.json new file mode 100644 index 00000000..7bbebd33 --- /dev/null +++ b/server-rs/testdata/develop/lua/hand_wb_both_families.json @@ -0,0 +1,6 @@ +{ + "IncrementalTint": 6, + "ProcessVersion": "15.4", + "Temperature": 5000, + "WhiteBalance": "Custom" +} diff --git a/server-rs/testdata/develop/lua/lensblur_object.json b/server-rs/testdata/develop/lua/lensblur_object.json new file mode 100644 index 00000000..18f0a295 --- /dev/null +++ b/server-rs/testdata/develop/lua/lensblur_object.json @@ -0,0 +1,345 @@ +{ + "AILook": [], + "AutoBrightness": false, + "AutoContrast": false, + "AutoExposure": false, + "AutoLateralCA": 1, + "AutoShadows": false, + "Blacks2012": -27, + "BlueHue": 0, + "BlueSaturation": 0, + "Brightness": 50, + "CameraProfile": "Adobe Standard", + "ChromaticAberrationB": 0, + "ChromaticAberrationR": 0, + "Clarity": 0, + "Clarity2012": 0, + "ColorGradeBlending": 50, + "ColorGradeGlobalHue": 0, + "ColorGradeGlobalLum": 0, + "ColorGradeGlobalSat": 0, + "ColorGradeHighlightLum": 0, + "ColorGradeMidtoneHue": 0, + "ColorGradeMidtoneLum": 0, + "ColorGradeMidtoneSat": 0, + "ColorGradeShadowLum": 0, + "ColorNoiseReduction": 25, + "ColorNoiseReductionDetail": 50, + "ColorNoiseReductionSmoothness": 50, + "Contrast": 25, + "Contrast2012": 20, + "ConvertToGrayscale": false, + "CropAngle": 0, + "CropBottom": 0.923333, + "CropConstrainToUnitSquare": 1, + "CropConstrainToWarp": 0, + "CropLeft": 0.214637, + "CropRight": 0.729191, + "CropTop": 0.151503, + "CurveRefineSaturation": 100, + "Defringe": 0, + "DefringeGreenAmount": 0, + "DefringeGreenHueHi": 60, + "DefringeGreenHueLo": 40, + "DefringePurpleAmount": 0, + "DefringePurpleHueHi": 70, + "DefringePurpleHueLo": 30, + "Dehaze": 0, + "EnableCalibration": true, + "EnableColorAdjustments": true, + "EnableDetail": true, + "EnableDistractionRemoval": true, + "EnableEffects": true, + "EnableGrayscaleMix": true, + "EnableLensCorrections": true, + "EnableMaskGroupBasedCorrections": true, + "EnableRedEye": true, + "EnableRetouch": true, + "EnableSplitToning": true, + "EnableToneCurve": true, + "EnableTransform": true, + "Exposure": 0, + "Exposure2012": 0.15, + "FillLight": 0, + "FilterList": [], + "GrainAmount": 0, + "GrainFrequency": 50, + "GrainSize": 25, + "GreenHue": 0, + "GreenSaturation": 0, + "HDREditMode": 0, + "HDRMaxValue": 4, + "HighlightRecovery": 0, + "Highlights2012": -100, + "HueAdjustmentAqua": 0, + "HueAdjustmentBlue": 0, + "HueAdjustmentGreen": 0, + "HueAdjustmentMagenta": 0, + "HueAdjustmentOrange": 0, + "HueAdjustmentPurple": 0, + "HueAdjustmentRed": 0, + "HueAdjustmentYellow": 0, + "LensBlur": { + "Active": false, + "BlurAmount": 50, + "BokehAspect": 0, + "BokehRotation": 0, + "BokehShape": 0, + "BokehShapeDetail": 0, + "CatEyeAmount": 0, + "CatEyeScale": 100, + "FocalRange": "-73 7 43 123", + "FocalRangeSource": 1, + "HighlightsBoost": 50, + "HighlightsThreshold": 50, + "ImageOrientation": 3, + "SampledArea": "-0.436979 -0.570281 1.563021 1.429719", + "SampledRange": "7 43", + "SphericalAberration": 0, + "SubjectRange": "7 43", + "Version": 1 + }, + "LensManualDistortionAmount": 0, + "LensProfileDigest": "000000000000000000000000000000BA", + "LensProfileDistortionScale": 100, + "LensProfileEnable": 1, + "LensProfileFilename": "Synthetic Lens - RAW.lcp", + "LensProfileIsEmbedded": false, + "LensProfileName": "Adobe (Synthetic Lens)", + "LensProfileSetup": "LensDefaults", + "LensProfileVignettingScale": 100, + "Look": { + "Amount": 1, + "Copyright": "Synthetic copyright notice", + "Group": { + "x-default": "Synthetic Group" + }, + "Name": "Adobe Landscape", + "Parameters": { + "ConvertToGrayscale": false, + "ProcessVersion": "15.4" + }, + "SupportsAmount": false, + "SupportsMonochrome": false, + "SupportsOutputReferred": false, + "UUID": "6F9C877E84273F4E8271E6B91BEB36A1" + }, + "LuminanceAdjustmentAqua": 0, + "LuminanceAdjustmentBlue": 0, + "LuminanceAdjustmentGreen": 0, + "LuminanceAdjustmentMagenta": 0, + "LuminanceAdjustmentOrange": 0, + "LuminanceAdjustmentPurple": 0, + "LuminanceAdjustmentRed": 0, + "LuminanceAdjustmentYellow": 0, + "LuminanceNoiseReductionContrast": 0, + "LuminanceNoiseReductionDetail": 50, + "LuminanceSmoothing": 0, + "MaskGroupBasedCorrections": [ + { + "CorrectionActive": true, + "CorrectionAmount": 1, + "CorrectionID": "00000000-0000-4000-8000-00000000007A", + "CorrectionMasks": [ + { + "Angle": -29.0882, + "Bottom": 0.635755, + "Feather": 45, + "Flipped": true, + "Left": 0.15409, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskID": "00000000-0000-4000-8000-00000000007B", + "MaskInverted": false, + "MaskName": "Mask 1", + "MaskSyncID": "000000000000000000000000000000BB", + "MaskValue": 1, + "Midpoint": 50, + "Right": 0.739495, + "Roundness": 0, + "Top": 0.478149, + "Version": 2, + "What": "Mask/CircularGradient" + } + ], + "CorrectionName": "Correction 1", + "CorrectionReferenceX": 0.446793, + "CorrectionReferenceY": 0.556952, + "CorrectionSyncID": "000000000000000000000000000000BC", + "LocalBlacks2012": 0, + "LocalBrightness": 0, + "LocalClarity": 0, + "LocalClarity2012": 0.198313, + "LocalContrast": 0, + "LocalContrast2012": 0, + "LocalCurveRefineSaturation": 100, + "LocalDefringe": 0, + "LocalDehaze": 0, + "LocalExposure": 0, + "LocalExposure2012": 0.214094, + "LocalGrain": 0, + "LocalHighlights2012": -0.708713, + "LocalHue": 0, + "LocalLuminanceNoise": 0, + "LocalMoire": 0, + "LocalSaturation": 0, + "LocalShadows2012": 0, + "LocalSharpness": 0, + "LocalTemperature": 0.072744, + "LocalTexture": 0.158268, + "LocalTint": 0, + "LocalToningHue": 0, + "LocalToningSaturation": 0, + "LocalWhites2012": 0.200797, + "What": "Correction" + }, + { + "CorrectionActive": true, + "CorrectionAmount": 1, + "CorrectionID": "00000000-0000-4000-8000-00000000007C", + "CorrectionMasks": [ + { + "FullX": 0.482977, + "FullY": 0.916184, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskID": "00000000-0000-4000-8000-00000000007D", + "MaskInverted": false, + "MaskName": "Mask 2", + "MaskSyncID": "000000000000000000000000000000BD", + "MaskValue": 1, + "What": "Mask/Gradient", + "ZeroX": 0.485755, + "ZeroY": 0.392832 + } + ], + "CorrectionName": "Correction 2", + "CorrectionReferenceX": 0.484366, + "CorrectionReferenceY": 0.654508, + "CorrectionSyncID": "000000000000000000000000000000BE", + "LocalBlacks2012": 0, + "LocalBrightness": 0, + "LocalClarity": 0, + "LocalClarity2012": 0, + "LocalContrast": 0, + "LocalContrast2012": 0, + "LocalCurveRefineSaturation": 100, + "LocalDefringe": 0, + "LocalDehaze": 0, + "LocalExposure": 0, + "LocalExposure2012": 0.154077, + "LocalGrain": 0, + "LocalHighlights2012": 0, + "LocalHue": 0, + "LocalLuminanceNoise": 0, + "LocalMoire": 0, + "LocalSaturation": 0, + "LocalShadows2012": 0, + "LocalSharpness": 0, + "LocalTemperature": 0, + "LocalTexture": 0, + "LocalTint": 0, + "LocalToningHue": 0, + "LocalToningSaturation": 0, + "LocalWhites2012": 0, + "What": "Correction" + } + ], + "ParametricDarks": 0, + "ParametricHighlightSplit": 75, + "ParametricHighlights": 0, + "ParametricLights": 0, + "ParametricMidtoneSplit": 50, + "ParametricShadowSplit": 25, + "ParametricShadows": 0, + "PerspectiveAspect": 0, + "PerspectiveHorizontal": 0, + "PerspectiveRotate": 0, + "PerspectiveScale": 100, + "PerspectiveUpright": 0, + "PerspectiveVertical": 0, + "PerspectiveX": 0, + "PerspectiveY": 0, + "PointColors": [], + "PostCropVignetteAmount": 0, + "PostCropVignetteFeather": 50, + "PostCropVignetteHighlightContrast": 0, + "PostCropVignetteMidpoint": 50, + "PostCropVignetteRoundness": 0, + "PostCropVignetteStyle": 1, + "ProcessVersion": "15.4", + "RedEyeInfo": [], + "RedHue": 0, + "RedSaturation": 0, + "SDRBlend": 0, + "SDRBrightness": 0, + "SDRClarity": 0, + "SDRContrast": 0, + "SDRHighlights": 0, + "SDRShadows": 0, + "SDRWhites": 0, + "Saturation": 0, + "SaturationAdjustmentAqua": 0, + "SaturationAdjustmentBlue": 0, + "SaturationAdjustmentGreen": 0, + "SaturationAdjustmentMagenta": 0, + "SaturationAdjustmentOrange": 0, + "SaturationAdjustmentPurple": 0, + "SaturationAdjustmentRed": 0, + "SaturationAdjustmentYellow": 0, + "ShadowTint": 0, + "Shadows": 5, + "Shadows2012": 100, + "SharpenDetail": 25, + "SharpenEdgeMasking": 0, + "SharpenRadius": 1, + "Sharpness": 40, + "SplitToningBalance": 0, + "SplitToningHighlightHue": 0, + "SplitToningHighlightSaturation": 0, + "SplitToningShadowHue": 0, + "SplitToningShadowSaturation": 0, + "Temperature": 5432, + "Texture": 0, + "Tint": 23, + "ToneCurveName2012": "Linear", + "ToneCurvePV2012": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Blue": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Green": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Red": [ + 0, + 0, + 255, + 255 + ], + "UprightCenterMode": 0, + "UprightCenterNormX": 0.5, + "UprightCenterNormY": 0.5, + "UprightFocalLength35mm": 35, + "UprightFocalMode": 0, + "UprightFourSegmentsCount": 0, + "UprightPreview": false, + "UprightTransformCount": 6, + "UprightVersion": 151388160, + "Vibrance": 0, + "VignetteAmount": 0, + "VignetteMidpoint": 50, + "WhiteBalance": "Custom", + "Whites2012": 0, + "orientation": "DA" +} diff --git a/server-rs/testdata/develop/lua/manifest.json b/server-rs/testdata/develop/lua/manifest.json new file mode 100644 index 00000000..a2a4d62d --- /dev/null +++ b/server-rs/testdata/develop/lua/manifest.json @@ -0,0 +1,326 @@ +{ + "description": "Scrubbed photo:getDevelopSettings() values (as JSON.lua encodes them) for the lrg-develop Lua/JSON reader. Generated by server-rs/scripts/develop_registry/extract_fixtures.py; see ../README.md for provenance, scrubbing and the hygiene rules. Files listed here are owned by the generator and replaced on every run; hand-written fixtures are named hand_*.json.", + "look_parameters_stub": { + "allowed_keys": [ + "ConvertToGrayscale", + "ProcessVersion" + ], + "meaning": "Look.Parameters reduced to these keys (or [] when neither exists); not Adobe data." + }, + "synthetic_values": { + "hex32": "20 zeros + 12 hex digits, e.g. 00000000000000000000000000000001", + "guid": "00000000-0000-4000-8000-<12 hex digits>", + "date": "2000-01-01T00:00:00+00:00", + "GrainSeed": 4000000001, + "names": "CorrectionName 'Correction N', MaskName 'Mask N' (numbered per file)", + "free_text": "Synthetic Text" + }, + "files": [ + { + "file": "mask_ai_subject.json", + "primary_shape": "mask_ai_subject", + "covers": [ + "depth_map_info", + "filterlist_empty", + "lensblur_object", + "look", + "look_camera_restricted", + "mask_ai_subject", + "mask_intersect", + "mask_linear_gradient", + "pv_15_4", + "remove_areas", + "retouch_areas" + ], + "process_version": "15.4", + "synthetic": false + }, + { + "file": "mask_ai_sky.json", + "primary_shape": "mask_ai_sky", + "covers": [ + "filterlist_empty", + "grain_seed", + "lensblur_empty", + "look", + "mask_ai_sky", + "pv_15_4" + ], + "process_version": "15.4", + "synthetic": false + }, + { + "file": "mask_ai_background.json", + "primary_shape": "mask_ai_background", + "covers": [ + "filterlist_filters", + "lensblur_empty", + "look", + "mask_ai_background", + "mask_ai_subject", + "mask_brush_aggregate", + "pv_15_4", + "retouch_areas", + "retouch_info" + ], + "process_version": "15.4", + "synthetic": false + }, + { + "file": "mask_ai_people_part_all.json", + "primary_shape": "mask_ai_people_part_all", + "covers": [ + "filterlist_empty", + "lensblur_empty", + "local_legacy_float", + "look", + "look_amount_float", + "mask_ai_people_part_all", + "pv_15_4" + ], + "process_version": "15.4", + "synthetic": false + }, + { + "file": "mask_ai_people_part_person.json", + "primary_shape": "mask_ai_people_part_person", + "covers": [ + "filterlist_empty", + "lensblur_empty", + "look", + "mask_ai_people_part_person", + "pv_11_0" + ], + "process_version": "11.0", + "synthetic": false + }, + { + "file": "mask_ai_landscape.json", + "primary_shape": "mask_ai_landscape", + "covers": [ + "filterlist_empty", + "lensblur_empty", + "local_curve", + "look", + "mask_ai_landscape", + "mask_ai_sky", + "mask_radial_gradient", + "pv_15_4" + ], + "process_version": "15.4", + "synthetic": false + }, + { + "file": "mask_select_object_polygon.json", + "primary_shape": "mask_select_object_polygon", + "covers": [ + "ailook_object", + "filterlist_empty", + "lensblur_empty", + "look", + "look_adaptive", + "mask_linear_gradient", + "mask_radial_gradient", + "mask_range_color", + "mask_select_object_polygon", + "mask_subtract", + "pv_15_4" + ], + "process_version": "15.4", + "synthetic": false + }, + { + "file": "mask_linear_gradient.json", + "primary_shape": "mask_linear_gradient", + "covers": [ + "filterlist_empty", + "lensblur_empty", + "look", + "mask_brush_aggregate", + "mask_linear_gradient", + "mask_radial_gradient", + "point_colors", + "pv_15_4" + ], + "process_version": "15.4", + "synthetic": false + }, + { + "file": "mask_radial_gradient.json", + "primary_shape": "mask_radial_gradient", + "covers": [ + "filterlist_empty", + "lensblur_empty", + "look", + "mask_radial_gradient", + "perspective_rotate_float", + "pv_15_4", + "wb_auto" + ], + "process_version": "15.4", + "synthetic": false + }, + { + "file": "mask_brush_aggregate.json", + "primary_shape": "mask_brush_aggregate", + "covers": [ + "filterlist_empty", + "lensblur_empty", + "look", + "mask_ai_background", + "mask_ai_subject", + "mask_brush_aggregate", + "number_exponent", + "pv_11_0" + ], + "process_version": "11.0", + "synthetic": false + }, + { + "file": "mask_range_luminance.json", + "primary_shape": "mask_range_luminance", + "covers": [ + "filterlist_empty", + "lensblur_empty", + "look", + "mask_ai_sky", + "mask_ai_subject", + "mask_intersect", + "mask_linear_gradient", + "mask_range_color", + "mask_range_color_area", + "mask_range_luminance", + "mask_subtract", + "pv_11_0", + "upright_transform" + ], + "process_version": "11.0", + "synthetic": false + }, + { + "file": "mask_range_color.json", + "primary_shape": "mask_range_color", + "covers": [ + "filterlist_empty", + "lensblur_empty", + "look", + "mask_range_color", + "pv_11_0" + ], + "process_version": "11.0", + "synthetic": false + }, + { + "file": "mask_range_color_area.json", + "primary_shape": "mask_range_color_area", + "covers": [ + "filterlist_empty", + "lensblur_empty", + "look", + "mask_ai_subject", + "mask_range_color", + "mask_range_color_area", + "pv_15_4" + ], + "process_version": "15.4", + "synthetic": false + }, + { + "file": "lensblur_object.json", + "primary_shape": "lensblur_object", + "covers": [ + "filterlist_empty", + "lensblur_object", + "look", + "mask_linear_gradient", + "mask_radial_gradient", + "pv_15_4" + ], + "process_version": "15.4", + "synthetic": false + }, + { + "file": "filterlist_filters.json", + "primary_shape": "filterlist_filters", + "covers": [ + "filterlist_filters", + "lensblur_empty", + "no_look", + "pv_15_4" + ], + "process_version": "15.4", + "synthetic": false + }, + { + "file": "no_look.json", + "primary_shape": "no_look", + "covers": [ + "filterlist_empty", + "lensblur_empty", + "no_look", + "pv_11_0" + ], + "process_version": "11.0", + "synthetic": false + }, + { + "file": "top_level_empty.json", + "primary_shape": "top_level_empty", + "covers": [ + "top_level_empty" + ], + "process_version": null, + "synthetic": true, + "note": "Synthetic: JSON.lua encodes an empty getDevelopSettings() table as []; the reader must treat it as 'no settings'. No such row exists in the source dump, so this file was written by the tool." + } + ], + "shapes_covered": [ + "ailook_object", + "depth_map_info", + "filterlist_empty", + "filterlist_filters", + "grain_seed", + "lensblur_empty", + "lensblur_object", + "local_curve", + "local_legacy_float", + "look", + "look_adaptive", + "look_amount_float", + "look_camera_restricted", + "mask_ai_background", + "mask_ai_landscape", + "mask_ai_people_part_all", + "mask_ai_people_part_person", + "mask_ai_sky", + "mask_ai_subject", + "mask_brush_aggregate", + "mask_intersect", + "mask_linear_gradient", + "mask_radial_gradient", + "mask_range_color", + "mask_range_color_area", + "mask_range_luminance", + "mask_select_object_polygon", + "mask_subtract", + "no_look", + "number_exponent", + "perspective_rotate_float", + "point_colors", + "pv_11_0", + "pv_15_4", + "remove_areas", + "retouch_areas", + "retouch_info", + "top_level_empty", + "upright_transform", + "wb_auto" + ], + "shapes_missing": [], + "scrub_report": { + "GrainSeed replaced by a synthetic constant": 1, + "Look name/UUID replaced (not a verified Adobe profile)": 1, + "Look.Parameters replaced by stub": 14, + "dropped md5 lookup-table key": 2 + } +} diff --git a/server-rs/testdata/develop/lua/mask_ai_background.json b/server-rs/testdata/develop/lua/mask_ai_background.json new file mode 100644 index 00000000..546c8ab1 --- /dev/null +++ b/server-rs/testdata/develop/lua/mask_ai_background.json @@ -0,0 +1,925 @@ +{ + "AutoBrightness": false, + "AutoContrast": false, + "AutoExposure": false, + "AutoLateralCA": 1, + "AutoShadows": false, + "Blacks2012": 0, + "BlueHue": -10, + "BlueSaturation": 0, + "Brightness": 50, + "CameraProfile": "Adobe Standard", + "ChromaticAberrationB": 0, + "ChromaticAberrationR": 0, + "Clarity": 0, + "Clarity2012": 0, + "ColorGradeBlending": 50, + "ColorGradeGlobalHue": 0, + "ColorGradeGlobalLum": 0, + "ColorGradeGlobalSat": 0, + "ColorGradeHighlightLum": 0, + "ColorGradeMidtoneHue": 0, + "ColorGradeMidtoneLum": 0, + "ColorGradeMidtoneSat": 0, + "ColorGradeShadowLum": 0, + "ColorNoiseReduction": 0, + "ColorNoiseReductionDetail": 50, + "ColorNoiseReductionSmoothness": 50, + "Contrast": 25, + "Contrast2012": 40, + "ConvertToGrayscale": false, + "CropAngle": 0, + "CropBottom": 1, + "CropConstrainToUnitSquare": 1, + "CropConstrainToWarp": 0, + "CropLeft": 0, + "CropRight": 1, + "CropTop": 0, + "CurveRefineSaturation": 100, + "Defringe": 0, + "DefringeGreenAmount": 0, + "DefringeGreenHueHi": 60, + "DefringeGreenHueLo": 40, + "DefringePurpleAmount": 0, + "DefringePurpleHueHi": 70, + "DefringePurpleHueLo": 30, + "Dehaze": 0, + "EnableCalibration": true, + "EnableColorAdjustments": true, + "EnableDetail": true, + "EnableDistractionRemoval": true, + "EnableEffects": true, + "EnableGrayscaleMix": true, + "EnableLensCorrections": true, + "EnableMaskGroupBasedCorrections": true, + "EnableRedEye": true, + "EnableRetouch": true, + "EnableSplitToning": true, + "EnableToneCurve": true, + "EnableTransform": true, + "Exposure": 0, + "Exposure2012": 0, + "FillLight": 0, + "FilterList": { + "Filters": [ + { + "CompressedSettings": "00000000000000000000000000000029", + "DstBoundsBottom": 4024, + "DstBoundsLeft": 0, + "DstBoundsRight": 6020, + "DstBoundsTop": 0, + "DstDefaultCropAreaBottom": 4012, + "DstDefaultCropAreaLeft": 12, + "DstDefaultCropAreaRight": 6012, + "DstDefaultCropAreaTop": 12, + "FilterID": 1, + "Images": [ + { + "BlendParams": [ + "751619277/1073741824", + "805306368/1073741824", + "32768/32768", + "32768/32768" + ], + "BlendType": 1, + "BlendWhiteBalanceMode": 2, + "ColorVariations": [ + { + "BlackLevels": [ + { + "Black": "0,1" + }, + { + "Black": "0,1" + }, + { + "Black": "0,1" + } + ], + "ImageGroup": [ + { + "GroupDigest": "0000000000000000000000000000002A", + "OriginalInstanceDigest": "0000000000000000000000000000002B", + "PixelType": 3, + "Planes": 3, + "SizeX": 6020, + "SizeY": 4024, + "Stage3X": 6000, + "Stage3Y": 4000 + } + ], + "Linearization": [ + { + "MapPolynomial": [ + "797897/1073741824", + "12147749/1073741824", + "0/1073741824", + "182216239/1073741824" + ] + }, + { + "MapPolynomial": [ + "1918753/1073741824", + "33221975/1073741824", + "0/1073741824", + "498329622/1073741824" + ] + }, + { + "MapPolynomial": [ + "1766773/1073741824", + "30658492/1073741824", + "0/1073741824", + "459877383/1073741824" + ] + } + ] + } + ], + "GlobalInputDigest": "0000000000000000000000000000002C", + "MatrixParamsA": [ + "25/100", + "25/100" + ], + "MatrixParamsB": [ + "25/100", + "25/100" + ], + "MatrixTypeA": 1, + "MatrixTypeB": 1, + "MatrixTypeC": 4, + "Opacity": "1,1", + "ReferenceDefaultCropAreaBottom": 4012, + "ReferenceDefaultCropAreaLeft": 12, + "ReferenceDefaultCropAreaRight": 6012, + "ReferenceDefaultCropAreaTop": 12, + "ReferenceImageAreaBottom": 4024, + "ReferenceImageAreaLeft": 0, + "ReferenceImageAreaRight": 6020, + "ReferenceImageAreaTop": 0 + } + ], + "MinDisplayVersion": 285212672, + "MinEditVersion": 285212672, + "Name": "Enhance", + "OrderIndexD": 1, + "OrderIndexN": 100000, + "ShouldDeleteImagesDueToUpstream": true, + "SrcBoundsBottom": 4024, + "SrcBoundsLeft": 0, + "SrcBoundsRight": 6020, + "SrcBoundsTop": 0, + "SrcDefaultCropAreaBottom": 4012, + "SrcDefaultCropAreaLeft": 12, + "SrcDefaultCropAreaRight": 6012, + "SrcDefaultCropAreaTop": 12, + "Title": "$$$/CRaw/Filter/Title/Denoise=Denoise" + } + ] + }, + "GrainAmount": 0, + "GrainFrequency": 50, + "GrainSize": 25, + "GreenHue": 0, + "GreenSaturation": 0, + "HDREditMode": 0, + "HDRMaxValue": 2.3, + "HighlightRecovery": 0, + "Highlights2012": 0, + "HueAdjustmentAqua": 0, + "HueAdjustmentBlue": 0, + "HueAdjustmentGreen": 0, + "HueAdjustmentMagenta": 0, + "HueAdjustmentOrange": 0, + "HueAdjustmentPurple": 0, + "HueAdjustmentRed": 0, + "HueAdjustmentYellow": 0, + "LensBlur": [], + "LensManualDistortionAmount": 0, + "LensProfileDigest": "0000000000000000000000000000002D", + "LensProfileDistortionScale": 100, + "LensProfileEnable": 1, + "LensProfileFilename": "Synthetic Lens - RAW.lcp", + "LensProfileIsEmbedded": false, + "LensProfileName": "Adobe (Synthetic Lens)", + "LensProfileSetup": "LensDefaults", + "LensProfileVignettingScale": 100, + "Look": { + "Amount": 1, + "Cluster": "Synthetic", + "Copyright": "Synthetic copyright notice", + "Group": { + "x-default": "Synthetic Group" + }, + "Name": "Artistic 04", + "Parameters": { + "ConvertToGrayscale": false, + "ProcessVersion": "15.4" + }, + "SupportsMonochrome": false, + "UUID": "4B2C86502E5F2637A81F96F8D96AC1F9" + }, + "LuminanceAdjustmentAqua": 0, + "LuminanceAdjustmentBlue": 0, + "LuminanceAdjustmentGreen": 0, + "LuminanceAdjustmentMagenta": 0, + "LuminanceAdjustmentOrange": 0, + "LuminanceAdjustmentPurple": 0, + "LuminanceAdjustmentRed": 0, + "LuminanceAdjustmentYellow": 0, + "LuminanceNoiseReductionContrast": 0, + "LuminanceNoiseReductionDetail": 50, + "LuminanceSmoothing": 0, + "MaskGroupBasedCorrections": [ + { + "CorrectionActive": true, + "CorrectionAmount": 1, + "CorrectionID": "00000000-0000-4000-8000-00000000000E", + "CorrectionMasks": [ + { + "FullMaskSize": "2880,1925", + "InputDigest": "0000000000000000000000000000002E", + "InputDigestVersion": 2, + "LocalInputDigest": "0000000000000000000000000000002F", + "LocalInputDigestVersion": 1, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskDigest": "00000000000000000000000000000030", + "MaskID": "00000000-0000-4000-8000-00000000000F", + "MaskInverted": false, + "MaskName": "Mask 1", + "MaskSubType": 1, + "MaskSyncID": "00000000000000000000000000000031", + "MaskValue": 1, + "MaskVersion": 1, + "ModelVersion": 251659306, + "Origin": "1069,210", + "ReferencePoint": "0.615696 0.252446", + "What": "Mask/Image", + "WholeImageArea": "188107/32768,188117/32768,62890293/32768,94246429/32768" + } + ], + "CorrectionName": "Correction 1", + "CorrectionReferenceX": 0.615696, + "CorrectionReferenceY": 0.252446, + "CorrectionSyncID": "00000000000000000000000000000032", + "LocalBlacks2012": 0, + "LocalBrightness": 0, + "LocalClarity": 0, + "LocalClarity2012": 0.241071, + "LocalContrast": 0, + "LocalContrast2012": 0, + "LocalCurveRefineSaturation": 100, + "LocalDefringe": 0, + "LocalDehaze": 0.037574, + "LocalExposure": 0, + "LocalExposure2012": 0.086031, + "LocalGrain": 0, + "LocalHighlights2012": -0.284552, + "LocalHue": 0, + "LocalLuminanceNoise": 0, + "LocalMoire": 0, + "LocalSaturation": 0, + "LocalShadows2012": 0.157134, + "LocalSharpness": 0.345843, + "LocalTemperature": 0, + "LocalTexture": 0.095517, + "LocalTint": 0, + "LocalToningHue": 0, + "LocalToningSaturation": 0, + "LocalWhites2012": 0.036598, + "What": "Correction" + }, + { + "CorrectionActive": true, + "CorrectionAmount": 1, + "CorrectionID": "00000000-0000-4000-8000-000000000010", + "CorrectionMasks": [ + { + "FullMaskSize": "2880,1925", + "InputDigest": "00000000000000000000000000000033", + "InputDigestVersion": 2, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskDigest": "00000000000000000000000000000034", + "MaskID": "00000000-0000-4000-8000-000000000011", + "MaskInverted": false, + "MaskName": "Mask 2", + "MaskSubCategoryID": 22, + "MaskSubType": 0, + "MaskSyncID": "00000000000000000000000000000035", + "MaskValue": 1, + "MaskVersion": 1, + "ModelVersion": 251659306, + "Origin": "0,0", + "ReferencePoint": "0.216018 0.298409", + "What": "Mask/Image", + "WholeImageArea": "188107/32768,188117/32768,62890293/32768,94246429/32768" + } + ], + "CorrectionName": "Correction 2", + "CorrectionReferenceX": 0.216018, + "CorrectionReferenceY": 0.298409, + "CorrectionSyncID": "00000000000000000000000000000036", + "LocalBlacks2012": 0, + "LocalBrightness": 0, + "LocalClarity": 0, + "LocalClarity2012": 0.14123, + "LocalContrast": 0, + "LocalContrast2012": 0, + "LocalCurveRefineSaturation": 100, + "LocalDefringe": 0, + "LocalDehaze": 0.097889, + "LocalExposure": 0, + "LocalExposure2012": 0, + "LocalGrain": 0, + "LocalHighlights2012": 0, + "LocalHue": 0, + "LocalLuminanceNoise": 0, + "LocalMoire": 0, + "LocalSaturation": 0, + "LocalShadows2012": 0, + "LocalSharpness": 0, + "LocalTemperature": 0, + "LocalTexture": 0, + "LocalTint": 0, + "LocalToningHue": 0, + "LocalToningSaturation": 0, + "LocalWhites2012": 0, + "What": "Correction" + }, + { + "CorrectionActive": true, + "CorrectionAmount": 1, + "CorrectionID": "00000000-0000-4000-8000-000000000012", + "CorrectionMasks": [ + { + "MaskActive": true, + "MaskBlendMode": 0, + "MaskID": "00000000-0000-4000-8000-000000000013", + "MaskInverted": false, + "MaskName": "Mask 3", + "MaskSyncID": "00000000000000000000000000000037", + "MaskValue": 1, + "Masks": [ + { + "CenterWeight": 0, + "Dabs": [ + "M 0.520898 0.305817" + ], + "Flow": 1, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskID": "00000000-0000-4000-8000-000000000014", + "MaskInverted": false, + "MaskSyncID": "00000000000000000000000000000038", + "MaskValue": 1, + "Radius": 0.053214, + "What": "Mask/Paint" + } + ], + "What": "Mask/Aggregate" + } + ], + "CorrectionName": "Correction 3", + "CorrectionReferenceX": 0.520898, + "CorrectionReferenceY": 0.305817, + "CorrectionSyncID": "00000000000000000000000000000039", + "LocalBlacks2012": 0, + "LocalBrightness": 0, + "LocalClarity": 0, + "LocalClarity2012": 0.265299, + "LocalContrast": 0, + "LocalContrast2012": 0, + "LocalCurveRefineSaturation": 100, + "LocalDefringe": 0, + "LocalDehaze": 0, + "LocalExposure": 0, + "LocalExposure2012": 0.260277, + "LocalGrain": 0, + "LocalHighlights2012": 0, + "LocalHue": 0, + "LocalLuminanceNoise": 0, + "LocalMoire": 0, + "LocalSaturation": 0.149554, + "LocalShadows2012": 0.105236, + "LocalSharpness": 0, + "LocalTemperature": 0, + "LocalTexture": 0, + "LocalTint": 0, + "LocalToningHue": 0, + "LocalToningSaturation": 0, + "LocalWhites2012": 0.185268, + "What": "Correction" + } + ], + "ParametricDarks": 0, + "ParametricHighlightSplit": 75, + "ParametricHighlights": 0, + "ParametricLights": 0, + "ParametricMidtoneSplit": 50, + "ParametricShadowSplit": 25, + "ParametricShadows": 0, + "PerspectiveAspect": 0, + "PerspectiveHorizontal": 0, + "PerspectiveRotate": 0, + "PerspectiveScale": 100, + "PerspectiveUpright": 0, + "PerspectiveVertical": 0, + "PerspectiveX": 0, + "PerspectiveY": 0, + "PointColors": [], + "PostCropVignetteAmount": -13, + "PostCropVignetteFeather": 50, + "PostCropVignetteHighlightContrast": 0, + "PostCropVignetteMidpoint": 50, + "PostCropVignetteRoundness": 0, + "PostCropVignetteStyle": 1, + "ProcessVersion": "15.4", + "RedEyeInfo": [], + "RedHue": 0, + "RedSaturation": 0, + "RetouchAreas": [ + { + "Masks": [ + { + "Alpha": 0, + "CenterValue": 1, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskID": "00000000-0000-4000-8000-000000000015", + "MaskInverted": false, + "MaskSyncID": "0000000000000000000000000000003A", + "MaskValue": 1, + "PerimeterValue": 0, + "SizeX": 0.012363, + "SizeY": 0.012363, + "What": "Mask/Ellipse", + "X": 0.380556, + "Y": 0.825826 + } + ], + "Method": "poisson", + "Opacity": 1, + "Seed": 2, + "SourceState": "sourceAutoComputed", + "SpotType": "heal_patchmatch", + "centerX": 0.380556, + "centerY": 0.825826, + "opacity": 1, + "pm_black_level": 9015, + "pm_gamma_type": 1, + "pm_input_digest": "0000000000000000000000000000003B", + "pm_local_input_digest": "0000000000000000000000000000003C", + "pm_patch": "0000000000000000000000000000003D", + "pm_remap_info_0": "0.143205926,0.150972769,0.000000000,1.000000000", + "pm_remap_info_1": "0.156786457,0.180239573,0.000000000,1.000000000", + "pm_remap_info_2": "0.155138478,0.178850994,0.000000000,1.000000000", + "pm_search_bottom": 3613, + "pm_search_left": 1997, + "pm_search_right": 2593, + "pm_search_top": 3017, + "pm_source_type": "full", + "pm_target_bottom": 3390, + "pm_target_left": 2221, + "pm_target_right": 2370, + "pm_target_top": 3241, + "pm_version": 327680, + "pm_whole_image_bottom": 4012, + "pm_whole_image_left": 12, + "pm_whole_image_right": 6012, + "pm_whole_image_top": 12, + "radius": 0.012363, + "seed": 2, + "sourceState": "sourceAutoComputed", + "sourceX": 0, + "sourceY": 0, + "spotType": "heal_patchmatch" + }, + { + "Masks": [ + { + "Alpha": 0, + "CenterValue": 1, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskID": "00000000-0000-4000-8000-000000000016", + "MaskInverted": false, + "MaskSyncID": "0000000000000000000000000000003E", + "MaskValue": 1, + "PerimeterValue": 0, + "SizeX": 0.012359, + "SizeY": 0.012359, + "What": "Mask/Ellipse", + "X": 0.484266, + "Y": 0.856735 + } + ], + "Method": "poisson", + "Opacity": 1, + "Seed": 2, + "SourceState": "sourceAutoComputed", + "SpotType": "heal_patchmatch", + "centerX": 0.484266, + "centerY": 0.856735, + "opacity": 1, + "pm_black_level": 9015, + "pm_gamma_type": 1, + "pm_input_digest": "0000000000000000000000000000003B", + "pm_local_input_digest": "0000000000000000000000000000003C", + "pm_patch": "0000000000000000000000000000003F", + "pm_remap_info_0": "0.143480584,0.145845726,0.000000000,1.000000000", + "pm_remap_info_1": "0.157976657,0.163241014,0.000000000,1.000000000", + "pm_remap_info_2": "0.156450748,0.160585940,0.000000000,1.000000000", + "pm_search_bottom": 3737, + "pm_search_left": 2618, + "pm_search_right": 3216, + "pm_search_top": 3141, + "pm_source_type": "full", + "pm_target_bottom": 3514, + "pm_target_left": 2843, + "pm_target_right": 2992, + "pm_target_top": 3364, + "pm_version": 327680, + "pm_whole_image_bottom": 4012, + "pm_whole_image_left": 12, + "pm_whole_image_right": 6012, + "pm_whole_image_top": 12, + "radius": 0.012359, + "seed": 2, + "sourceState": "sourceAutoComputed", + "sourceX": 0, + "sourceY": 0, + "spotType": "heal_patchmatch" + }, + { + "Masks": [ + { + "Alpha": 0, + "CenterValue": 1, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskID": "00000000-0000-4000-8000-000000000017", + "MaskInverted": false, + "MaskSyncID": "00000000000000000000000000000040", + "MaskValue": 1, + "PerimeterValue": 0, + "SizeX": 0.012415, + "SizeY": 0.012415, + "What": "Mask/Ellipse", + "X": 0.201711, + "Y": 0.746502 + } + ], + "Method": "poisson", + "Opacity": 1, + "Seed": 2, + "SourceState": "sourceAutoComputed", + "SpotType": "heal_patchmatch", + "centerX": 0.201711, + "centerY": 0.746502, + "opacity": 1, + "pm_black_level": 9015, + "pm_gamma_type": 1, + "pm_input_digest": "0000000000000000000000000000003B", + "pm_local_input_digest": "0000000000000000000000000000003C", + "pm_patch": "00000000000000000000000000000041", + "pm_remap_info_0": "0.147875175,0.156572819,0.000000000,1.000000000", + "pm_remap_info_1": "0.171862364,0.197802708,0.000000000,1.000000000", + "pm_remap_info_2": "0.172167540,0.197253376,0.000000000,1.000000000", + "pm_search_bottom": 3298, + "pm_search_left": 922, + "pm_search_right": 1522, + "pm_search_top": 2698, + "pm_source_type": "full", + "pm_target_bottom": 3073, + "pm_target_left": 1147, + "pm_target_right": 1297, + "pm_target_top": 2923, + "pm_version": 327680, + "pm_whole_image_bottom": 4012, + "pm_whole_image_left": 12, + "pm_whole_image_right": 6012, + "pm_whole_image_top": 12, + "radius": 0.012415, + "seed": 2, + "sourceState": "sourceAutoComputed", + "sourceX": 0, + "sourceY": 0, + "spotType": "heal_patchmatch" + }, + { + "Masks": [ + { + "Alpha": 0, + "CenterValue": 1, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskID": "00000000-0000-4000-8000-000000000018", + "MaskInverted": false, + "MaskSyncID": "00000000000000000000000000000042", + "MaskValue": 1, + "PerimeterValue": 0, + "SizeX": 0.012332, + "SizeY": 0.012332, + "What": "Mask/Ellipse", + "X": 0.63026, + "Y": 0.581435 + } + ], + "Method": "poisson", + "Opacity": 1, + "Seed": 2, + "SourceState": "sourceAutoComputed", + "SpotType": "heal_patchmatch", + "centerX": 0.63026, + "centerY": 0.581435, + "opacity": 1, + "pm_black_level": 9015, + "pm_gamma_type": 1, + "pm_input_digest": "0000000000000000000000000000003B", + "pm_local_input_digest": "0000000000000000000000000000003C", + "pm_patch": "00000000000000000000000000000043", + "pm_remap_info_0": "0.145983055,0.157534137,0.000000000,1.000000000", + "pm_remap_info_1": "0.166445404,0.200625613,0.000000000,1.000000000", + "pm_remap_info_2": "0.165606171,0.199893191,0.000000000,1.000000000", + "pm_search_bottom": 2635, + "pm_search_left": 3495, + "pm_search_right": 4091, + "pm_search_top": 2039, + "pm_source_type": "full", + "pm_target_bottom": 2412, + "pm_target_left": 3719, + "pm_target_right": 3868, + "pm_target_top": 2263, + "pm_version": 327680, + "pm_whole_image_bottom": 4012, + "pm_whole_image_left": 12, + "pm_whole_image_right": 6012, + "pm_whole_image_top": 12, + "radius": 0.012332, + "seed": 2, + "sourceState": "sourceAutoComputed", + "sourceX": 0, + "sourceY": 0, + "spotType": "heal_patchmatch" + }, + { + "Masks": [ + { + "Alpha": 0, + "CenterValue": 1, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskID": "00000000-0000-4000-8000-000000000019", + "MaskInverted": false, + "MaskSyncID": "00000000000000000000000000000044", + "MaskValue": 1, + "PerimeterValue": 0, + "SizeX": 0.012494, + "SizeY": 0.012494, + "What": "Mask/Ellipse", + "X": 0.922177, + "Y": 0.639007 + } + ], + "Method": "poisson", + "Opacity": 1, + "Seed": 2, + "SourceState": "sourceAutoComputed", + "SpotType": "heal_patchmatch", + "centerX": 0.922177, + "centerY": 0.639007, + "opacity": 1, + "pm_black_level": 9015, + "pm_gamma_type": 1, + "pm_input_digest": "0000000000000000000000000000003B", + "pm_local_input_digest": "0000000000000000000000000000003C", + "pm_patch": "00000000000000000000000000000045", + "pm_remap_info_0": "0.154299229,0.165804535,0.000000000,1.000000000", + "pm_remap_info_1": "0.192355230,0.223880365,0.000000000,1.000000000", + "pm_remap_info_2": "0.193667501,0.222507060,0.000000000,1.000000000", + "pm_search_bottom": 2869, + "pm_search_left": 5245, + "pm_search_right": 5845, + "pm_search_top": 2267, + "pm_source_type": "full", + "pm_target_bottom": 2643, + "pm_target_left": 5470, + "pm_target_right": 5621, + "pm_target_top": 2493, + "pm_version": 327680, + "pm_whole_image_bottom": 4012, + "pm_whole_image_left": 12, + "pm_whole_image_right": 6012, + "pm_whole_image_top": 12, + "radius": 0.012494, + "seed": 2, + "sourceState": "sourceAutoComputed", + "sourceX": 0, + "sourceY": 0, + "spotType": "heal_patchmatch" + }, + { + "Masks": [ + { + "Alpha": 0, + "CenterValue": 1, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskID": "00000000-0000-4000-8000-00000000001A", + "MaskInverted": false, + "MaskSyncID": "00000000000000000000000000000046", + "MaskValue": 1, + "PerimeterValue": 0, + "SizeX": 0.01242, + "SizeY": 0.01242, + "What": "Mask/Ellipse", + "X": 0.785311, + "Y": 0.796906 + } + ], + "Method": "poisson", + "Opacity": 1, + "Seed": 2, + "SourceState": "sourceAutoComputed", + "SpotType": "heal_patchmatch", + "centerX": 0.785311, + "centerY": 0.796906, + "opacity": 1, + "pm_black_level": 9015, + "pm_gamma_type": 1, + "pm_input_digest": "0000000000000000000000000000003B", + "pm_local_input_digest": "0000000000000000000000000000003C", + "pm_patch": "00000000000000000000000000000047", + "pm_remap_info_0": "0.143572137,0.147493705,0.000000000,1.000000000", + "pm_remap_info_1": "0.157747775,0.169069961,0.000000000,1.000000000", + "pm_remap_info_2": "0.156038761,0.167879760,0.000000000,1.000000000", + "pm_search_bottom": 3500, + "pm_search_left": 4424, + "pm_search_right": 5024, + "pm_search_top": 2900, + "pm_source_type": "full", + "pm_target_bottom": 3275, + "pm_target_left": 4649, + "pm_target_right": 4799, + "pm_target_top": 3125, + "pm_version": 327680, + "pm_whole_image_bottom": 4012, + "pm_whole_image_left": 12, + "pm_whole_image_right": 6012, + "pm_whole_image_top": 12, + "radius": 0.01242, + "seed": 2, + "sourceState": "sourceAutoComputed", + "sourceX": 0, + "sourceY": 0, + "spotType": "heal_patchmatch" + } + ], + "RetouchInfo": [ + { + "Method": "poisson", + "centerX": 0.380556, + "centerY": 0.825826, + "opacity": 1, + "radius": 0.012363, + "sourceState": "sourceAutoComputed", + "sourceX": 0, + "sourceY": 0, + "spotType": "heal_patchmatch" + }, + { + "Method": "poisson", + "centerX": 0.484266, + "centerY": 0.856735, + "opacity": 1, + "radius": 0.012359, + "sourceState": "sourceAutoComputed", + "sourceX": 0, + "sourceY": 0, + "spotType": "heal_patchmatch" + }, + { + "Method": "poisson", + "centerX": 0.201711, + "centerY": 0.746502, + "opacity": 1, + "radius": 0.012415, + "sourceState": "sourceAutoComputed", + "sourceX": 0, + "sourceY": 0, + "spotType": "heal_patchmatch" + }, + { + "Method": "poisson", + "centerX": 0.63026, + "centerY": 0.581435, + "opacity": 1, + "radius": 0.012332, + "sourceState": "sourceAutoComputed", + "sourceX": 0, + "sourceY": 0, + "spotType": "heal_patchmatch" + }, + { + "Method": "poisson", + "centerX": 0.922177, + "centerY": 0.639007, + "opacity": 1, + "radius": 0.012494, + "sourceState": "sourceAutoComputed", + "sourceX": 0, + "sourceY": 0, + "spotType": "heal_patchmatch" + }, + { + "Method": "poisson", + "centerX": 0.785311, + "centerY": 0.796906, + "opacity": 1, + "radius": 0.01242, + "sourceState": "sourceAutoComputed", + "sourceX": 0, + "sourceY": 0, + "spotType": "heal_patchmatch" + } + ], + "SDRBlend": 0, + "SDRBrightness": 0, + "SDRClarity": 0, + "SDRContrast": 0, + "SDRHighlights": 0, + "SDRShadows": 0, + "SDRWhites": 0, + "Saturation": 0, + "SaturationAdjustmentAqua": 0, + "SaturationAdjustmentBlue": 31, + "SaturationAdjustmentGreen": 0, + "SaturationAdjustmentMagenta": 0, + "SaturationAdjustmentOrange": 41, + "SaturationAdjustmentPurple": 0, + "SaturationAdjustmentRed": 8, + "SaturationAdjustmentYellow": 0, + "ShadowTint": 0, + "Shadows": 5, + "Shadows2012": 0, + "SharpenDetail": 25, + "SharpenEdgeMasking": 0, + "SharpenRadius": 1, + "Sharpness": 40, + "SplitToningBalance": 0, + "SplitToningHighlightHue": 0, + "SplitToningHighlightSaturation": 0, + "SplitToningShadowHue": 0, + "SplitToningShadowSaturation": 0, + "Temperature": 5950, + "Texture": 0, + "Tint": 19, + "ToneCurveName2012": "Custom", + "ToneCurvePV2012": [ + 0, + 23, + 79, + 80, + 207, + 215, + 255, + 255 + ], + "ToneCurvePV2012Blue": [ + 0, + 0, + 143, + 129, + 255, + 255 + ], + "ToneCurvePV2012Green": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Red": [ + 0, + 0, + 255, + 255 + ], + "UprightCenterMode": 0, + "UprightCenterNormX": 0.5, + "UprightCenterNormY": 0.5, + "UprightFocalLength35mm": 35, + "UprightFocalMode": 0, + "UprightFourSegmentsCount": 0, + "UprightPreview": false, + "UprightTransformCount": 6, + "UprightVersion": 151388160, + "Vibrance": 0, + "VignetteAmount": 0, + "VignetteMidpoint": 50, + "WhiteBalance": "Custom", + "Whites2012": 0, + "orientation": "AB" +} diff --git a/server-rs/testdata/develop/lua/mask_ai_landscape.json b/server-rs/testdata/develop/lua/mask_ai_landscape.json new file mode 100644 index 00000000..4c5436f4 --- /dev/null +++ b/server-rs/testdata/develop/lua/mask_ai_landscape.json @@ -0,0 +1,460 @@ +{ + "AutoBrightness": false, + "AutoContrast": false, + "AutoExposure": false, + "AutoLateralCA": 1, + "AutoShadows": false, + "Blacks2012": -31, + "BlueHue": 0, + "BlueSaturation": 0, + "Brightness": 50, + "CameraProfile": "Adobe Standard", + "ChromaticAberrationB": 0, + "ChromaticAberrationR": 0, + "Clarity": 0, + "Clarity2012": 0, + "ColorGradeBlending": 50, + "ColorGradeGlobalHue": 0, + "ColorGradeGlobalLum": 0, + "ColorGradeGlobalSat": 0, + "ColorGradeHighlightLum": 0, + "ColorGradeMidtoneHue": 0, + "ColorGradeMidtoneLum": 0, + "ColorGradeMidtoneSat": 0, + "ColorGradeShadowLum": 0, + "ColorNoiseReduction": 25, + "ColorNoiseReductionDetail": 50, + "ColorNoiseReductionSmoothness": 50, + "Contrast": 25, + "Contrast2012": 3, + "ConvertToGrayscale": false, + "CropAngle": 0, + "CropBottom": 1, + "CropConstrainToUnitSquare": 1, + "CropConstrainToWarp": 0, + "CropLeft": 0, + "CropRight": 1, + "CropTop": 0, + "CurveRefineSaturation": 100, + "Defringe": 0, + "DefringeGreenAmount": 0, + "DefringeGreenHueHi": 60, + "DefringeGreenHueLo": 40, + "DefringePurpleAmount": 0, + "DefringePurpleHueHi": 70, + "DefringePurpleHueLo": 30, + "Dehaze": 0, + "EnableCalibration": true, + "EnableColorAdjustments": true, + "EnableDetail": true, + "EnableDistractionRemoval": true, + "EnableEffects": true, + "EnableGrayscaleMix": true, + "EnableLensCorrections": true, + "EnableMaskGroupBasedCorrections": true, + "EnableRedEye": true, + "EnableRetouch": true, + "EnableSplitToning": true, + "EnableToneCurve": true, + "EnableTransform": true, + "Exposure": 0, + "Exposure2012": 0, + "FillLight": 0, + "FilterList": [], + "GrainAmount": 0, + "GrainFrequency": 50, + "GrainSize": 25, + "GreenHue": 0, + "GreenSaturation": 0, + "HDREditMode": 0, + "HDRMaxValue": 4, + "HighlightRecovery": 0, + "Highlights2012": -70, + "HueAdjustmentAqua": 0, + "HueAdjustmentBlue": 0, + "HueAdjustmentGreen": 0, + "HueAdjustmentMagenta": 0, + "HueAdjustmentOrange": 0, + "HueAdjustmentPurple": 0, + "HueAdjustmentRed": 0, + "HueAdjustmentYellow": 0, + "LensBlur": [], + "LensManualDistortionAmount": 0, + "LensProfileDigest": "00000000000000000000000000000061", + "LensProfileDistortionScale": 100, + "LensProfileEnable": 1, + "LensProfileFilename": "Synthetic Lens - RAW.lcp", + "LensProfileIsEmbedded": false, + "LensProfileName": "Adobe (Synthetic Lens)", + "LensProfileSetup": "Custom", + "LensProfileVignettingScale": 100, + "Look": { + "Amount": 1, + "Group": { + "x-default": "Synthetic Group" + }, + "Name": "Adobe Landscape", + "Parameters": { + "ConvertToGrayscale": false, + "ProcessVersion": "15.4" + }, + "SupportsAmount": false, + "SupportsMonochrome": false, + "SupportsOutputReferred": false, + "UUID": "6F9C877E84273F4E8271E6B91BEB36A1" + }, + "LuminanceAdjustmentAqua": 0, + "LuminanceAdjustmentBlue": 0, + "LuminanceAdjustmentGreen": 0, + "LuminanceAdjustmentMagenta": 0, + "LuminanceAdjustmentOrange": 0, + "LuminanceAdjustmentPurple": 0, + "LuminanceAdjustmentRed": 0, + "LuminanceAdjustmentYellow": 0, + "LuminanceNoiseReductionContrast": 0, + "LuminanceNoiseReductionDetail": 50, + "LuminanceSmoothing": 0, + "MaskGroupBasedCorrections": [ + { + "CorrectionActive": true, + "CorrectionAmount": 1, + "CorrectionID": "00000000-0000-4000-8000-00000000002D", + "CorrectionMasks": [ + { + "FullMaskSize": "2880,1920", + "InputDigest": "00000000000000000000000000000062", + "InputDigestVersion": 2, + "LocalInputDigest": "00000000000000000000000000000063", + "LocalInputDigestVersion": 1, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskDigest": "00000000000000000000000000000064", + "MaskID": "00000000-0000-4000-8000-00000000002E", + "MaskInverted": false, + "MaskName": "Mask 1", + "MaskSubCategoryID": 50002, + "MaskSubType": 0, + "MaskSyncID": "00000000000000000000000000000065", + "MaskValue": 1, + "MaskVersion": 1, + "ModelVersion": 234881976, + "Origin": "0,190", + "ReferencePoint": "0.109816 0.457527", + "What": "Mask/Image", + "WholeImageArea": "0/1,0/1,1920/1,2880/1" + } + ], + "CorrectionName": "Correction 1", + "CorrectionReferenceX": 0.109816, + "CorrectionReferenceY": 0.457527, + "CorrectionSyncID": "00000000000000000000000000000066", + "LocalBlacks2012": -0.158952, + "LocalBrightness": 0, + "LocalClarity": 0, + "LocalClarity2012": 0.123182, + "LocalContrast": 0, + "LocalContrast2012": 0.198752, + "LocalCurveRefineSaturation": 100, + "LocalDefringe": 0, + "LocalDehaze": 0, + "LocalExposure": 0, + "LocalExposure2012": 0.182881, + "LocalGrain": 0, + "LocalHighlights2012": 0, + "LocalHue": 0, + "LocalLuminanceNoise": 0, + "LocalMoire": 0, + "LocalSaturation": 0, + "LocalShadows2012": -0.018868, + "LocalSharpness": 0, + "LocalTemperature": 0.151631, + "LocalTexture": 0.183176, + "LocalTint": 0, + "LocalToningHue": 0, + "LocalToningSaturation": 0, + "LocalWhites2012": 0.16308, + "What": "Correction" + }, + { + "CorrectionActive": true, + "CorrectionAmount": 1, + "CorrectionID": "00000000-0000-4000-8000-00000000002F", + "CorrectionMasks": [ + { + "FullMaskSize": "2880,1920", + "InputDigest": "00000000000000000000000000000062", + "InputDigestVersion": 2, + "LocalInputDigest": "00000000000000000000000000000063", + "LocalInputDigestVersion": 1, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskDigest": "00000000000000000000000000000067", + "MaskID": "00000000-0000-4000-8000-000000000030", + "MaskInverted": false, + "MaskName": "Mask 2", + "MaskSubCategoryID": 50003, + "MaskSubType": 0, + "MaskSyncID": "00000000000000000000000000000068", + "MaskValue": 1, + "MaskVersion": 1, + "ModelVersion": 234881976, + "Origin": "879,1260", + "ReferencePoint": "0.739978 0.885218", + "What": "Mask/Image", + "WholeImageArea": "0/1,0/1,1920/1,2880/1" + } + ], + "CorrectionName": "Correction 2", + "CorrectionReferenceX": 0.739978, + "CorrectionReferenceY": 0.885218, + "CorrectionSyncID": "00000000000000000000000000000069", + "LocalBlacks2012": 0, + "LocalBrightness": 0, + "LocalClarity": 0, + "LocalClarity2012": 0.244497, + "LocalContrast": 0, + "LocalContrast2012": 0, + "LocalCurveRefineSaturation": 100, + "LocalDefringe": 0, + "LocalDehaze": 0, + "LocalExposure": 0, + "LocalExposure2012": 0, + "LocalGrain": 0, + "LocalHighlights2012": 0, + "LocalHue": 0, + "LocalLuminanceNoise": 0, + "LocalMoire": 0, + "LocalSaturation": 0, + "LocalShadows2012": 0, + "LocalSharpness": 0, + "LocalTemperature": 0, + "LocalTexture": 0.191873, + "LocalTint": 0, + "LocalToningHue": 0, + "LocalToningSaturation": 0, + "LocalWhites2012": 0, + "MainCurve": [ + "0,0", + "32,16", + "64,50", + "128,128", + "192,202", + "255,255" + ], + "What": "Correction" + }, + { + "CorrectionActive": true, + "CorrectionAmount": 1, + "CorrectionID": "00000000-0000-4000-8000-000000000031", + "CorrectionMasks": [ + { + "FullMaskSize": "2880,1920", + "InputDigest": "00000000000000000000000000000062", + "InputDigestVersion": 2, + "LocalInputDigest": "00000000000000000000000000000063", + "LocalInputDigestVersion": 1, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskDigest": "0000000000000000000000000000006A", + "MaskID": "00000000-0000-4000-8000-000000000032", + "MaskInverted": false, + "MaskName": "Mask 3", + "MaskSubType": 2, + "MaskSyncID": "0000000000000000000000000000006B", + "MaskValue": 1, + "MaskVersion": 1, + "ModelVersion": 234881976, + "Origin": "0,0", + "ReferencePoint": "0.439479 0.205403", + "What": "Mask/Image", + "WholeImageArea": "0/1,0/1,1920/1,2880/1" + } + ], + "CorrectionName": "Correction 3", + "CorrectionReferenceX": 0.439479, + "CorrectionReferenceY": 0.205403, + "CorrectionSyncID": "0000000000000000000000000000006C", + "LocalBlacks2012": 0, + "LocalBrightness": 0, + "LocalClarity": 0, + "LocalClarity2012": 0, + "LocalContrast": 0, + "LocalContrast2012": 0, + "LocalCurveRefineSaturation": 100, + "LocalDefringe": 0, + "LocalDehaze": 0, + "LocalExposure": 0, + "LocalExposure2012": -0.042895, + "LocalGrain": 0, + "LocalHighlights2012": -0.799823, + "LocalHue": 0, + "LocalLuminanceNoise": 0, + "LocalMoire": 0, + "LocalSaturation": 0.304393, + "LocalShadows2012": 0, + "LocalSharpness": 0, + "LocalTemperature": 0, + "LocalTexture": 0, + "LocalTint": 0, + "LocalToningHue": 0, + "LocalToningSaturation": 0, + "LocalWhites2012": 0, + "What": "Correction" + }, + { + "CorrectionActive": true, + "CorrectionAmount": 1, + "CorrectionID": "00000000-0000-4000-8000-000000000033", + "CorrectionMasks": [ + { + "Angle": 0, + "Bottom": 0.605582, + "Feather": 68, + "Flipped": true, + "Left": 0.308815, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskID": "00000000-0000-4000-8000-000000000034", + "MaskInverted": false, + "MaskName": "Mask 4", + "MaskSyncID": "0000000000000000000000000000006D", + "MaskValue": 1, + "Midpoint": 50, + "Right": 0.777085, + "Roundness": 0, + "Top": 0.403836, + "Version": 2, + "What": "Mask/CircularGradient" + } + ], + "CorrectionName": "Correction 4", + "CorrectionReferenceX": 0.54295, + "CorrectionReferenceY": 0.504709, + "CorrectionSyncID": "0000000000000000000000000000006E", + "LocalBlacks2012": 0, + "LocalBrightness": 0, + "LocalClarity": 0, + "LocalClarity2012": 0.270686, + "LocalContrast": 0, + "LocalContrast2012": 0, + "LocalCurveRefineSaturation": 100, + "LocalDefringe": 0, + "LocalDehaze": 0, + "LocalExposure": 0, + "LocalExposure2012": 0, + "LocalGrain": 0, + "LocalHighlights2012": 0, + "LocalHue": 0, + "LocalLuminanceNoise": 0, + "LocalMoire": 0, + "LocalSaturation": 0, + "LocalShadows2012": 0, + "LocalSharpness": 0, + "LocalTemperature": 0, + "LocalTexture": 0, + "LocalTint": 0, + "LocalToningHue": 0, + "LocalToningSaturation": 0, + "LocalWhites2012": 0.155267, + "What": "Correction" + } + ], + "ParametricDarks": 0, + "ParametricHighlightSplit": 75, + "ParametricHighlights": 0, + "ParametricLights": 0, + "ParametricMidtoneSplit": 50, + "ParametricShadowSplit": 25, + "ParametricShadows": 0, + "PerspectiveAspect": 0, + "PerspectiveHorizontal": 0, + "PerspectiveRotate": 0, + "PerspectiveScale": 100, + "PerspectiveUpright": 0, + "PerspectiveVertical": 0, + "PerspectiveX": 0, + "PerspectiveY": 0, + "PointColors": [], + "PostCropVignetteAmount": 0, + "PostCropVignetteFeather": 50, + "PostCropVignetteHighlightContrast": 0, + "PostCropVignetteMidpoint": 50, + "PostCropVignetteRoundness": 0, + "PostCropVignetteStyle": 1, + "ProcessVersion": "15.4", + "RedEyeInfo": [], + "RedHue": 0, + "RedSaturation": 0, + "SDRBlend": 0, + "SDRBrightness": 0, + "SDRClarity": 0, + "SDRContrast": 0, + "SDRHighlights": 0, + "SDRShadows": 0, + "SDRWhites": 0, + "Saturation": 1, + "SaturationAdjustmentAqua": 0, + "SaturationAdjustmentBlue": 0, + "SaturationAdjustmentGreen": 0, + "SaturationAdjustmentMagenta": 0, + "SaturationAdjustmentOrange": 0, + "SaturationAdjustmentPurple": 0, + "SaturationAdjustmentRed": 0, + "SaturationAdjustmentYellow": 0, + "ShadowTint": 0, + "Shadows": 5, + "Shadows2012": 25, + "SharpenDetail": 25, + "SharpenEdgeMasking": 0, + "SharpenRadius": 1, + "Sharpness": 40, + "SplitToningBalance": 0, + "SplitToningHighlightHue": 0, + "SplitToningHighlightSaturation": 0, + "SplitToningShadowHue": 0, + "SplitToningShadowSaturation": 0, + "Temperature": 6900, + "Texture": 0, + "Tint": 14, + "ToneCurveName2012": "Linear", + "ToneCurvePV2012": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Blue": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Green": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Red": [ + 0, + 0, + 255, + 255 + ], + "UprightCenterMode": 0, + "UprightCenterNormX": 0.5, + "UprightCenterNormY": 0.5, + "UprightFocalLength35mm": 35, + "UprightFocalMode": 0, + "UprightFourSegmentsCount": 0, + "UprightPreview": false, + "UprightTransformCount": 6, + "UprightVersion": 151388160, + "Vibrance": 15, + "VignetteAmount": 0, + "VignetteMidpoint": 50, + "WhiteBalance": "As Shot", + "Whites2012": 30, + "orientation": "AB" +} diff --git a/server-rs/testdata/develop/lua/mask_ai_people_part_all.json b/server-rs/testdata/develop/lua/mask_ai_people_part_all.json new file mode 100644 index 00000000..da3da2e9 --- /dev/null +++ b/server-rs/testdata/develop/lua/mask_ai_people_part_all.json @@ -0,0 +1,694 @@ +{ + "AutoBrightness": false, + "AutoContrast": false, + "AutoExposure": false, + "AutoLateralCA": 1, + "AutoShadows": false, + "Blacks2012": -6, + "BlueHue": 0, + "BlueSaturation": 0, + "Brightness": 50, + "CameraProfile": "Adobe Standard", + "ChromaticAberrationB": 0, + "ChromaticAberrationR": 0, + "Clarity": 0, + "Clarity2012": 0, + "ColorGradeBlending": 50, + "ColorGradeGlobalHue": 0, + "ColorGradeGlobalLum": 0, + "ColorGradeGlobalSat": 0, + "ColorGradeHighlightLum": 0, + "ColorGradeMidtoneHue": 0, + "ColorGradeMidtoneLum": 0, + "ColorGradeMidtoneSat": 0, + "ColorGradeShadowLum": 0, + "ColorNoiseReduction": 25, + "ColorNoiseReductionDetail": 90, + "ColorNoiseReductionSmoothness": 90, + "Contrast": 25, + "Contrast2012": -23, + "ConvertToGrayscale": false, + "CropAngle": 0, + "CropBottom": 1, + "CropConstrainToUnitSquare": 1, + "CropConstrainToWarp": 0, + "CropLeft": 0, + "CropRight": 1, + "CropTop": 0, + "CurveRefineSaturation": 100, + "Defringe": 0, + "DefringeGreenAmount": 0, + "DefringeGreenHueHi": 60, + "DefringeGreenHueLo": 40, + "DefringePurpleAmount": 0, + "DefringePurpleHueHi": 70, + "DefringePurpleHueLo": 30, + "Dehaze": 0, + "EnableCalibration": true, + "EnableColorAdjustments": true, + "EnableDetail": true, + "EnableDistractionRemoval": true, + "EnableEffects": true, + "EnableGrayscaleMix": true, + "EnableLensCorrections": true, + "EnableMaskGroupBasedCorrections": true, + "EnableRedEye": true, + "EnableRetouch": true, + "EnableSplitToning": true, + "EnableToneCurve": true, + "EnableTransform": true, + "Exposure": 0, + "Exposure2012": 0, + "FillLight": 0, + "FilterList": [], + "GrainAmount": 0, + "GrainFrequency": 69, + "GrainSize": 11, + "GreenHue": 0, + "GreenSaturation": 0, + "HDREditMode": 0, + "HDRMaxValue": 4, + "HighlightRecovery": 0, + "Highlights2012": 7, + "HueAdjustmentAqua": 0, + "HueAdjustmentBlue": 0, + "HueAdjustmentGreen": 17, + "HueAdjustmentMagenta": 0, + "HueAdjustmentOrange": 0, + "HueAdjustmentPurple": 0, + "HueAdjustmentRed": 0, + "HueAdjustmentYellow": -21, + "LensBlur": [], + "LensManualDistortionAmount": 0, + "LensProfileDigest": "00000000000000000000000000000023", + "LensProfileDistortionScale": 100, + "LensProfileEnable": 1, + "LensProfileFilename": "Synthetic Lens - RAW.lcp", + "LensProfileIsEmbedded": false, + "LensProfileName": "Adobe (Synthetic Lens)", + "LensProfileSetup": "LensDefaults", + "LensProfileVignettingScale": 100, + "Look": { + "Amount": 0.86, + "Cluster": "Synthetic", + "Copyright": "Synthetic copyright notice", + "Group": { + "x-default": "Synthetic Group" + }, + "Name": "Modern 08", + "Parameters": { + "ConvertToGrayscale": false, + "ProcessVersion": "15.4" + }, + "SupportsMonochrome": false, + "UUID": "DA1C3775662D6B6A75F8BC2CEEB3724A" + }, + "LuminanceAdjustmentAqua": 0, + "LuminanceAdjustmentBlue": -9, + "LuminanceAdjustmentGreen": 45, + "LuminanceAdjustmentMagenta": 0, + "LuminanceAdjustmentOrange": 0, + "LuminanceAdjustmentPurple": 0, + "LuminanceAdjustmentRed": 0, + "LuminanceAdjustmentYellow": 0, + "LuminanceNoiseReductionContrast": 0, + "LuminanceNoiseReductionDetail": 50, + "LuminanceSmoothing": 0, + "MaskGroupBasedCorrections": [ + { + "CorrectionActive": true, + "CorrectionAmount": 0.37, + "CorrectionID": "00000000-0000-4000-8000-00000000001B", + "CorrectionMasks": [ + { + "FullMaskSize": "2880,1920", + "InputDigest": "00000000000000000000000000000024", + "InputDigestVersion": 2, + "LocalInputDigest": "00000000000000000000000000000025", + "LocalInputDigestVersion": 1, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskDigest": "00000000000000000000000000000048", + "MaskID": "00000000-0000-4000-8000-00000000001C", + "MaskInverted": false, + "MaskName": "Mask 1", + "MaskSubCategoryID": 2, + "MaskSubType": 3, + "MaskSyncID": "00000000000000000000000000000049", + "MaskValue": 1, + "MaskVersion": 1, + "ModelVersion": 234881976, + "Origin": "1682,626", + "ReferencePoint": "0.655781 0.495613", + "What": "Mask/Image", + "WholeImageArea": "0/1,0/1,1920/1,2880/1" + } + ], + "CorrectionName": "Correction 1", + "CorrectionReferenceX": 0.655781, + "CorrectionReferenceY": 0.495613, + "CorrectionSyncID": "0000000000000000000000000000004A", + "LocalBlacks2012": 0, + "LocalBrightness": 0, + "LocalClarity": -1, + "LocalClarity2012": -0.670511, + "LocalContrast": 0, + "LocalContrast2012": -0.02926, + "LocalCurveRefineSaturation": 100, + "LocalDefringe": 0, + "LocalDehaze": 0.11, + "LocalExposure": 0, + "LocalExposure2012": 0.0125, + "LocalHighlights2012": 0, + "LocalHue": 0, + "LocalLuminanceNoise": 0.6477, + "LocalMoire": 0, + "LocalSaturation": 0, + "LocalShadows2012": -0.060105, + "LocalSharpness": 0.102742, + "LocalTemperature": 0, + "LocalTexture": -0.358785, + "LocalTint": 0, + "LocalToningHue": 240, + "LocalToningSaturation": 0, + "LocalWhites2012": 0, + "What": "Correction" + }, + { + "CorrectionActive": true, + "CorrectionAmount": 1, + "CorrectionID": "00000000-0000-4000-8000-00000000001D", + "CorrectionMasks": [ + { + "ErrorReason": 1, + "InputDigest": "00000000000000000000000000000024", + "InputDigestVersion": 2, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskID": "00000000-0000-4000-8000-00000000001E", + "MaskInverted": false, + "MaskName": "Mask 2", + "MaskSubCategoryID": 8, + "MaskSubType": 3, + "MaskSyncID": "0000000000000000000000000000004B", + "MaskValue": 1, + "MaskVersion": 1, + "ModelVersion": 234881976, + "ReferencePoint": "0.500000 0.500000", + "What": "Mask/Image" + } + ], + "CorrectionName": "Correction 2", + "CorrectionReferenceX": 0.5, + "CorrectionReferenceY": 0.5, + "CorrectionSyncID": "0000000000000000000000000000004C", + "LocalBlacks2012": 0, + "LocalBrightness": 0, + "LocalClarity": 0.655172, + "LocalClarity2012": 0.06, + "LocalContrast": 0, + "LocalContrast2012": 0, + "LocalCurveRefineSaturation": 100, + "LocalDefringe": 0, + "LocalDehaze": 0, + "LocalExposure": 0.172414, + "LocalExposure2012": 0.0275, + "LocalHighlights2012": 0, + "LocalHue": 0, + "LocalLuminanceNoise": 0, + "LocalMoire": 0, + "LocalSaturation": -0.193433, + "LocalShadows2012": 0, + "LocalSharpness": 0, + "LocalTemperature": 0, + "LocalTexture": 0, + "LocalTint": 0, + "LocalToningHue": 240, + "LocalToningSaturation": 0, + "LocalWhites2012": 0.03, + "What": "Correction" + }, + { + "CorrectionActive": true, + "CorrectionAmount": 1, + "CorrectionID": "00000000-0000-4000-8000-00000000001F", + "CorrectionMasks": [ + { + "ErrorReason": 1, + "InputDigest": "00000000000000000000000000000024", + "InputDigestVersion": 2, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskID": "00000000-0000-4000-8000-000000000020", + "MaskInverted": false, + "MaskName": "Mask 3", + "MaskSubCategoryID": 3, + "MaskSubType": 3, + "MaskSyncID": "0000000000000000000000000000004D", + "MaskValue": 1, + "MaskVersion": 1, + "ModelVersion": 234881976, + "ReferencePoint": "0.500000 0.500000", + "What": "Mask/Image" + } + ], + "CorrectionName": "Correction 3", + "CorrectionReferenceX": 0.5, + "CorrectionReferenceY": 0.5, + "CorrectionSyncID": "0000000000000000000000000000004E", + "LocalBlacks2012": 0, + "LocalBrightness": 0, + "LocalClarity": 0.655172, + "LocalClarity2012": 0.03, + "LocalContrast": 0, + "LocalContrast2012": 0, + "LocalCurveRefineSaturation": 100, + "LocalDefringe": 0, + "LocalDehaze": 0.013561, + "LocalExposure": 0.172414, + "LocalExposure2012": 0.044809, + "LocalHighlights2012": -0.061579, + "LocalHue": 0, + "LocalLuminanceNoise": 0, + "LocalMoire": 0, + "LocalSaturation": 0.460495, + "LocalShadows2012": 0, + "LocalSharpness": 0.1, + "LocalTemperature": 0, + "LocalTexture": 0.2, + "LocalTint": 0, + "LocalToningHue": 240, + "LocalToningSaturation": 0, + "LocalWhites2012": 0.070018, + "What": "Correction" + }, + { + "CorrectionActive": true, + "CorrectionAmount": 1, + "CorrectionID": "00000000-0000-4000-8000-000000000021", + "CorrectionMasks": [ + { + "FullMaskSize": "2880,1920", + "InputDigest": "00000000000000000000000000000024", + "InputDigestVersion": 2, + "LocalInputDigest": "00000000000000000000000000000025", + "LocalInputDigestVersion": 1, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskDigest": "0000000000000000000000000000004F", + "MaskID": "00000000-0000-4000-8000-000000000022", + "MaskInverted": false, + "MaskName": "Mask 4", + "MaskSubCategoryID": 6, + "MaskSubType": 3, + "MaskSyncID": "00000000000000000000000000000050", + "MaskValue": 1, + "MaskVersion": 1, + "ModelVersion": 234881976, + "Origin": "1704,949", + "ReferencePoint": "0.599329 0.507310", + "What": "Mask/Image", + "WholeImageArea": "0/1,0/1,1920/1,2880/1" + } + ], + "CorrectionName": "Correction 4", + "CorrectionReferenceX": 0.599329, + "CorrectionReferenceY": 0.50731, + "CorrectionSyncID": "00000000000000000000000000000051", + "LocalBlacks2012": 0, + "LocalBrightness": 0, + "LocalClarity": 0, + "LocalClarity2012": 0.1, + "LocalContrast": 0, + "LocalContrast2012": 0, + "LocalCurveRefineSaturation": 100, + "LocalDefringe": 0, + "LocalDehaze": 0, + "LocalExposure": 0, + "LocalExposure2012": 0, + "LocalHighlights2012": 0, + "LocalHue": 0, + "LocalLuminanceNoise": 0, + "LocalMoire": 0, + "LocalSaturation": 0, + "LocalShadows2012": 0, + "LocalSharpness": 0.05, + "LocalTemperature": 0, + "LocalTexture": 0.2, + "LocalTint": 0, + "LocalToningHue": 240, + "LocalToningSaturation": 0, + "LocalWhites2012": 0.03, + "What": "Correction" + }, + { + "CorrectionActive": true, + "CorrectionAmount": 1, + "CorrectionID": "00000000-0000-4000-8000-000000000023", + "CorrectionMasks": [ + { + "ErrorReason": 1, + "InputDigest": "00000000000000000000000000000024", + "InputDigestVersion": 2, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskID": "00000000-0000-4000-8000-000000000024", + "MaskInverted": false, + "MaskName": "Mask 5", + "MaskSubCategoryID": 12, + "MaskSubType": 3, + "MaskSyncID": "00000000000000000000000000000052", + "MaskValue": 1, + "MaskVersion": 1, + "ModelVersion": 234881976, + "ReferencePoint": "0.500000 0.500000", + "What": "Mask/Image" + } + ], + "CorrectionName": "Correction 5", + "CorrectionReferenceX": 0.5, + "CorrectionReferenceY": 0.5, + "CorrectionSyncID": "00000000000000000000000000000053", + "LocalBlacks2012": 0, + "LocalBrightness": 0, + "LocalClarity": 0, + "LocalClarity2012": 0.03, + "LocalContrast": 0, + "LocalContrast2012": 0, + "LocalCurveRefineSaturation": 100, + "LocalDefringe": 0, + "LocalDehaze": 0, + "LocalExposure": 0.103448, + "LocalExposure2012": 0.0775, + "LocalHighlights2012": 0, + "LocalHue": 0, + "LocalLuminanceNoise": 0, + "LocalMoire": 0, + "LocalSaturation": -0.4, + "LocalShadows2012": 0, + "LocalSharpness": 0, + "LocalTemperature": 0, + "LocalTexture": 0.1, + "LocalTint": 0, + "LocalToningHue": 233, + "LocalToningSaturation": 0, + "LocalWhites2012": 0, + "What": "Correction" + }, + { + "CorrectionActive": true, + "CorrectionAmount": 1, + "CorrectionID": "00000000-0000-4000-8000-000000000025", + "CorrectionMasks": [ + { + "FullMaskSize": "2880,1920", + "InputDigest": "00000000000000000000000000000024", + "InputDigestVersion": 2, + "LocalInputDigest": "00000000000000000000000000000025", + "LocalInputDigestVersion": 1, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskDigest": "00000000000000000000000000000054", + "MaskID": "00000000-0000-4000-8000-000000000026", + "MaskInverted": false, + "MaskName": "Mask 6", + "MaskSubCategoryID": 5, + "MaskSubType": 3, + "MaskSyncID": "00000000000000000000000000000055", + "MaskValue": 1, + "MaskVersion": 1, + "ModelVersion": 234881976, + "Origin": "1822,546", + "ReferencePoint": "0.755010 0.378701", + "What": "Mask/Image", + "WholeImageArea": "0/1,0/1,1920/1,2880/1" + } + ], + "CorrectionName": "Correction 6", + "CorrectionReferenceX": 0.75501, + "CorrectionReferenceY": 0.378701, + "CorrectionSyncID": "00000000000000000000000000000056", + "LocalBlacks2012": 0, + "LocalBrightness": 0, + "LocalClarity": 0, + "LocalClarity2012": 0.07, + "LocalContrast": 0, + "LocalContrast2012": 0, + "LocalCurveRefineSaturation": 100, + "LocalDefringe": 0, + "LocalDehaze": 0, + "LocalExposure": 0, + "LocalExposure2012": 0, + "LocalHighlights2012": 0, + "LocalHue": 0, + "LocalLuminanceNoise": 0, + "LocalMoire": 0, + "LocalSaturation": 0.03, + "LocalShadows2012": 0, + "LocalSharpness": 0, + "LocalTemperature": 0, + "LocalTexture": 0.32, + "LocalTint": 0, + "LocalToningHue": 240, + "LocalToningSaturation": 0, + "LocalWhites2012": 0, + "What": "Correction" + }, + { + "CorrectionActive": true, + "CorrectionAmount": 0.28, + "CorrectionID": "00000000-0000-4000-8000-000000000027", + "CorrectionMasks": [ + { + "FullMaskSize": "2880,1920", + "InputDigest": "00000000000000000000000000000024", + "InputDigestVersion": 2, + "LocalInputDigest": "00000000000000000000000000000025", + "LocalInputDigestVersion": 1, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskDigest": "00000000000000000000000000000057", + "MaskID": "00000000-0000-4000-8000-000000000028", + "MaskInverted": false, + "MaskName": "Mask 7", + "MaskSubCategoryID": 4, + "MaskSubType": 3, + "MaskSyncID": "00000000000000000000000000000058", + "MaskValue": 1, + "MaskVersion": 1, + "ModelVersion": 234881976, + "Origin": "1156,848", + "ReferencePoint": "0.749172 0.624221", + "What": "Mask/Image", + "WholeImageArea": "0/1,0/1,1920/1,2880/1" + } + ], + "CorrectionName": "Correction 7", + "CorrectionReferenceX": 0.749172, + "CorrectionReferenceY": 0.624221, + "CorrectionSyncID": "00000000000000000000000000000059", + "LocalBlacks2012": 0, + "LocalBrightness": 0, + "LocalClarity": 0, + "LocalClarity2012": -0.67, + "LocalContrast": 0, + "LocalContrast2012": -0.03, + "LocalCurveRefineSaturation": 100, + "LocalDefringe": 0, + "LocalDehaze": 0.11, + "LocalExposure": 0, + "LocalExposure2012": 0.0125, + "LocalGrain": 0, + "LocalHighlights2012": 0, + "LocalHue": 0, + "LocalLuminanceNoise": 0.65, + "LocalMoire": 0, + "LocalSaturation": 0, + "LocalShadows2012": -0.06, + "LocalSharpness": 0.1, + "LocalTemperature": 0, + "LocalTexture": -0.36, + "LocalTint": 0, + "LocalToningHue": 240, + "LocalToningSaturation": 0, + "LocalWhites2012": 0, + "What": "Correction" + }, + { + "CorrectionActive": true, + "CorrectionAmount": 1, + "CorrectionID": "00000000-0000-4000-8000-000000000029", + "CorrectionMasks": [ + { + "ErrorReason": 1, + "InputDigest": "00000000000000000000000000000024", + "InputDigestVersion": 2, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskID": "00000000-0000-4000-8000-00000000002A", + "MaskInverted": false, + "MaskName": "Mask 8", + "MaskSubCategoryID": 7, + "MaskSubType": 3, + "MaskSyncID": "0000000000000000000000000000005A", + "MaskValue": 1, + "MaskVersion": 1, + "ModelVersion": 234881976, + "ReferencePoint": "0.500000 0.500000", + "What": "Mask/Image" + } + ], + "CorrectionName": "Correction 8", + "CorrectionReferenceX": 0.5, + "CorrectionReferenceY": 0.5, + "CorrectionSyncID": "0000000000000000000000000000005B", + "LocalBlacks2012": 0, + "LocalBrightness": 0, + "LocalClarity": 0, + "LocalClarity2012": 0.07, + "LocalContrast": 0, + "LocalContrast2012": 0, + "LocalCurveRefineSaturation": 100, + "LocalDefringe": 0, + "LocalDehaze": 0, + "LocalExposure": 0, + "LocalExposure2012": 0, + "LocalGrain": 0, + "LocalHighlights2012": 0, + "LocalHue": 0, + "LocalLuminanceNoise": 0, + "LocalMoire": 0, + "LocalSaturation": 0.02, + "LocalShadows2012": 0, + "LocalSharpness": 0.03, + "LocalTemperature": 0, + "LocalTexture": 0.21, + "LocalTint": 0, + "LocalToningHue": 240, + "LocalToningSaturation": 0, + "LocalWhites2012": 0, + "What": "Correction" + } + ], + "ParametricDarks": 0, + "ParametricHighlightSplit": 75, + "ParametricHighlights": 0, + "ParametricLights": 0, + "ParametricMidtoneSplit": 50, + "ParametricShadowSplit": 25, + "ParametricShadows": 0, + "PerspectiveAspect": 0, + "PerspectiveHorizontal": 0, + "PerspectiveRotate": 0, + "PerspectiveScale": 100, + "PerspectiveUpright": 0, + "PerspectiveVertical": 0, + "PerspectiveX": 0, + "PerspectiveY": 0, + "PointColors": [], + "PostCropVignetteAmount": 0, + "PostCropVignetteFeather": 50, + "PostCropVignetteHighlightContrast": 0, + "PostCropVignetteMidpoint": 50, + "PostCropVignetteRoundness": 0, + "PostCropVignetteStyle": 1, + "ProcessVersion": "15.4", + "RedEyeInfo": [], + "RedHue": 0, + "RedSaturation": 0, + "SDRBlend": 0, + "SDRBrightness": 0, + "SDRClarity": 0, + "SDRContrast": 0, + "SDRHighlights": 0, + "SDRShadows": 0, + "SDRWhites": 0, + "Saturation": -3, + "SaturationAdjustmentAqua": -7, + "SaturationAdjustmentBlue": 0, + "SaturationAdjustmentGreen": -93, + "SaturationAdjustmentMagenta": -11, + "SaturationAdjustmentOrange": 0, + "SaturationAdjustmentPurple": 0, + "SaturationAdjustmentRed": 8, + "SaturationAdjustmentYellow": -33, + "ShadowTint": -27, + "Shadows": 5, + "Shadows2012": 7, + "SharpenDetail": 35, + "SharpenEdgeMasking": 74, + "SharpenRadius": 0.5, + "Sharpness": 50, + "SplitToningBalance": 0, + "SplitToningHighlightHue": 0, + "SplitToningHighlightSaturation": 0, + "SplitToningShadowHue": 0, + "SplitToningShadowSaturation": 0, + "Temperature": 6150, + "Texture": 10, + "Tint": 21, + "ToneCurveName2012": "Custom", + "ToneCurvePV2012": [ + 0, + 39, + 62, + 62, + 128, + 128, + 188, + 175, + 255, + 244 + ], + "ToneCurvePV2012Blue": [ + 0, + 0, + 60, + 40, + 128, + 128, + 192, + 210, + 255, + 255 + ], + "ToneCurvePV2012Green": [ + 0, + 0, + 60, + 40, + 128, + 128, + 192, + 210, + 255, + 255 + ], + "ToneCurvePV2012Red": [ + 0, + 0, + 60, + 40, + 128, + 128, + 192, + 210, + 255, + 255 + ], + "UprightCenterMode": 0, + "UprightCenterNormX": 0.5, + "UprightCenterNormY": 0.5, + "UprightFocalLength35mm": 35, + "UprightFocalMode": 0, + "UprightFourSegmentsCount": 0, + "UprightPreview": false, + "UprightTransformCount": 6, + "UprightVersion": 151388160, + "Vibrance": 53, + "VignetteAmount": 0, + "VignetteMidpoint": 50, + "WhiteBalance": "As Shot", + "Whites2012": 17, + "orientation": "AB" +} diff --git a/server-rs/testdata/develop/lua/mask_ai_people_part_person.json b/server-rs/testdata/develop/lua/mask_ai_people_part_person.json new file mode 100644 index 00000000..325841c1 --- /dev/null +++ b/server-rs/testdata/develop/lua/mask_ai_people_part_person.json @@ -0,0 +1,255 @@ +{ + "AutoBrightness": false, + "AutoContrast": false, + "AutoExposure": false, + "AutoLateralCA": 1, + "AutoShadows": false, + "Blacks2012": -18, + "BlueHue": 0, + "BlueSaturation": 0, + "Brightness": 50, + "CameraProfile": "Adobe Standard", + "ChromaticAberrationB": 0, + "ChromaticAberrationR": 0, + "Clarity": 0, + "Clarity2012": 38, + "ColorGradeBlending": 50, + "ColorGradeGlobalHue": 0, + "ColorGradeGlobalLum": 0, + "ColorGradeGlobalSat": 0, + "ColorGradeHighlightLum": 0, + "ColorGradeMidtoneHue": 0, + "ColorGradeMidtoneLum": 0, + "ColorGradeMidtoneSat": 0, + "ColorGradeShadowLum": 0, + "ColorNoiseReduction": 25, + "ColorNoiseReductionDetail": 50, + "ColorNoiseReductionSmoothness": 50, + "Contrast": 25, + "Contrast2012": 22, + "ConvertToGrayscale": true, + "CropAngle": 0, + "CropBottom": 0.909137, + "CropConstrainToUnitSquare": 1, + "CropConstrainToWarp": 0, + "CropLeft": 0.181726, + "CropRight": 1, + "CropTop": 0.090863, + "CurveRefineSaturation": 100, + "Defringe": 0, + "DefringeGreenAmount": 0, + "DefringeGreenHueHi": 60, + "DefringeGreenHueLo": 40, + "DefringePurpleAmount": 0, + "DefringePurpleHueHi": 70, + "DefringePurpleHueLo": 30, + "Dehaze": 14, + "EnableCalibration": true, + "EnableColorAdjustments": true, + "EnableDetail": true, + "EnableDistractionRemoval": true, + "EnableEffects": true, + "EnableGrayscaleMix": true, + "EnableLensCorrections": true, + "EnableMaskGroupBasedCorrections": true, + "EnableRedEye": true, + "EnableRetouch": true, + "EnableSplitToning": true, + "EnableToneCurve": true, + "EnableTransform": true, + "Exposure": 0, + "Exposure2012": 0.83, + "FillLight": 0, + "FilterList": [], + "GrainAmount": 0, + "GrainFrequency": 50, + "GrainSize": 25, + "GrayMixerAqua": 0, + "GrayMixerBlue": 0, + "GrayMixerGreen": 0, + "GrayMixerMagenta": 0, + "GrayMixerOrange": 0, + "GrayMixerPurple": 0, + "GrayMixerRed": 0, + "GrayMixerYellow": 0, + "GreenHue": 0, + "GreenSaturation": 0, + "HDREditMode": 0, + "HDRMaxValue": 4, + "HighlightRecovery": 0, + "Highlights2012": -64, + "LensBlur": [], + "LensManualDistortionAmount": 0, + "LensProfileDigest": "0000000000000000000000000000005C", + "LensProfileDistortionScale": 100, + "LensProfileEnable": 1, + "LensProfileFilename": "Synthetic Lens - RAW.lcp", + "LensProfileIsEmbedded": false, + "LensProfileName": "Adobe (Synthetic Lens)", + "LensProfileSetup": "LensDefaults", + "LensProfileVignettingScale": 100, + "Look": { + "Amount": 1, + "Group": { + "x-default": "Synthetic Group" + }, + "Name": "Adobe Monochrome", + "Parameters": { + "ConvertToGrayscale": true, + "ProcessVersion": "15.4" + }, + "SupportsAmount": false, + "SupportsMonochrome": false, + "SupportsOutputReferred": false, + "UUID": "0CFE8F8AB5F63B2A73CE0B0077D20817" + }, + "LuminanceNoiseReductionContrast": 0, + "LuminanceNoiseReductionDetail": 50, + "LuminanceSmoothing": 0, + "MaskGroupBasedCorrections": [ + { + "CorrectionActive": true, + "CorrectionAmount": 1, + "CorrectionID": "00000000-0000-4000-8000-00000000002B", + "CorrectionMasks": [ + { + "FullMaskSize": "2880,1920", + "InputDigest": "0000000000000000000000000000005D", + "InputDigestVersion": 2, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskDigest": "0000000000000000000000000000005E", + "MaskID": "00000000-0000-4000-8000-00000000002C", + "MaskInverted": false, + "MaskName": "Mask 1", + "MaskSubCategoryID": 12, + "MaskSubType": 0, + "MaskSyncID": "0000000000000000000000000000005F", + "MaskValue": 1, + "MaskVersion": 1, + "ModelVersion": 234881976, + "Origin": "1532,1039", + "ReferencePoint": "0.544965 0.557040", + "What": "Mask/Image", + "WholeImageArea": "0/1,0/1,1920/1,2880/1" + } + ], + "CorrectionName": "Correction 1", + "CorrectionReferenceX": 0.544965, + "CorrectionReferenceY": 0.55704, + "CorrectionSyncID": "00000000000000000000000000000060", + "LocalBlacks2012": 0, + "LocalBrightness": 0, + "LocalClarity": 0, + "LocalClarity2012": 0, + "LocalContrast": 0, + "LocalContrast2012": -1, + "LocalCurveRefineSaturation": 100, + "LocalDefringe": 0, + "LocalDehaze": 0, + "LocalExposure": 0, + "LocalExposure2012": -0.040094, + "LocalHighlights2012": 0, + "LocalHue": 0, + "LocalLuminanceNoise": 0, + "LocalMoire": 0, + "LocalSaturation": 0, + "LocalShadows2012": 0, + "LocalSharpness": 0, + "LocalTemperature": 0, + "LocalTexture": 0, + "LocalTint": 0, + "LocalToningHue": 0, + "LocalToningSaturation": 0, + "LocalWhites2012": 0, + "What": "Correction" + } + ], + "ParametricDarks": 0, + "ParametricHighlightSplit": 75, + "ParametricHighlights": 0, + "ParametricLights": 0, + "ParametricMidtoneSplit": 50, + "ParametricShadowSplit": 25, + "ParametricShadows": 0, + "PerspectiveAspect": 0, + "PerspectiveHorizontal": 0, + "PerspectiveRotate": 0, + "PerspectiveScale": 100, + "PerspectiveUpright": 0, + "PerspectiveVertical": 0, + "PerspectiveX": 0, + "PerspectiveY": 0, + "PointColors": [], + "PostCropVignetteAmount": -25, + "PostCropVignetteFeather": 50, + "PostCropVignetteHighlightContrast": 0, + "PostCropVignetteMidpoint": 50, + "PostCropVignetteRoundness": 0, + "PostCropVignetteStyle": 1, + "ProcessVersion": "11.0", + "RedEyeInfo": [], + "RedHue": 0, + "RedSaturation": 0, + "SDRBlend": 0, + "SDRBrightness": 0, + "SDRClarity": 0, + "SDRContrast": 0, + "SDRHighlights": 0, + "SDRShadows": 0, + "SDRWhites": 0, + "ShadowTint": 0, + "Shadows": 5, + "Shadows2012": 47, + "SharpenDetail": 25, + "SharpenEdgeMasking": 0, + "SharpenRadius": 1, + "Sharpness": 40, + "SplitToningBalance": 0, + "SplitToningHighlightHue": 0, + "SplitToningHighlightSaturation": 0, + "SplitToningShadowHue": 0, + "SplitToningShadowSaturation": 0, + "Temperature": 5100, + "Texture": 37, + "Tint": 11, + "ToneCurveName2012": "Linear", + "ToneCurvePV2012": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Blue": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Green": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Red": [ + 0, + 0, + 255, + 255 + ], + "UprightCenterMode": 0, + "UprightCenterNormX": 0.5, + "UprightCenterNormY": 0.5, + "UprightFocalLength35mm": 35, + "UprightFocalMode": 0, + "UprightFourSegmentsCount": 0, + "UprightPreview": false, + "UprightTransformCount": 6, + "UprightVersion": 151388160, + "VignetteAmount": 0, + "VignetteMidpoint": 50, + "WhiteBalance": "As Shot", + "Whites2012": 8, + "orientation": "AB" +} diff --git a/server-rs/testdata/develop/lua/mask_ai_sky.json b/server-rs/testdata/develop/lua/mask_ai_sky.json new file mode 100644 index 00000000..e8255502 --- /dev/null +++ b/server-rs/testdata/develop/lua/mask_ai_sky.json @@ -0,0 +1,260 @@ +{ + "AutoBrightness": false, + "AutoContrast": false, + "AutoExposure": false, + "AutoLateralCA": 1, + "AutoShadows": false, + "Blacks2012": 0, + "BlueHue": 0, + "BlueSaturation": 0, + "Brightness": 50, + "CameraProfile": "Adobe Standard", + "ChromaticAberrationB": 0, + "ChromaticAberrationR": 0, + "Clarity": 0, + "Clarity2012": 60, + "ColorGradeBlending": 50, + "ColorGradeGlobalHue": 0, + "ColorGradeGlobalLum": 0, + "ColorGradeGlobalSat": 0, + "ColorGradeHighlightLum": 0, + "ColorGradeMidtoneHue": 0, + "ColorGradeMidtoneLum": 0, + "ColorGradeMidtoneSat": 0, + "ColorGradeShadowLum": 0, + "ColorNoiseReduction": 25, + "ColorNoiseReductionDetail": 50, + "ColorNoiseReductionSmoothness": 50, + "Contrast": 25, + "Contrast2012": 41, + "ConvertToGrayscale": true, + "CropAngle": 0.37, + "CropBottom": 1, + "CropConstrainAspectRatio": true, + "CropConstrainToUnitSquare": 1, + "CropConstrainToWarp": 0, + "CropLeft": 0.006929, + "CropRight": 0.993071, + "CropTop": 0, + "CurveRefineSaturation": 100, + "Defringe": 0, + "DefringeGreenAmount": 0, + "DefringeGreenHueHi": 60, + "DefringeGreenHueLo": 40, + "DefringePurpleAmount": 0, + "DefringePurpleHueHi": 70, + "DefringePurpleHueLo": 30, + "Dehaze": 22, + "EnableCalibration": true, + "EnableColorAdjustments": true, + "EnableDetail": true, + "EnableDistractionRemoval": true, + "EnableEffects": true, + "EnableGrayscaleMix": true, + "EnableLensCorrections": true, + "EnableMaskGroupBasedCorrections": true, + "EnableRedEye": true, + "EnableRetouch": true, + "EnableSplitToning": true, + "EnableToneCurve": true, + "EnableTransform": true, + "Exposure": 0, + "Exposure2012": -0.25, + "FillLight": 0, + "FilterList": [], + "GrainAmount": 63, + "GrainFrequency": 50, + "GrainSeed": 4000000001, + "GrainSize": 25, + "GrayMixerAqua": 0, + "GrayMixerBlue": 0, + "GrayMixerGreen": 0, + "GrayMixerMagenta": 0, + "GrayMixerOrange": 0, + "GrayMixerPurple": 0, + "GrayMixerRed": 0, + "GrayMixerYellow": 0, + "GreenHue": 0, + "GreenSaturation": 0, + "HDREditMode": 0, + "HDRMaxValue": 2.3, + "HighlightRecovery": 0, + "Highlights2012": -100, + "LensBlur": [], + "LensManualDistortionAmount": 0, + "LensProfileDigest": "00000000000000000000000000000023", + "LensProfileDistortionScale": 100, + "LensProfileEnable": 1, + "LensProfileFilename": "Synthetic Lens - RAW.lcp", + "LensProfileIsEmbedded": false, + "LensProfileName": "Adobe (Synthetic Lens)", + "LensProfileSetup": "LensDefaults", + "LensProfileVignettingScale": 100, + "Look": { + "Amount": 1, + "Copyright": "Synthetic copyright notice", + "Group": { + "x-default": "Synthetic Group" + }, + "Name": "Adobe Monochrome", + "Parameters": { + "ConvertToGrayscale": true, + "ProcessVersion": "15.4" + }, + "SupportsAmount": false, + "SupportsMonochrome": false, + "SupportsOutputReferred": false, + "UUID": "0CFE8F8AB5F63B2A73CE0B0077D20817" + }, + "LuminanceNoiseReductionContrast": 0, + "LuminanceNoiseReductionDetail": 50, + "LuminanceSmoothing": 0, + "MaskGroupBasedCorrections": [ + { + "CorrectionActive": true, + "CorrectionAmount": 1, + "CorrectionID": "00000000-0000-4000-8000-00000000000C", + "CorrectionMasks": [ + { + "FullMaskSize": "2880,1920", + "InputDigest": "00000000000000000000000000000024", + "InputDigestVersion": 2, + "LocalInputDigest": "00000000000000000000000000000025", + "LocalInputDigestVersion": 1, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskDigest": "00000000000000000000000000000026", + "MaskID": "00000000-0000-4000-8000-00000000000D", + "MaskInverted": false, + "MaskName": "Mask 1", + "MaskSubType": 2, + "MaskSyncID": "00000000000000000000000000000027", + "MaskValue": 1, + "MaskVersion": 1, + "ModelVersion": 234881976, + "Origin": "0,0", + "ReferencePoint": "0.683599 0.180160", + "What": "Mask/Image", + "WholeImageArea": "0/1,0/1,1920/1,2880/1" + } + ], + "CorrectionName": "Correction 1", + "CorrectionReferenceX": 0.683599, + "CorrectionReferenceY": 0.18016, + "CorrectionSyncID": "00000000000000000000000000000028", + "LocalBlacks2012": 0, + "LocalBrightness": 0, + "LocalClarity": 0, + "LocalClarity2012": 0.288411, + "LocalContrast": 0, + "LocalContrast2012": 0, + "LocalCurveRefineSaturation": 100, + "LocalDefringe": 0, + "LocalDehaze": 0, + "LocalExposure": 0, + "LocalExposure2012": -0.079613, + "LocalGrain": 0, + "LocalHighlights2012": 0, + "LocalHue": 0, + "LocalLuminanceNoise": 0, + "LocalMoire": 0, + "LocalSaturation": 0, + "LocalShadows2012": 0, + "LocalSharpness": 0, + "LocalTemperature": 0, + "LocalTexture": 0.253116, + "LocalTint": 0, + "LocalToningHue": 0, + "LocalToningSaturation": 0, + "LocalWhites2012": 0.097842, + "What": "Correction" + } + ], + "ParametricDarks": 0, + "ParametricHighlightSplit": 75, + "ParametricHighlights": 0, + "ParametricLights": 0, + "ParametricMidtoneSplit": 50, + "ParametricShadowSplit": 25, + "ParametricShadows": 0, + "PerspectiveAspect": 0, + "PerspectiveHorizontal": 0, + "PerspectiveRotate": 0, + "PerspectiveScale": 100, + "PerspectiveUpright": 0, + "PerspectiveVertical": 0, + "PerspectiveX": 0, + "PerspectiveY": 0, + "PointColors": [], + "PostCropVignetteAmount": 0, + "PostCropVignetteFeather": 50, + "PostCropVignetteHighlightContrast": 0, + "PostCropVignetteMidpoint": 50, + "PostCropVignetteRoundness": 0, + "PostCropVignetteStyle": 1, + "ProcessVersion": "15.4", + "RedEyeInfo": [], + "RedHue": 0, + "RedSaturation": 0, + "SDRBlend": 0, + "SDRBrightness": 0, + "SDRClarity": 0, + "SDRContrast": 0, + "SDRHighlights": 0, + "SDRShadows": 0, + "SDRWhites": 0, + "ShadowTint": 0, + "Shadows": 5, + "Shadows2012": 100, + "SharpenDetail": 25, + "SharpenEdgeMasking": 0, + "SharpenRadius": 1, + "Sharpness": 40, + "SplitToningBalance": 0, + "SplitToningHighlightHue": 0, + "SplitToningHighlightSaturation": 0, + "SplitToningShadowHue": 0, + "SplitToningShadowSaturation": 0, + "Temperature": 5750, + "Texture": 66, + "Tint": 4, + "ToneCurveName2012": "Linear", + "ToneCurvePV2012": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Blue": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Green": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Red": [ + 0, + 0, + 255, + 255 + ], + "UprightCenterMode": 0, + "UprightCenterNormX": 0.5, + "UprightCenterNormY": 0.5, + "UprightFocalLength35mm": 35, + "UprightFocalMode": 0, + "UprightFourSegmentsCount": 0, + "UprightPreview": false, + "UprightTransformCount": 6, + "UprightVersion": 151388160, + "VignetteAmount": 0, + "VignetteMidpoint": 50, + "WhiteBalance": "As Shot", + "Whites2012": 0, + "orientation": "AB" +} diff --git a/server-rs/testdata/develop/lua/mask_ai_subject.json b/server-rs/testdata/develop/lua/mask_ai_subject.json new file mode 100644 index 00000000..4988f9ba --- /dev/null +++ b/server-rs/testdata/develop/lua/mask_ai_subject.json @@ -0,0 +1,810 @@ +{ + "AutoBrightness": false, + "AutoContrast": false, + "AutoExposure": false, + "AutoLateralCA": 0, + "AutoShadows": false, + "Blacks2012": -46, + "BlueHue": 0, + "BlueSaturation": 0, + "Brightness": 50, + "CameraProfile": "Adobe Standard", + "ChromaticAberrationB": 0, + "ChromaticAberrationR": 0, + "Clarity": 0, + "Clarity2012": 24, + "ColorGradeBlending": 50, + "ColorGradeGlobalHue": 0, + "ColorGradeGlobalLum": 0, + "ColorGradeGlobalSat": 0, + "ColorGradeHighlightLum": -8, + "ColorGradeMidtoneHue": 41, + "ColorGradeMidtoneLum": 22, + "ColorGradeMidtoneSat": 37, + "ColorGradeShadowLum": -21, + "ColorNoiseReduction": 0, + "ColorNoiseReductionDetail": 50, + "ColorNoiseReductionSmoothness": 50, + "Contrast": 25, + "Contrast2012": 0, + "ConvertToGrayscale": false, + "CropAngle": 0, + "CropBottom": 1, + "CropConstrainToUnitSquare": 1, + "CropConstrainToWarp": 0, + "CropLeft": 0, + "CropRight": 1, + "CropTop": 0, + "CurveRefineSaturation": 100, + "Defringe": 0, + "DefringeGreenAmount": 0, + "DefringeGreenHueHi": 60, + "DefringeGreenHueLo": 40, + "DefringePurpleAmount": 0, + "DefringePurpleHueHi": 70, + "DefringePurpleHueLo": 30, + "Dehaze": 20, + "DepthMapInfo": { + "BaseHighlightGuideInputDigest": "00000000000000000000000000000001", + "BaseHighlightGuideTable": "00000000000000000000000000000002", + "BaseHighlightGuideVersion": 1, + "BaseLayeredDepthInputDigest": "00000000000000000000000000000003", + "BaseLayeredDepthTable": "00000000000000000000000000000004", + "BaseLayeredDepthVersion": 1, + "BaseRawDepthInputDigest": "00000000000000000000000000000005", + "BaseRawDepthTable": "00000000000000000000000000000006", + "BaseRawDepthVersion": 12, + "DepthSource": 2 + }, + "EnableCalibration": true, + "EnableColorAdjustments": true, + "EnableDetail": true, + "EnableDistractionRemoval": true, + "EnableEffects": true, + "EnableGrayscaleMix": true, + "EnableLensCorrections": true, + "EnableMaskGroupBasedCorrections": true, + "EnableRedEye": true, + "EnableRetouch": true, + "EnableSplitToning": true, + "EnableToneCurve": true, + "EnableTransform": true, + "Exposure": 0, + "Exposure2012": 0, + "FillLight": 0, + "FilterList": [], + "GrainAmount": 0, + "GrainFrequency": 50, + "GrainSize": 25, + "GreenHue": 0, + "GreenSaturation": 0, + "HDREditMode": 0, + "HDRMaxValue": 4, + "HighlightRecovery": 0, + "Highlights2012": -100, + "HueAdjustmentAqua": 0, + "HueAdjustmentBlue": 0, + "HueAdjustmentGreen": 0, + "HueAdjustmentMagenta": 0, + "HueAdjustmentOrange": 0, + "HueAdjustmentPurple": 0, + "HueAdjustmentRed": 0, + "HueAdjustmentYellow": 0, + "LensBlur": { + "Active": true, + "BlurAmount": 50, + "BokehAspect": 0, + "BokehRotation": 50, + "BokehShape": 0, + "BokehShapeDetail": 1, + "CatEyeAmount": 0, + "CatEyeScale": 100, + "FocalRange": "-79 1 57 137", + "FocalRangeSource": 2, + "HighlightsBoost": 50, + "HighlightsThreshold": 50, + "ImageOrientation": 0, + "SampledArea": "0.664039 0.528562 0.666010 0.529875", + "SampledRange": "1 57", + "SphericalAberration": 0, + "SubjectRange": "17 53", + "Version": 1 + }, + "LensManualDistortionAmount": 0, + "LensProfileDigest": "00000000000000000000000000000007", + "LensProfileDistortionScale": 100, + "LensProfileEnable": 0, + "LensProfileFilename": "Synthetic Lens - RAW.lcp", + "LensProfileIsEmbedded": false, + "LensProfileName": "Adobe (Synthetic Lens)", + "LensProfileSetup": "LensDefaults", + "LensProfileVignettingScale": 100, + "Look": { + "Amount": 1, + "CameraModelRestriction": "Synthetic Camera", + "Copyright": "Synthetic copyright notice", + "Group": { + "x-default": "Synthetic Group" + }, + "Name": "Synthetic Profile", + "Parameters": { + "ConvertToGrayscale": false, + "ProcessVersion": "15.4" + }, + "SortName": { + "x-default": "Synthetic" + }, + "SupportsAmount": false, + "UUID": "00000000000000000000000000000008" + }, + "LuminanceAdjustmentAqua": 0, + "LuminanceAdjustmentBlue": 0, + "LuminanceAdjustmentGreen": 0, + "LuminanceAdjustmentMagenta": 0, + "LuminanceAdjustmentOrange": 0, + "LuminanceAdjustmentPurple": 0, + "LuminanceAdjustmentRed": 0, + "LuminanceAdjustmentYellow": 0, + "LuminanceNoiseReductionContrast": 0, + "LuminanceNoiseReductionDetail": 50, + "LuminanceSmoothing": 0, + "MaskGroupBasedCorrections": [ + { + "CorrectionActive": true, + "CorrectionAmount": 1, + "CorrectionID": "00000000-0000-4000-8000-000000000001", + "CorrectionMasks": [ + { + "FullMaskSize": "2880,1920", + "InputDigest": "00000000000000000000000000000009", + "InputDigestVersion": 2, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskDigest": "0000000000000000000000000000000A", + "MaskID": "00000000-0000-4000-8000-000000000002", + "MaskInverted": false, + "MaskName": "Mask 1", + "MaskSubType": 1, + "MaskSyncID": "0000000000000000000000000000000B", + "MaskValue": 1, + "MaskVersion": 1, + "ModelVersion": 251659306, + "Origin": "824,939", + "ReferencePoint": "0.433594 0.659824", + "What": "Mask/Image", + "WholeImageArea": "0/1,0/1,1920/1,2880/1" + } + ], + "CorrectionName": "Correction 1", + "CorrectionReferenceX": 0.433594, + "CorrectionReferenceY": 0.659824, + "CorrectionSyncID": "0000000000000000000000000000000C", + "LocalBlacks2012": -0.14894, + "LocalBrightness": 0, + "LocalClarity": 0, + "LocalClarity2012": 0, + "LocalContrast": 0, + "LocalContrast2012": 0, + "LocalCurveRefineSaturation": 100, + "LocalDefringe": 0, + "LocalDehaze": 0, + "LocalExposure": 0, + "LocalExposure2012": 0.12775, + "LocalGrain": 0, + "LocalHighlights2012": 0, + "LocalHue": 0, + "LocalLuminanceNoise": 0, + "LocalMoire": 0, + "LocalSaturation": 0, + "LocalShadows2012": 0, + "LocalSharpness": 0, + "LocalTemperature": 0, + "LocalTexture": 0, + "LocalTint": 0, + "LocalToningHue": 0, + "LocalToningSaturation": 0, + "LocalWhites2012": 0.17021, + "What": "Correction" + }, + { + "CorrectionActive": true, + "CorrectionAmount": 1, + "CorrectionID": "00000000-0000-4000-8000-000000000003", + "CorrectionMasks": [ + { + "FullMaskSize": "2880,1920", + "InputDigest": "00000000000000000000000000000009", + "InputDigestVersion": 2, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskDigest": "0000000000000000000000000000000A", + "MaskID": "00000000-0000-4000-8000-000000000004", + "MaskInverted": false, + "MaskName": "Mask 2", + "MaskSubType": 1, + "MaskSyncID": "0000000000000000000000000000000B", + "MaskValue": 1, + "MaskVersion": 1, + "ModelVersion": 251659306, + "Origin": "824,939", + "ReferencePoint": "0.433594 0.659824", + "What": "Mask/Image", + "WholeImageArea": "0/1,0/1,1920/1,2880/1" + }, + { + "FullX": 0.435791, + "FullY": 0.586874, + "MaskActive": true, + "MaskBlendMode": 1, + "MaskID": "00000000-0000-4000-8000-000000000005", + "MaskInverted": true, + "MaskName": "Mask 3", + "MaskSyncID": "0000000000000000000000000000000D", + "MaskValue": 0, + "What": "Mask/Gradient", + "ZeroX": 0.453764, + "ZeroY": 0.747063 + } + ], + "CorrectionName": "Correction 2", + "CorrectionReferenceX": 0.433594, + "CorrectionReferenceY": 0.659824, + "CorrectionSyncID": "0000000000000000000000000000000E", + "LocalBlacks2012": 0, + "LocalBrightness": 0, + "LocalClarity": 0, + "LocalClarity2012": 0, + "LocalContrast": 0, + "LocalContrast2012": 0, + "LocalCurveRefineSaturation": 100, + "LocalDefringe": 0, + "LocalDehaze": 0, + "LocalExposure": 0, + "LocalExposure2012": 0, + "LocalGrain": 0, + "LocalHighlights2012": 0, + "LocalHue": 0, + "LocalLuminanceNoise": 0, + "LocalMoire": 0, + "LocalSaturation": 0, + "LocalShadows2012": 0, + "LocalSharpness": 0, + "LocalTemperature": 0.31876, + "LocalTexture": 0, + "LocalTint": 0, + "LocalToningHue": 0, + "LocalToningSaturation": 0, + "LocalWhites2012": 0, + "What": "Correction" + } + ], + "ParametricDarks": 0, + "ParametricHighlightSplit": 75, + "ParametricHighlights": 0, + "ParametricLights": 0, + "ParametricMidtoneSplit": 50, + "ParametricShadowSplit": 25, + "ParametricShadows": 0, + "PerspectiveAspect": 0, + "PerspectiveHorizontal": 0, + "PerspectiveRotate": 0, + "PerspectiveScale": 100, + "PerspectiveUpright": 0, + "PerspectiveVertical": 0, + "PerspectiveX": 0, + "PerspectiveY": 0, + "PointColors": [], + "PostCropVignetteAmount": -34, + "PostCropVignetteFeather": 50, + "PostCropVignetteHighlightContrast": 0, + "PostCropVignetteMidpoint": 50, + "PostCropVignetteRoundness": 0, + "PostCropVignetteStyle": 1, + "ProcessVersion": "15.4", + "RedEyeInfo": [], + "RedHue": 0, + "RedSaturation": 0, + "RemoveAreas": [ + { + "IngestInfo": "0000000000000000000000000000000F", + "Masks": [ + { + "CenterWeight": 1, + "Dabs": [ + "d 0.089310 0.374088", + "d 0.090058 0.385844", + "d 0.090806 0.397599", + "d 0.090897 0.409408", + "d 0.091079 0.421214", + "d 0.091260 0.433020", + "d 0.091340 0.444829", + "d 0.091420 0.456637", + "d 0.092401 0.468354", + "d 0.094060 0.479898", + "d 0.095190 0.491585", + "d 0.096649 0.503190" + ], + "Flow": 1, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskID": "00000000-0000-4000-8000-000000000006", + "MaskInverted": false, + "MaskSyncID": "00000000000000000000000000000010", + "MaskValue": 1, + "Radius": 0.039351, + "What": "Mask/Paint" + } + ], + "Method": "poisson", + "Opacity": 1, + "Seed": 1, + "SourceState": "sourceAutoComputed", + "SpotType": "heal_patchmatch", + "fill_method": "firefly", + "pm_black_level": 0, + "pm_clio_model_version": "synthetic-model-version", + "pm_gamma_type": 1, + "pm_input_digest": "00000000000000000000000000000011", + "pm_mask_remap_info": "0.000000000,1.000000000,0.000000000,1.000000000", + "pm_patch": "00000000000000000000000000000012", + "pm_patch_mask": "00000000000000000000000000000013", + "pm_patch_variations": [ + { + "pm_patch_variation": "00000000000000000000000000000014" + }, + { + "pm_patch_variation": "00000000000000000000000000000015" + } + ], + "pm_remap_info_0": "0.002929732,0.143434808,0.000000000,1.000000000", + "pm_remap_info_1": "0.005920500,0.263065547,0.000000000,1.000000000", + "pm_remap_info_2": "0.003814755,0.169649810,0.000000000,1.000000000", + "pm_remap_info_variation_0_0": "0.002792401,0.143434808,0.000000000,1.000000000", + "pm_remap_info_variation_0_1": "0.005920500,0.263035029,0.000000000,1.000000000", + "pm_remap_info_variation_0_2": "0.003814755,0.169649810,0.000000000,1.000000000", + "pm_remap_info_variation_1_0": "0.003250172,0.143434808,0.000000000,1.000000000", + "pm_remap_info_variation_1_1": "0.006790265,0.263035029,0.000000000,1.000000000", + "pm_remap_info_variation_1_2": "0.003814755,0.169649810,0.000000000,1.000000000", + "pm_search_bottom": 2300, + "pm_search_left": 24, + "pm_search_right": 1042, + "pm_search_top": 900, + "pm_source_type": "full", + "pm_target_bottom": 2061, + "pm_target_left": 263, + "pm_target_right": 755, + "pm_target_top": 1139, + "pm_version": 327680, + "pm_whole_image_bottom": 3648, + "pm_whole_image_left": 0, + "pm_whole_image_right": 5472, + "pm_whole_image_top": 0 + }, + { + "IngestInfo": "0000000000000000000000000000000F", + "Masks": [ + { + "CenterWeight": 1, + "Dabs": [ + "d 0.194140 0.351316", + "d 0.197709 0.361842", + "d 0.201953 0.371786", + "d 0.206198 0.381730", + "d 0.210765 0.391349", + "d 0.215331 0.400967", + "d 0.220073 0.410392", + "d 0.224814 0.419818", + "d 0.229690 0.429087", + "d 0.234755 0.438127", + "d 0.232785 0.449560", + "d 0.226736 0.457115" + ], + "Flow": 1, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskID": "00000000-0000-4000-8000-000000000007", + "MaskInverted": false, + "MaskSyncID": "00000000000000000000000000000016", + "MaskValue": 1, + "Radius": 0.039351, + "What": "Mask/Paint" + } + ], + "Method": "poisson", + "Opacity": 1, + "Seed": 1, + "SourceState": "sourceAutoComputed", + "SpotType": "heal_patchmatch", + "fill_method": "firefly", + "pm_black_level": 0, + "pm_clio_model_version": "synthetic-model-version", + "pm_full_image_bottom": 3648, + "pm_full_image_left": 0, + "pm_full_image_right": 5472, + "pm_full_image_top": 0, + "pm_gamma_type": 1, + "pm_input_digest": "00000000000000000000000000000011", + "pm_mask_remap_info": "0.000000000,1.000000000,0.000000000,1.000000000", + "pm_patch": "00000000000000000000000000000017", + "pm_patch_mask": "00000000000000000000000000000018", + "pm_patch_variations": [ + { + "pm_patch_variation": "00000000000000000000000000000019" + }, + { + "pm_patch_variation": "0000000000000000000000000000001A" + } + ], + "pm_remap_info_0": "0.006942855,0.050583657,0.000000000,1.000000000", + "pm_remap_info_1": "0.014084077,0.100724801,0.000000000,1.000000000", + "pm_remap_info_2": "0.005996795,0.050141145,0.000000000,1.000000000", + "pm_remap_info_variation_0_0": "0.004394598,0.045273516,0.000000000,1.000000000", + "pm_remap_info_variation_0_1": "0.009475852,0.083573662,0.000000000,1.000000000", + "pm_remap_info_variation_0_2": "0.004837110,0.042816814,0.000000000,1.000000000", + "pm_remap_info_variation_1_0": "0.003814755,0.050583657,0.000000000,1.000000000", + "pm_remap_info_variation_1_1": "0.008316167,0.095292591,0.000000000,1.000000000", + "pm_remap_info_variation_1_2": "0.004486152,0.042816814,0.000000000,1.000000000", + "pm_search_bottom": 2171, + "pm_search_left": 558, + "pm_search_right": 1952, + "pm_search_top": 777, + "pm_source_type": "full", + "pm_target_bottom": 1896, + "pm_target_left": 834, + "pm_target_right": 1513, + "pm_target_top": 1053, + "pm_version": 327680, + "pm_whole_image_bottom": 3648, + "pm_whole_image_left": 0, + "pm_whole_image_right": 5472, + "pm_whole_image_top": 0 + }, + { + "IngestInfo": "0000000000000000000000000000000F", + "Masks": [ + { + "FullMaskSize": "2880,1920", + "Gesture": [ + { + "BrushGestureInterpretation": 0, + "CenterWeight": 0, + "Dabs": [ + "r 0.018427", + "h 1.0000", + "d 0.423497 0.760203", + "d 0.427167 0.760709", + "d 0.430850 0.760913", + "d 0.434535 0.760993", + "d 0.438220 0.761040", + "d 0.441905 0.761053", + "d 0.445588 0.760863", + "d 0.449237 0.760082", + "d 0.452921 0.759930", + "d 0.456602 0.760175", + "d 0.460274 0.760651", + "d 0.463956 0.760897", + "d 0.467637 0.760637", + "d 0.471322 0.760593", + "d 0.475004 0.760357", + "d 0.478678 0.760793", + "d 0.482354 0.761201", + "d 0.486010 0.761888", + "d 0.489665 0.762603", + "d 0.493298 0.763534" + ], + "Flow": 1, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskID": "00000000-0000-4000-8000-000000000008", + "MaskInverted": false, + "MaskSyncID": "0000000000000000000000000000001B", + "MaskValue": 1, + "Radius": 0, + "What": "Mask/Paint" + } + ], + "InputDigest": "0000000000000000000000000000001C", + "InputDigestVersion": 2, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskDigest": "0000000000000000000000000000001D", + "MaskID": "00000000-0000-4000-8000-000000000009", + "MaskInverted": false, + "MaskSubType": 0, + "MaskSyncID": "0000000000000000000000000000001E", + "MaskValue": 1, + "MaskVersion": 1, + "ModelVersion": 234881976, + "Origin": "1118,1374", + "ReferencePoint": "0.458984 0.756598", + "What": "Mask/Image", + "WholeImageArea": "0/1,0/1,1920/1,2880/1" + } + ], + "Method": "poisson", + "Opacity": 1, + "Seed": 2, + "SourceState": "sourceAutoComputed", + "SpotType": "heal_patchmatch", + "fill_method": "firefly", + "pm_black_level": 0, + "pm_clio_model_version": "synthetic-model-version", + "pm_current_variation_index": 1, + "pm_full_image_bottom": 3648, + "pm_full_image_left": 0, + "pm_full_image_right": 5472, + "pm_full_image_top": 0, + "pm_gamma_type": 1, + "pm_input_digest": "00000000000000000000000000000011", + "pm_mask_remap_info": "0.000000000,1.000000000,0.000000000,1.000000000", + "pm_patch": "0000000000000000000000000000001F", + "pm_patch_mask": "00000000000000000000000000000020", + "pm_patch_variations": [ + { + "pm_patch_variation": "00000000000000000000000000000021" + }, + { + "pm_patch_variation": "00000000000000000000000000000022" + } + ], + "pm_remap_info_0": "0.000488289,0.424078733,0.000000000,1.000000000", + "pm_remap_info_1": "0.000518807,0.931700647,0.000000000,1.000000000", + "pm_remap_info_2": "0.000350958,0.658716738,0.000000000,1.000000000", + "pm_remap_info_variation_0_0": "0.000579843,0.435584038,0.000000000,1.000000000", + "pm_remap_info_variation_0_1": "0.001174945,1.000000000,0.000000000,1.000000000", + "pm_remap_info_variation_0_2": "0.000930800,0.726207376,0.000000000,1.000000000", + "pm_remap_info_variation_1_0": "0.000534066,0.390386820,0.000000000,1.000000000", + "pm_remap_info_variation_1_1": "0.001174945,0.841580808,0.000000000,1.000000000", + "pm_remap_info_variation_1_2": "0.001007095,0.612359822,0.000000000,1.000000000", + "pm_search_bottom": 3129, + "pm_search_left": 1937, + "pm_search_right": 3172, + "pm_search_top": 2424, + "pm_source_type": "full", + "pm_target_bottom": 2950, + "pm_target_left": 2117, + "pm_target_right": 2889, + "pm_target_top": 2604, + "pm_version": 327680, + "pm_whole_image_bottom": 3648, + "pm_whole_image_left": 0, + "pm_whole_image_right": 5472, + "pm_whole_image_top": 0 + } + ], + "RetouchAreas": [ + { + "IngestInfo": "0000000000000000000000000000000F", + "Masks": [ + { + "CenterWeight": 1, + "Dabs": [ + "d 0.089310 0.374088", + "d 0.090058 0.385844", + "d 0.090806 0.397599", + "d 0.090897 0.409408", + "d 0.091079 0.421214", + "d 0.091260 0.433020", + "d 0.091340 0.444829", + "d 0.091420 0.456637", + "d 0.092401 0.468354", + "d 0.094060 0.479898", + "d 0.095190 0.491585", + "d 0.096649 0.503190" + ], + "Flow": 1, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskID": "00000000-0000-4000-8000-00000000000A", + "MaskInverted": false, + "MaskSyncID": "00000000000000000000000000000010", + "MaskValue": 1, + "Radius": 0.039351, + "What": "Mask/Paint" + } + ], + "Method": "poisson", + "Opacity": 1, + "Seed": 1, + "SourceState": "sourceAutoComputed", + "SpotType": "heal_patchmatch", + "fill_method": "firefly", + "pm_black_level": 0, + "pm_clio_model_version": "synthetic-model-version", + "pm_gamma_type": 1, + "pm_input_digest": "00000000000000000000000000000011", + "pm_mask_remap_info": "0.000000000,1.000000000,0.000000000,1.000000000", + "pm_patch": "00000000000000000000000000000012", + "pm_patch_mask": "00000000000000000000000000000013", + "pm_patch_variations": [ + { + "pm_patch_variation": "00000000000000000000000000000014" + }, + { + "pm_patch_variation": "00000000000000000000000000000015" + } + ], + "pm_remap_info_0": "0.002929732,0.143434808,0.000000000,1.000000000", + "pm_remap_info_1": "0.005920500,0.263065547,0.000000000,1.000000000", + "pm_remap_info_2": "0.003814755,0.169649810,0.000000000,1.000000000", + "pm_remap_info_variation_0_0": "0.002792401,0.143434808,0.000000000,1.000000000", + "pm_remap_info_variation_0_1": "0.005920500,0.263035029,0.000000000,1.000000000", + "pm_remap_info_variation_0_2": "0.003814755,0.169649810,0.000000000,1.000000000", + "pm_remap_info_variation_1_0": "0.003250172,0.143434808,0.000000000,1.000000000", + "pm_remap_info_variation_1_1": "0.006790265,0.263035029,0.000000000,1.000000000", + "pm_remap_info_variation_1_2": "0.003814755,0.169649810,0.000000000,1.000000000", + "pm_search_bottom": 2300, + "pm_search_left": 24, + "pm_search_right": 1042, + "pm_search_top": 900, + "pm_source_type": "full", + "pm_target_bottom": 2061, + "pm_target_left": 263, + "pm_target_right": 755, + "pm_target_top": 1139, + "pm_version": 327680, + "pm_whole_image_bottom": 3648, + "pm_whole_image_left": 0, + "pm_whole_image_right": 5472, + "pm_whole_image_top": 0 + }, + { + "IngestInfo": "0000000000000000000000000000000F", + "Masks": [ + { + "CenterWeight": 1, + "Dabs": [ + "d 0.194140 0.351316", + "d 0.197709 0.361842", + "d 0.201953 0.371786", + "d 0.206198 0.381730", + "d 0.210765 0.391349", + "d 0.215331 0.400967", + "d 0.220073 0.410392", + "d 0.224814 0.419818", + "d 0.229690 0.429087", + "d 0.234755 0.438127", + "d 0.232785 0.449560", + "d 0.226736 0.457115" + ], + "Flow": 1, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskID": "00000000-0000-4000-8000-00000000000B", + "MaskInverted": false, + "MaskSyncID": "00000000000000000000000000000016", + "MaskValue": 1, + "Radius": 0.039351, + "What": "Mask/Paint" + } + ], + "Method": "poisson", + "Opacity": 1, + "Seed": 1, + "SourceState": "sourceAutoComputed", + "SpotType": "heal_patchmatch", + "fill_method": "firefly", + "pm_black_level": 0, + "pm_clio_model_version": "synthetic-model-version", + "pm_full_image_bottom": 3648, + "pm_full_image_left": 0, + "pm_full_image_right": 5472, + "pm_full_image_top": 0, + "pm_gamma_type": 1, + "pm_input_digest": "00000000000000000000000000000011", + "pm_mask_remap_info": "0.000000000,1.000000000,0.000000000,1.000000000", + "pm_patch": "00000000000000000000000000000017", + "pm_patch_mask": "00000000000000000000000000000018", + "pm_patch_variations": [ + { + "pm_patch_variation": "00000000000000000000000000000019" + }, + { + "pm_patch_variation": "0000000000000000000000000000001A" + } + ], + "pm_remap_info_0": "0.006942855,0.050583657,0.000000000,1.000000000", + "pm_remap_info_1": "0.014084077,0.100724801,0.000000000,1.000000000", + "pm_remap_info_2": "0.005996795,0.050141145,0.000000000,1.000000000", + "pm_remap_info_variation_0_0": "0.004394598,0.045273516,0.000000000,1.000000000", + "pm_remap_info_variation_0_1": "0.009475852,0.083573662,0.000000000,1.000000000", + "pm_remap_info_variation_0_2": "0.004837110,0.042816814,0.000000000,1.000000000", + "pm_remap_info_variation_1_0": "0.003814755,0.050583657,0.000000000,1.000000000", + "pm_remap_info_variation_1_1": "0.008316167,0.095292591,0.000000000,1.000000000", + "pm_remap_info_variation_1_2": "0.004486152,0.042816814,0.000000000,1.000000000", + "pm_search_bottom": 2171, + "pm_search_left": 558, + "pm_search_right": 1952, + "pm_search_top": 777, + "pm_source_type": "full", + "pm_target_bottom": 1896, + "pm_target_left": 834, + "pm_target_right": 1513, + "pm_target_top": 1053, + "pm_version": 327680, + "pm_whole_image_bottom": 3648, + "pm_whole_image_left": 0, + "pm_whole_image_right": 5472, + "pm_whole_image_top": 0 + } + ], + "SDRBlend": 0, + "SDRBrightness": 0, + "SDRClarity": 0, + "SDRContrast": 0, + "SDRHighlights": 0, + "SDRShadows": 0, + "SDRWhites": 0, + "Saturation": -8, + "SaturationAdjustmentAqua": 0, + "SaturationAdjustmentBlue": 0, + "SaturationAdjustmentGreen": 0, + "SaturationAdjustmentMagenta": 0, + "SaturationAdjustmentOrange": 0, + "SaturationAdjustmentPurple": 0, + "SaturationAdjustmentRed": 0, + "SaturationAdjustmentYellow": 0, + "ShadowTint": 0, + "Shadows": 5, + "Shadows2012": 100, + "SharpenDetail": 25, + "SharpenEdgeMasking": 0, + "SharpenRadius": 1, + "Sharpness": 0, + "SplitToningBalance": 0, + "SplitToningHighlightHue": 207, + "SplitToningHighlightSaturation": 16, + "SplitToningShadowHue": 211, + "SplitToningShadowSaturation": 32, + "Temperature": 6000, + "Texture": 10, + "Tint": 10, + "ToneCurveName2012": "Custom", + "ToneCurvePV2012": [ + 0, + 37, + 26, + 41, + 64, + 56, + 128, + 128, + 192, + 196, + 255, + 255 + ], + "ToneCurvePV2012Blue": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Green": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Red": [ + 0, + 0, + 255, + 255 + ], + "UprightCenterMode": 0, + "UprightCenterNormX": 0.5, + "UprightCenterNormY": 0.5, + "UprightFocalLength35mm": 35, + "UprightFocalMode": 0, + "UprightFourSegmentsCount": 0, + "UprightPreview": false, + "UprightTransformCount": 6, + "UprightVersion": 151388160, + "Vibrance": 0, + "VignetteAmount": 0, + "VignetteMidpoint": 50, + "WhiteBalance": "Custom", + "Whites2012": 0, + "orientation": "AB" +} diff --git a/server-rs/testdata/develop/lua/mask_brush_aggregate.json b/server-rs/testdata/develop/lua/mask_brush_aggregate.json new file mode 100644 index 00000000..3d46589a --- /dev/null +++ b/server-rs/testdata/develop/lua/mask_brush_aggregate.json @@ -0,0 +1,392 @@ +{ + "AutoBrightness": false, + "AutoContrast": false, + "AutoExposure": false, + "AutoLateralCA": 0, + "AutoShadows": false, + "Blacks2012": -20, + "BlueHue": 0, + "BlueSaturation": 0, + "Brightness": 50, + "CameraProfile": "Adobe Standard", + "ChromaticAberrationB": 0, + "ChromaticAberrationR": 0, + "Clarity": 0, + "Clarity2012": 0, + "ColorGradeBlending": 50, + "ColorGradeGlobalHue": 0, + "ColorGradeGlobalLum": 0, + "ColorGradeGlobalSat": 0, + "ColorGradeHighlightLum": 0, + "ColorGradeMidtoneHue": 0, + "ColorGradeMidtoneLum": 0, + "ColorGradeMidtoneSat": 0, + "ColorGradeShadowLum": 0, + "ColorNoiseReduction": 0, + "ColorNoiseReductionDetail": 50, + "ColorNoiseReductionSmoothness": 50, + "Contrast": 25, + "Contrast2012": 14, + "ConvertToGrayscale": false, + "CropAngle": 0, + "CropBottom": 0.724616, + "CropConstrainToUnitSquare": 1, + "CropConstrainToWarp": 0, + "CropLeft": 0.137692, + "CropRight": 0.862308, + "CropTop": 1e-6, + "CurveRefineSaturation": 100, + "Defringe": 0, + "DefringeGreenAmount": 0, + "DefringeGreenHueHi": 60, + "DefringeGreenHueLo": 40, + "DefringePurpleAmount": 0, + "DefringePurpleHueHi": 70, + "DefringePurpleHueLo": 30, + "Dehaze": 0, + "EnableCalibration": true, + "EnableColorAdjustments": true, + "EnableDetail": true, + "EnableDistractionRemoval": true, + "EnableEffects": true, + "EnableGrayscaleMix": true, + "EnableLensCorrections": true, + "EnableMaskGroupBasedCorrections": true, + "EnableRedEye": true, + "EnableRetouch": true, + "EnableSplitToning": true, + "EnableToneCurve": true, + "EnableTransform": true, + "Exposure": 0, + "Exposure2012": 1.6, + "FillLight": 0, + "FilterList": [], + "GrainAmount": 0, + "GrainFrequency": 50, + "GrainSize": 25, + "GreenHue": 0, + "GreenSaturation": 0, + "HDREditMode": 0, + "HDRMaxValue": 4, + "HighlightRecovery": 0, + "Highlights2012": -51, + "HueAdjustmentAqua": 0, + "HueAdjustmentBlue": 0, + "HueAdjustmentGreen": 0, + "HueAdjustmentMagenta": 0, + "HueAdjustmentOrange": 0, + "HueAdjustmentPurple": 0, + "HueAdjustmentRed": 0, + "HueAdjustmentYellow": 0, + "LensBlur": [], + "LensManualDistortionAmount": 0, + "LensProfileDigest": "00000000000000000000000000000094", + "LensProfileDistortionScale": 100, + "LensProfileEnable": 0, + "LensProfileFilename": "Synthetic Lens - RAW.lcp", + "LensProfileIsEmbedded": false, + "LensProfileName": "Adobe (Synthetic Lens)", + "LensProfileSetup": "LensDefaults", + "LensProfileVignettingScale": 100, + "Look": { + "Amount": 1, + "Group": { + "x-default": "Synthetic Group" + }, + "Name": "Adobe Landscape", + "Parameters": { + "ConvertToGrayscale": false, + "ProcessVersion": "15.4" + }, + "SupportsAmount": false, + "SupportsMonochrome": false, + "SupportsOutputReferred": false, + "UUID": "6F9C877E84273F4E8271E6B91BEB36A1" + }, + "LuminanceAdjustmentAqua": 0, + "LuminanceAdjustmentBlue": 0, + "LuminanceAdjustmentGreen": 0, + "LuminanceAdjustmentMagenta": 0, + "LuminanceAdjustmentOrange": 0, + "LuminanceAdjustmentPurple": 0, + "LuminanceAdjustmentRed": 0, + "LuminanceAdjustmentYellow": 0, + "LuminanceNoiseReductionContrast": 0, + "LuminanceNoiseReductionDetail": 50, + "LuminanceSmoothing": 0, + "MaskGroupBasedCorrections": [ + { + "CorrectionActive": true, + "CorrectionAmount": 1, + "CorrectionID": "00000000-0000-4000-8000-000000000054", + "CorrectionMasks": [ + { + "FullMaskSize": "2880,1920", + "InputDigest": "0000000000000000000000000000005D", + "InputDigestVersion": 2, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskDigest": "00000000000000000000000000000095", + "MaskID": "00000000-0000-4000-8000-000000000055", + "MaskInverted": false, + "MaskName": "Mask 1", + "MaskSubCategoryID": 22, + "MaskSubType": 0, + "MaskSyncID": "00000000000000000000000000000096", + "MaskValue": 1, + "MaskVersion": 1, + "ModelVersion": 251659306, + "Origin": "0,0", + "ReferencePoint": "0.175447 0.260695", + "What": "Mask/Image", + "WholeImageArea": "0/1,0/1,1920/1,2880/1" + } + ], + "CorrectionName": "Correction 1", + "CorrectionReferenceX": 0.175447, + "CorrectionReferenceY": 0.260695, + "CorrectionSyncID": "00000000000000000000000000000097", + "LocalBlacks2012": 0, + "LocalBrightness": 0, + "LocalClarity": 0, + "LocalClarity2012": 0, + "LocalContrast": 0, + "LocalContrast2012": 0, + "LocalCurveRefineSaturation": 100, + "LocalDefringe": 0, + "LocalDehaze": 0, + "LocalExposure": 0, + "LocalExposure2012": -0.13866, + "LocalHighlights2012": 0, + "LocalHue": 0, + "LocalLuminanceNoise": 0, + "LocalMoire": 0, + "LocalSaturation": 0, + "LocalShadows2012": 0, + "LocalSharpness": 0, + "LocalTemperature": 0, + "LocalTexture": -1, + "LocalTint": 0, + "LocalToningHue": 0, + "LocalToningSaturation": 0, + "LocalWhites2012": 0, + "What": "Correction" + }, + { + "CorrectionActive": true, + "CorrectionAmount": 1, + "CorrectionID": "00000000-0000-4000-8000-000000000056", + "CorrectionMasks": [ + { + "FullMaskSize": "2880,1920", + "InputDigest": "0000000000000000000000000000005D", + "InputDigestVersion": 2, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskDigest": "00000000000000000000000000000098", + "MaskID": "00000000-0000-4000-8000-000000000057", + "MaskInverted": false, + "MaskName": "Mask 2", + "MaskSubType": 1, + "MaskSyncID": "00000000000000000000000000000099", + "MaskValue": 1, + "MaskVersion": 1, + "ModelVersion": 251659306, + "Origin": "739,237", + "ReferencePoint": "0.505759 0.654251", + "What": "Mask/Image", + "WholeImageArea": "0/1,0/1,1920/1,2880/1" + } + ], + "CorrectionName": "Correction 2", + "CorrectionReferenceX": 0.505759, + "CorrectionReferenceY": 0.654251, + "CorrectionSyncID": "0000000000000000000000000000009A", + "LocalBlacks2012": 0, + "LocalBrightness": 0, + "LocalClarity": 0, + "LocalClarity2012": 0.597288, + "LocalContrast": 0, + "LocalContrast2012": 0, + "LocalCurveRefineSaturation": 100, + "LocalDefringe": 0, + "LocalDehaze": 0.143819, + "LocalExposure": 0, + "LocalExposure2012": 0.059994, + "LocalHighlights2012": 0, + "LocalHue": 0, + "LocalLuminanceNoise": 0, + "LocalMoire": 0, + "LocalSaturation": 0, + "LocalShadows2012": 0, + "LocalSharpness": 0.086183, + "LocalTemperature": 0, + "LocalTexture": 0.680621, + "LocalTint": 0, + "LocalToningHue": 0, + "LocalToningSaturation": 0, + "LocalWhites2012": 0, + "What": "Correction" + }, + { + "CorrectionActive": true, + "CorrectionAmount": 1, + "CorrectionID": "00000000-0000-4000-8000-000000000058", + "CorrectionMasks": [ + { + "MaskActive": true, + "MaskBlendMode": 0, + "MaskID": "00000000-0000-4000-8000-000000000059", + "MaskInverted": false, + "MaskName": "Mask 3", + "MaskSyncID": "0000000000000000000000000000009B", + "MaskValue": 1, + "Masks": [ + { + "CenterWeight": 0, + "Dabs": [ + "d 0.512770 0.275115" + ], + "Flow": 1, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskID": "00000000-0000-4000-8000-00000000005A", + "MaskInverted": false, + "MaskSyncID": "0000000000000000000000000000009C", + "MaskValue": 1, + "Radius": 0.05825, + "What": "Mask/Paint" + } + ], + "What": "Mask/Aggregate" + } + ], + "CorrectionName": "Correction 3", + "CorrectionReferenceX": 0.51277, + "CorrectionReferenceY": 0.275115, + "CorrectionSyncID": "0000000000000000000000000000009D", + "LocalBlacks2012": 0, + "LocalBrightness": 0, + "LocalClarity": 0, + "LocalClarity2012": 0, + "LocalContrast": 0, + "LocalContrast2012": 0, + "LocalCurveRefineSaturation": 100, + "LocalDefringe": 0, + "LocalDehaze": 0, + "LocalExposure": 0, + "LocalExposure2012": 0.202781, + "LocalHighlights2012": 0.188778, + "LocalHue": 0, + "LocalLuminanceNoise": 0, + "LocalMoire": 0, + "LocalSaturation": 0, + "LocalShadows2012": 0.146669, + "LocalSharpness": 0, + "LocalTemperature": 0, + "LocalTexture": 0, + "LocalTint": 0, + "LocalToningHue": 0, + "LocalToningSaturation": 0, + "LocalWhites2012": 0, + "What": "Correction" + } + ], + "ParametricDarks": 0, + "ParametricHighlightSplit": 75, + "ParametricHighlights": 0, + "ParametricLights": 0, + "ParametricMidtoneSplit": 50, + "ParametricShadowSplit": 25, + "ParametricShadows": 0, + "PerspectiveAspect": 0, + "PerspectiveHorizontal": 0, + "PerspectiveRotate": 0, + "PerspectiveScale": 100, + "PerspectiveUpright": 0, + "PerspectiveVertical": 0, + "PerspectiveX": 0, + "PerspectiveY": 0, + "PointColors": [], + "PostCropVignetteAmount": 0, + "PostCropVignetteFeather": 50, + "PostCropVignetteHighlightContrast": 0, + "PostCropVignetteMidpoint": 50, + "PostCropVignetteRoundness": 0, + "PostCropVignetteStyle": 1, + "ProcessVersion": "11.0", + "RedEyeInfo": [], + "RedHue": 0, + "RedSaturation": 0, + "SDRBlend": 0, + "SDRBrightness": 0, + "SDRClarity": 0, + "SDRContrast": 0, + "SDRHighlights": 0, + "SDRShadows": 0, + "SDRWhites": 0, + "Saturation": 1, + "SaturationAdjustmentAqua": 0, + "SaturationAdjustmentBlue": 0, + "SaturationAdjustmentGreen": 0, + "SaturationAdjustmentMagenta": 0, + "SaturationAdjustmentOrange": 0, + "SaturationAdjustmentPurple": 0, + "SaturationAdjustmentRed": 0, + "SaturationAdjustmentYellow": 0, + "ShadowTint": 0, + "Shadows": 5, + "Shadows2012": 14, + "SharpenDetail": 25, + "SharpenEdgeMasking": 0, + "SharpenRadius": 1, + "Sharpness": 0, + "SplitToningBalance": 0, + "SplitToningHighlightHue": 0, + "SplitToningHighlightSaturation": 0, + "SplitToningShadowHue": 0, + "SplitToningShadowSaturation": 0, + "Temperature": 6750, + "Texture": 0, + "Tint": 4, + "ToneCurveName2012": "Linear", + "ToneCurvePV2012": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Blue": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Green": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Red": [ + 0, + 0, + 255, + 255 + ], + "UprightCenterMode": 0, + "UprightCenterNormX": 0.5, + "UprightCenterNormY": 0.5, + "UprightFocalLength35mm": 35, + "UprightFocalMode": 0, + "UprightFourSegmentsCount": 0, + "UprightPreview": false, + "UprightTransformCount": 6, + "UprightVersion": 151388160, + "Vibrance": 15, + "VignetteAmount": 0, + "VignetteMidpoint": 50, + "WhiteBalance": "Custom", + "Whites2012": 31, + "orientation": "AB" +} diff --git a/server-rs/testdata/develop/lua/mask_linear_gradient.json b/server-rs/testdata/develop/lua/mask_linear_gradient.json new file mode 100644 index 00000000..19a4882c --- /dev/null +++ b/server-rs/testdata/develop/lua/mask_linear_gradient.json @@ -0,0 +1,641 @@ +{ + "AILook": [], + "AutoBrightness": false, + "AutoContrast": false, + "AutoExposure": false, + "AutoLateralCA": 0, + "AutoShadows": false, + "Blacks2012": 7, + "BlueHue": 0, + "BlueSaturation": 50, + "Brightness": 50, + "CameraProfile": "Adobe Standard", + "ChromaticAberrationB": 0, + "ChromaticAberrationR": 0, + "Clarity": 0, + "Clarity2012": -24, + "ColorGradeBlending": 50, + "ColorGradeGlobalHue": 0, + "ColorGradeGlobalLum": 0, + "ColorGradeGlobalSat": 0, + "ColorGradeHighlightLum": 0, + "ColorGradeMidtoneHue": 233, + "ColorGradeMidtoneLum": 3, + "ColorGradeMidtoneSat": 27, + "ColorGradeShadowLum": 0, + "ColorNoiseReduction": 0, + "ColorNoiseReductionDetail": 50, + "ColorNoiseReductionSmoothness": 50, + "Contrast": 25, + "Contrast2012": 0, + "ConvertToGrayscale": false, + "CropAngle": 0, + "CropBottom": 1, + "CropConstrainToUnitSquare": 1, + "CropConstrainToWarp": 0, + "CropLeft": 0, + "CropRight": 1, + "CropTop": 0, + "CurveRefineSaturation": 100, + "Defringe": 0, + "DefringeGreenAmount": 0, + "DefringeGreenHueHi": 60, + "DefringeGreenHueLo": 40, + "DefringePurpleAmount": 0, + "DefringePurpleHueHi": 70, + "DefringePurpleHueLo": 30, + "Dehaze": 0, + "EnableCalibration": true, + "EnableColorAdjustments": true, + "EnableDetail": true, + "EnableDistractionRemoval": true, + "EnableEffects": true, + "EnableGrayscaleMix": true, + "EnableLensCorrections": true, + "EnableMaskGroupBasedCorrections": true, + "EnableRedEye": true, + "EnableRetouch": true, + "EnableSplitToning": true, + "EnableToneCurve": true, + "EnableTransform": true, + "Exposure": 0, + "Exposure2012": -0.15, + "FillLight": 0, + "FilterList": [], + "GrainAmount": 0, + "GrainFrequency": 50, + "GrainSize": 25, + "GreenHue": 0, + "GreenSaturation": -81, + "HDREditMode": 0, + "HDRMaxValue": 4, + "HighlightRecovery": 0, + "Highlights2012": -100, + "HueAdjustmentAqua": 0, + "HueAdjustmentBlue": 0, + "HueAdjustmentGreen": 0, + "HueAdjustmentMagenta": 0, + "HueAdjustmentOrange": 0, + "HueAdjustmentPurple": 0, + "HueAdjustmentRed": 0, + "HueAdjustmentYellow": 0, + "LensBlur": [], + "LensManualDistortionAmount": 0, + "LensProfileDigest": "0000000000000000000000000000005C", + "LensProfileDistortionScale": 100, + "LensProfileEnable": 0, + "LensProfileFilename": "Synthetic Lens - RAW.lcp", + "LensProfileIsEmbedded": false, + "LensProfileName": "Adobe (Synthetic Lens)", + "LensProfileSetup": "LensDefaults", + "LensProfileVignettingScale": 100, + "Look": { + "Amount": 1, + "Group": { + "x-default": "Synthetic Group" + }, + "Name": "Adobe Landscape", + "Parameters": { + "ConvertToGrayscale": false, + "ProcessVersion": "15.4" + }, + "SupportsAmount": false, + "SupportsMonochrome": false, + "SupportsOutputReferred": false, + "UUID": "6F9C877E84273F4E8271E6B91BEB36A1" + }, + "LuminanceAdjustmentAqua": 0, + "LuminanceAdjustmentBlue": 0, + "LuminanceAdjustmentGreen": 0, + "LuminanceAdjustmentMagenta": 0, + "LuminanceAdjustmentOrange": 0, + "LuminanceAdjustmentPurple": 0, + "LuminanceAdjustmentRed": 0, + "LuminanceAdjustmentYellow": 0, + "LuminanceNoiseReductionContrast": 0, + "LuminanceNoiseReductionDetail": 50, + "LuminanceSmoothing": 0, + "MaskGroupBasedCorrections": [ + { + "CorrectionActive": true, + "CorrectionAmount": 1, + "CorrectionID": "00000000-0000-4000-8000-000000000045", + "CorrectionMasks": [ + { + "Angle": 0, + "Bottom": 0.955174, + "Feather": 39, + "Flipped": true, + "Left": 0.619582, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskID": "00000000-0000-4000-8000-000000000046", + "MaskInverted": false, + "MaskName": "Mask 1", + "MaskSyncID": "00000000000000000000000000000084", + "MaskValue": 1, + "Midpoint": 50, + "Right": 1.14176, + "Roundness": 0, + "Top": 0.473223, + "Version": 2, + "What": "Mask/CircularGradient" + } + ], + "CorrectionName": "Correction 1", + "CorrectionReferenceX": 0.880671, + "CorrectionReferenceY": 0.714198, + "CorrectionSyncID": "00000000000000000000000000000085", + "LocalBlacks2012": 0, + "LocalBrightness": 0, + "LocalClarity": 0, + "LocalClarity2012": 0, + "LocalContrast": 0, + "LocalContrast2012": 0, + "LocalCurveRefineSaturation": 100, + "LocalDefringe": 0, + "LocalDehaze": 0, + "LocalExposure": 0, + "LocalExposure2012": -0.318182, + "LocalGrain": 0, + "LocalHighlights2012": 0, + "LocalHue": 0, + "LocalLuminanceNoise": 0, + "LocalMoire": 0, + "LocalSaturation": 0, + "LocalShadows2012": 0, + "LocalSharpness": 0, + "LocalTemperature": 0, + "LocalTexture": 0, + "LocalTint": 0, + "LocalToningHue": 0, + "LocalToningSaturation": 0, + "LocalWhites2012": 0, + "What": "Correction" + }, + { + "CorrectionActive": true, + "CorrectionAmount": 1, + "CorrectionID": "00000000-0000-4000-8000-000000000047", + "CorrectionMasks": [ + { + "FullX": 0.509917, + "FullY": 1.00247, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskID": "00000000-0000-4000-8000-000000000048", + "MaskInverted": false, + "MaskName": "Mask 2", + "MaskSyncID": "00000000000000000000000000000086", + "MaskValue": 1, + "What": "Mask/Gradient", + "ZeroX": 0.553541, + "ZeroY": 0.500557 + } + ], + "CorrectionName": "Correction 2", + "CorrectionReferenceX": 0.531729, + "CorrectionReferenceY": 0.751513, + "CorrectionSyncID": "00000000000000000000000000000087", + "LocalBlacks2012": 0, + "LocalBrightness": 0, + "LocalClarity": 0, + "LocalClarity2012": 0, + "LocalContrast": 0, + "LocalContrast2012": 0, + "LocalCurveRefineSaturation": 100, + "LocalDefringe": 0, + "LocalDehaze": 0, + "LocalExposure": 0, + "LocalExposure2012": -0.389725, + "LocalGrain": 0, + "LocalHighlights2012": 0, + "LocalHue": 0, + "LocalLuminanceNoise": 0, + "LocalMoire": 0, + "LocalSaturation": 0, + "LocalShadows2012": 0, + "LocalSharpness": 0, + "LocalTemperature": 0, + "LocalTexture": 0, + "LocalTint": 0, + "LocalToningHue": 0, + "LocalToningSaturation": 0, + "LocalWhites2012": 0, + "What": "Correction" + }, + { + "CorrectionActive": true, + "CorrectionAmount": 1, + "CorrectionID": "00000000-0000-4000-8000-000000000049", + "CorrectionMasks": [ + { + "Angle": 7.00257, + "Bottom": 0.778739, + "Feather": 41, + "Flipped": true, + "Left": 0.420459, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskID": "00000000-0000-4000-8000-00000000004A", + "MaskInverted": false, + "MaskName": "Mask 3", + "MaskSyncID": "00000000000000000000000000000088", + "MaskValue": 1, + "Midpoint": 50, + "Right": 0.921505, + "Roundness": 0, + "Top": 0.17932, + "Version": 2, + "What": "Mask/CircularGradient" + } + ], + "CorrectionName": "Correction 3", + "CorrectionReferenceX": 0.670982, + "CorrectionReferenceY": 0.479029, + "CorrectionSyncID": "00000000000000000000000000000089", + "LocalBlacks2012": 0, + "LocalBrightness": 0, + "LocalClarity": 0, + "LocalClarity2012": 0.291903, + "LocalContrast": 0, + "LocalContrast2012": 0.206155, + "LocalCurveRefineSaturation": 100, + "LocalDefringe": 0, + "LocalDehaze": 0, + "LocalExposure": 0, + "LocalExposure2012": 0.120218, + "LocalGrain": 0, + "LocalHighlights2012": 0, + "LocalHue": 0, + "LocalLuminanceNoise": 0, + "LocalMoire": 0, + "LocalSaturation": 0, + "LocalShadows2012": 0, + "LocalSharpness": 0.207576, + "LocalTemperature": -0.219271, + "LocalTexture": 0.349763, + "LocalTint": -0.083333, + "LocalToningHue": 0, + "LocalToningSaturation": 0, + "LocalWhites2012": 0, + "What": "Correction" + }, + { + "CorrectionActive": true, + "CorrectionAmount": 1, + "CorrectionID": "00000000-0000-4000-8000-00000000004B", + "CorrectionMasks": [ + { + "Angle": 0, + "Bottom": 0.730232, + "Feather": 50, + "Flipped": true, + "Left": 0.052917, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskID": "00000000-0000-4000-8000-00000000004C", + "MaskInverted": false, + "MaskName": "Mask 4", + "MaskSyncID": "0000000000000000000000000000008A", + "MaskValue": 1, + "Midpoint": 50, + "Right": 0.148266, + "Roundness": 0, + "Top": 0.483844, + "Version": 2, + "What": "Mask/CircularGradient" + } + ], + "CorrectionName": "Correction 4", + "CorrectionReferenceX": 0.100592, + "CorrectionReferenceY": 0.607038, + "CorrectionSyncID": "0000000000000000000000000000008B", + "LocalBlacks2012": 0, + "LocalBrightness": 0, + "LocalClarity": 0, + "LocalClarity2012": 0, + "LocalContrast": 0, + "LocalContrast2012": 0, + "LocalCurveRefineSaturation": 100, + "LocalDefringe": 0, + "LocalDehaze": 0, + "LocalExposure": 0, + "LocalExposure2012": 0.12339, + "LocalGrain": 0, + "LocalHighlights2012": 0, + "LocalHue": 0, + "LocalLuminanceNoise": 0, + "LocalMoire": 0, + "LocalSaturation": 0, + "LocalShadows2012": 0, + "LocalSharpness": 0, + "LocalTemperature": 0, + "LocalTexture": 0, + "LocalTint": 0, + "LocalToningHue": 0, + "LocalToningSaturation": 0, + "LocalWhites2012": 0, + "What": "Correction" + }, + { + "CorrectionActive": true, + "CorrectionAmount": 1, + "CorrectionID": "00000000-0000-4000-8000-00000000004D", + "CorrectionMasks": [ + { + "FullX": 0.566299, + "FullY": 0.006376, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskID": "00000000-0000-4000-8000-00000000004E", + "MaskInverted": false, + "MaskName": "Mask 5", + "MaskSyncID": "0000000000000000000000000000008C", + "MaskValue": 1, + "What": "Mask/Gradient", + "ZeroX": 0.531497, + "ZeroY": 0.440867 + } + ], + "CorrectionName": "Correction 5", + "CorrectionReferenceX": 0.548898, + "CorrectionReferenceY": 0.223622, + "CorrectionSyncID": "0000000000000000000000000000008D", + "LocalBlacks2012": 0, + "LocalBrightness": 0, + "LocalClarity": 0, + "LocalClarity2012": 0, + "LocalContrast": 0, + "LocalContrast2012": 0, + "LocalCurveRefineSaturation": 100, + "LocalDefringe": 0, + "LocalDehaze": 0, + "LocalExposure": 0, + "LocalExposure2012": -0.404025, + "LocalGrain": 0, + "LocalHighlights2012": 0, + "LocalHue": 0, + "LocalLuminanceNoise": 0, + "LocalMoire": 0, + "LocalSaturation": 0, + "LocalShadows2012": 0, + "LocalSharpness": 0, + "LocalTemperature": 0, + "LocalTexture": 0, + "LocalTint": 0, + "LocalToningHue": 0, + "LocalToningSaturation": 0, + "LocalWhites2012": 0, + "What": "Correction" + }, + { + "CorrectionActive": true, + "CorrectionAmount": 1, + "CorrectionID": "00000000-0000-4000-8000-00000000004F", + "CorrectionMasks": [ + { + "MaskActive": true, + "MaskBlendMode": 0, + "MaskID": "00000000-0000-4000-8000-000000000050", + "MaskInverted": false, + "MaskName": "Mask 6", + "MaskSyncID": "0000000000000000000000000000008E", + "MaskValue": 1, + "Masks": [ + { + "CenterWeight": 0, + "Dabs": [ + "M 0.127164 0.359131", + "M 0.155825 0.366110", + "M 0.185345 0.368479", + "M 0.214858 0.371079", + "M 0.244369 0.373502", + "M 0.273338 0.381961", + "M 0.302358 0.390236", + "M 0.329828 0.406118", + "M 0.358081 0.418612", + "M 0.387178 0.426457", + "M 0.414354 0.443667", + "M 0.437440 0.469658", + "M 0.450716 0.508608", + "M 0.450792 0.547478", + "M 0.422148 0.557750", + "M 0.393014 0.564312", + "M 0.364183 0.555231", + "M 0.335200 0.546646", + "M 0.305882 0.540951", + "M 0.276741 0.533659", + "M 0.247437 0.528484", + "M 0.217876 0.528241", + "M 0.188336 0.527080", + "M 0.158781 0.526075", + "M 0.129347 0.522754", + "M 0.105269 0.499143", + "M 0.089190 0.462264", + "M 0.105555 0.434181", + "M 0.134980 0.437147", + "M 0.164046 0.445249", + "M 0.192894 0.454941", + "M 0.222174 0.460304", + "M 0.251557 0.464613", + "M 0.251984 0.465959", + "M 0.222791 0.458958", + "M 0.193528 0.452660", + "M 0.164251 0.446496", + "M 0.134984 0.440228", + "M 0.105814 0.433038", + "M 0.084877 0.423161", + "M 0.113357 0.434806", + "M 0.142345 0.443514", + "M 0.171549 0.450269", + "M 0.200861 0.456040", + "M 0.230174 0.461811", + "M 0.259579 0.466346", + "M 0.288921 0.471466", + "M 0.317518 0.482363", + "M 0.346392 0.491590", + "M 0.375858 0.494794", + "M 0.405205 0.499735", + "M 0.434123 0.508950", + "M 0.463288 0.516186", + "M 0.492075 0.525497", + "M 0.504503 0.525621" + ], + "Flow": 1, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskID": "00000000-0000-4000-8000-000000000051", + "MaskInverted": false, + "MaskSyncID": "0000000000000000000000000000008F", + "MaskValue": 1, + "Radius": 0.098546, + "What": "Mask/Paint" + } + ], + "What": "Mask/Aggregate" + } + ], + "CorrectionName": "Correction 6", + "CorrectionReferenceX": 0.259579, + "CorrectionReferenceY": 0.466346, + "CorrectionSyncID": "00000000000000000000000000000090", + "LocalBlacks2012": 0, + "LocalBrightness": 0, + "LocalClarity": 0, + "LocalClarity2012": 0.600062, + "LocalContrast": 0, + "LocalContrast2012": 0, + "LocalCurveRefineSaturation": 100, + "LocalDefringe": 0, + "LocalDehaze": 0, + "LocalExposure": 0, + "LocalExposure2012": 0, + "LocalGrain": 0, + "LocalHighlights2012": 0, + "LocalHue": 0, + "LocalLuminanceNoise": 0, + "LocalMoire": 0, + "LocalSaturation": 0, + "LocalShadows2012": 0, + "LocalSharpness": 0, + "LocalTemperature": 0, + "LocalTexture": 0.389021, + "LocalTint": 0, + "LocalToningHue": 0, + "LocalToningSaturation": 0, + "LocalWhites2012": 0, + "What": "Correction" + } + ], + "ParametricDarks": 0, + "ParametricHighlightSplit": 75, + "ParametricHighlights": 0, + "ParametricLights": 0, + "ParametricMidtoneSplit": 50, + "ParametricShadowSplit": 25, + "ParametricShadows": 0, + "PerspectiveAspect": 0, + "PerspectiveHorizontal": 0, + "PerspectiveRotate": 0, + "PerspectiveScale": 100, + "PerspectiveUpright": 0, + "PerspectiveVertical": 0, + "PerspectiveX": 0, + "PerspectiveY": 0, + "PointColors": [ + { + "HueRange": { + "LowerFull": 0.33, + "LowerNone": 0, + "UpperFull": 0.67, + "UpperNone": 1 + }, + "HueShift": 0.51, + "LumRange": { + "LowerFull": 0.15, + "LowerNone": 0, + "UpperFull": 0.51, + "UpperNone": 1 + }, + "LumScale": 0, + "RangeAmount": 0.5, + "SatRange": { + "LowerFull": 0, + "LowerNone": 0, + "UpperFull": 0.29, + "UpperNone": 0.84 + }, + "SatScale": 0.67, + "SrcHue": 3.92575, + "SrcLum": 0.089831, + "SrcSat": 0.109076, + "Variance": 0 + } + ], + "PostCropVignetteAmount": -71, + "PostCropVignetteFeather": 50, + "PostCropVignetteHighlightContrast": 0, + "PostCropVignetteMidpoint": 50, + "PostCropVignetteRoundness": 0, + "PostCropVignetteStyle": 1, + "ProcessVersion": "15.4", + "RedEyeInfo": [], + "RedHue": 28, + "RedSaturation": -40, + "SDRBlend": 0, + "SDRBrightness": 0, + "SDRClarity": 0, + "SDRContrast": 0, + "SDRHighlights": 0, + "SDRShadows": 0, + "SDRWhites": 0, + "Saturation": 0, + "SaturationAdjustmentAqua": 0, + "SaturationAdjustmentBlue": -35, + "SaturationAdjustmentGreen": 0, + "SaturationAdjustmentMagenta": 0, + "SaturationAdjustmentOrange": 0, + "SaturationAdjustmentPurple": 0, + "SaturationAdjustmentRed": 0, + "SaturationAdjustmentYellow": 0, + "ShadowTint": 0, + "Shadows": 5, + "Shadows2012": 0, + "SharpenDetail": 25, + "SharpenEdgeMasking": 46, + "SharpenRadius": 1, + "Sharpness": 40, + "SplitToningBalance": 0, + "SplitToningHighlightHue": 42, + "SplitToningHighlightSaturation": 32, + "SplitToningShadowHue": 265, + "SplitToningShadowSaturation": 35, + "Temperature": 5420, + "Texture": 33, + "Tint": 15, + "ToneCurveName2012": "Custom", + "ToneCurvePV2012": [ + 0, + 19, + 52, + 52, + 150, + 132, + 255, + 255 + ], + "ToneCurvePV2012Blue": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Green": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Red": [ + 0, + 0, + 255, + 255 + ], + "UprightCenterMode": 0, + "UprightCenterNormX": 0.5, + "UprightCenterNormY": 0.5, + "UprightFocalLength35mm": 35, + "UprightFocalMode": 0, + "UprightFourSegmentsCount": 0, + "UprightPreview": false, + "UprightTransformCount": 6, + "UprightVersion": 151388160, + "Vibrance": 0, + "VignetteAmount": 0, + "VignetteMidpoint": 50, + "WhiteBalance": "Custom", + "Whites2012": 5, + "orientation": "AB" +} diff --git a/server-rs/testdata/develop/lua/mask_radial_gradient.json b/server-rs/testdata/develop/lua/mask_radial_gradient.json new file mode 100644 index 00000000..bf11171c --- /dev/null +++ b/server-rs/testdata/develop/lua/mask_radial_gradient.json @@ -0,0 +1,274 @@ +{ + "AutoBrightness": false, + "AutoContrast": false, + "AutoExposure": false, + "AutoLateralCA": 1, + "AutoShadows": false, + "AutoWhiteVersion": 134348800, + "Blacks2012": -1, + "BlueHue": 0, + "BlueSaturation": 0, + "Brightness": 50, + "CameraProfile": "Adobe Standard", + "ChromaticAberrationB": 0, + "ChromaticAberrationR": 0, + "Clarity": 0, + "Clarity2012": 0, + "ColorGradeBlending": 50, + "ColorGradeGlobalHue": 0, + "ColorGradeGlobalLum": 0, + "ColorGradeGlobalSat": 0, + "ColorGradeHighlightLum": 0, + "ColorGradeMidtoneHue": 0, + "ColorGradeMidtoneLum": 0, + "ColorGradeMidtoneSat": 0, + "ColorGradeShadowLum": 0, + "ColorNoiseReduction": 25, + "ColorNoiseReductionDetail": 50, + "ColorNoiseReductionSmoothness": 50, + "Contrast": 25, + "Contrast2012": 5, + "ConvertToGrayscale": false, + "CropAngle": 0.5, + "CropBottom": 1, + "CropConstrainToUnitSquare": 1, + "CropConstrainToWarp": 0, + "CropLeft": 0.009332, + "CropRight": 0.990668, + "CropTop": 0, + "CurveRefineSaturation": 100, + "Defringe": 0, + "DefringeGreenAmount": 0, + "DefringeGreenHueHi": 60, + "DefringeGreenHueLo": 40, + "DefringePurpleAmount": 0, + "DefringePurpleHueHi": 70, + "DefringePurpleHueLo": 30, + "Dehaze": 0, + "EnableCalibration": true, + "EnableColorAdjustments": true, + "EnableDetail": true, + "EnableDistractionRemoval": true, + "EnableEffects": true, + "EnableGrayscaleMix": true, + "EnableLensCorrections": true, + "EnableMaskGroupBasedCorrections": true, + "EnableRedEye": true, + "EnableRetouch": true, + "EnableSplitToning": true, + "EnableToneCurve": true, + "EnableTransform": true, + "Exposure": 0, + "Exposure2012": 1.15, + "FillLight": 0, + "FilterList": [], + "GrainAmount": 0, + "GrainFrequency": 50, + "GrainSize": 25, + "GreenHue": 0, + "GreenSaturation": 0, + "HDREditMode": 0, + "HDRMaxValue": 4, + "HighlightRecovery": 0, + "Highlights2012": -62, + "HueAdjustmentAqua": 0, + "HueAdjustmentBlue": 0, + "HueAdjustmentGreen": 0, + "HueAdjustmentMagenta": 0, + "HueAdjustmentOrange": 0, + "HueAdjustmentPurple": 0, + "HueAdjustmentRed": 0, + "HueAdjustmentYellow": 0, + "LensBlur": [], + "LensManualDistortionAmount": 0, + "LensProfileDigest": "00000000000000000000000000000091", + "LensProfileDistortionScale": 100, + "LensProfileEnable": 1, + "LensProfileFilename": "Synthetic Lens - RAW.lcp", + "LensProfileIsEmbedded": false, + "LensProfileName": "Adobe (Synthetic Lens)", + "LensProfileSetup": "Custom", + "LensProfileVignettingScale": 200, + "Look": { + "Amount": 1, + "Group": { + "x-default": "Synthetic Group" + }, + "Name": "Adobe Landscape", + "Parameters": { + "ConvertToGrayscale": false, + "ProcessVersion": "15.4" + }, + "SupportsAmount": false, + "SupportsMonochrome": false, + "SupportsOutputReferred": false, + "UUID": "6F9C877E84273F4E8271E6B91BEB36A1" + }, + "LuminanceAdjustmentAqua": 0, + "LuminanceAdjustmentBlue": 0, + "LuminanceAdjustmentGreen": 0, + "LuminanceAdjustmentMagenta": 0, + "LuminanceAdjustmentOrange": 0, + "LuminanceAdjustmentPurple": 0, + "LuminanceAdjustmentRed": 0, + "LuminanceAdjustmentYellow": 0, + "LuminanceNoiseReductionContrast": 0, + "LuminanceNoiseReductionDetail": 50, + "LuminanceSmoothing": 0, + "MaskGroupBasedCorrections": [ + { + "CorrectionActive": true, + "CorrectionAmount": 1, + "CorrectionID": "00000000-0000-4000-8000-000000000052", + "CorrectionMasks": [ + { + "Angle": 11.4507, + "Bottom": 0.770739, + "Feather": 47, + "Flipped": true, + "Left": 0.321614, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskID": "00000000-0000-4000-8000-000000000053", + "MaskInverted": false, + "MaskName": "Mask 1", + "MaskSyncID": "00000000000000000000000000000092", + "MaskValue": 1, + "Midpoint": 50, + "Right": 0.851948, + "Roundness": 0, + "Top": 0.351598, + "Version": 2, + "What": "Mask/CircularGradient" + } + ], + "CorrectionName": "Correction 1", + "CorrectionReferenceX": 0.586781, + "CorrectionReferenceY": 0.561168, + "CorrectionSyncID": "00000000000000000000000000000093", + "LocalBlacks2012": 0, + "LocalBrightness": 0, + "LocalClarity": 0, + "LocalClarity2012": 0, + "LocalContrast": 0, + "LocalContrast2012": 0, + "LocalCurveRefineSaturation": 100, + "LocalDefringe": 0, + "LocalDehaze": 0, + "LocalExposure": 0, + "LocalExposure2012": 0, + "LocalGrain": 0, + "LocalHighlights2012": 0, + "LocalHue": 0, + "LocalLuminanceNoise": 0, + "LocalMoire": 0, + "LocalSaturation": 0, + "LocalShadows2012": 0, + "LocalSharpness": 0, + "LocalTemperature": 0.12976, + "LocalTexture": 0, + "LocalTint": 0, + "LocalToningHue": 0, + "LocalToningSaturation": 0, + "LocalWhites2012": 0, + "What": "Correction" + } + ], + "ParametricDarks": 0, + "ParametricHighlightSplit": 75, + "ParametricHighlights": 0, + "ParametricLights": 0, + "ParametricMidtoneSplit": 50, + "ParametricShadowSplit": 25, + "ParametricShadows": 0, + "PerspectiveAspect": 0, + "PerspectiveHorizontal": 0, + "PerspectiveRotate": -0.6, + "PerspectiveScale": 100, + "PerspectiveUpright": 0, + "PerspectiveVertical": 0, + "PerspectiveX": 0, + "PerspectiveY": 0, + "PointColors": [], + "PostCropVignetteAmount": -19, + "PostCropVignetteFeather": 50, + "PostCropVignetteHighlightContrast": 0, + "PostCropVignetteMidpoint": 50, + "PostCropVignetteRoundness": 0, + "PostCropVignetteStyle": 1, + "ProcessVersion": "15.4", + "RedEyeInfo": [], + "RedHue": 0, + "RedSaturation": 0, + "SDRBlend": 0, + "SDRBrightness": 0, + "SDRClarity": 0, + "SDRContrast": 0, + "SDRHighlights": 0, + "SDRShadows": 0, + "SDRWhites": 0, + "Saturation": 3, + "SaturationAdjustmentAqua": 45, + "SaturationAdjustmentBlue": 0, + "SaturationAdjustmentGreen": 0, + "SaturationAdjustmentMagenta": 0, + "SaturationAdjustmentOrange": 36, + "SaturationAdjustmentPurple": 0, + "SaturationAdjustmentRed": 0, + "SaturationAdjustmentYellow": 0, + "ShadowTint": 0, + "Shadows": 5, + "Shadows2012": 68, + "SharpenDetail": 25, + "SharpenEdgeMasking": 0, + "SharpenRadius": 1, + "Sharpness": 40, + "SplitToningBalance": 0, + "SplitToningHighlightHue": 0, + "SplitToningHighlightSaturation": 0, + "SplitToningShadowHue": 0, + "SplitToningShadowSaturation": 0, + "Temperature": 5600, + "Texture": 0, + "Tint": 7, + "ToneCurveName2012": "Linear", + "ToneCurvePV2012": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Blue": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Green": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Red": [ + 0, + 0, + 255, + 255 + ], + "UprightCenterMode": 0, + "UprightCenterNormX": 0.5, + "UprightCenterNormY": 0.5, + "UprightFocalLength35mm": 35, + "UprightFocalMode": 0, + "UprightFourSegmentsCount": 0, + "UprightPreview": false, + "UprightTransformCount": 6, + "UprightVersion": 151388160, + "Vibrance": 51, + "VignetteAmount": 0, + "VignetteMidpoint": 50, + "WhiteBalance": "Auto", + "Whites2012": 26, + "orientation": "AB" +} diff --git a/server-rs/testdata/develop/lua/mask_range_color.json b/server-rs/testdata/develop/lua/mask_range_color.json new file mode 100644 index 00000000..c9c0ccc0 --- /dev/null +++ b/server-rs/testdata/develop/lua/mask_range_color.json @@ -0,0 +1,272 @@ +{ + "AutoBrightness": false, + "AutoContrast": false, + "AutoExposure": false, + "AutoLateralCA": 0, + "AutoShadows": false, + "Blacks2012": -21, + "BlueHue": 0, + "BlueSaturation": 0, + "Brightness": 50, + "CameraProfile": "Adobe Standard", + "ChromaticAberrationB": 0, + "ChromaticAberrationR": 0, + "Clarity": 0, + "Clarity2012": 0, + "ColorGradeBlending": 50, + "ColorGradeGlobalHue": 0, + "ColorGradeGlobalLum": 0, + "ColorGradeGlobalSat": 0, + "ColorGradeHighlightLum": 0, + "ColorGradeMidtoneHue": 0, + "ColorGradeMidtoneLum": 0, + "ColorGradeMidtoneSat": 0, + "ColorGradeShadowLum": 0, + "ColorNoiseReduction": 0, + "ColorNoiseReductionDetail": 50, + "ColorNoiseReductionSmoothness": 50, + "Contrast": 25, + "Contrast2012": 4, + "ConvertToGrayscale": false, + "CropAngle": 0, + "CropBottom": 0.95739, + "CropConstrainToUnitSquare": 1, + "CropConstrainToWarp": 0, + "CropLeft": 0, + "CropRight": 0.95739, + "CropTop": 0, + "CurveRefineSaturation": 100, + "Defringe": 0, + "DefringeGreenAmount": 0, + "DefringeGreenHueHi": 60, + "DefringeGreenHueLo": 40, + "DefringePurpleAmount": 0, + "DefringePurpleHueHi": 70, + "DefringePurpleHueLo": 30, + "Dehaze": 0, + "EnableCalibration": true, + "EnableColorAdjustments": true, + "EnableDetail": true, + "EnableDistractionRemoval": true, + "EnableEffects": true, + "EnableGrayscaleMix": true, + "EnableLensCorrections": true, + "EnableMaskGroupBasedCorrections": true, + "EnableRedEye": true, + "EnableRetouch": true, + "EnableSplitToning": true, + "EnableToneCurve": true, + "EnableTransform": true, + "Exposure": 0, + "Exposure2012": 0.6, + "FillLight": 0, + "FilterList": [], + "GrainAmount": 0, + "GrainFrequency": 50, + "GrainSize": 25, + "GreenHue": 0, + "GreenSaturation": 0, + "HDREditMode": 0, + "HDRMaxValue": 4, + "HighlightRecovery": 0, + "Highlights2012": -85, + "HueAdjustmentAqua": 0, + "HueAdjustmentBlue": 0, + "HueAdjustmentGreen": 0, + "HueAdjustmentMagenta": 0, + "HueAdjustmentOrange": 0, + "HueAdjustmentPurple": 0, + "HueAdjustmentRed": 0, + "HueAdjustmentYellow": 0, + "LensBlur": [], + "LensManualDistortionAmount": 0, + "LensProfileDigest": "00000000000000000000000000000091", + "LensProfileDistortionScale": 100, + "LensProfileEnable": 0, + "LensProfileFilename": "Synthetic Lens - RAW.lcp", + "LensProfileIsEmbedded": false, + "LensProfileName": "Adobe (Synthetic Lens)", + "LensProfileSetup": "LensDefaults", + "LensProfileVignettingScale": 100, + "Look": { + "Amount": 1, + "Group": { + "x-default": "Synthetic Group" + }, + "Name": "Adobe Landscape", + "Parameters": { + "ConvertToGrayscale": false, + "ProcessVersion": "15.4" + }, + "SupportsAmount": false, + "SupportsMonochrome": false, + "SupportsOutputReferred": false, + "UUID": "6F9C877E84273F4E8271E6B91BEB36A1" + }, + "LuminanceAdjustmentAqua": 0, + "LuminanceAdjustmentBlue": 0, + "LuminanceAdjustmentGreen": 0, + "LuminanceAdjustmentMagenta": 0, + "LuminanceAdjustmentOrange": 0, + "LuminanceAdjustmentPurple": 0, + "LuminanceAdjustmentRed": 0, + "LuminanceAdjustmentYellow": 0, + "LuminanceNoiseReductionContrast": 0, + "LuminanceNoiseReductionDetail": 50, + "LuminanceSmoothing": 0, + "MaskGroupBasedCorrections": [ + { + "CorrectionActive": true, + "CorrectionAmount": 1, + "CorrectionID": "00000000-0000-4000-8000-000000000070", + "CorrectionMasks": [ + { + "CorrectionRangeMask": { + "ColorAmount": 0.494949, + "Invert": false, + "PointModels": [ + "0.255405 0.034566 0.419137 0.692175 0.651248 0" + ], + "SampleType": 0, + "Type": 1, + "Version": 3 + }, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskID": "00000000-0000-4000-8000-000000000071", + "MaskInverted": false, + "MaskName": "Mask 1", + "MaskSyncID": "000000000000000000000000000000B0", + "MaskValue": 1, + "What": "Mask/RangeMask" + } + ], + "CorrectionName": "Correction 1", + "CorrectionReferenceX": 0.692175, + "CorrectionReferenceY": 0.651248, + "CorrectionSyncID": "000000000000000000000000000000B1", + "LocalBlacks2012": 0, + "LocalBrightness": 0, + "LocalClarity": 0, + "LocalClarity2012": 0.798791, + "LocalContrast": 0, + "LocalContrast2012": 0, + "LocalCurveRefineSaturation": 100, + "LocalDefringe": 0, + "LocalDehaze": 0, + "LocalExposure": 0, + "LocalExposure2012": 0, + "LocalHighlights2012": 0, + "LocalHue": 0, + "LocalLuminanceNoise": 0, + "LocalMoire": 0, + "LocalSaturation": 0, + "LocalShadows2012": 0, + "LocalSharpness": 0, + "LocalTemperature": 0, + "LocalTexture": 0, + "LocalTint": 0, + "LocalToningHue": 0, + "LocalToningSaturation": 0, + "LocalWhites2012": 0, + "What": "Correction" + } + ], + "ParametricDarks": 0, + "ParametricHighlightSplit": 75, + "ParametricHighlights": 0, + "ParametricLights": 0, + "ParametricMidtoneSplit": 50, + "ParametricShadowSplit": 25, + "ParametricShadows": 0, + "PerspectiveAspect": 0, + "PerspectiveHorizontal": 0, + "PerspectiveRotate": 0, + "PerspectiveScale": 100, + "PerspectiveUpright": 0, + "PerspectiveVertical": 0, + "PerspectiveX": 0, + "PerspectiveY": 0, + "PointColors": [], + "PostCropVignetteAmount": 0, + "PostCropVignetteFeather": 50, + "PostCropVignetteHighlightContrast": 0, + "PostCropVignetteMidpoint": 50, + "PostCropVignetteRoundness": 0, + "PostCropVignetteStyle": 1, + "ProcessVersion": "11.0", + "RedEyeInfo": [], + "RedHue": 0, + "RedSaturation": 0, + "SDRBlend": 0, + "SDRBrightness": 0, + "SDRClarity": 0, + "SDRContrast": 0, + "SDRHighlights": 0, + "SDRShadows": 0, + "SDRWhites": 0, + "Saturation": -1, + "SaturationAdjustmentAqua": 0, + "SaturationAdjustmentBlue": 0, + "SaturationAdjustmentGreen": 0, + "SaturationAdjustmentMagenta": 0, + "SaturationAdjustmentOrange": 0, + "SaturationAdjustmentPurple": 0, + "SaturationAdjustmentRed": 0, + "SaturationAdjustmentYellow": 0, + "ShadowTint": 0, + "Shadows": 5, + "Shadows2012": 48, + "SharpenDetail": 25, + "SharpenEdgeMasking": 0, + "SharpenRadius": 1, + "Sharpness": 0, + "SplitToningBalance": 0, + "SplitToningHighlightHue": 0, + "SplitToningHighlightSaturation": 0, + "SplitToningShadowHue": 0, + "SplitToningShadowSaturation": 0, + "Temperature": 6200, + "Texture": 0, + "Tint": 4, + "ToneCurveName2012": "Linear", + "ToneCurvePV2012": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Blue": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Green": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Red": [ + 0, + 0, + 255, + 255 + ], + "UprightCenterMode": 0, + "UprightCenterNormX": 0.5, + "UprightCenterNormY": 0.5, + "UprightFocalLength35mm": 35, + "UprightFocalMode": 0, + "UprightFourSegmentsCount": 0, + "UprightPreview": false, + "UprightTransformCount": 6, + "UprightVersion": 151388160, + "Vibrance": 14, + "VignetteAmount": 0, + "VignetteMidpoint": 50, + "WhiteBalance": "As Shot", + "Whites2012": 14, + "orientation": "AB" +} diff --git a/server-rs/testdata/develop/lua/mask_range_color_area.json b/server-rs/testdata/develop/lua/mask_range_color_area.json new file mode 100644 index 00000000..eb108596 --- /dev/null +++ b/server-rs/testdata/develop/lua/mask_range_color_area.json @@ -0,0 +1,481 @@ +{ + "AILook": [], + "AutoBrightness": false, + "AutoContrast": false, + "AutoExposure": false, + "AutoLateralCA": 1, + "AutoShadows": false, + "Blacks2012": 78, + "BlueHue": 0, + "BlueSaturation": 0, + "Brightness": 50, + "CameraProfile": "Adobe Standard", + "ChromaticAberrationB": 0, + "ChromaticAberrationR": 0, + "Clarity": 0, + "Clarity2012": -4, + "ColorGradeBlending": 50, + "ColorGradeGlobalHue": 0, + "ColorGradeGlobalLum": 0, + "ColorGradeGlobalSat": 0, + "ColorGradeHighlightLum": 0, + "ColorGradeMidtoneHue": 0, + "ColorGradeMidtoneLum": 0, + "ColorGradeMidtoneSat": 0, + "ColorGradeShadowLum": 0, + "ColorNoiseReduction": 25, + "ColorNoiseReductionDetail": 90, + "ColorNoiseReductionSmoothness": 90, + "Contrast": 25, + "Contrast2012": -14, + "ConvertToGrayscale": false, + "CropAngle": 0, + "CropBottom": 1, + "CropConstrainToUnitSquare": 1, + "CropConstrainToWarp": 0, + "CropLeft": 0, + "CropRight": 1, + "CropTop": 0, + "CurveRefineSaturation": 43, + "Defringe": 0, + "DefringeGreenAmount": 0, + "DefringeGreenHueHi": 60, + "DefringeGreenHueLo": 40, + "DefringePurpleAmount": 0, + "DefringePurpleHueHi": 70, + "DefringePurpleHueLo": 30, + "Dehaze": 13, + "EnableCalibration": true, + "EnableColorAdjustments": true, + "EnableDetail": true, + "EnableDistractionRemoval": true, + "EnableEffects": true, + "EnableGrayscaleMix": true, + "EnableLensCorrections": true, + "EnableMaskGroupBasedCorrections": true, + "EnableRedEye": true, + "EnableRetouch": true, + "EnableSplitToning": true, + "EnableToneCurve": true, + "EnableTransform": true, + "Exposure": 0, + "Exposure2012": 0, + "FillLight": 0, + "FilterList": [], + "GrainAmount": 0, + "GrainFrequency": 50, + "GrainSize": 25, + "GreenHue": 0, + "GreenSaturation": 0, + "HDREditMode": 0, + "HDRMaxValue": 4, + "HighlightRecovery": 0, + "Highlights2012": -76, + "HueAdjustmentAqua": 0, + "HueAdjustmentBlue": 0, + "HueAdjustmentGreen": 0, + "HueAdjustmentMagenta": 0, + "HueAdjustmentOrange": 0, + "HueAdjustmentPurple": 0, + "HueAdjustmentRed": 0, + "HueAdjustmentYellow": 0, + "LensBlur": [], + "LensManualDistortionAmount": 0, + "LensProfileDistortionScale": 100, + "LensProfileEnable": 1, + "LensProfileSetup": "LensDefaults", + "LensProfileVignettingScale": 100, + "Look": { + "Amount": 1, + "Group": { + "x-default": "Synthetic Group" + }, + "Name": "Adobe Color", + "Parameters": { + "ConvertToGrayscale": false, + "ProcessVersion": "15.4" + }, + "SupportsAmount": false, + "SupportsMonochrome": false, + "SupportsOutputReferred": false, + "UUID": "B952C231111CD8E0ECCF14B86BAA7077" + }, + "LuminanceAdjustmentAqua": 0, + "LuminanceAdjustmentBlue": 0, + "LuminanceAdjustmentGreen": 0, + "LuminanceAdjustmentMagenta": 0, + "LuminanceAdjustmentOrange": 0, + "LuminanceAdjustmentPurple": 0, + "LuminanceAdjustmentRed": 0, + "LuminanceAdjustmentYellow": 0, + "LuminanceNoiseReductionContrast": 0, + "LuminanceNoiseReductionDetail": 50, + "LuminanceSmoothing": 0, + "MaskGroupBasedCorrections": [ + { + "CorrectionActive": true, + "CorrectionAmount": 1, + "CorrectionID": "00000000-0000-4000-8000-000000000072", + "CorrectionMasks": [ + { + "DidOverrideInputDigestMismatch": true, + "FullMaskSize": "2880,1920", + "InputDigest": "0000000000000000000000000000009E", + "InputDigestVersion": 2, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskDigest": "000000000000000000000000000000B2", + "MaskID": "00000000-0000-4000-8000-000000000073", + "MaskInverted": true, + "MaskName": "Mask 1", + "MaskSubType": 1, + "MaskSyncID": "000000000000000000000000000000B3", + "MaskValue": 1, + "MaskVersion": 1, + "ModelVersion": 234881976, + "Origin": "0,195", + "ReferencePoint": "0.382812 0.576471", + "What": "Mask/Image", + "WholeImageArea": "0/1,0/1,1920/1,2880/1" + } + ], + "CorrectionName": "Correction 1", + "CorrectionReferenceX": 0.382812, + "CorrectionReferenceY": 0.576471, + "CorrectionSyncID": "000000000000000000000000000000B4", + "LocalBlacks2012": 0, + "LocalBrightness": 0, + "LocalClarity": 0, + "LocalClarity2012": 0.221649, + "LocalContrast": 0, + "LocalContrast2012": 0, + "LocalCurveRefineSaturation": 100, + "LocalDefringe": 0, + "LocalDehaze": 0, + "LocalExposure": 0, + "LocalExposure2012": 0.33407, + "LocalHighlights2012": 0.37613, + "LocalHue": 0, + "LocalLuminanceNoise": 0.425658, + "LocalMoire": 0, + "LocalSaturation": 0, + "LocalShadows2012": 0.245725, + "LocalSharpness": 0.144163, + "LocalTemperature": -0.113404, + "LocalTexture": 0, + "LocalTint": 0, + "LocalToningHue": 54, + "LocalToningSaturation": 0, + "LocalWhites2012": 0, + "What": "Correction" + }, + { + "CorrectionActive": true, + "CorrectionAmount": 1, + "CorrectionID": "00000000-0000-4000-8000-000000000074", + "CorrectionMasks": [ + { + "DidOverrideInputDigestMismatch": true, + "FullMaskSize": "2880,1920", + "InputDigest": "0000000000000000000000000000009E", + "InputDigestVersion": 2, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskDigest": "000000000000000000000000000000B2", + "MaskID": "00000000-0000-4000-8000-000000000075", + "MaskInverted": false, + "MaskName": "Mask 2", + "MaskSubType": 1, + "MaskSyncID": "000000000000000000000000000000B5", + "MaskValue": 1, + "MaskVersion": 1, + "ModelVersion": 234881976, + "Origin": "0,195", + "ReferencePoint": "0.382812 0.576471", + "What": "Mask/Image", + "WholeImageArea": "0/1,0/1,1920/1,2880/1" + } + ], + "CorrectionName": "Correction 2", + "CorrectionReferenceX": 0.382812, + "CorrectionReferenceY": 0.576471, + "CorrectionSyncID": "000000000000000000000000000000B6", + "LocalBlacks2012": 0, + "LocalBrightness": 0, + "LocalClarity": 0, + "LocalClarity2012": 0.473762, + "LocalContrast": 0, + "LocalContrast2012": 0, + "LocalCurveRefineSaturation": 100, + "LocalDefringe": 0, + "LocalDehaze": 0, + "LocalExposure": 0, + "LocalExposure2012": 0, + "LocalHighlights2012": 0.237814, + "LocalHue": 0, + "LocalLuminanceNoise": 0, + "LocalMoire": 0, + "LocalSaturation": 0, + "LocalShadows2012": 0.342767, + "LocalSharpness": 0, + "LocalTemperature": 0, + "LocalTexture": 0.139986, + "LocalTint": 0, + "LocalToningHue": 54, + "LocalToningSaturation": 0, + "LocalWhites2012": 0.023438, + "What": "Correction" + }, + { + "CorrectionActive": true, + "CorrectionAmount": 1, + "CorrectionID": "00000000-0000-4000-8000-000000000076", + "CorrectionMasks": [ + { + "DidOverrideInputDigestMismatch": true, + "FullMaskSize": "2880,1920", + "InputDigest": "0000000000000000000000000000009E", + "InputDigestVersion": 2, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskDigest": "000000000000000000000000000000B2", + "MaskID": "00000000-0000-4000-8000-000000000077", + "MaskInverted": true, + "MaskName": "Mask 3", + "MaskSubType": 1, + "MaskSyncID": "000000000000000000000000000000B5", + "MaskValue": 1, + "MaskVersion": 1, + "ModelVersion": 234881976, + "Origin": "0,195", + "ReferencePoint": "0.382812 0.576471", + "What": "Mask/Image", + "WholeImageArea": "0/1,0/1,1920/1,2880/1" + } + ], + "CorrectionName": "Correction 3", + "CorrectionReferenceX": 0.382812, + "CorrectionReferenceY": 0.576471, + "CorrectionSyncID": "000000000000000000000000000000B7", + "LocalBlacks2012": 0, + "LocalBrightness": 0, + "LocalClarity": 0, + "LocalClarity2012": 0.357557, + "LocalContrast": 0, + "LocalContrast2012": 0, + "LocalCurveRefineSaturation": 100, + "LocalDefringe": 0, + "LocalDehaze": 0.032626, + "LocalExposure": 0, + "LocalExposure2012": 0.114632, + "LocalHighlights2012": -0.422022, + "LocalHue": 0, + "LocalLuminanceNoise": 0, + "LocalMoire": 0, + "LocalSaturation": 0, + "LocalShadows2012": 0.568544, + "LocalSharpness": 0, + "LocalTemperature": 0, + "LocalTexture": 0, + "LocalTint": 0, + "LocalToningHue": 54, + "LocalToningSaturation": 0, + "LocalWhites2012": 0, + "What": "Correction" + }, + { + "CorrectionActive": true, + "CorrectionAmount": 1, + "CorrectionID": "00000000-0000-4000-8000-000000000078", + "CorrectionMasks": [ + { + "CorrectionRangeMask": { + "AreaModels": [ + { + "AreaComponents": [ + "0.496178 0.211467 0.237057 0.021401", + "0.604646 0.228862 0.260861 0.034520", + "0.713115 0.241558 0.297818 0.050960", + "0.821584 0.268116 0.337690 0.071190", + "0.930053 0.294125 0.403296 0.048887" + ], + "ColorRangeMaskAreaSampleInfo": "0.109375 0.247059 0.644477 0.259033 0.688264 0.259033 0.644477 0.485377 0.688264 0.485377 0" + } + ], + "ColorAmount": 0.329945, + "Invert": false, + "SampleType": 1, + "Type": 1, + "Version": 3 + }, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskID": "00000000-0000-4000-8000-000000000079", + "MaskInverted": false, + "MaskName": "Mask 4", + "MaskSyncID": "000000000000000000000000000000B8", + "MaskValue": 1, + "What": "Mask/RangeMask" + } + ], + "CorrectionName": "Correction 4", + "CorrectionReferenceX": 0.109375, + "CorrectionReferenceY": 0.247059, + "CorrectionSyncID": "000000000000000000000000000000B9", + "LocalBlacks2012": 0, + "LocalBrightness": 0, + "LocalClarity": 0, + "LocalClarity2012": 0.312402, + "LocalContrast": 0, + "LocalContrast2012": 0, + "LocalCurveRefineSaturation": 100, + "LocalDefringe": 0, + "LocalDehaze": 0, + "LocalExposure": 0, + "LocalExposure2012": 0, + "LocalHighlights2012": 0.061616, + "LocalHue": 0, + "LocalLuminanceNoise": 0, + "LocalMoire": 0, + "LocalSaturation": 0, + "LocalShadows2012": 0.158215, + "LocalSharpness": 0, + "LocalTemperature": 0, + "LocalTexture": 0, + "LocalTint": 0, + "LocalToningHue": 54, + "LocalToningSaturation": 0, + "LocalWhites2012": 0.268377, + "What": "Correction" + } + ], + "ParametricDarks": 0, + "ParametricHighlightSplit": 75, + "ParametricHighlights": 0, + "ParametricLights": 0, + "ParametricMidtoneSplit": 50, + "ParametricShadowSplit": 25, + "ParametricShadows": 0, + "PerspectiveAspect": 0, + "PerspectiveHorizontal": 0, + "PerspectiveRotate": 0, + "PerspectiveScale": 100, + "PerspectiveUpright": 0, + "PerspectiveVertical": 0, + "PerspectiveX": 0, + "PerspectiveY": 0, + "PointColors": [], + "PostCropVignetteAmount": 0, + "PostCropVignetteFeather": 50, + "PostCropVignetteHighlightContrast": 0, + "PostCropVignetteMidpoint": 50, + "PostCropVignetteRoundness": 0, + "PostCropVignetteStyle": 1, + "ProcessVersion": "15.4", + "RedEyeInfo": [], + "RedHue": 0, + "RedSaturation": 0, + "SDRBlend": 0, + "SDRBrightness": 0, + "SDRClarity": 0, + "SDRContrast": 0, + "SDRHighlights": 0, + "SDRShadows": 0, + "SDRWhites": 0, + "Saturation": 6, + "SaturationAdjustmentAqua": 0, + "SaturationAdjustmentBlue": 0, + "SaturationAdjustmentGreen": 0, + "SaturationAdjustmentMagenta": 0, + "SaturationAdjustmentOrange": -3, + "SaturationAdjustmentPurple": 0, + "SaturationAdjustmentRed": 1, + "SaturationAdjustmentYellow": 0, + "ShadowTint": 0, + "Shadows": 5, + "Shadows2012": 58, + "SharpenDetail": 35, + "SharpenEdgeMasking": 74, + "SharpenRadius": 1, + "Sharpness": 50, + "SplitToningBalance": 0, + "SplitToningHighlightHue": 0, + "SplitToningHighlightSaturation": 0, + "SplitToningShadowHue": 0, + "SplitToningShadowSaturation": 0, + "Temperature": 5200, + "Texture": 9, + "Tint": 39, + "ToneCurveName2012": "Custom", + "ToneCurvePV2012": [ + 0, + 31, + 26, + 19, + 62, + 26, + 128, + 128, + 194, + 237, + 255, + 241 + ], + "ToneCurvePV2012Blue": [ + 0, + 0, + 41, + 27, + 63, + 53, + 128, + 128, + 206, + 214, + 255, + 255 + ], + "ToneCurvePV2012Green": [ + 0, + 0, + 41, + 27, + 63, + 53, + 128, + 128, + 206, + 214, + 255, + 255 + ], + "ToneCurvePV2012Red": [ + 0, + 0, + 41, + 27, + 63, + 53, + 128, + 128, + 206, + 214, + 255, + 255 + ], + "UprightCenterMode": 0, + "UprightCenterNormX": 0.5, + "UprightCenterNormY": 0.5, + "UprightFocalLength35mm": 35, + "UprightFocalMode": 0, + "UprightFourSegmentsCount": 0, + "UprightPreview": false, + "UprightTransformCount": 6, + "UprightVersion": 151388160, + "Vibrance": 21, + "VignetteAmount": 0, + "VignetteMidpoint": 50, + "WhiteBalance": "As Shot", + "Whites2012": -62, + "orientation": "AB" +} diff --git a/server-rs/testdata/develop/lua/mask_range_luminance.json b/server-rs/testdata/develop/lua/mask_range_luminance.json new file mode 100644 index 00000000..4187ba12 --- /dev/null +++ b/server-rs/testdata/develop/lua/mask_range_luminance.json @@ -0,0 +1,797 @@ +{ + "AutoBrightness": false, + "AutoContrast": false, + "AutoExposure": false, + "AutoLateralCA": 1, + "AutoShadows": false, + "Blacks2012": -18, + "BlueHue": 0, + "BlueSaturation": 0, + "Brightness": 50, + "CameraProfile": "Adobe Standard", + "ChromaticAberrationB": 0, + "ChromaticAberrationR": 0, + "Clarity": 0, + "Clarity2012": 0, + "ColorGradeBlending": 50, + "ColorGradeGlobalHue": 0, + "ColorGradeGlobalLum": 0, + "ColorGradeGlobalSat": 0, + "ColorGradeHighlightLum": 0, + "ColorGradeMidtoneHue": 0, + "ColorGradeMidtoneLum": 0, + "ColorGradeMidtoneSat": 0, + "ColorGradeShadowLum": 0, + "ColorNoiseReduction": 25, + "ColorNoiseReductionDetail": 50, + "ColorNoiseReductionSmoothness": 50, + "Contrast": 25, + "Contrast2012": 5, + "ConvertToGrayscale": false, + "CropAngle": 0, + "CropBottom": 1, + "CropConstrainToUnitSquare": 1, + "CropConstrainToWarp": 0, + "CropLeft": 0, + "CropRight": 1, + "CropTop": 0, + "CurveRefineSaturation": 100, + "Defringe": 0, + "DefringeGreenAmount": 0, + "DefringeGreenHueHi": 60, + "DefringeGreenHueLo": 40, + "DefringePurpleAmount": 0, + "DefringePurpleHueHi": 70, + "DefringePurpleHueLo": 30, + "Dehaze": 0, + "EnableCalibration": true, + "EnableColorAdjustments": true, + "EnableDetail": true, + "EnableDistractionRemoval": true, + "EnableEffects": true, + "EnableGrayscaleMix": true, + "EnableLensCorrections": true, + "EnableMaskGroupBasedCorrections": true, + "EnableRedEye": true, + "EnableRetouch": true, + "EnableSplitToning": true, + "EnableToneCurve": true, + "EnableTransform": true, + "Exposure": 0, + "Exposure2012": -0.18, + "FillLight": 0, + "FilterList": [], + "GrainAmount": 0, + "GrainFrequency": 50, + "GrainSize": 25, + "GreenHue": 0, + "GreenSaturation": 0, + "HDREditMode": 0, + "HDRMaxValue": 4, + "HighlightRecovery": 0, + "Highlights2012": -69, + "HueAdjustmentAqua": 0, + "HueAdjustmentBlue": 0, + "HueAdjustmentGreen": 0, + "HueAdjustmentMagenta": 0, + "HueAdjustmentOrange": 0, + "HueAdjustmentPurple": 0, + "HueAdjustmentRed": 0, + "HueAdjustmentYellow": 0, + "LensBlur": [], + "LensManualDistortionAmount": 0, + "LensProfileDigest": "00000000000000000000000000000071", + "LensProfileDistortionScale": 100, + "LensProfileEnable": 1, + "LensProfileFilename": "Synthetic Lens - RAW.lcp", + "LensProfileIsEmbedded": false, + "LensProfileName": "Adobe (Synthetic Lens)", + "LensProfileSetup": "LensDefaults", + "LensProfileVignettingScale": 100, + "Look": { + "Amount": 1, + "Group": { + "x-default": "Synthetic Group" + }, + "Name": "Adobe Landscape", + "Parameters": { + "ConvertToGrayscale": false, + "ProcessVersion": "15.4" + }, + "SupportsAmount": false, + "SupportsMonochrome": false, + "SupportsOutputReferred": false, + "UUID": "6F9C877E84273F4E8271E6B91BEB36A1" + }, + "LuminanceAdjustmentAqua": 0, + "LuminanceAdjustmentBlue": 0, + "LuminanceAdjustmentGreen": 0, + "LuminanceAdjustmentMagenta": 0, + "LuminanceAdjustmentOrange": 0, + "LuminanceAdjustmentPurple": 0, + "LuminanceAdjustmentRed": 0, + "LuminanceAdjustmentYellow": 0, + "LuminanceNoiseReductionContrast": 0, + "LuminanceNoiseReductionDetail": 50, + "LuminanceSmoothing": 0, + "MaskGroupBasedCorrections": [ + { + "CorrectionActive": true, + "CorrectionAmount": 1, + "CorrectionID": "00000000-0000-4000-8000-00000000005B", + "CorrectionMasks": [ + { + "DidOverrideInputDigestMismatch": true, + "FullMaskSize": "2880,1920", + "InputDigest": "0000000000000000000000000000009E", + "InputDigestVersion": 2, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskDigest": "0000000000000000000000000000009F", + "MaskID": "00000000-0000-4000-8000-00000000005C", + "MaskInverted": false, + "MaskName": "Mask 1", + "MaskSubType": 2, + "MaskSyncID": "000000000000000000000000000000A0", + "MaskValue": 1, + "MaskVersion": 1, + "ModelVersion": 234881976, + "Origin": "0,0", + "ReferencePoint": "0.849124 0.192913", + "What": "Mask/Image", + "WholeImageArea": "0/1,0/1,1920/1,2880/1" + } + ], + "CorrectionName": "Correction 1", + "CorrectionReferenceX": 0.849124, + "CorrectionReferenceY": 0.192913, + "CorrectionSyncID": "000000000000000000000000000000A1", + "LocalBlacks2012": -0.163571, + "LocalBrightness": 0, + "LocalClarity": 0, + "LocalClarity2012": 0, + "LocalContrast": 0, + "LocalContrast2012": 0, + "LocalCurveRefineSaturation": 100, + "LocalDefringe": 0, + "LocalDehaze": 0.367482, + "LocalExposure": 0, + "LocalExposure2012": -0.145391, + "LocalHighlights2012": -0.729363, + "LocalHue": 0, + "LocalLuminanceNoise": 0, + "LocalMoire": 0, + "LocalSaturation": 0, + "LocalShadows2012": 0.471895, + "LocalSharpness": 0, + "LocalTemperature": 0, + "LocalTexture": 0, + "LocalTint": 0, + "LocalToningHue": 54, + "LocalToningSaturation": 0, + "LocalWhites2012": 0.286409, + "What": "Correction" + }, + { + "CorrectionActive": true, + "CorrectionAmount": 1, + "CorrectionID": "00000000-0000-4000-8000-00000000005D", + "CorrectionMasks": [ + { + "FullX": 0.655526, + "FullY": 0.000787, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskID": "00000000-0000-4000-8000-00000000005E", + "MaskInverted": false, + "MaskName": "Mask 2", + "MaskSyncID": "000000000000000000000000000000A2", + "MaskValue": 1, + "What": "Mask/Gradient", + "ZeroX": 0.654904, + "ZeroY": -0.033101 + }, + { + "CorrectionRangeMask": { + "AreaModels": [ + { + "AreaComponents": [ + "0.880262 0.600397 0.112558 0.020362", + "0.887434 0.595842 0.103235 0.042886", + "0.894606 0.594644 0.109270 0.036752", + "0.901778 0.583383 0.095727 0.066841", + "0.908949 0.584123 0.096841 0.059902" + ], + "ColorRangeMaskAreaSampleInfo": "0.679688 0.811765 0.382875 0.068578 0.407736 0.068578 0.382875 0.094464 0.407736 0.094464 0" + } + ], + "ColorAmount": 0.18619, + "Invert": true, + "SampleType": 1, + "Type": 1, + "Version": 3 + }, + "MaskActive": true, + "MaskBlendMode": 1, + "MaskID": "00000000-0000-4000-8000-00000000005F", + "MaskInverted": true, + "MaskName": "Mask 3", + "MaskSyncID": "000000000000000000000000000000A3", + "MaskValue": 0, + "What": "Mask/RangeMask" + } + ], + "CorrectionName": "Correction 2", + "CorrectionReferenceX": 0.655215, + "CorrectionReferenceY": -0.016157, + "CorrectionSyncID": "000000000000000000000000000000A4", + "LocalBlacks2012": -0.181653, + "LocalBrightness": 0, + "LocalClarity": 0, + "LocalClarity2012": 0, + "LocalContrast": 0, + "LocalContrast2012": 0, + "LocalCurveRefineSaturation": 100, + "LocalDefringe": 0, + "LocalDehaze": 0, + "LocalExposure": 0, + "LocalExposure2012": 0, + "LocalHighlights2012": 0, + "LocalHue": 0, + "LocalLuminanceNoise": 0, + "LocalMoire": 0, + "LocalSaturation": 0, + "LocalShadows2012": 0, + "LocalSharpness": 0, + "LocalTemperature": -0.039456, + "LocalTexture": 0, + "LocalTint": 0, + "LocalToningHue": 54, + "LocalToningSaturation": 0, + "LocalWhites2012": 0, + "What": "Correction" + }, + { + "CorrectionActive": true, + "CorrectionAmount": 1, + "CorrectionID": "00000000-0000-4000-8000-000000000060", + "CorrectionMasks": [ + { + "DidOverrideInputDigestMismatch": true, + "FullMaskSize": "2880,1920", + "InputDigest": "0000000000000000000000000000009E", + "InputDigestVersion": 2, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskDigest": "000000000000000000000000000000A5", + "MaskID": "00000000-0000-4000-8000-000000000061", + "MaskInverted": false, + "MaskName": "Mask 4", + "MaskSubType": 1, + "MaskSyncID": "000000000000000000000000000000A6", + "MaskValue": 1, + "MaskVersion": 1, + "ModelVersion": 234881976, + "Origin": "578,241", + "ReferencePoint": "0.507869 0.433732", + "What": "Mask/Image", + "WholeImageArea": "0/1,0/1,1920/1,2880/1" + } + ], + "CorrectionName": "Correction 3", + "CorrectionReferenceX": 0.507869, + "CorrectionReferenceY": 0.433732, + "CorrectionSyncID": "000000000000000000000000000000A7", + "LocalBlacks2012": 0, + "LocalBrightness": 0, + "LocalClarity": 0, + "LocalClarity2012": 0.246315, + "LocalContrast": 0, + "LocalContrast2012": 0, + "LocalCurveRefineSaturation": 100, + "LocalDefringe": 0, + "LocalDehaze": 0.141706, + "LocalExposure": 0, + "LocalExposure2012": -0.059159, + "LocalHighlights2012": -0.5284, + "LocalHue": 0, + "LocalLuminanceNoise": 0, + "LocalMoire": 0, + "LocalSaturation": 0, + "LocalShadows2012": 0.398585, + "LocalSharpness": 0, + "LocalTemperature": 0.18637, + "LocalTexture": 0, + "LocalTint": 0.208776, + "LocalToningHue": 54, + "LocalToningSaturation": 0, + "LocalWhites2012": 0.477987, + "What": "Correction" + }, + { + "CorrectionActive": true, + "CorrectionAmount": 1, + "CorrectionID": "00000000-0000-4000-8000-000000000062", + "CorrectionMasks": [ + { + "DidOverrideInputDigestMismatch": true, + "FullMaskSize": "2880,1920", + "InputDigest": "0000000000000000000000000000009E", + "InputDigestVersion": 2, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskDigest": "0000000000000000000000000000009F", + "MaskID": "00000000-0000-4000-8000-000000000063", + "MaskInverted": true, + "MaskName": "Mask 5", + "MaskSubType": 2, + "MaskSyncID": "000000000000000000000000000000A0", + "MaskValue": 1, + "MaskVersion": 1, + "ModelVersion": 234881976, + "Origin": "0,0", + "ReferencePoint": "0.849124 0.192913", + "What": "Mask/Image", + "WholeImageArea": "0/1,0/1,1920/1,2880/1" + }, + { + "CorrectionRangeMask": { + "AreaModels": [ + { + "AreaComponents": [ + "0.003632 0.518128 0.584108 0.513846", + "0.145064 0.479507 0.588556 0.645149", + "0.286496 0.500000 0.610399 0.648192", + "0.427928 0.519005 0.565110 0.654124", + "0.569360 0.546914 0.532753 0.107178" + ], + "ColorRangeMaskAreaSampleInfo": "0.156584 0.593828 0.824486 0.465676 0.889352 0.465676 0.824486 0.672463 0.889352 0.672463 0" + } + ], + "ColorAmount": 0.420274, + "Invert": true, + "SampleType": 1, + "Type": 1, + "Version": 3 + }, + "MaskActive": true, + "MaskBlendMode": 1, + "MaskID": "00000000-0000-4000-8000-000000000064", + "MaskInverted": true, + "MaskName": "Mask 6", + "MaskSyncID": "000000000000000000000000000000A8", + "MaskValue": 0, + "What": "Mask/RangeMask" + }, + { + "DidOverrideInputDigestMismatch": true, + "FullMaskSize": "2880,1920", + "InputDigest": "0000000000000000000000000000009E", + "InputDigestVersion": 2, + "MaskActive": true, + "MaskBlendMode": 1, + "MaskDigest": "000000000000000000000000000000A5", + "MaskID": "00000000-0000-4000-8000-000000000065", + "MaskInverted": false, + "MaskName": "Mask 7", + "MaskSubType": 1, + "MaskSyncID": "000000000000000000000000000000A6", + "MaskValue": 0, + "MaskVersion": 1, + "ModelVersion": 234881976, + "Origin": "578,241", + "ReferencePoint": "0.507869 0.433732", + "What": "Mask/Image", + "WholeImageArea": "0/1,0/1,1920/1,2880/1" + } + ], + "CorrectionName": "Correction 4", + "CorrectionReferenceX": 0.849124, + "CorrectionReferenceY": 0.192913, + "CorrectionSyncID": "000000000000000000000000000000A9", + "LocalBlacks2012": 0, + "LocalBrightness": 0, + "LocalClarity": 0, + "LocalClarity2012": 0.226513, + "LocalContrast": 0, + "LocalContrast2012": 0, + "LocalCurveRefineSaturation": 100, + "LocalDefringe": 0, + "LocalDehaze": 0.360947, + "LocalExposure": 0, + "LocalExposure2012": 0, + "LocalHighlights2012": -0.778253, + "LocalHue": 0, + "LocalLuminanceNoise": 0, + "LocalMoire": 0, + "LocalSaturation": 0, + "LocalShadows2012": 0.440645, + "LocalSharpness": 0, + "LocalTemperature": 0, + "LocalTexture": 0, + "LocalTint": 0, + "LocalToningHue": 54, + "LocalToningSaturation": 0, + "LocalWhites2012": 0.351661, + "What": "Correction" + }, + { + "CorrectionActive": true, + "CorrectionAmount": 1, + "CorrectionID": "00000000-0000-4000-8000-000000000066", + "CorrectionMasks": [ + { + "CorrectionRangeMask": { + "AreaModels": [ + { + "AreaComponents": [ + "0.579721 0.432197 0.142420 0.063202", + "0.630899 0.471061 0.180529 0.085312", + "0.682078 0.508751 0.251797 0.066203", + "0.733257 0.555505 0.325185 0.079915", + "0.784436 0.600275 0.383017 0.021519" + ], + "ColorRangeMaskAreaSampleInfo": "0.147912 0.311305 0.358603 0.415559 0.394377 0.415559 0.358603 0.460111 0.394377 0.460111 0" + } + ], + "ColorAmount": 0, + "Invert": false, + "SampleType": 1, + "Type": 1, + "Version": 3 + }, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskID": "00000000-0000-4000-8000-000000000067", + "MaskInverted": false, + "MaskName": "Mask 8", + "MaskSyncID": "000000000000000000000000000000AA", + "MaskValue": 1, + "What": "Mask/RangeMask" + }, + { + "DidOverrideInputDigestMismatch": true, + "FullMaskSize": "2880,1920", + "InputDigest": "0000000000000000000000000000009E", + "InputDigestVersion": 2, + "MaskActive": true, + "MaskBlendMode": 1, + "MaskDigest": "0000000000000000000000000000009F", + "MaskID": "00000000-0000-4000-8000-000000000068", + "MaskInverted": false, + "MaskName": "Mask 9", + "MaskSubType": 2, + "MaskSyncID": "000000000000000000000000000000A0", + "MaskValue": 0, + "MaskVersion": 1, + "ModelVersion": 234881976, + "Origin": "0,0", + "ReferencePoint": "0.849124 0.192913", + "What": "Mask/Image", + "WholeImageArea": "0/1,0/1,1920/1,2880/1" + } + ], + "CorrectionName": "Correction 5", + "CorrectionReferenceX": 0.147912, + "CorrectionReferenceY": 0.311305, + "CorrectionSyncID": "000000000000000000000000000000AB", + "LocalBlacks2012": 0, + "LocalBrightness": 0, + "LocalClarity": 0, + "LocalClarity2012": 0, + "LocalContrast": 0, + "LocalContrast2012": 0, + "LocalCurveRefineSaturation": 100, + "LocalDefringe": 0, + "LocalDehaze": 0.180867, + "LocalExposure": 0, + "LocalExposure2012": 0, + "LocalHighlights2012": 0, + "LocalHue": 0, + "LocalLuminanceNoise": 0, + "LocalMoire": 0, + "LocalSaturation": 0, + "LocalShadows2012": 0, + "LocalSharpness": 0, + "LocalTemperature": 0, + "LocalTexture": 0, + "LocalTint": 0, + "LocalToningHue": 54, + "LocalToningSaturation": 0, + "LocalWhites2012": 0, + "What": "Correction" + }, + { + "CorrectionActive": true, + "CorrectionAmount": 1, + "CorrectionID": "00000000-0000-4000-8000-000000000069", + "CorrectionMasks": [ + { + "DidOverrideInputDigestMismatch": true, + "FullMaskSize": "2880,1920", + "InputDigest": "0000000000000000000000000000009E", + "InputDigestVersion": 2, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskDigest": "000000000000000000000000000000A5", + "MaskID": "00000000-0000-4000-8000-00000000006A", + "MaskInverted": false, + "MaskName": "Mask 10", + "MaskSubType": 1, + "MaskSyncID": "000000000000000000000000000000A6", + "MaskValue": 1, + "MaskVersion": 1, + "ModelVersion": 234881976, + "Origin": "578,241", + "ReferencePoint": "0.507869 0.433732", + "What": "Mask/Image", + "WholeImageArea": "0/1,0/1,1920/1,2880/1" + }, + { + "CorrectionRangeMask": { + "Invert": true, + "LumRange": "0.000000 0.000000 0.109133 0.359133", + "LuminanceDepthSampleInfo": "0 0.170630 0.651623", + "SampleType": 1, + "Type": 2, + "Version": 3 + }, + "MaskActive": true, + "MaskBlendMode": 1, + "MaskID": "00000000-0000-4000-8000-00000000006B", + "MaskInverted": true, + "MaskName": "Mask 11", + "MaskSyncID": "000000000000000000000000000000AC", + "MaskValue": 0, + "What": "Mask/RangeMask" + } + ], + "CorrectionName": "Correction 6", + "CorrectionReferenceX": 0.507869, + "CorrectionReferenceY": 0.433732, + "CorrectionSyncID": "000000000000000000000000000000AD", + "LocalBlacks2012": 0, + "LocalBrightness": 0, + "LocalClarity": 0, + "LocalClarity2012": 0.167207, + "LocalContrast": 0, + "LocalContrast2012": 0, + "LocalCurveRefineSaturation": 100, + "LocalDefringe": 0, + "LocalDehaze": 0, + "LocalExposure": 0, + "LocalExposure2012": 0, + "LocalHighlights2012": 0, + "LocalHue": 0, + "LocalLuminanceNoise": 0.20514, + "LocalMoire": 0, + "LocalSaturation": 0, + "LocalShadows2012": 0.457252, + "LocalSharpness": 0, + "LocalTemperature": 0, + "LocalTexture": 0, + "LocalTint": 0, + "LocalToningHue": 54, + "LocalToningSaturation": 0, + "LocalWhites2012": 0, + "What": "Correction" + }, + { + "CorrectionActive": true, + "CorrectionAmount": 1, + "CorrectionID": "00000000-0000-4000-8000-00000000006C", + "CorrectionMasks": [ + { + "DidOverrideInputDigestMismatch": true, + "FullMaskSize": "2880,1920", + "InputDigest": "0000000000000000000000000000009E", + "InputDigestVersion": 2, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskDigest": "000000000000000000000000000000A5", + "MaskID": "00000000-0000-4000-8000-00000000006D", + "MaskInverted": false, + "MaskName": "Mask 12", + "MaskSubType": 1, + "MaskSyncID": "000000000000000000000000000000A6", + "MaskValue": 1, + "MaskVersion": 1, + "ModelVersion": 234881976, + "Origin": "578,241", + "ReferencePoint": "0.507869 0.433732", + "What": "Mask/Image", + "WholeImageArea": "0/1,0/1,1920/1,2880/1" + } + ], + "CorrectionName": "Correction 7", + "CorrectionReferenceX": 0.507869, + "CorrectionReferenceY": 0.433732, + "CorrectionSyncID": "000000000000000000000000000000AE", + "LocalBlacks2012": 0, + "LocalBrightness": 0, + "LocalClarity": 0, + "LocalClarity2012": 0.148634, + "LocalContrast": 0, + "LocalContrast2012": 0, + "LocalCurveRefineSaturation": 100, + "LocalDefringe": 0, + "LocalDehaze": 0, + "LocalExposure": 0, + "LocalExposure2012": 0, + "LocalHighlights2012": 0, + "LocalHue": 0, + "LocalLuminanceNoise": 0, + "LocalMoire": 0, + "LocalSaturation": 0.234178, + "LocalShadows2012": 0, + "LocalSharpness": 0, + "LocalTemperature": 0, + "LocalTexture": 0, + "LocalTint": 0, + "LocalToningHue": 54, + "LocalToningSaturation": 0, + "LocalWhites2012": 0, + "What": "Correction" + }, + { + "CorrectionActive": true, + "CorrectionAmount": 1, + "CorrectionID": "00000000-0000-4000-8000-00000000006E", + "CorrectionMasks": [ + { + "DidOverrideInputDigestMismatch": true, + "FullMaskSize": "2880,1920", + "InputDigest": "0000000000000000000000000000009E", + "InputDigestVersion": 2, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskDigest": "0000000000000000000000000000009F", + "MaskID": "00000000-0000-4000-8000-00000000006F", + "MaskInverted": true, + "MaskName": "Mask 13", + "MaskSubType": 2, + "MaskSyncID": "000000000000000000000000000000A0", + "MaskValue": 1, + "MaskVersion": 1, + "ModelVersion": 234881976, + "Origin": "0,0", + "ReferencePoint": "0.849124 0.192913", + "What": "Mask/Image", + "WholeImageArea": "0/1,0/1,1920/1,2880/1" + } + ], + "CorrectionName": "Correction 8", + "CorrectionReferenceX": 0.849124, + "CorrectionReferenceY": 0.192913, + "CorrectionSyncID": "000000000000000000000000000000AF", + "LocalBlacks2012": 0, + "LocalBrightness": 0, + "LocalClarity": 0, + "LocalClarity2012": 0, + "LocalContrast": 0, + "LocalContrast2012": 0, + "LocalCurveRefineSaturation": 100, + "LocalDefringe": 0, + "LocalDehaze": 0.08294, + "LocalExposure": 0, + "LocalExposure2012": 0, + "LocalHighlights2012": 0, + "LocalHue": 0, + "LocalLuminanceNoise": 0, + "LocalMoire": 0, + "LocalSaturation": 0, + "LocalShadows2012": 0.10623, + "LocalSharpness": 0, + "LocalTemperature": 0, + "LocalTexture": 0, + "LocalTint": 0, + "LocalToningHue": 54, + "LocalToningSaturation": 0, + "LocalWhites2012": 0, + "What": "Correction" + } + ], + "ParametricDarks": 0, + "ParametricHighlightSplit": 75, + "ParametricHighlights": 0, + "ParametricLights": 0, + "ParametricMidtoneSplit": 50, + "ParametricShadowSplit": 25, + "ParametricShadows": 0, + "PerspectiveAspect": 0, + "PerspectiveHorizontal": 0, + "PerspectiveRotate": 0, + "PerspectiveScale": 100, + "PerspectiveUpright": 1, + "PerspectiveVertical": 0, + "PerspectiveX": 0, + "PerspectiveY": 0, + "PointColors": [], + "PostCropVignetteAmount": 0, + "PostCropVignetteFeather": 50, + "PostCropVignetteHighlightContrast": 0, + "PostCropVignetteMidpoint": 50, + "PostCropVignetteRoundness": 0, + "PostCropVignetteStyle": 1, + "ProcessVersion": "11.0", + "RedEyeInfo": [], + "RedHue": 0, + "RedSaturation": 0, + "SDRBlend": 0, + "SDRBrightness": 0, + "SDRClarity": 0, + "SDRContrast": 0, + "SDRHighlights": 0, + "SDRShadows": 0, + "SDRWhites": 0, + "Saturation": -1, + "SaturationAdjustmentAqua": 0, + "SaturationAdjustmentBlue": 0, + "SaturationAdjustmentGreen": 0, + "SaturationAdjustmentMagenta": 0, + "SaturationAdjustmentOrange": 0, + "SaturationAdjustmentPurple": 0, + "SaturationAdjustmentRed": 0, + "SaturationAdjustmentYellow": 0, + "ShadowTint": 0, + "Shadows": 5, + "Shadows2012": 34, + "SharpenDetail": 25, + "SharpenEdgeMasking": 0, + "SharpenRadius": 1, + "Sharpness": 40, + "SplitToningBalance": 0, + "SplitToningHighlightHue": 0, + "SplitToningHighlightSaturation": 0, + "SplitToningShadowHue": 0, + "SplitToningShadowSaturation": 0, + "Temperature": 5900, + "Texture": 0, + "Tint": 13, + "ToneCurveName2012": "Linear", + "ToneCurvePV2012": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Blue": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Green": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Red": [ + 0, + 0, + 255, + 255 + ], + "UprightCenterMode": 0, + "UprightCenterNormX": 0.480111, + "UprightCenterNormY": 0.489254, + "UprightFocalLength35mm": 24.0001, + "UprightFocalMode": 0, + "UprightFourSegmentsCount": 0, + "UprightPreview": false, + "UprightTransformCount": 6, + "UprightTransform_0": "1.000000000,0.000000000,0.000000000,0.000000000,1.000000000,0.000000000,0.000000000,0.000000000,1.000000000", + "UprightTransform_1": "1.055698179,0.028631950,-0.028155720,-0.003328639,1.090155732,-0.014687297,-0.000106508,0.056697952,1.000000000", + "UprightTransform_2": "0.379491099,0.016994786,-0.016994786,-0.538573202,0.483891419,0.339246548,-0.892097432,0.053558999,1.000000000", + "UprightTransform_3": "1.003086259,0.001374858,-0.002230559,-0.003093430,1.003086259,0.000003585,0.000000000,0.000000000,1.000000000", + "UprightTransform_4": "1.095060021,0.049147969,-0.047933506,-0.005582674,1.149764502,-0.022282349,-0.000009013,0.097400702,1.000000000", + "UprightTransform_5": "1.000000000,0.000000000,0.000000000,0.000000000,1.000000000,0.000000000,0.000000000,0.000000000,1.000000000", + "UprightVersion": 151388160, + "Vibrance": 15, + "VignetteAmount": 0, + "VignetteMidpoint": 50, + "WhiteBalance": "As Shot", + "Whites2012": 11, + "orientation": "AB" +} diff --git a/server-rs/testdata/develop/lua/mask_select_object_polygon.json b/server-rs/testdata/develop/lua/mask_select_object_polygon.json new file mode 100644 index 00000000..0294d083 --- /dev/null +++ b/server-rs/testdata/develop/lua/mask_select_object_polygon.json @@ -0,0 +1,651 @@ +{ + "AILook": { + "AILookData": "0000000000000000000000000000006F", + "Active": true, + "InputDigest": "00000000000000000000000000000070", + "InputDigestVersion": 5, + "ModelVersion": 285409280, + "Version": 285212672 + }, + "AutoBrightness": false, + "AutoContrast": false, + "AutoExposure": false, + "AutoLateralCA": 1, + "AutoShadows": false, + "Blacks2012": 0, + "BlueHue": 0, + "BlueSaturation": 0, + "Brightness": 50, + "CameraProfile": "Adobe Standard", + "ChromaticAberrationB": 0, + "ChromaticAberrationR": 0, + "Clarity": 0, + "Clarity2012": 0, + "ColorGradeBlending": 50, + "ColorGradeGlobalHue": 0, + "ColorGradeGlobalLum": 0, + "ColorGradeGlobalSat": 0, + "ColorGradeHighlightLum": 0, + "ColorGradeMidtoneHue": 0, + "ColorGradeMidtoneLum": 0, + "ColorGradeMidtoneSat": 0, + "ColorGradeShadowLum": 0, + "ColorNoiseReduction": 25, + "ColorNoiseReductionDetail": 50, + "ColorNoiseReductionSmoothness": 50, + "Contrast": 25, + "Contrast2012": 0, + "ConvertToGrayscale": false, + "CropAngle": 0, + "CropBottom": 1, + "CropConstrainToUnitSquare": 1, + "CropConstrainToWarp": 0, + "CropLeft": 0, + "CropRight": 1, + "CropTop": 0, + "CurveRefineSaturation": 100, + "Defringe": 0, + "DefringeGreenAmount": 0, + "DefringeGreenHueHi": 60, + "DefringeGreenHueLo": 40, + "DefringePurpleAmount": 0, + "DefringePurpleHueHi": 70, + "DefringePurpleHueLo": 30, + "Dehaze": 0, + "EnableCalibration": true, + "EnableColorAdjustments": true, + "EnableDetail": true, + "EnableDistractionRemoval": true, + "EnableEffects": true, + "EnableGrayscaleMix": true, + "EnableLensCorrections": true, + "EnableMaskGroupBasedCorrections": true, + "EnableRedEye": true, + "EnableRetouch": true, + "EnableSplitToning": true, + "EnableToneCurve": true, + "EnableTransform": true, + "Exposure": 0, + "Exposure2012": 0, + "FillLight": 0, + "FilterList": [], + "GrainAmount": 0, + "GrainFrequency": 50, + "GrainSize": 25, + "GreenHue": 0, + "GreenSaturation": 0, + "HDREditMode": 0, + "HDRMaxValue": 4, + "HighlightRecovery": 0, + "Highlights2012": 0, + "HueAdjustmentAqua": 0, + "HueAdjustmentBlue": 0, + "HueAdjustmentGreen": 0, + "HueAdjustmentMagenta": 0, + "HueAdjustmentOrange": 10, + "HueAdjustmentPurple": 0, + "HueAdjustmentRed": 0, + "HueAdjustmentYellow": 0, + "LensBlur": [], + "LensManualDistortionAmount": 0, + "LensProfileDigest": "00000000000000000000000000000071", + "LensProfileDistortionScale": 100, + "LensProfileEnable": 1, + "LensProfileFilename": "Synthetic Lens - RAW.lcp", + "LensProfileIsEmbedded": false, + "LensProfileName": "Adobe (Synthetic Lens)", + "LensProfileSetup": "LensDefaults", + "LensProfileVignettingScale": 100, + "Look": { + "Amount": 1, + "Cluster": "Synthetic", + "Copyright": "Synthetic copyright notice", + "Group": { + "x-default": "Synthetic Group" + }, + "Name": "Adaptive Color", + "Parameters": { + "ConvertToGrayscale": false, + "ProcessVersion": "15.4" + }, + "SortName": { + "x-default": "Synthetic" + }, + "SupportsMonochrome": false, + "SupportsOutputReferred": false, + "UUID": "226E6263ED94405B973B41F5A6E67BE9", + "isAdobeAdaptive": true + }, + "LuminanceAdjustmentAqua": 0, + "LuminanceAdjustmentBlue": 0, + "LuminanceAdjustmentGreen": 0, + "LuminanceAdjustmentMagenta": 0, + "LuminanceAdjustmentOrange": -12, + "LuminanceAdjustmentPurple": 0, + "LuminanceAdjustmentRed": 0, + "LuminanceAdjustmentYellow": 10, + "LuminanceNoiseReductionContrast": 0, + "LuminanceNoiseReductionDetail": 50, + "LuminanceSmoothing": 0, + "MaskGroupBasedCorrections": [ + { + "CorrectionActive": true, + "CorrectionAmount": 1, + "CorrectionID": "00000000-0000-4000-8000-000000000035", + "CorrectionMasks": [ + { + "FullX": 0.145275, + "FullY": 0.883266, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskID": "00000000-0000-4000-8000-000000000036", + "MaskInverted": false, + "MaskName": "Mask 1", + "MaskSyncID": "00000000000000000000000000000072", + "MaskValue": 1, + "What": "Mask/Gradient", + "ZeroX": 0.243963, + "ZeroY": 0.585304 + } + ], + "CorrectionName": "Correction 1", + "CorrectionReferenceX": 0.194619, + "CorrectionReferenceY": 0.734285, + "CorrectionSyncID": "00000000000000000000000000000073", + "LocalBlacks2012": 0, + "LocalBrightness": 0, + "LocalClarity": 0, + "LocalClarity2012": 0, + "LocalContrast": 0, + "LocalContrast2012": 0.331126, + "LocalCurveRefineSaturation": 100, + "LocalDefringe": 0, + "LocalDehaze": 0, + "LocalExposure": 0, + "LocalExposure2012": -0.047392, + "LocalGrain": 0, + "LocalHighlights2012": 0, + "LocalHue": 0, + "LocalLuminanceNoise": 0, + "LocalMoire": 0, + "LocalSaturation": 0, + "LocalShadows2012": 0, + "LocalSharpness": 0, + "LocalTemperature": 0, + "LocalTexture": 0, + "LocalTint": 0, + "LocalToningHue": 0, + "LocalToningSaturation": 0, + "LocalWhites2012": 0, + "What": "Correction" + }, + { + "CorrectionActive": true, + "CorrectionAmount": 1, + "CorrectionID": "00000000-0000-4000-8000-000000000037", + "CorrectionMasks": [ + { + "FullX": 0.529205, + "FullY": 0.119562, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskID": "00000000-0000-4000-8000-000000000038", + "MaskInverted": false, + "MaskName": "Mask 2", + "MaskSyncID": "00000000000000000000000000000074", + "MaskValue": 1, + "What": "Mask/Gradient", + "ZeroX": 0.531103, + "ZeroY": 0.335766 + } + ], + "CorrectionName": "Correction 2", + "CorrectionReferenceX": 0.530154, + "CorrectionReferenceY": 0.227664, + "CorrectionSyncID": "00000000000000000000000000000075", + "LocalBlacks2012": 0, + "LocalBrightness": 0, + "LocalClarity": 0, + "LocalClarity2012": 0, + "LocalContrast": 0, + "LocalContrast2012": 0, + "LocalCurveRefineSaturation": 100, + "LocalDefringe": 0, + "LocalDehaze": 0, + "LocalExposure": 0, + "LocalExposure2012": -0.132916, + "LocalGrain": 0, + "LocalHighlights2012": 0, + "LocalHue": 0, + "LocalLuminanceNoise": 0, + "LocalMoire": 0, + "LocalSaturation": 0, + "LocalShadows2012": 0, + "LocalSharpness": 0, + "LocalTemperature": 0, + "LocalTexture": 0, + "LocalTint": 0, + "LocalToningHue": 0, + "LocalToningSaturation": 0, + "LocalWhites2012": 0.187759, + "What": "Correction" + }, + { + "CorrectionActive": true, + "CorrectionAmount": 1, + "CorrectionID": "00000000-0000-4000-8000-000000000039", + "CorrectionMasks": [ + { + "FullMaskSize": "2880,1920", + "Gesture": [ + { + "MaskActive": true, + "MaskBlendMode": 0, + "MaskID": "00000000-0000-4000-8000-00000000003A", + "MaskInverted": false, + "MaskSyncID": "00000000000000000000000000000076", + "MaskValue": 1, + "Points": [ + { + "X": 0.622731, + "Y": 0.463914 + }, + { + "X": 0.752816, + "Y": 0.463794 + }, + { + "X": 0.623224, + "Y": 0.863986 + }, + { + "X": 0.753822, + "Y": 0.865196 + } + ], + "What": "Mask/Polygon" + } + ], + "InputDigest": "0000000000000000000000000000005D", + "InputDigestVersion": 2, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskDigest": "00000000000000000000000000000077", + "MaskID": "00000000-0000-4000-8000-00000000003B", + "MaskInverted": false, + "MaskName": "Mask 3", + "MaskSubType": 0, + "MaskSyncID": "00000000000000000000000000000078", + "MaskValue": 1, + "MaskVersion": 1, + "ModelVersion": 234881976, + "Origin": "1842,926", + "ReferencePoint": "0.681846 0.760097", + "What": "Mask/Image", + "WholeImageArea": "0/1,0/1,1920/1,2880/1" + }, + { + "Angle": 0, + "Bottom": 0.678696, + "Feather": 71, + "Flipped": true, + "Left": 0.117562, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskID": "00000000-0000-4000-8000-00000000003C", + "MaskInverted": false, + "MaskName": "Mask 4", + "MaskSyncID": "00000000000000000000000000000079", + "MaskValue": 1, + "Midpoint": 50, + "Right": 0.344094, + "Roundness": 0, + "Top": 0.523675, + "Version": 2, + "What": "Mask/CircularGradient" + } + ], + "CorrectionName": "Correction 3", + "CorrectionReferenceX": 0.681846, + "CorrectionReferenceY": 0.760097, + "CorrectionSyncID": "0000000000000000000000000000007A", + "LocalBlacks2012": 0, + "LocalBrightness": 0, + "LocalClarity": 0, + "LocalClarity2012": 0.127535, + "LocalContrast": 0, + "LocalContrast2012": 0.203849, + "LocalCurveRefineSaturation": 100, + "LocalDefringe": 0, + "LocalDehaze": 0.200952, + "LocalExposure": 0, + "LocalExposure2012": 0.145385, + "LocalGrain": 0, + "LocalHighlights2012": 0, + "LocalHue": 0, + "LocalLuminanceNoise": 0, + "LocalMoire": 0, + "LocalSaturation": 0.095095, + "LocalShadows2012": 0, + "LocalSharpness": 0, + "LocalTemperature": 0.084851, + "LocalTexture": 0.096699, + "LocalTint": 0, + "LocalToningHue": 0, + "LocalToningSaturation": 0, + "LocalWhites2012": 0.091474, + "What": "Correction" + }, + { + "CorrectionActive": true, + "CorrectionAmount": 1, + "CorrectionID": "00000000-0000-4000-8000-00000000003D", + "CorrectionMasks": [ + { + "FullMaskSize": "2880,1920", + "Gesture": [ + { + "MaskActive": true, + "MaskBlendMode": 0, + "MaskID": "00000000-0000-4000-8000-00000000003E", + "MaskInverted": false, + "MaskSyncID": "0000000000000000000000000000007B", + "MaskValue": 1, + "Points": [ + { + "X": 0.623535, + "Y": 0.433401 + }, + { + "X": 0.751802, + "Y": 0.433183 + }, + { + "X": 0.623997, + "Y": 0.855748 + }, + { + "X": 0.752735, + "Y": 0.856913 + } + ], + "What": "Mask/Polygon" + } + ], + "InputDigest": "0000000000000000000000000000005D", + "InputDigestVersion": 2, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskDigest": "0000000000000000000000000000007C", + "MaskID": "00000000-0000-4000-8000-00000000003F", + "MaskInverted": false, + "MaskName": "Mask 5", + "MaskSubType": 0, + "MaskSyncID": "0000000000000000000000000000007D", + "MaskValue": 1, + "MaskVersion": 1, + "ModelVersion": 234881976, + "Origin": "1842,926", + "ReferencePoint": "0.687583 0.748593", + "What": "Mask/Image", + "WholeImageArea": "0/1,0/1,1920/1,2880/1" + }, + { + "FullX": 0.700736, + "FullY": 0.654146, + "MaskActive": true, + "MaskBlendMode": 1, + "MaskID": "00000000-0000-4000-8000-000000000040", + "MaskInverted": false, + "MaskName": "Mask 6", + "MaskSyncID": "0000000000000000000000000000007E", + "MaskValue": 0, + "What": "Mask/Gradient", + "ZeroX": 0.691912, + "ZeroY": 0.525544 + } + ], + "CorrectionName": "Correction 4", + "CorrectionReferenceX": 0.687583, + "CorrectionReferenceY": 0.748593, + "CorrectionSyncID": "0000000000000000000000000000007F", + "LocalBlacks2012": 0, + "LocalBrightness": 0, + "LocalClarity": 0, + "LocalClarity2012": 0, + "LocalContrast": 0, + "LocalContrast2012": 0, + "LocalCurveRefineSaturation": 100, + "LocalDefringe": 0, + "LocalDehaze": 0, + "LocalExposure": 0, + "LocalExposure2012": 0.08125, + "LocalGrain": 0, + "LocalHighlights2012": 0, + "LocalHue": 0, + "LocalLuminanceNoise": 0, + "LocalMoire": 0, + "LocalSaturation": 0, + "LocalShadows2012": 0, + "LocalSharpness": 0, + "LocalTemperature": 0, + "LocalTexture": 0, + "LocalTint": 0, + "LocalToningHue": 0, + "LocalToningSaturation": 0, + "LocalWhites2012": 0, + "What": "Correction" + }, + { + "CorrectionActive": true, + "CorrectionAmount": 1, + "CorrectionID": "00000000-0000-4000-8000-000000000041", + "CorrectionMasks": [ + { + "Angle": 0, + "Bottom": 0.947338, + "Feather": 38, + "Flipped": true, + "Left": 0.317468, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskID": "00000000-0000-4000-8000-000000000042", + "MaskInverted": false, + "MaskName": "Mask 7", + "MaskSyncID": "00000000000000000000000000000080", + "MaskValue": 1, + "Midpoint": 50, + "Right": 1.02402, + "Roundness": 0, + "Top": 0.565817, + "Version": 2, + "What": "Mask/CircularGradient" + } + ], + "CorrectionName": "Correction 5", + "CorrectionReferenceX": 0.670744, + "CorrectionReferenceY": 0.756578, + "CorrectionSyncID": "00000000000000000000000000000081", + "LocalBlacks2012": 0, + "LocalBrightness": 0, + "LocalClarity": 0, + "LocalClarity2012": 0.103322, + "LocalContrast": 0, + "LocalContrast2012": 0, + "LocalCurveRefineSaturation": 100, + "LocalDefringe": 0, + "LocalDehaze": -0.15, + "LocalExposure": 0, + "LocalExposure2012": 0.05, + "LocalGrain": 0, + "LocalHighlights2012": 0, + "LocalHue": 0, + "LocalLuminanceNoise": 0, + "LocalMoire": 0, + "LocalSaturation": 0.165822, + "LocalShadows2012": 0, + "LocalSharpness": 0, + "LocalTemperature": 0.119361, + "LocalTexture": -0.107047, + "LocalTint": 0, + "LocalToningHue": 0, + "LocalToningSaturation": 0, + "LocalWhites2012": 0.15, + "What": "Correction" + }, + { + "CorrectionActive": true, + "CorrectionAmount": 1, + "CorrectionID": "00000000-0000-4000-8000-000000000043", + "CorrectionMasks": [ + { + "CorrectionRangeMask": { + "ColorAmount": 1, + "Invert": false, + "PointModels": [ + "0.742481 0.780084 1.000000 0.662277 0.317560 0" + ], + "SampleType": 0, + "Type": 1, + "Version": 3 + }, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskID": "00000000-0000-4000-8000-000000000044", + "MaskInverted": false, + "MaskName": "Mask 8", + "MaskSyncID": "00000000000000000000000000000082", + "MaskValue": 1, + "What": "Mask/RangeMask" + } + ], + "CorrectionName": "Correction 6", + "CorrectionReferenceX": 0.662277, + "CorrectionReferenceY": 0.31756, + "CorrectionSyncID": "00000000000000000000000000000083", + "LocalBlacks2012": 0, + "LocalBrightness": 0, + "LocalClarity": 0, + "LocalClarity2012": 0, + "LocalContrast": 0, + "LocalContrast2012": 0, + "LocalCurveRefineSaturation": 100, + "LocalDefringe": 0, + "LocalDehaze": 0, + "LocalExposure": 0, + "LocalExposure2012": 0, + "LocalGrain": 0, + "LocalHighlights2012": 0, + "LocalHue": 0, + "LocalLuminanceNoise": 0, + "LocalMoire": 0, + "LocalSaturation": 0.610358, + "LocalShadows2012": 0, + "LocalSharpness": 0, + "LocalTemperature": 0.267332, + "LocalTexture": 0, + "LocalTint": 0, + "LocalToningHue": 0, + "LocalToningSaturation": 0, + "LocalWhites2012": 0, + "What": "Correction" + } + ], + "ParametricDarks": 0, + "ParametricHighlightSplit": 75, + "ParametricHighlights": 0, + "ParametricLights": 0, + "ParametricMidtoneSplit": 50, + "ParametricShadowSplit": 25, + "ParametricShadows": 0, + "PerspectiveAspect": 0, + "PerspectiveHorizontal": 0, + "PerspectiveRotate": 0, + "PerspectiveScale": 100, + "PerspectiveUpright": 0, + "PerspectiveVertical": 0, + "PerspectiveX": 0, + "PerspectiveY": 0, + "PointColors": [], + "PostCropVignetteAmount": 0, + "PostCropVignetteFeather": 50, + "PostCropVignetteHighlightContrast": 0, + "PostCropVignetteMidpoint": 50, + "PostCropVignetteRoundness": 0, + "PostCropVignetteStyle": 1, + "ProcessVersion": "15.4", + "RedEyeInfo": [], + "RedHue": 0, + "RedSaturation": 0, + "SDRBlend": 0, + "SDRBrightness": 0, + "SDRClarity": 0, + "SDRContrast": 0, + "SDRHighlights": 0, + "SDRShadows": 0, + "SDRWhites": 0, + "Saturation": 0, + "SaturationAdjustmentAqua": 0, + "SaturationAdjustmentBlue": 0, + "SaturationAdjustmentGreen": 0, + "SaturationAdjustmentMagenta": 0, + "SaturationAdjustmentOrange": 10, + "SaturationAdjustmentPurple": 0, + "SaturationAdjustmentRed": 0, + "SaturationAdjustmentYellow": 10, + "ShadowTint": 0, + "Shadows": 5, + "Shadows2012": 0, + "SharpenDetail": 25, + "SharpenEdgeMasking": 0, + "SharpenRadius": 1, + "Sharpness": 40, + "SplitToningBalance": 0, + "SplitToningHighlightHue": 33, + "SplitToningHighlightSaturation": 0, + "SplitToningShadowHue": 218, + "SplitToningShadowSaturation": 0, + "Temperature": 6250, + "Texture": 0, + "Tint": 19, + "ToneCurveName2012": "Linear", + "ToneCurvePV2012": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Blue": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Green": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Red": [ + 0, + 0, + 255, + 255 + ], + "UprightCenterMode": 0, + "UprightCenterNormX": 0.5, + "UprightCenterNormY": 0.5, + "UprightFocalLength35mm": 35, + "UprightFocalMode": 0, + "UprightFourSegmentsCount": 0, + "UprightPreview": false, + "UprightTransformCount": 6, + "UprightVersion": 151388160, + "Vibrance": 20, + "VignetteAmount": 0, + "VignetteMidpoint": 50, + "WhiteBalance": "As Shot", + "Whites2012": 0, + "orientation": "AB" +} diff --git a/server-rs/testdata/develop/lua/no_look.json b/server-rs/testdata/develop/lua/no_look.json new file mode 100644 index 00000000..a9bd091d --- /dev/null +++ b/server-rs/testdata/develop/lua/no_look.json @@ -0,0 +1,193 @@ +{ + "AutoBrightness": false, + "AutoContrast": false, + "AutoExposure": false, + "AutoLateralCA": 1, + "AutoShadows": false, + "Blacks2012": 0, + "BlueHue": 0, + "BlueSaturation": 0, + "Brightness": 50, + "CameraProfile": "Camera Standard v2", + "ChromaticAberrationB": 0, + "ChromaticAberrationR": 0, + "Clarity": 0, + "Clarity2012": 77, + "ColorGradeBlending": 50, + "ColorGradeGlobalHue": 0, + "ColorGradeGlobalLum": 0, + "ColorGradeGlobalSat": 0, + "ColorGradeHighlightLum": 0, + "ColorGradeMidtoneHue": 0, + "ColorGradeMidtoneLum": 0, + "ColorGradeMidtoneSat": 0, + "ColorGradeShadowLum": 0, + "ColorNoiseReduction": 25, + "ColorNoiseReductionDetail": 50, + "ColorNoiseReductionSmoothness": 50, + "Contrast": 25, + "Contrast2012": 0, + "ConvertToGrayscale": false, + "CropAngle": 0, + "CropBottom": 1, + "CropConstrainToUnitSquare": 1, + "CropConstrainToWarp": 0, + "CropLeft": 0.054543, + "CropRight": 0.808755, + "CropTop": 0.245788, + "CurveRefineSaturation": 100, + "Defringe": 0, + "DefringeGreenAmount": 0, + "DefringeGreenHueHi": 60, + "DefringeGreenHueLo": 40, + "DefringePurpleAmount": 0, + "DefringePurpleHueHi": 70, + "DefringePurpleHueLo": 30, + "Dehaze": 85, + "EnableCalibration": true, + "EnableColorAdjustments": true, + "EnableDetail": true, + "EnableDistractionRemoval": true, + "EnableEffects": true, + "EnableGrayscaleMix": true, + "EnableLensCorrections": true, + "EnableMaskGroupBasedCorrections": true, + "EnableRedEye": true, + "EnableRetouch": true, + "EnableSplitToning": true, + "EnableToneCurve": true, + "EnableTransform": true, + "Exposure": 0, + "Exposure2012": 0.66, + "FillLight": 0, + "FilterList": [], + "GrainAmount": 0, + "GrainFrequency": 50, + "GrainSize": 25, + "GreenHue": 0, + "GreenSaturation": 0, + "HDREditMode": 0, + "HDRMaxValue": 4, + "HighlightRecovery": 0, + "Highlights2012": 0, + "HueAdjustmentAqua": 0, + "HueAdjustmentBlue": 0, + "HueAdjustmentGreen": 0, + "HueAdjustmentMagenta": 0, + "HueAdjustmentOrange": 0, + "HueAdjustmentPurple": 0, + "HueAdjustmentRed": 0, + "HueAdjustmentYellow": 0, + "LensBlur": [], + "LensManualDistortionAmount": 0, + "LensProfileEnable": 1, + "LensProfileSetup": "LensDefaults", + "LuminanceAdjustmentAqua": 0, + "LuminanceAdjustmentBlue": 0, + "LuminanceAdjustmentGreen": 0, + "LuminanceAdjustmentMagenta": 0, + "LuminanceAdjustmentOrange": 0, + "LuminanceAdjustmentPurple": 0, + "LuminanceAdjustmentRed": 0, + "LuminanceAdjustmentYellow": 0, + "LuminanceNoiseReductionContrast": 0, + "LuminanceNoiseReductionDetail": 50, + "LuminanceSmoothing": 0, + "ParametricDarks": 0, + "ParametricHighlightSplit": 75, + "ParametricHighlights": 0, + "ParametricLights": 0, + "ParametricMidtoneSplit": 50, + "ParametricShadowSplit": 25, + "ParametricShadows": 0, + "PerspectiveAspect": 0, + "PerspectiveHorizontal": 0, + "PerspectiveRotate": 0, + "PerspectiveScale": 100, + "PerspectiveUpright": 0, + "PerspectiveVertical": 0, + "PerspectiveX": 0, + "PerspectiveY": 0, + "PointColors": [], + "PostCropVignetteAmount": 0, + "PostCropVignetteFeather": 50, + "PostCropVignetteHighlightContrast": 0, + "PostCropVignetteMidpoint": 50, + "PostCropVignetteRoundness": 0, + "PostCropVignetteStyle": 1, + "ProcessVersion": "11.0", + "RedEyeInfo": [], + "RedHue": 0, + "RedSaturation": 0, + "SDRBlend": 0, + "SDRBrightness": 0, + "SDRClarity": 0, + "SDRContrast": 0, + "SDRHighlights": 0, + "SDRShadows": 0, + "SDRWhites": 0, + "Saturation": 0, + "SaturationAdjustmentAqua": 0, + "SaturationAdjustmentBlue": 0, + "SaturationAdjustmentGreen": 0, + "SaturationAdjustmentMagenta": 0, + "SaturationAdjustmentOrange": 0, + "SaturationAdjustmentPurple": 0, + "SaturationAdjustmentRed": 0, + "SaturationAdjustmentYellow": 0, + "ShadowTint": 0, + "Shadows": 5, + "Shadows2012": 0, + "SharpenDetail": 25, + "SharpenEdgeMasking": 0, + "SharpenRadius": 1, + "Sharpness": 0, + "SplitToningBalance": 0, + "SplitToningHighlightHue": 0, + "SplitToningHighlightSaturation": 0, + "SplitToningShadowHue": 0, + "SplitToningShadowSaturation": 0, + "Temperature": 5700, + "Texture": 26, + "Tint": 8, + "ToneCurveName2012": "Linear", + "ToneCurvePV2012": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Blue": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Green": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Red": [ + 0, + 0, + 255, + 255 + ], + "UprightCenterMode": 0, + "UprightCenterNormX": 0.5, + "UprightCenterNormY": 0.5, + "UprightFocalLength35mm": 35, + "UprightFocalMode": 0, + "UprightFourSegmentsCount": 0, + "UprightPreview": false, + "UprightTransformCount": 6, + "UprightVersion": 151388160, + "Vibrance": 0, + "VignetteAmount": 0, + "VignetteMidpoint": 50, + "WhiteBalance": "As Shot", + "Whites2012": 0, + "orientation": "AB" +} diff --git a/server-rs/testdata/develop/lua/top_level_empty.json b/server-rs/testdata/develop/lua/top_level_empty.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/server-rs/testdata/develop/lua/top_level_empty.json @@ -0,0 +1 @@ +[] diff --git a/server-rs/testdata/develop/registry_snapshot.json b/server-rs/testdata/develop/registry_snapshot.json new file mode 100644 index 00000000..4b0f9034 --- /dev/null +++ b/server-rs/testdata/develop/registry_snapshot.json @@ -0,0 +1,12726 @@ +{ + "description": "Generated from lrg-develop's key registry by tests/registry_snapshot.rs; do not edit. Regenerate with LRG_BLESS=1 cargo test -p lrg-develop --test registry_snapshot.", + "patterns": [ + { + "frame": "NotApplicable", + "id": "table_blob", + "kind": "Any", + "levels": [], + "matcher": "PrefixHex32(\"Table_\")", + "note": "Base85 + zlib payloads named after their MD5: profile lookup tables, legacy inline mask bitmaps, Denoise/Enhance results. Never written.", + "policy": { + "class": "COMPUTED" + }, + "presence": "Both" + }, + { + "frame": "NotApplicable", + "id": "brush_table_blob", + "kind": "Any", + "levels": [ + "mask-tool" + ], + "matcher": "PrefixHex32(\"BrushTable_\")", + "note": "Brush-stroke payload of a Mask/Aggregate in getDevelopSettings(), next to MaskBrushTable. Never written.", + "policy": { + "class": "COMPUTED" + }, + "presence": "LuaOnly" + }, + { + "frame": "PerFrameOnly", + "id": "patch_state", + "kind": "Any", + "levels": [ + "struct:RetouchArea" + ], + "matcher": "Prefix(\"pm_\")", + "note": "Content-aware / generative patch state of a retouch or remove spot (pm_patch, pm_patch_variations, pm_remap_info_*, pm_search_*, pm_target_*, ...).", + "policy": { + "class": "NEVER" + }, + "presence": "Both" + }, + { + "frame": "NotApplicable", + "id": "upright_transform", + "kind": "Str", + "levels": [ + "global" + ], + "matcher": "PrefixIndex(\"UprightTransform_\")", + "note": "Upright solver matrices (9 floats each), recomputed by Lightroom when PerspectiveUpright is set. Never written.", + "policy": { + "class": "COMPUTED" + }, + "presence": "Both" + }, + { + "frame": "PerFrameOnly", + "id": "upright_guides", + "kind": "Str", + "levels": [ + "global" + ], + "matcher": "PrefixIndex(\"UprightFourSegments_\")", + "note": "Guided Upright guide lines drawn by the user (4 floats each).", + "policy": { + "class": "NEVER" + }, + "presence": "Both" + }, + { + "frame": "NotApplicable", + "id": "filter_list_payload", + "kind": "Any", + "levels": [ + "struct:FilterList" + ], + "matcher": "AnyName", + "note": "Everything below FilterList (Filters, Images, ImageGroup, Alpha, ColorVariations, BlackLevels, Linearization, GenAIInfo, ...): Denoise, Raw Details, Super Resolution and generative-remove payloads. Kept opaque, never written; the generative provenance inside is NEVER as well.", + "policy": { + "class": "COMPUTED" + }, + "presence": "Both" + } + ], + "row_count": 563, + "rows": [ + { + "default_non_raw": { + "value": "As Shot" + }, + "default_raw": { + "value": "As Shot" + }, + "file_kind": "Both", + "fmt": "Text", + "frame": "PerFrameAdjusted", + "kind": "Enum([\"As Shot\", \"Auto\", \"Custom\", \"Daylight\", \"Cloudy\", \"Shade\", \"Tungsten\", \"Fluorescent\", \"Flash\"])", + "level": "global", + "min_pv": null, + "name": "WhiteBalance", + "note": "Never average. The preset writer derives `Custom` whenever it writes Temperature/Tint, which a preset carries only when this is `Custom` or absent: next to any other mode they are that photo's resolved values (SkipReason::WhiteBalanceMode). `Auto` is resolved per photo by LrC (see AutoWhiteVersion). Raw and non-raw have different preset lists, so a mixed batch must not share one value. [all PV]", + "plus_sign": false, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "absent", + "default_raw": "no_default", + "file_kind": "RawOnly", + "fmt": "Int", + "frame": "PerFrameAdjusted", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "Temperature", + "note": "Raw-only key. Gate: drop from presets that can hit non-raw targets; learn relative to the example's as-shot value, not as absolute Kelvin (lighting differs per scene). Bug (f), fixed in PR 1d: the style engine read `Temp`; it now reads the white balance typed (`WbSetting`). [all PV; raw only]", + "plus_sign": false, + "policy": { + "class": "LEARN†", + "gate": "RawOnly" + }, + "presence": "Both", + "range": [ + 2000.0, + 50000.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "absent", + "default_raw": "no_default", + "file_kind": "RawOnly", + "fmt": "Int", + "frame": "PerFrameAdjusted", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "Tint", + "note": "Raw-only, same gate as Temperature. Bug (f), fixed in PR 1d: non-raw must use IncrementalTint; the style engine blended `Tint` for both. [all PV; raw only]", + "plus_sign": true, + "policy": { + "class": "LEARN†", + "gate": "RawOnly" + }, + "presence": "Both", + "range": [ + -150.0, + 150.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": "absent", + "file_kind": "NonRawOnly", + "fmt": "Int", + "frame": "PerFrameAdjusted", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "IncrementalTemperature", + "note": "Non-raw counterpart of Temperature. Not in this raw-only corpus; seen in 5 profile Look.Parameters and (research) in every non-raw catalog row checked. [all PV; non-raw only]", + "plus_sign": true, + "policy": { + "class": "LEARN†", + "gate": "NonRawOnly" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": "absent", + "file_kind": "NonRawOnly", + "fmt": "Int", + "frame": "PerFrameAdjusted", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "IncrementalTint", + "note": "Non-raw counterpart of Tint. [all PV; non-raw only]", + "plus_sign": true, + "policy": { + "class": "LEARN†", + "gate": "NonRawOnly" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "NotApplicable", + "kind": "VersionU32", + "level": "global", + "min_pv": null, + "name": "AutoWhiteVersion", + "note": "Written by LrC when WhiteBalance=Auto was resolved.", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Int", + "frame": "NotApplicable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "AsShotTemperature", + "note": "Not a photo setting in XMP. Meaning inside a preset record unknown (research §8 open point). [seen only inside applied-Preset/Parameters (a few sidecars)]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Int", + "frame": "NotApplicable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "AsShotTint", + "note": "Not a photo setting in XMP. Meaning inside a preset record unknown (research §8 open point). [seen only inside applied-Preset/Parameters (a few sidecars)]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0.0 + }, + "default_raw": { + "value": 0.0 + }, + "file_kind": "Both", + "fmt": "Fixed(2)", + "frame": "PerFrameAdjusted", + "kind": "Real", + "level": "global", + "min_pv": "6.7", + "name": "Exposure2012", + "note": "[PV2012+ (PV ≥ 2012; legacy `Exposure` for PV2003/2010)]", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -5.0, + 5.0 + ], + "recipe_alias": "global.exposure", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "PerFrameAdjusted", + "kind": "Int", + "level": "global", + "min_pv": "6.7", + "name": "Contrast2012", + "note": "[PV2012+]", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": "global.contrast", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": "6.7", + "name": "Highlights2012", + "note": "[PV2012+]", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": "global.highlights", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "PerFrameAdjusted", + "kind": "Int", + "level": "global", + "min_pv": "6.7", + "name": "Shadows2012", + "note": "[PV2012+]", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": "global.shadows", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "PerFrameAdjusted", + "kind": "Int", + "level": "global", + "min_pv": "6.7", + "name": "Whites2012", + "note": "[PV2012+]", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": "global.whites", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": "6.7", + "name": "Blacks2012", + "note": "[PV2012+]", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": "global.blacks", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "Texture", + "note": "[LrC 8.3+]", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": "global.texture", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "PerFrameAdjusted", + "kind": "Int", + "level": "global", + "min_pv": "6.7", + "name": "Clarity2012", + "note": "[PV2012+]", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": "global.clarity", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "PerFrameAdjusted", + "kind": "Int", + "level": "global", + "min_pv": "6.7", + "name": "Dehaze", + "note": "[PV2012+]", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": "global.dehaze", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "Vibrance", + "note": "[all PV]", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": "global.vibrance", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "Saturation", + "note": "[all PV]", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": "global.saturation", + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "global", + "min_pv": null, + "name": "ToneMapStrength", + "note": "Not in LrC 15.5.1 UI as far as observed; do not write. [known from Camera Raw; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Trim6", + "frame": "NotApplicable", + "kind": "Real", + "level": "global", + "min_pv": null, + "name": "Exposure", + "note": "Superseded by the *2012 keys. Read for fidelity, never learned or written; a PV2012+ photo ignores them. [PV2003/PV2010; a few bundled presets and every getDevelopSettings() table (as defaults)]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "NotApplicable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "Contrast", + "note": "Superseded by the *2012 keys. Read for fidelity, never learned or written; a PV2012+ photo ignores them. [PV2003/PV2010; a few bundled presets and every getDevelopSettings() table (as defaults)]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "NotApplicable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "Brightness", + "note": "Superseded by the *2012 keys. Read for fidelity, never learned or written; a PV2012+ photo ignores them. [PV2003/PV2010; a few bundled presets and every getDevelopSettings() table (as defaults)]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "NotApplicable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "Shadows", + "note": "Superseded by the *2012 keys. Read for fidelity, never learned or written; a PV2012+ photo ignores them. [PV2003/PV2010; a few bundled presets and every getDevelopSettings() table (as defaults)]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "NotApplicable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "FillLight", + "note": "Superseded by the *2012 keys. Read for fidelity, never learned or written; a PV2012+ photo ignores them. [PV2003/PV2010; a few bundled presets and every getDevelopSettings() table (as defaults)]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "NotApplicable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "HighlightRecovery", + "note": "Superseded by the *2012 keys. Read for fidelity, never learned or written; a PV2012+ photo ignores them. [PV2003/PV2010; a few bundled presets and every getDevelopSettings() table (as defaults)]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "NotApplicable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "Clarity", + "note": "Superseded by the *2012 keys. Read for fidelity, never learned or written; a PV2012+ photo ignores them. [PV2003/PV2010; a few bundled presets and every getDevelopSettings() table (as defaults)]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": "Linear" + }, + "default_raw": { + "value": "Linear" + }, + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Str", + "level": "global", + "min_pv": "6.7", + "name": "ToneCurveName2012", + "note": "Display label, sometimes localised. Serializer derives: `Linear` for the identity curve, else `Custom`. Never learn from it. [PV2012+]", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": [ + 0, + 0, + 255, + 255 + ] + }, + "default_raw": { + "value": [ + 0, + 0, + 255, + 255 + ] + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Curve(Global)", + "level": "global", + "min_pv": "6.7", + "name": "ToneCurvePV2012", + "note": "Learn by resampling to a fixed x grid; write back as a small point set. [PV2012+]", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": [ + 0, + 0, + 255, + 255 + ] + }, + "default_raw": { + "value": [ + 0, + 0, + 255, + 255 + ] + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Curve(Global)", + "level": "global", + "min_pv": "6.7", + "name": "ToneCurvePV2012Red", + "note": "[PV2012+]", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": [ + 0, + 0, + 255, + 255 + ] + }, + "default_raw": { + "value": [ + 0, + 0, + 255, + 255 + ] + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Curve(Global)", + "level": "global", + "min_pv": "6.7", + "name": "ToneCurvePV2012Green", + "note": "[PV2012+]", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": [ + 0, + 0, + 255, + 255 + ] + }, + "default_raw": { + "value": [ + 0, + 0, + 255, + 255 + ] + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Curve(Global)", + "level": "global", + "min_pv": "6.7", + "name": "ToneCurvePV2012Blue", + "note": "[PV2012+]", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "PerFrameAdjusted", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "ParametricShadows", + "note": "", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": "global.tone_curve.shadows", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "PerFrameAdjusted", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "ParametricDarks", + "note": "", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": "global.tone_curve.darks", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "PerFrameAdjusted", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "ParametricLights", + "note": "", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": "global.tone_curve.lights", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "PerFrameAdjusted", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "ParametricHighlights", + "note": "", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": "global.tone_curve.highlights", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 25 + }, + "default_raw": { + "value": 25 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "ParametricShadowSplit", + "note": "Keep the ordering Shadow < Midtone < Highlight when averaging.", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + 0.0, + 100.0 + ], + "recipe_alias": "global.tone_curve.shadow_split", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 50 + }, + "default_raw": { + "value": 50 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "ParametricMidtoneSplit", + "note": "", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + 0.0, + 100.0 + ], + "recipe_alias": "global.tone_curve.midtone_split", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 75 + }, + "default_raw": { + "value": 75 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "ParametricHighlightSplit", + "note": "", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + 0.0, + 100.0 + ], + "recipe_alias": "global.tone_curve.highlight_split", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 100 + }, + "default_raw": { + "value": 100 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "CurveRefineSaturation", + "note": "Low priority. [written by recent LrC only (about a fifth of the sampled sidecars)]", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + 0.0, + 100.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Int", + "frame": "NotApplicable", + "kind": "Curve(Global)", + "level": "global", + "min_pv": null, + "name": "ExtendedToneCurvePV2012", + "note": "Probably HDR-extended curves. Research: no XMP counterpart found. [in a few getDevelopSettings() tables (3 of 1,294 training rows); never observed in XMP]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Int", + "frame": "NotApplicable", + "kind": "Curve(Global)", + "level": "global", + "min_pv": null, + "name": "ExtendedToneCurvePV2012Red", + "note": "Probably HDR-extended curves. Research: no XMP counterpart found. [in a few getDevelopSettings() tables (3 of 1,294 training rows); never observed in XMP]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Int", + "frame": "NotApplicable", + "kind": "Curve(Global)", + "level": "global", + "min_pv": null, + "name": "ExtendedToneCurvePV2012Green", + "note": "Probably HDR-extended curves. Research: no XMP counterpart found. [in a few getDevelopSettings() tables (3 of 1,294 training rows); never observed in XMP]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Int", + "frame": "NotApplicable", + "kind": "Curve(Global)", + "level": "global", + "min_pv": null, + "name": "ExtendedToneCurvePV2012Blue", + "note": "Probably HDR-extended curves. Research: no XMP counterpart found. [in a few getDevelopSettings() tables (3 of 1,294 training rows); never observed in XMP]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "global", + "min_pv": null, + "name": "ExtendedToneCurveName2012", + "note": "Name for the extended curves above. [known from Camera Raw; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "NotApplicable", + "kind": "Curve(Global)", + "level": "global", + "min_pv": null, + "name": "ToneCurve", + "note": "Legacy. Never write; the parser keeps them for round-trip only. [legacy PV2003/2010 (11 Classic presets)]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "NotApplicable", + "kind": "Curve(Global)", + "level": "global", + "min_pv": null, + "name": "ToneCurveRed", + "note": "Legacy. Never write; the parser keeps them for round-trip only. [legacy PV2003/2010 (11 Classic presets)]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "NotApplicable", + "kind": "Curve(Global)", + "level": "global", + "min_pv": null, + "name": "ToneCurveGreen", + "note": "Legacy. Never write; the parser keeps them for round-trip only. [legacy PV2003/2010 (11 Classic presets)]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "NotApplicable", + "kind": "Curve(Global)", + "level": "global", + "min_pv": null, + "name": "ToneCurveBlue", + "note": "Legacy. Never write; the parser keeps them for round-trip only. [legacy PV2003/2010 (11 Classic presets)]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Str", + "level": "global", + "min_pv": null, + "name": "ToneCurveName", + "note": "Legacy. Never write; the parser keeps them for round-trip only. [legacy PV2003/2010 (11 Classic presets)]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "HueAdjustmentRed", + "note": "8 keys: HueAdjustmentRed, HueAdjustmentOrange, HueAdjustmentYellow, HueAdjustmentGreen, HueAdjustmentAqua, HueAdjustmentBlue, HueAdjustmentPurple, HueAdjustmentMagenta", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": "global.hsl.red.hue", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "HueAdjustmentOrange", + "note": "8 keys: HueAdjustmentRed, HueAdjustmentOrange, HueAdjustmentYellow, HueAdjustmentGreen, HueAdjustmentAqua, HueAdjustmentBlue, HueAdjustmentPurple, HueAdjustmentMagenta", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": "global.hsl.orange.hue", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "HueAdjustmentYellow", + "note": "8 keys: HueAdjustmentRed, HueAdjustmentOrange, HueAdjustmentYellow, HueAdjustmentGreen, HueAdjustmentAqua, HueAdjustmentBlue, HueAdjustmentPurple, HueAdjustmentMagenta", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": "global.hsl.yellow.hue", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "HueAdjustmentGreen", + "note": "8 keys: HueAdjustmentRed, HueAdjustmentOrange, HueAdjustmentYellow, HueAdjustmentGreen, HueAdjustmentAqua, HueAdjustmentBlue, HueAdjustmentPurple, HueAdjustmentMagenta", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": "global.hsl.green.hue", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "HueAdjustmentAqua", + "note": "8 keys: HueAdjustmentRed, HueAdjustmentOrange, HueAdjustmentYellow, HueAdjustmentGreen, HueAdjustmentAqua, HueAdjustmentBlue, HueAdjustmentPurple, HueAdjustmentMagenta", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": "global.hsl.aqua.hue", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "HueAdjustmentBlue", + "note": "8 keys: HueAdjustmentRed, HueAdjustmentOrange, HueAdjustmentYellow, HueAdjustmentGreen, HueAdjustmentAqua, HueAdjustmentBlue, HueAdjustmentPurple, HueAdjustmentMagenta", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": "global.hsl.blue.hue", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "HueAdjustmentPurple", + "note": "8 keys: HueAdjustmentRed, HueAdjustmentOrange, HueAdjustmentYellow, HueAdjustmentGreen, HueAdjustmentAqua, HueAdjustmentBlue, HueAdjustmentPurple, HueAdjustmentMagenta", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": "global.hsl.purple.hue", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "HueAdjustmentMagenta", + "note": "8 keys: HueAdjustmentRed, HueAdjustmentOrange, HueAdjustmentYellow, HueAdjustmentGreen, HueAdjustmentAqua, HueAdjustmentBlue, HueAdjustmentPurple, HueAdjustmentMagenta", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": "global.hsl.magenta.hue", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "SaturationAdjustmentRed", + "note": "8 keys.", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": "global.hsl.red.saturation", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "SaturationAdjustmentOrange", + "note": "8 keys.", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": "global.hsl.orange.saturation", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "SaturationAdjustmentYellow", + "note": "8 keys.", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": "global.hsl.yellow.saturation", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "SaturationAdjustmentGreen", + "note": "8 keys.", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": "global.hsl.green.saturation", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "SaturationAdjustmentAqua", + "note": "8 keys.", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": "global.hsl.aqua.saturation", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "SaturationAdjustmentBlue", + "note": "8 keys.", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": "global.hsl.blue.saturation", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "SaturationAdjustmentPurple", + "note": "8 keys.", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": "global.hsl.purple.saturation", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "SaturationAdjustmentMagenta", + "note": "8 keys.", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": "global.hsl.magenta.saturation", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "LuminanceAdjustmentRed", + "note": "8 keys.", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": "global.hsl.red.luminance", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "LuminanceAdjustmentOrange", + "note": "8 keys.", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": "global.hsl.orange.luminance", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "LuminanceAdjustmentYellow", + "note": "8 keys.", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": "global.hsl.yellow.luminance", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "LuminanceAdjustmentGreen", + "note": "8 keys.", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": "global.hsl.green.luminance", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "LuminanceAdjustmentAqua", + "note": "8 keys.", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": "global.hsl.aqua.luminance", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "LuminanceAdjustmentBlue", + "note": "8 keys.", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": "global.hsl.blue.luminance", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "LuminanceAdjustmentPurple", + "note": "8 keys.", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": "global.hsl.purple.luminance", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "LuminanceAdjustmentMagenta", + "note": "8 keys.", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": "global.hsl.magenta.luminance", + "ui": "identity" + }, + { + "default_non_raw": { + "value": false + }, + "default_raw": { + "value": false + }, + "file_kind": "Both", + "fmt": "Text", + "frame": "Shareable", + "kind": "Bool(TitleCase)", + "level": "global", + "min_pv": null, + "name": "ConvertToGrayscale", + "note": "Categorical: weighted majority, never a mean. Gate: B&W and colour examples must not be averaged into one recipe (today they are, research §2).", + "plus_sign": false, + "policy": { + "class": "LEARN†", + "gate": "Categorical" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "GrayMixerRed", + "note": "Only meaningful with ConvertToGrayscale=True. 8 keys.", + "plus_sign": true, + "policy": { + "class": "LEARN†", + "gate": "DependsOn(\"ConvertToGrayscale\")" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "GrayMixerOrange", + "note": "Only meaningful with ConvertToGrayscale=True. 8 keys.", + "plus_sign": true, + "policy": { + "class": "LEARN†", + "gate": "DependsOn(\"ConvertToGrayscale\")" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "GrayMixerYellow", + "note": "Only meaningful with ConvertToGrayscale=True. 8 keys.", + "plus_sign": true, + "policy": { + "class": "LEARN†", + "gate": "DependsOn(\"ConvertToGrayscale\")" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "GrayMixerGreen", + "note": "Only meaningful with ConvertToGrayscale=True. 8 keys.", + "plus_sign": true, + "policy": { + "class": "LEARN†", + "gate": "DependsOn(\"ConvertToGrayscale\")" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "GrayMixerAqua", + "note": "Only meaningful with ConvertToGrayscale=True. 8 keys.", + "plus_sign": true, + "policy": { + "class": "LEARN†", + "gate": "DependsOn(\"ConvertToGrayscale\")" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "GrayMixerBlue", + "note": "Only meaningful with ConvertToGrayscale=True. 8 keys.", + "plus_sign": true, + "policy": { + "class": "LEARN†", + "gate": "DependsOn(\"ConvertToGrayscale\")" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "GrayMixerPurple", + "note": "Only meaningful with ConvertToGrayscale=True. 8 keys.", + "plus_sign": true, + "policy": { + "class": "LEARN†", + "gate": "DependsOn(\"ConvertToGrayscale\")" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "GrayMixerMagenta", + "note": "Only meaningful with ConvertToGrayscale=True. 8 keys.", + "plus_sign": true, + "policy": { + "class": "LEARN†", + "gate": "DependsOn(\"ConvertToGrayscale\")" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Bool(TitleCase)", + "level": "global", + "min_pv": null, + "name": "AutoGrayscaleMix", + "note": "Preset instruction \"compute auto B&W mix on apply\". Behaviour through applyDevelopSettings unverified. [presets only (13)]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "SplitToningShadowHue", + "note": "Circular mean; weight by saturation. [LrC 10+ reuses the split-toning keys]", + "plus_sign": false, + "policy": { + "class": "LEARN†", + "gate": "CircularHue(\"SplitToningShadowSaturation\")" + }, + "presence": "Both", + "range": [ + 0.0, + 360.0 + ], + "recipe_alias": "global.color_grading.shadows.hue", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "SplitToningShadowSaturation", + "note": "", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + 0.0, + 100.0 + ], + "recipe_alias": "global.color_grading.shadows.saturation", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "SplitToningHighlightHue", + "note": "Circular mean.", + "plus_sign": false, + "policy": { + "class": "LEARN†", + "gate": "CircularHue(\"SplitToningHighlightSaturation\")" + }, + "presence": "Both", + "range": [ + 0.0, + 360.0 + ], + "recipe_alias": "global.color_grading.highlights.hue", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "SplitToningHighlightSaturation", + "note": "", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + 0.0, + 100.0 + ], + "recipe_alias": "global.color_grading.highlights.saturation", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "SplitToningBalance", + "note": "", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": "global.color_grading.balance", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "ColorGradeShadowLum", + "note": "Bug (f): plugin warns \"not supported by Lightroom\" and drops it. [LrC 10+]", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "ColorGradeHighlightLum", + "note": "Bug (f): same false warning. [LrC 10+]", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "ColorGradeMidtoneHue", + "note": "Circular mean. [LrC 10+]", + "plus_sign": false, + "policy": { + "class": "LEARN†", + "gate": "CircularHue(\"ColorGradeMidtoneSat\")" + }, + "presence": "Both", + "range": [ + 0.0, + 360.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "ColorGradeMidtoneSat", + "note": "[LrC 10+]", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + 0.0, + 100.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "ColorGradeMidtoneLum", + "note": "[LrC 10+]", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "ColorGradeGlobalHue", + "note": "Circular mean. [LrC 10+]", + "plus_sign": false, + "policy": { + "class": "LEARN†", + "gate": "CircularHue(\"ColorGradeGlobalSat\")" + }, + "presence": "Both", + "range": [ + 0.0, + 360.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "ColorGradeGlobalSat", + "note": "[LrC 10+]", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + 0.0, + 100.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "ColorGradeGlobalLum", + "note": "Missing from the LLM schema today. [LrC 10+]", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 50 + }, + "default_raw": { + "value": 50 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "ColorGradeBlending", + "note": "Non-zero default. [LrC 10+]", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + 0.0, + 100.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 40 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "Sharpness", + "note": "Default differs raw/non-raw: learn per file type (or as delta to the type default).", + "plus_sign": false, + "policy": { + "class": "LEARN†", + "gate": "FileKindDefault" + }, + "presence": "Both", + "range": [ + 0.0, + 150.0 + ], + "recipe_alias": "global.sharpening", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 1.0 + }, + "default_raw": { + "value": 1.0 + }, + "file_kind": "Both", + "fmt": "Fixed(1)", + "frame": "Shareable", + "kind": "Real", + "level": "global", + "min_pv": null, + "name": "SharpenRadius", + "note": "", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + 0.5, + 3.0 + ], + "recipe_alias": "global.sharpen_radius", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 25 + }, + "default_raw": { + "value": 25 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "SharpenDetail", + "note": "Non-zero default.", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + 0.0, + 100.0 + ], + "recipe_alias": "global.sharpen_detail", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "SharpenEdgeMasking", + "note": "", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + 0.0, + 100.0 + ], + "recipe_alias": "global.sharpen_masking", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "LuminanceSmoothing", + "note": "", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + 0.0, + 100.0 + ], + "recipe_alias": "global.noise_reduction", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 50 + }, + "default_raw": { + "value": 50 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "LuminanceNoiseReductionDetail", + "note": "[written only when LuminanceSmoothing was touched]", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + 0.0, + 100.0 + ], + "recipe_alias": "global.noise_reduction_detail", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "LuminanceNoiseReductionContrast", + "note": "[as above]", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + 0.0, + 100.0 + ], + "recipe_alias": "global.noise_reduction_contrast", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 25 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "ColorNoiseReduction", + "note": "Default differs raw/non-raw.", + "plus_sign": false, + "policy": { + "class": "LEARN†", + "gate": "FileKindDefault" + }, + "presence": "Both", + "range": [ + 0.0, + 100.0 + ], + "recipe_alias": "global.color_noise_reduction", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 50 + }, + "default_raw": { + "value": 50 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "ColorNoiseReductionDetail", + "note": "", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + 0.0, + 100.0 + ], + "recipe_alias": "global.color_noise_reduction_detail", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 50 + }, + "default_raw": { + "value": 50 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "ColorNoiseReductionSmoothness", + "note": "", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + 0.0, + 100.0 + ], + "recipe_alias": "global.color_noise_reduction_smoothness", + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "StructSeq(IsoDependent)", + "level": "global", + "min_pv": null, + "name": "ISODependent", + "note": "Preset-only table. Whether applyDevelopSettings/plugin presets accept it is unverified. [Adobe ISO-adaptive presets only (8)]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "IntFlag", + "level": "global", + "min_pv": null, + "name": "LensProfileEnable", + "note": "Bug (f): plugin writes `EnableLensCorrections` (panel switch) instead.", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + 0.0, + 1.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": "LensDefaults" + }, + "default_raw": { + "value": "LensDefaults" + }, + "file_kind": "Both", + "fmt": "Text", + "frame": "Shareable", + "kind": "Enum([\"LensDefaults\", \"Auto\", \"Custom\"])", + "level": "global", + "min_pv": null, + "name": "LensProfileSetup", + "note": "Gate: only `LensDefaults`/`Auto` are transferable; `Custom` pins a specific profile (PHOTO).", + "plus_sign": false, + "policy": { + "class": "LEARN†", + "gate": "OnlyValues([\"LensDefaults\", \"Auto\"])" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "PerFrameOnly", + "kind": "Str", + "level": "global", + "min_pv": null, + "name": "LensProfileName", + "note": "Lens identity. LrC fills it itself for LensDefaults/Auto.", + "plus_sign": false, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "PerFrameOnly", + "kind": "Str", + "level": "global", + "min_pv": null, + "name": "LensProfileFilename", + "note": "Lens identity.", + "plus_sign": false, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "PerFrameOnly", + "kind": "Hex32", + "level": "global", + "min_pv": null, + "name": "LensProfileDigest", + "note": "Lens identity digest.", + "plus_sign": false, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "PerFrameOnly", + "kind": "Bool(TitleCase)", + "level": "global", + "min_pv": null, + "name": "LensProfileIsEmbedded", + "note": "True for in-camera (embedded) profiles.", + "plus_sign": false, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 100 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "LensProfileDistortionScale", + "note": "Only meaningful with LensProfileEnable=1.", + "plus_sign": false, + "policy": { + "class": "LEARN†", + "gate": "DependsOn(\"LensProfileEnable\")" + }, + "presence": "Both", + "range": [ + 0.0, + 200.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 100 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "LensProfileVignettingScale", + "note": "Only meaningful with LensProfileEnable=1 (0 seen = profile vignetting off).", + "plus_sign": false, + "policy": { + "class": "LEARN†", + "gate": "DependsOn(\"LensProfileEnable\")" + }, + "presence": "Both", + "range": [ + 0.0, + 200.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "IntFlag", + "level": "global", + "min_pv": null, + "name": "AutoLateralCA", + "note": "", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + 0.0, + 1.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "PerFrameOnly", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "LensManualDistortionAmount", + "note": "Geometry: disables geometric masks (research §4.3).", + "plus_sign": true, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "VignetteAmount", + "note": "Low priority.", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 50 + }, + "default_raw": { + "value": 50 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "VignetteMidpoint", + "note": "[written only when VignetteAmount ≠ 0 (2 sidecars in full corpus)]", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + 0.0, + 100.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "DefringePurpleAmount", + "note": "Low priority, lens-dependent.", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + 0.0, + 20.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 30 + }, + "default_raw": { + "value": 30 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "DefringePurpleHueLo", + "note": "", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + 0.0, + 100.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 70 + }, + "default_raw": { + "value": 70 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "DefringePurpleHueHi", + "note": "", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + 0.0, + 100.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "DefringeGreenAmount", + "note": "", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + 0.0, + 20.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 40 + }, + "default_raw": { + "value": 40 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "DefringeGreenHueLo", + "note": "", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + 0.0, + 100.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 60 + }, + "default_raw": { + "value": 60 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "DefringeGreenHueHi", + "note": "", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + 0.0, + 100.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "Defringe", + "note": "Legacy CA controls. Never write. [legacy; in every getDevelopSettings() table (Lua) and the Zeroed template, never observed in XMP]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "ChromaticAberrationR", + "note": "Legacy CA controls. Never write. [legacy; in every getDevelopSettings() table (Lua) and the Zeroed template, never observed in XMP]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "ChromaticAberrationB", + "note": "Legacy CA controls. Never write. [legacy; in every getDevelopSettings() table (Lua) and the Zeroed template, never observed in XMP]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "NotApplicable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "LensProfileChromaticAberrationScale", + "note": "Legacy CA control. Never write. [legacy (Zeroed template only; never observed in XMP or getDevelopSettings())]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "PerFrameOnly", + "kind": "EnumInt([0, 1, 2, 3, 4, 5])", + "level": "global", + "min_pv": null, + "name": "PerspectiveUpright", + "note": "Write 1–4 only, never 5 and never the UprightTransform_* matrices; LrC recomputes. Disables geometric masks.", + "plus_sign": false, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "PerFrameOnly", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "PerspectiveVertical", + "note": "Geometry.", + "plus_sign": true, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "PerFrameOnly", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "PerspectiveHorizontal", + "note": "Geometry.", + "plus_sign": true, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0.0 + }, + "default_raw": { + "value": 0.0 + }, + "file_kind": "Both", + "fmt": "Fixed(1)", + "frame": "PerFrameOnly", + "kind": "Real", + "level": "global", + "min_pv": null, + "name": "PerspectiveRotate", + "note": "Geometry.", + "plus_sign": true, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": [ + -10.0, + 10.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 100 + }, + "default_raw": { + "value": 100 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "PerFrameOnly", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "PerspectiveScale", + "note": "Geometry.", + "plus_sign": false, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": [ + 50.0, + 150.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "PerFrameOnly", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "PerspectiveAspect", + "note": "Geometry.", + "plus_sign": true, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0.0 + }, + "default_raw": { + "value": 0.0 + }, + "file_kind": "Both", + "fmt": "Fixed(2)", + "frame": "PerFrameOnly", + "kind": "Real", + "level": "global", + "min_pv": null, + "name": "PerspectiveX", + "note": "Geometry.", + "plus_sign": true, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0.0 + }, + "default_raw": { + "value": 0.0 + }, + "file_kind": "Both", + "fmt": "Fixed(2)", + "frame": "PerFrameOnly", + "kind": "Real", + "level": "global", + "min_pv": null, + "name": "PerspectiveY", + "note": "Geometry.", + "plus_sign": true, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "NotApplicable", + "kind": "VersionU32", + "level": "global", + "min_pv": null, + "name": "UprightVersion", + "note": "UprightVersion, UprightTransformCount, UprightTransform_0..5 (9-float matrices), UprightCenterMode, UprightCenterNormX/Y, UprightFocalMode, UprightFocalLength35mm, UprightPreview, UprightDependentDigest, UprightGuidedDependentDigest, UprightFourSegmentsCount. [written when PerspectiveUpright ≠ 0]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "NotApplicable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "UprightTransformCount", + "note": "UprightVersion, UprightTransformCount, UprightTransform_0..5 (9-float matrices), UprightCenterMode, UprightCenterNormX/Y, UprightFocalMode, UprightFocalLength35mm, UprightPreview, UprightDependentDigest, UprightGuidedDependentDigest, UprightFourSegmentsCount. [written when PerspectiveUpright ≠ 0]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "NotApplicable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "UprightCenterMode", + "note": "UprightVersion, UprightTransformCount, UprightTransform_0..5 (9-float matrices), UprightCenterMode, UprightCenterNormX/Y, UprightFocalMode, UprightFocalLength35mm, UprightPreview, UprightDependentDigest, UprightGuidedDependentDigest, UprightFourSegmentsCount. [written when PerspectiveUpright ≠ 0]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Trim6", + "frame": "NotApplicable", + "kind": "Real", + "level": "global", + "min_pv": null, + "name": "UprightCenterNormX", + "note": "UprightVersion, UprightTransformCount, UprightTransform_0..5 (9-float matrices), UprightCenterMode, UprightCenterNormX/Y, UprightFocalMode, UprightFocalLength35mm, UprightPreview, UprightDependentDigest, UprightGuidedDependentDigest, UprightFourSegmentsCount. [written when PerspectiveUpright ≠ 0]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Trim6", + "frame": "NotApplicable", + "kind": "Real", + "level": "global", + "min_pv": null, + "name": "UprightCenterNormY", + "note": "UprightVersion, UprightTransformCount, UprightTransform_0..5 (9-float matrices), UprightCenterMode, UprightCenterNormX/Y, UprightFocalMode, UprightFocalLength35mm, UprightPreview, UprightDependentDigest, UprightGuidedDependentDigest, UprightFourSegmentsCount. [written when PerspectiveUpright ≠ 0]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "NotApplicable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "UprightFocalMode", + "note": "UprightVersion, UprightTransformCount, UprightTransform_0..5 (9-float matrices), UprightCenterMode, UprightCenterNormX/Y, UprightFocalMode, UprightFocalLength35mm, UprightPreview, UprightDependentDigest, UprightGuidedDependentDigest, UprightFourSegmentsCount. [written when PerspectiveUpright ≠ 0]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Trim6", + "frame": "NotApplicable", + "kind": "Real", + "level": "global", + "min_pv": null, + "name": "UprightFocalLength35mm", + "note": "UprightVersion, UprightTransformCount, UprightTransform_0..5 (9-float matrices), UprightCenterMode, UprightCenterNormX/Y, UprightFocalMode, UprightFocalLength35mm, UprightPreview, UprightDependentDigest, UprightGuidedDependentDigest, UprightFourSegmentsCount. [written when PerspectiveUpright ≠ 0]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Bool(TitleCase)", + "level": "global", + "min_pv": null, + "name": "UprightPreview", + "note": "UprightVersion, UprightTransformCount, UprightTransform_0..5 (9-float matrices), UprightCenterMode, UprightCenterNormX/Y, UprightFocalMode, UprightFocalLength35mm, UprightPreview, UprightDependentDigest, UprightGuidedDependentDigest, UprightFourSegmentsCount. [written when PerspectiveUpright ≠ 0]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Hex32", + "level": "global", + "min_pv": null, + "name": "UprightDependentDigest", + "note": "UprightVersion, UprightTransformCount, UprightTransform_0..5 (9-float matrices), UprightCenterMode, UprightCenterNormX/Y, UprightFocalMode, UprightFocalLength35mm, UprightPreview, UprightDependentDigest, UprightGuidedDependentDigest, UprightFourSegmentsCount. [written when PerspectiveUpright ≠ 0]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Hex32", + "level": "global", + "min_pv": null, + "name": "UprightGuidedDependentDigest", + "note": "UprightVersion, UprightTransformCount, UprightTransform_0..5 (9-float matrices), UprightCenterMode, UprightCenterNormX/Y, UprightFocalMode, UprightFocalLength35mm, UprightPreview, UprightDependentDigest, UprightGuidedDependentDigest, UprightFourSegmentsCount. [written when PerspectiveUpright ≠ 0]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "NotApplicable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "UprightFourSegmentsCount", + "note": "UprightVersion, UprightTransformCount, UprightTransform_0..5 (9-float matrices), UprightCenterMode, UprightCenterNormX/Y, UprightFocalMode, UprightFocalLength35mm, UprightPreview, UprightDependentDigest, UprightGuidedDependentDigest, UprightFourSegmentsCount. [written when PerspectiveUpright ≠ 0]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0.0 + }, + "default_raw": { + "value": 0.0 + }, + "file_kind": "Both", + "fmt": "Trim6", + "frame": "PerFrameOnly", + "kind": "Real", + "level": "global", + "min_pv": null, + "name": "CropTop", + "note": "Crop corners are opposite corners of a rectangle rotated by CropAngle (research §4).", + "plus_sign": false, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": [ + 0.0, + 1.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0.0 + }, + "default_raw": { + "value": 0.0 + }, + "file_kind": "Both", + "fmt": "Trim6", + "frame": "PerFrameOnly", + "kind": "Real", + "level": "global", + "min_pv": null, + "name": "CropLeft", + "note": "", + "plus_sign": false, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": [ + 0.0, + 1.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 1.0 + }, + "default_raw": { + "value": 1.0 + }, + "file_kind": "Both", + "fmt": "Trim6", + "frame": "PerFrameOnly", + "kind": "Real", + "level": "global", + "min_pv": null, + "name": "CropBottom", + "note": "", + "plus_sign": false, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": [ + 0.0, + 1.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 1.0 + }, + "default_raw": { + "value": 1.0 + }, + "file_kind": "Both", + "fmt": "Trim6", + "frame": "PerFrameOnly", + "kind": "Real", + "level": "global", + "min_pv": null, + "name": "CropRight", + "note": "", + "plus_sign": false, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": [ + 0.0, + 1.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0.0 + }, + "default_raw": { + "value": 0.0 + }, + "file_kind": "Both", + "fmt": "Trim6", + "frame": "PerFrameOnly", + "kind": "Real", + "level": "global", + "min_pv": null, + "name": "CropAngle", + "note": "", + "plus_sign": false, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": [ + -45.0, + 45.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "PerFrameOnly", + "kind": "IntFlag", + "level": "global", + "min_pv": null, + "name": "CropConstrainToWarp", + "note": "", + "plus_sign": false, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": [ + 0.0, + 1.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 1 + }, + "default_raw": { + "value": 1 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "PerFrameOnly", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "CropConstrainToUnitSquare", + "note": "Always 1 when written.", + "plus_sign": false, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Bool(TitleCase)", + "level": "global", + "min_pv": null, + "name": "HasCrop", + "note": "Derived: written iff the crop differs from the full frame. [XMP only (not in the Lua table)]", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "XmpOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "PerFrameOnly", + "kind": "Bool(TitleCase)", + "level": "global", + "min_pv": null, + "name": "CropConstrainAspectRatio", + "note": "Aspect-ratio lock of the crop tool: per-photo UI state. [Lua only (getDevelopSettings(), 66 of 1,294 training rows); never observed in XMP]", + "plus_sign": false, + "policy": { + "class": "PHOTO" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "global", + "min_pv": null, + "name": "CropWidth", + "note": "ACR crop-to-size settings; not an LrC feature as far as observed. [known from Camera Raw; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "global", + "min_pv": null, + "name": "CropHeight", + "note": "ACR crop-to-size settings; not an LrC feature as far as observed. [known from Camera Raw; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "global", + "min_pv": null, + "name": "CropUnit", + "note": "ACR crop-to-size settings; not an LrC feature as far as observed. [known from Camera Raw; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "global", + "min_pv": null, + "name": "CropUnits", + "note": "ACR crop-to-size settings; not an LrC feature as far as observed. [known from Camera Raw; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "PostCropVignetteAmount", + "note": "", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": "global.vignette", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 50 + }, + "default_raw": { + "value": 50 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "PostCropVignetteMidpoint", + "note": "", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + 0.0, + 100.0 + ], + "recipe_alias": "global.vignette_midpoint", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 50 + }, + "default_raw": { + "value": 50 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "PostCropVignetteFeather", + "note": "", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + 0.0, + 100.0 + ], + "recipe_alias": "global.vignette_feather", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "PostCropVignetteRoundness", + "note": "", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": "global.vignette_roundness", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 1 + }, + "default_raw": { + "value": 1 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "EnumInt([1, 2, 3])", + "level": "global", + "min_pv": null, + "name": "PostCropVignetteStyle", + "note": "Categorical (majority).", + "plus_sign": false, + "policy": { + "class": "LEARN†", + "gate": "Categorical" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "PostCropVignetteHighlightContrast", + "note": "", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + 0.0, + 100.0 + ], + "recipe_alias": "global.vignette_highlights", + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": false + }, + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Bool(TitleCase)", + "level": "global", + "min_pv": null, + "name": "OverrideLookVignette", + "note": "Set by LrC when the user vignette overrides a vignette built into the Look; whether we must set it when writing PostCropVignette* is unverified.", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "GrainAmount", + "note": "", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + 0.0, + 100.0 + ], + "recipe_alias": "global.grain", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 25 + }, + "default_raw": { + "value": 25 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "GrainSize", + "note": "Zeroed template uses 50; the Adobe default and the corpus mode are 25.", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + 0.0, + 100.0 + ], + "recipe_alias": "global.grain_size", + "ui": "identity" + }, + { + "default_non_raw": { + "value": 50 + }, + "default_raw": { + "value": 50 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "GrainFrequency", + "note": "", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + 0.0, + 100.0 + ], + "recipe_alias": "global.grain_roughness", + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "NotApplicable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "GrainSeed", + "note": "Grain pattern seed chosen by LrC. [recent LrC]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "global", + "min_pv": null, + "name": "Glow", + "note": "Newer engine feature (the Glow panel, with GlowRange/GlowWarmth/GlowSpread/GlowStyle); type, range and default not established. [one sidecar of the local corpus, Camera Raw 18.7 engine (October 2026); never observed in LrC 15.5.1 output]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "global", + "min_pv": null, + "name": "GlowRange", + "note": "Newer engine features not seen in LrC 15.5.1 output. [known from Camera Raw 18.6; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "global", + "min_pv": null, + "name": "GlowWarmth", + "note": "Newer engine features not seen in LrC 15.5.1 output. [known from Camera Raw 18.6; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "global", + "min_pv": null, + "name": "GlowSpread", + "note": "Newer engine features not seen in LrC 15.5.1 output. [known from Camera Raw 18.6; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "global", + "min_pv": null, + "name": "GlowStyle", + "note": "Newer engine features not seen in LrC 15.5.1 output. [known from Camera Raw 18.6; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "global", + "min_pv": null, + "name": "ReshapeAmount", + "note": "Newer engine features not seen in LrC 15.5.1 output. [known from Camera Raw 18.6; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "Shareable", + "kind": "Struct(LensBlur)", + "level": "global", + "min_pv": null, + "name": "LensBlur", + "note": "Gate: needs a depth map, i.e. an AI update after applying; FocalRange semantics are UNKNOWN, so v1 writes only Active + the look sliders. [LrC 13+ (Adobe ships lens-blur-only presets)]", + "plus_sign": false, + "policy": { + "class": "LEARN†", + "gate": "NeedsAiUpdate" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "ShadowTint", + "note": "", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "RedHue", + "note": "", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "RedSaturation", + "note": "", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "GreenHue", + "note": "", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "GreenSaturation", + "note": "", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "BlueHue", + "note": "", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "BlueSaturation", + "note": "`BluePrimarySaturation` does not exist (silently dropped by LrC, research §7).", + "plus_sign": true, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": "Embedded" + }, + "default_raw": { + "value": "Adobe Standard" + }, + "file_kind": "Both", + "fmt": "Text", + "frame": "Shareable", + "kind": "Str", + "level": "global", + "min_pv": null, + "name": "CameraProfile", + "note": "Gate: transfer only Adobe Standard and the symbolic Default* values; camera-specific names only to the same make. The creative profile is the `Look`. Bug (f): dead `profile` branch writes `CameraProfile=\"Adobe Color\"`, which is a Look name and is silently dropped.", + "plus_sign": false, + "policy": { + "class": "LEARN†", + "gate": "CameraRestricted" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Hex32", + "level": "global", + "min_pv": null, + "name": "CameraProfileDigest", + "note": "", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "Shareable", + "kind": "Struct(Look)", + "level": "global", + "min_pv": null, + "name": "Look", + "note": "Identity = Look.Name + Look.UUID, strength = Look.Amount. Gate: camera-restricted Looks only to the same camera; Adaptive looks need an AI update (AILook). [LrC 7.3+]", + "plus_sign": false, + "policy": { + "class": "LEARN†", + "gate": "CameraRestricted" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Hex32", + "level": "global", + "min_pv": null, + "name": "LookTable", + "note": "Profile lookup-table references.", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Hex32", + "level": "global", + "min_pv": null, + "name": "RGBTable", + "note": "Profile lookup-table references.", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "global", + "min_pv": null, + "name": "LookTableAmount", + "note": "Part of a profile definition, not a photo setting. [profile-definition fields (Look.Parameters) / known from Camera Raw]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "global", + "min_pv": null, + "name": "RGBTableAmount", + "note": "Part of a profile definition, not a photo setting. [profile-definition fields (Look.Parameters) / known from Camera Raw]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "global", + "min_pv": null, + "name": "RGBTables", + "note": "Part of a profile definition, not a photo setting. [profile-definition fields (Look.Parameters) / known from Camera Raw]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "global", + "min_pv": null, + "name": "ProfileGainTableMap", + "note": "Part of a profile definition, not a photo setting. [profile-definition fields (Look.Parameters) / known from Camera Raw]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "global", + "min_pv": null, + "name": "ProfileGainTableMap2", + "note": "Part of a profile definition, not a photo setting. [profile-definition fields (Look.Parameters) / known from Camera Raw]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "global", + "min_pv": null, + "name": "ProfileToneCurve", + "note": "Part of a profile definition, not a photo setting. [profile-definition fields (Look.Parameters) / known from Camera Raw]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "global", + "min_pv": null, + "name": "MissingCameraProfile", + "note": "Part of a profile definition, not a photo setting. [profile-definition fields (Look.Parameters) / known from Camera Raw]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Hex32", + "level": "global", + "min_pv": null, + "name": "MissingCameraProfileDigest", + "note": "Part of a profile definition, not a photo setting. [profile-definition fields (Look.Parameters) / known from Camera Raw]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Struct(AiLook)", + "level": "global", + "min_pv": null, + "name": "AILook", + "note": "Computed state of an adaptive profile. [Adaptive Color profile]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "CompoundFixed6", + "frame": "NotApplicable", + "kind": "PerFormat { lua: StructSeq(PointColor), xmp: StrSeq }", + "level": "global", + "min_pv": null, + "name": "PointColors", + "note": "Layout only partly decoded; values are sampled from one photo. Round-trip only. [LrC 13+]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "CompoundFixed6", + "frame": "NotApplicable", + "kind": "StrSeq", + "level": "global", + "min_pv": null, + "name": "ColorVariance", + "note": "Meaning of -50 unknown (research §8). [LrC 13+]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "PerFrameOnly", + "kind": "IntFlag", + "level": "global", + "min_pv": null, + "name": "HDREditMode", + "note": "Never learn: the 8-bit JPEG export cannot show HDR. Keep the photo's value. [LrC 12+]", + "plus_sign": false, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": [ + 0.0, + 1.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 4.0 + }, + "file_kind": "Both", + "fmt": "Fixed(2)", + "frame": "PerFrameOnly", + "kind": "Real", + "level": "global", + "min_pv": null, + "name": "HDRMaxValue", + "note": "Non-raw default not established: the one non-raw readback (E13, a virtual copy of a TIFF, LrC 15.6) has 2.3, which may be per image, so the generator holds it back (NON_RAW_READBACK_NOT_CONSTANT). [LrC 12+]", + "plus_sign": true, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "PerFrameOnly", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "SDRBrightness", + "note": "[LrC 12+, only with HDREditMode=1]", + "plus_sign": true, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "PerFrameOnly", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "SDRContrast", + "note": "[LrC 12+, only with HDREditMode=1]", + "plus_sign": true, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "PerFrameOnly", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "SDRClarity", + "note": "[LrC 12+, only with HDREditMode=1]", + "plus_sign": true, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "PerFrameOnly", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "SDRHighlights", + "note": "[LrC 12+, only with HDREditMode=1]", + "plus_sign": true, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "PerFrameOnly", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "SDRShadows", + "note": "[LrC 12+, only with HDREditMode=1]", + "plus_sign": true, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "PerFrameOnly", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "SDRWhites", + "note": "[LrC 12+, only with HDREditMode=1]", + "plus_sign": true, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": 0 + }, + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "PerFrameOnly", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "SDRBlend", + "note": "[LrC 12+, only with HDREditMode=1]", + "plus_sign": true, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Bool(TitleCase)", + "level": "global", + "min_pv": null, + "name": "AutoTone", + "note": "Preset instruction \"run Auto Tone on apply\"; collides with explicit tone values; applyDevelopSettings has its own `flattenAutoNow` flag. Not for v1. [presets (23) + 1 sidecar]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Hex32", + "level": "global", + "min_pv": null, + "name": "AutoToneDigest", + "note": "Cache keys of the last auto-tone evaluation.", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Hex32", + "level": "global", + "min_pv": null, + "name": "AutoToneDigestNoSat", + "note": "Cache keys of the last auto-tone evaluation.", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Hex32", + "level": "global", + "min_pv": null, + "name": "AutoToneDigestPV2", + "note": "Cache keys of the last auto-tone evaluation.", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "global", + "min_pv": null, + "name": "SourceAutoExposure2012", + "note": "SourceAutoExposure2012 ... SourceAutoVibrance. [known from Camera Raw; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "global", + "min_pv": null, + "name": "SourceAutoContrast2012", + "note": "SourceAutoExposure2012 ... SourceAutoVibrance. [known from Camera Raw; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "global", + "min_pv": null, + "name": "SourceAutoHighlights2012", + "note": "SourceAutoExposure2012 ... SourceAutoVibrance. [known from Camera Raw; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "global", + "min_pv": null, + "name": "SourceAutoShadows2012", + "note": "SourceAutoExposure2012 ... SourceAutoVibrance. [known from Camera Raw; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "global", + "min_pv": null, + "name": "SourceAutoWhites2012", + "note": "SourceAutoExposure2012 ... SourceAutoVibrance. [known from Camera Raw; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "global", + "min_pv": null, + "name": "SourceAutoBlacks2012", + "note": "SourceAutoExposure2012 ... SourceAutoVibrance. [known from Camera Raw; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "global", + "min_pv": null, + "name": "SourceAutoSaturation", + "note": "SourceAutoExposure2012 ... SourceAutoVibrance. [known from Camera Raw; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "global", + "min_pv": null, + "name": "SourceAutoVibrance", + "note": "SourceAutoExposure2012 ... SourceAutoVibrance. [known from Camera Raw; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": false + }, + "default_raw": { + "value": false + }, + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Bool(TitleCase)", + "level": "global", + "min_pv": null, + "name": "AutoExposure", + "note": "Legacy auto flags. [legacy PV2003/2010]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": false + }, + "default_raw": { + "value": false + }, + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Bool(TitleCase)", + "level": "global", + "min_pv": null, + "name": "AutoShadows", + "note": "Legacy auto flags. [legacy PV2003/2010]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": false + }, + "default_raw": { + "value": false + }, + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Bool(TitleCase)", + "level": "global", + "min_pv": null, + "name": "AutoBrightness", + "note": "Legacy auto flags. [legacy PV2003/2010]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": false + }, + "default_raw": { + "value": false + }, + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Bool(TitleCase)", + "level": "global", + "min_pv": null, + "name": "AutoContrast", + "note": "Legacy auto flags. [legacy PV2003/2010]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Hex32", + "level": "global", + "min_pv": null, + "name": "ToggleStyleDigest", + "note": "Probably identifies an applied \"style\"/preset for toggling; meaning unverified. [recent LrC (a few sampled sidecars)]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Trim6", + "frame": "NotApplicable", + "kind": "Real", + "level": "global", + "min_pv": null, + "name": "ToggleStyleAmount", + "note": "Observed 0.67..1. [as above]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "unknown" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "PerFrameOnly", + "kind": "StructSeq(RetouchArea)", + "level": "global", + "min_pv": null, + "name": "RetouchAreas", + "note": "Heal/clone/content-aware spots.", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "PerFrameOnly", + "kind": "StructSeq(RetouchArea)", + "level": "global", + "min_pv": null, + "name": "RemoveAreas", + "note": "Remove tool / generative remove. [LrC 13+]", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "PerFrameOnly", + "kind": "PerFormat { lua: StructSeq(RetouchInfo), xmp: StrSeq }", + "level": "global", + "min_pv": null, + "name": "RetouchInfo", + "note": "[legacy spot removal]", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "PerFrameOnly", + "kind": "StrSeq", + "level": "global", + "min_pv": null, + "name": "RedEyeInfo", + "note": "Present (empty) in the Zeroed Lua template. [legacy]", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Struct(FilterList)", + "level": "global", + "min_pv": null, + "name": "FilterList", + "note": "Enhance/AI filter payloads; FilterIDs 3/4/6 are remove/heal/distraction removal (NEVER). [LrC 12.3+ (Denoise, Raw Details, Super Resolution)]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "NotApplicable", + "kind": "Int", + "level": "global", + "min_pv": null, + "name": "AllowFilters", + "note": "[with FilterList]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Struct(DepthMapInfo)", + "level": "global", + "min_pv": null, + "name": "DepthMapInfo", + "note": "[lens blur / depth]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Struct(RangeMaskMapInfo)", + "level": "global", + "min_pv": null, + "name": "RangeMaskMapInfo", + "note": "Per-photo image statistics for range masks. [range masks]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "CorrectionSeq", + "level": "global", + "min_pv": null, + "name": "DepthBasedCorrections", + "note": "Old depth-based local corrections. [known from Camera Raw; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "Shareable", + "kind": "CorrectionSeq", + "level": "global", + "min_pv": null, + "name": "MaskGroupBasedCorrections", + "note": "Per-item policy follows the mask components (sub-tables below): semantic AI components are LEARN, geometry PHOTO, brush NEVER. [crs:Version ≥ 14 (LrC 11+)]", + "plus_sign": false, + "policy": { + "class": "LEARN†", + "gate": "ByMaskType" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "PerFrameOnly", + "kind": "CorrectionSeq", + "level": "global", + "min_pv": null, + "name": "GradientBasedCorrections", + "note": "Never write (no AI masks, no add/subtract). A parser may migrate them read-only. [legacy; 0 in corpus]", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "PerFrameOnly", + "kind": "CorrectionSeq", + "level": "global", + "min_pv": null, + "name": "CircularGradientBasedCorrections", + "note": "Never write (no AI masks, no add/subtract). A parser may migrate them read-only. [legacy; 0 in corpus]", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "PerFrameOnly", + "kind": "CorrectionSeq", + "level": "global", + "min_pv": null, + "name": "PaintBasedCorrections", + "note": "Never write (no AI masks, no add/subtract). A parser may migrate them read-only. [legacy; 0 in corpus]", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Fixed(1)", + "frame": "NotApplicable", + "kind": "VersionStr", + "level": "global", + "min_pv": null, + "name": "ProcessVersion", + "note": "Write only when switching PV on purpose; omitting it keeps the photo's PV. Learning must refuse to mix PV2003/2010 examples.", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "VersionStr", + "level": "global", + "min_pv": null, + "name": "Version", + "note": "Must be ≤ the engine of the target LrC (LrC 15.5.1 = 18.5.1).", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "NotApplicable", + "kind": "VersionU32", + "level": "global", + "min_pv": null, + "name": "CompatibleVersion", + "note": "Feature → version mapping unknown; omit unless copying Adobe's value for the same feature.", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": true + }, + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Bool(TitleCase)", + "level": "global", + "min_pv": null, + "name": "HasSettings", + "note": "Always True. [XMP only]", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "XmpOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": false + }, + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Bool(TitleCase)", + "level": "global", + "min_pv": null, + "name": "AlreadyApplied", + "note": "[sidecars only]", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "XmpOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Str", + "level": "global", + "min_pv": null, + "name": "RawFileName", + "note": "Photo identity; set from the target file. [sidecars only]", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "XmpOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "NotApplicable", + "kind": "VersionU32", + "level": "global", + "min_pv": null, + "name": "RenderVersion", + "note": "[known from Camera Raw; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Struct(Preset)", + "level": "global", + "min_pv": null, + "name": "Preset", + "note": "Written by LrC; its Parameters subtree is a preset, not the photo's settings. [sidecars only (about a tenth of them)]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "XmpOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": true + }, + "default_raw": { + "value": true + }, + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Bool(TitleCase)", + "level": "global", + "min_pv": null, + "name": "EnableCalibration", + "note": "EnableCalibration, EnableColorAdjustments, EnableDetail, EnableEffects, EnableGrayscaleMix, EnableLensCorrections, EnableRedEye, EnableRetouch, EnableSplitToning, EnableToneCurve, EnableTransform, EnableMaskGroupBasedCorrections, EnableGradientBasedCorrections, EnableDistractionRemoval. Never write `false`; write `true` only for panels we set (panel switches, not features). [in Zeroed.lrtemplate and the SDK table; 0 occurrences in the sidecar corpus or the bundled preset/profile files]", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": true + }, + "default_raw": { + "value": true + }, + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Bool(TitleCase)", + "level": "global", + "min_pv": null, + "name": "EnableColorAdjustments", + "note": "EnableCalibration, EnableColorAdjustments, EnableDetail, EnableEffects, EnableGrayscaleMix, EnableLensCorrections, EnableRedEye, EnableRetouch, EnableSplitToning, EnableToneCurve, EnableTransform, EnableMaskGroupBasedCorrections, EnableGradientBasedCorrections, EnableDistractionRemoval. Never write `false`; write `true` only for panels we set (panel switches, not features). [in Zeroed.lrtemplate and the SDK table; 0 occurrences in the sidecar corpus or the bundled preset/profile files]", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": true + }, + "default_raw": { + "value": true + }, + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Bool(TitleCase)", + "level": "global", + "min_pv": null, + "name": "EnableDetail", + "note": "EnableCalibration, EnableColorAdjustments, EnableDetail, EnableEffects, EnableGrayscaleMix, EnableLensCorrections, EnableRedEye, EnableRetouch, EnableSplitToning, EnableToneCurve, EnableTransform, EnableMaskGroupBasedCorrections, EnableGradientBasedCorrections, EnableDistractionRemoval. Never write `false`; write `true` only for panels we set (panel switches, not features). [in Zeroed.lrtemplate and the SDK table; 0 occurrences in the sidecar corpus or the bundled preset/profile files]", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": true + }, + "default_raw": { + "value": true + }, + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Bool(TitleCase)", + "level": "global", + "min_pv": null, + "name": "EnableEffects", + "note": "EnableCalibration, EnableColorAdjustments, EnableDetail, EnableEffects, EnableGrayscaleMix, EnableLensCorrections, EnableRedEye, EnableRetouch, EnableSplitToning, EnableToneCurve, EnableTransform, EnableMaskGroupBasedCorrections, EnableGradientBasedCorrections, EnableDistractionRemoval. Never write `false`; write `true` only for panels we set (panel switches, not features). [in Zeroed.lrtemplate and the SDK table; 0 occurrences in the sidecar corpus or the bundled preset/profile files]", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": true + }, + "default_raw": { + "value": true + }, + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Bool(TitleCase)", + "level": "global", + "min_pv": null, + "name": "EnableGrayscaleMix", + "note": "EnableCalibration, EnableColorAdjustments, EnableDetail, EnableEffects, EnableGrayscaleMix, EnableLensCorrections, EnableRedEye, EnableRetouch, EnableSplitToning, EnableToneCurve, EnableTransform, EnableMaskGroupBasedCorrections, EnableGradientBasedCorrections, EnableDistractionRemoval. Never write `false`; write `true` only for panels we set (panel switches, not features). [in Zeroed.lrtemplate and the SDK table; 0 occurrences in the sidecar corpus or the bundled preset/profile files]", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": true + }, + "default_raw": { + "value": true + }, + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Bool(TitleCase)", + "level": "global", + "min_pv": null, + "name": "EnableLensCorrections", + "note": "EnableCalibration, EnableColorAdjustments, EnableDetail, EnableEffects, EnableGrayscaleMix, EnableLensCorrections, EnableRedEye, EnableRetouch, EnableSplitToning, EnableToneCurve, EnableTransform, EnableMaskGroupBasedCorrections, EnableGradientBasedCorrections, EnableDistractionRemoval. Never write `false`; write `true` only for panels we set (panel switches, not features). [in Zeroed.lrtemplate and the SDK table; 0 occurrences in the sidecar corpus or the bundled preset/profile files]", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": true + }, + "default_raw": { + "value": true + }, + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Bool(TitleCase)", + "level": "global", + "min_pv": null, + "name": "EnableRedEye", + "note": "EnableCalibration, EnableColorAdjustments, EnableDetail, EnableEffects, EnableGrayscaleMix, EnableLensCorrections, EnableRedEye, EnableRetouch, EnableSplitToning, EnableToneCurve, EnableTransform, EnableMaskGroupBasedCorrections, EnableGradientBasedCorrections, EnableDistractionRemoval. Never write `false`; write `true` only for panels we set (panel switches, not features). [in Zeroed.lrtemplate and the SDK table; 0 occurrences in the sidecar corpus or the bundled preset/profile files]", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": true + }, + "default_raw": { + "value": true + }, + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Bool(TitleCase)", + "level": "global", + "min_pv": null, + "name": "EnableRetouch", + "note": "EnableCalibration, EnableColorAdjustments, EnableDetail, EnableEffects, EnableGrayscaleMix, EnableLensCorrections, EnableRedEye, EnableRetouch, EnableSplitToning, EnableToneCurve, EnableTransform, EnableMaskGroupBasedCorrections, EnableGradientBasedCorrections, EnableDistractionRemoval. Never write `false`; write `true` only for panels we set (panel switches, not features). [in Zeroed.lrtemplate and the SDK table; 0 occurrences in the sidecar corpus or the bundled preset/profile files]", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": true + }, + "default_raw": { + "value": true + }, + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Bool(TitleCase)", + "level": "global", + "min_pv": null, + "name": "EnableSplitToning", + "note": "EnableCalibration, EnableColorAdjustments, EnableDetail, EnableEffects, EnableGrayscaleMix, EnableLensCorrections, EnableRedEye, EnableRetouch, EnableSplitToning, EnableToneCurve, EnableTransform, EnableMaskGroupBasedCorrections, EnableGradientBasedCorrections, EnableDistractionRemoval. Never write `false`; write `true` only for panels we set (panel switches, not features). [in Zeroed.lrtemplate and the SDK table; 0 occurrences in the sidecar corpus or the bundled preset/profile files]", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": true + }, + "default_raw": { + "value": true + }, + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Bool(TitleCase)", + "level": "global", + "min_pv": null, + "name": "EnableToneCurve", + "note": "EnableCalibration, EnableColorAdjustments, EnableDetail, EnableEffects, EnableGrayscaleMix, EnableLensCorrections, EnableRedEye, EnableRetouch, EnableSplitToning, EnableToneCurve, EnableTransform, EnableMaskGroupBasedCorrections, EnableGradientBasedCorrections, EnableDistractionRemoval. Never write `false`; write `true` only for panels we set (panel switches, not features). [in Zeroed.lrtemplate and the SDK table; 0 occurrences in the sidecar corpus or the bundled preset/profile files]", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": true + }, + "default_raw": { + "value": true + }, + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Bool(TitleCase)", + "level": "global", + "min_pv": null, + "name": "EnableTransform", + "note": "EnableCalibration, EnableColorAdjustments, EnableDetail, EnableEffects, EnableGrayscaleMix, EnableLensCorrections, EnableRedEye, EnableRetouch, EnableSplitToning, EnableToneCurve, EnableTransform, EnableMaskGroupBasedCorrections, EnableGradientBasedCorrections, EnableDistractionRemoval. Never write `false`; write `true` only for panels we set (panel switches, not features). [in Zeroed.lrtemplate and the SDK table; 0 occurrences in the sidecar corpus or the bundled preset/profile files]", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": true + }, + "default_raw": { + "value": true + }, + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Bool(TitleCase)", + "level": "global", + "min_pv": null, + "name": "EnableMaskGroupBasedCorrections", + "note": "EnableCalibration, EnableColorAdjustments, EnableDetail, EnableEffects, EnableGrayscaleMix, EnableLensCorrections, EnableRedEye, EnableRetouch, EnableSplitToning, EnableToneCurve, EnableTransform, EnableMaskGroupBasedCorrections, EnableGradientBasedCorrections, EnableDistractionRemoval. Never write `false`; write `true` only for panels we set (panel switches, not features). [in Zeroed.lrtemplate and the SDK table; 0 occurrences in the sidecar corpus or the bundled preset/profile files]", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": true + }, + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Bool(TitleCase)", + "level": "global", + "min_pv": null, + "name": "EnableGradientBasedCorrections", + "note": "EnableCalibration, EnableColorAdjustments, EnableDetail, EnableEffects, EnableGrayscaleMix, EnableLensCorrections, EnableRedEye, EnableRetouch, EnableSplitToning, EnableToneCurve, EnableTransform, EnableMaskGroupBasedCorrections, EnableGradientBasedCorrections, EnableDistractionRemoval. Never write `false`; write `true` only for panels we set (panel switches, not features). [in Zeroed.lrtemplate and the SDK table; 0 occurrences in the sidecar corpus or the bundled preset/profile files]", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": { + "value": true + }, + "default_raw": { + "value": true + }, + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Bool(TitleCase)", + "level": "global", + "min_pv": null, + "name": "EnableDistractionRemoval", + "note": "EnableCalibration, EnableColorAdjustments, EnableDetail, EnableEffects, EnableGrayscaleMix, EnableLensCorrections, EnableRedEye, EnableRetouch, EnableSplitToning, EnableToneCurve, EnableTransform, EnableMaskGroupBasedCorrections, EnableGradientBasedCorrections, EnableDistractionRemoval. Never write `false`; write `true` only for panels we set (panel switches, not features). [in Zeroed.lrtemplate and the SDK table; 0 occurrences in the sidecar corpus or the bundled preset/profile files]", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "PerFrameOnly", + "kind": "Str", + "level": "global", + "min_pv": null, + "name": "orientation", + "note": "Read-only input for the coordinate transform (E13 decides the runtime format). Never write. [catalog / Lua only]", + "plus_sign": false, + "policy": { + "class": "PHOTO" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Enum([\"Normal\", \"Look\"])", + "level": "header", + "min_pv": null, + "name": "PresetType", + "note": "", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "XmpOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Hex32", + "level": "header", + "min_pv": null, + "name": "UUID", + "note": "Stable per generated preset; LrDevelopPreset:getUuid() differs from it (research §5).", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "XmpOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Str", + "level": "header", + "min_pv": null, + "name": "Cluster", + "note": "", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "XmpOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "LangAlt", + "level": "header", + "min_pv": null, + "name": "Name", + "note": "", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "XmpOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "LangAlt", + "level": "header", + "min_pv": null, + "name": "ShortName", + "note": "Empty Alt allowed.", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "XmpOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "LangAlt", + "level": "header", + "min_pv": null, + "name": "SortName", + "note": "", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "XmpOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "LangAlt", + "level": "header", + "min_pv": null, + "name": "Group", + "note": "Use \"LrGeniusAI\".", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "XmpOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "LangAlt", + "level": "header", + "min_pv": null, + "name": "Description", + "note": "", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "XmpOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Str", + "level": "header", + "min_pv": null, + "name": "Copyright", + "note": "", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "XmpOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Str", + "level": "header", + "min_pv": null, + "name": "ContactInfo", + "note": "", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "XmpOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": "" + }, + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Str", + "level": "header", + "min_pv": null, + "name": "CameraModelRestriction", + "note": "Set to the model when a camera-restricted Look is included.", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "XmpOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Bool(TitleCase)", + "level": "header", + "min_pv": null, + "name": "SupportsAmount", + "note": "Enables the amount slider (0..200 %).", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "XmpOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Bool(TitleCase)", + "level": "header", + "min_pv": null, + "name": "SupportsAmount2", + "note": "Meaning vs SupportsAmount unverified (E4 compares). [newer presets (330)]", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "XmpOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Bool(TitleCase)", + "level": "header", + "min_pv": null, + "name": "SupportsColor", + "note": "", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "XmpOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Bool(TitleCase)", + "level": "header", + "min_pv": null, + "name": "SupportsMonochrome", + "note": "False when the preset forces colour/B&W semantics.", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "XmpOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Bool(TitleCase)", + "level": "header", + "min_pv": null, + "name": "SupportsHighDynamicRange", + "note": "", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "XmpOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Bool(TitleCase)", + "level": "header", + "min_pv": null, + "name": "SupportsNormalDynamicRange", + "note": "", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "XmpOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Bool(TitleCase)", + "level": "header", + "min_pv": null, + "name": "SupportsSceneReferred", + "note": "", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "XmpOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Bool(TitleCase)", + "level": "header", + "min_pv": null, + "name": "SupportsOutputReferred", + "note": "", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "XmpOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Bool(TitleCase)", + "level": "header", + "min_pv": null, + "name": "RequiresRGBTables", + "note": "[newer presets]", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "XmpOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Bool(TitleCase)", + "level": "header", + "min_pv": null, + "name": "ShowInPresets", + "note": "[few presets]", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "XmpOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Bool(TitleCase)", + "level": "header", + "min_pv": null, + "name": "ShowInQuickActions", + "note": "[few presets]", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "XmpOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Enum([\"Adobe Default\", \"Camera Settings\"])", + "level": "header", + "min_pv": null, + "name": "Baseline", + "note": "Probably the reference state for the amount slider (amount 0 % = baseline). Unverified. [presets (20) and applied-Preset records]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "XmpOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Enum([\"Correction\"])", + "level": "correction", + "min_pv": null, + "name": "What", + "note": "", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 1.0 + }, + "file_kind": "Both", + "fmt": "Trim6", + "frame": "Shareable", + "kind": "Real", + "level": "correction", + "min_pv": null, + "name": "CorrectionAmount", + "note": "Research: observed 0.21–2.", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + 0.0, + 2.0 + ], + "recipe_alias": null, + "ui": { + "div": 100.0 + } + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": true + }, + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Bool(Lower)", + "level": "correction", + "min_pv": null, + "name": "CorrectionActive", + "note": "Always true when we write.", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Str", + "level": "correction", + "min_pv": null, + "name": "CorrectionName", + "note": "We write \"LrGenius · \". Never infer semantics from names.", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Hex32", + "level": "correction", + "min_pv": null, + "name": "CorrectionSyncID", + "note": "Stable, role-derived id; whether an equal id replaces or duplicates is E5.", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "Shareable", + "kind": "ComponentSeq", + "level": "correction", + "min_pv": null, + "name": "CorrectionMasks", + "note": "Policy per component (mask-tool table).", + "plus_sign": false, + "policy": { + "class": "LEARN†", + "gate": "ByMaskType" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 0.0 + }, + "file_kind": "Both", + "fmt": "Trim6", + "frame": "Shareable", + "kind": "Real", + "level": "correction", + "min_pv": "6.7", + "name": "LocalExposure2012", + "note": "Dodge 0.0625 = +0.25 EV (Adobe local presets). [PV2012+]", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -1.0, + 1.0 + ], + "recipe_alias": null, + "ui": { + "div": 4.0 + } + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 0.0 + }, + "file_kind": "Both", + "fmt": "Trim6", + "frame": "Shareable", + "kind": "Real", + "level": "correction", + "min_pv": "6.7", + "name": "LocalContrast2012", + "note": "[PV2012+]", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -1.0, + 1.0 + ], + "recipe_alias": null, + "ui": { + "div": 100.0 + } + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 0.0 + }, + "file_kind": "Both", + "fmt": "Trim6", + "frame": "Shareable", + "kind": "Real", + "level": "correction", + "min_pv": "6.7", + "name": "LocalHighlights2012", + "note": "[PV2012+]", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -1.0, + 1.0 + ], + "recipe_alias": null, + "ui": { + "div": 100.0 + } + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 0.0 + }, + "file_kind": "Both", + "fmt": "Trim6", + "frame": "Shareable", + "kind": "Real", + "level": "correction", + "min_pv": "6.7", + "name": "LocalShadows2012", + "note": "[PV2012+]", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -1.0, + 1.0 + ], + "recipe_alias": null, + "ui": { + "div": 100.0 + } + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 0.0 + }, + "file_kind": "Both", + "fmt": "Trim6", + "frame": "Shareable", + "kind": "Real", + "level": "correction", + "min_pv": "6.7", + "name": "LocalWhites2012", + "note": "[PV2012+]", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -1.0, + 1.0 + ], + "recipe_alias": null, + "ui": { + "div": 100.0 + } + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 0.0 + }, + "file_kind": "Both", + "fmt": "Trim6", + "frame": "Shareable", + "kind": "Real", + "level": "correction", + "min_pv": "6.7", + "name": "LocalBlacks2012", + "note": "[PV2012+]", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -1.0, + 1.0 + ], + "recipe_alias": null, + "ui": { + "div": 100.0 + } + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 0.0 + }, + "file_kind": "Both", + "fmt": "Trim6", + "frame": "Shareable", + "kind": "Real", + "level": "correction", + "min_pv": "6.7", + "name": "LocalClarity2012", + "note": "[PV2012+]", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -1.0, + 1.0 + ], + "recipe_alias": null, + "ui": { + "div": 100.0 + } + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 0.0 + }, + "file_kind": "Both", + "fmt": "Trim6", + "frame": "Shareable", + "kind": "Real", + "level": "correction", + "min_pv": null, + "name": "LocalDehaze", + "note": "[no \"2012\" suffix]", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -1.0, + 1.0 + ], + "recipe_alias": null, + "ui": { + "div": 100.0 + } + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 0.0 + }, + "file_kind": "Both", + "fmt": "Trim6", + "frame": "Shareable", + "kind": "Real", + "level": "correction", + "min_pv": null, + "name": "LocalTexture", + "note": "[no suffix]", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -1.0, + 1.0 + ], + "recipe_alias": null, + "ui": { + "div": 100.0 + } + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 0.0 + }, + "file_kind": "Both", + "fmt": "Trim6", + "frame": "Shareable", + "kind": "Real", + "level": "correction", + "min_pv": null, + "name": "LocalSaturation", + "note": "[no suffix]", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -1.0, + 1.0 + ], + "recipe_alias": null, + "ui": { + "div": 100.0 + } + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 0.0 + }, + "file_kind": "Both", + "fmt": "Trim6", + "frame": "Shareable", + "kind": "Real", + "level": "correction", + "min_pv": null, + "name": "LocalSharpness", + "note": "", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -1.0, + 1.0 + ], + "recipe_alias": null, + "ui": { + "div": 100.0 + } + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 0.0 + }, + "file_kind": "Both", + "fmt": "Trim6", + "frame": "Shareable", + "kind": "Real", + "level": "correction", + "min_pv": null, + "name": "LocalTemperature", + "note": "The LLM schema range is right in UI units; the serializer must divide by 100. [no suffix]", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -1.0, + 1.0 + ], + "recipe_alias": null, + "ui": { + "div": 100.0 + } + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 0.0 + }, + "file_kind": "Both", + "fmt": "Trim6", + "frame": "Shareable", + "kind": "Real", + "level": "correction", + "min_pv": null, + "name": "LocalTint", + "note": "[no suffix]", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -1.0, + 1.0 + ], + "recipe_alias": null, + "ui": { + "div": 100.0 + } + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 0.0 + }, + "file_kind": "Both", + "fmt": "Trim6", + "frame": "Shareable", + "kind": "Real", + "level": "correction", + "min_pv": null, + "name": "LocalLuminanceNoise", + "note": "UI range from knowledge; only 0..1 observed. The plugin's `local_Noise` must be `local_LuminanceNoise`.", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -1.0, + 1.0 + ], + "recipe_alias": null, + "ui": { + "div": 100.0 + } + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 0.0 + }, + "file_kind": "Both", + "fmt": "Trim6", + "frame": "Shareable", + "kind": "Real", + "level": "correction", + "min_pv": null, + "name": "LocalMoire", + "note": "", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -1.0, + 1.0 + ], + "recipe_alias": null, + "ui": { + "div": 100.0 + } + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 0.0 + }, + "file_kind": "Both", + "fmt": "Trim6", + "frame": "Shareable", + "kind": "Real", + "level": "correction", + "min_pv": null, + "name": "LocalDefringe", + "note": "", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -1.0, + 1.0 + ], + "recipe_alias": null, + "ui": { + "div": 100.0 + } + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 0.0 + }, + "file_kind": "Both", + "fmt": "Trim6", + "frame": "NotApplicable", + "kind": "Real", + "level": "correction", + "min_pv": null, + "name": "LocalHue", + "note": "Scale unverified; 2 non-zero values in the sample.", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "unknown" + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 0.0 + }, + "file_kind": "Both", + "fmt": "Trim6", + "frame": "Shareable", + "kind": "Real", + "level": "correction", + "min_pv": null, + "name": "LocalToningHue", + "note": "Circular; ignore when LocalToningSaturation = 0.", + "plus_sign": false, + "policy": { + "class": "LEARN†", + "gate": "CircularHue(\"LocalToningSaturation\")" + }, + "presence": "Both", + "range": [ + 0.0, + 360.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 0.0 + }, + "file_kind": "Both", + "fmt": "Trim6", + "frame": "Shareable", + "kind": "Real", + "level": "correction", + "min_pv": null, + "name": "LocalToningSaturation", + "note": "", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + 0.0, + 1.0 + ], + "recipe_alias": null, + "ui": { + "div": 100.0 + } + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 100.0 + }, + "file_kind": "Both", + "fmt": "Trim6", + "frame": "Shareable", + "kind": "Real", + "level": "correction", + "min_pv": null, + "name": "LocalCurveRefineSaturation", + "note": "Only 100 and 30 observed. [LrC 13+]", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + 0.0, + 100.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 0.0 + }, + "file_kind": "Both", + "fmt": "Trim6", + "frame": "NotApplicable", + "kind": "Real", + "level": "correction", + "min_pv": null, + "name": "LocalGrain", + "note": "Only 0 and 1 observed; scale unverified. [LrC 13+]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "unknown" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Curve(Local)", + "level": "correction", + "min_pv": null, + "name": "MainCurve", + "note": "Low priority. [LrC 13+]", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Curve(Local)", + "level": "correction", + "min_pv": null, + "name": "RedCurve", + "note": "Low priority. [LrC 13+]", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Curve(Local)", + "level": "correction", + "min_pv": null, + "name": "GreenCurve", + "note": "Low priority. [LrC 13+]", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Curve(Local)", + "level": "correction", + "min_pv": null, + "name": "BlueCurve", + "note": "Low priority. [LrC 13+]", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "CompoundFixed6", + "frame": "NotApplicable", + "kind": "StrSeq", + "level": "correction", + "min_pv": null, + "name": "LocalPointColors", + "note": "As global PointColors. [LrC 13+]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "CompoundFixed6", + "frame": "NotApplicable", + "kind": "StrSeq", + "level": "correction", + "min_pv": null, + "name": "LocalColorVariance", + "note": "[LrC 13+]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 0.0 + }, + "file_kind": "Both", + "fmt": "Trim6", + "frame": "NotApplicable", + "kind": "Real", + "level": "correction", + "min_pv": null, + "name": "LocalExposure", + "note": "Adobe presets write them as 0; the serializer may emit 0 for fidelity, never other values. (LocalClarity 0.655 appears only in legacy local presets.) [legacy PV2010 local keys]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 0.0 + }, + "file_kind": "Both", + "fmt": "Trim6", + "frame": "NotApplicable", + "kind": "Real", + "level": "correction", + "min_pv": null, + "name": "LocalContrast", + "note": "Adobe presets write them as 0; the serializer may emit 0 for fidelity, never other values. (LocalClarity 0.655 appears only in legacy local presets.) [legacy PV2010 local keys]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 0.0 + }, + "file_kind": "Both", + "fmt": "Trim6", + "frame": "NotApplicable", + "kind": "Real", + "level": "correction", + "min_pv": null, + "name": "LocalClarity", + "note": "Adobe presets write them as 0; the serializer may emit 0 for fidelity, never other values. (LocalClarity 0.655 appears only in legacy local presets.) [legacy PV2010 local keys]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 0.0 + }, + "file_kind": "Both", + "fmt": "Trim6", + "frame": "NotApplicable", + "kind": "Real", + "level": "correction", + "min_pv": null, + "name": "LocalBrightness", + "note": "Adobe presets write them as 0; the serializer may emit 0 for fidelity, never other values. (LocalClarity 0.655 appears only in legacy local presets.) [legacy PV2010 local keys]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 0.0 + }, + "file_kind": "Both", + "fmt": "Trim6", + "frame": "NotApplicable", + "kind": "Real", + "level": "correction", + "min_pv": null, + "name": "LocalCorrectedDepth", + "note": "Always 0. [depth corrections]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "correction", + "min_pv": null, + "name": "LocalColorGradeShadowHue", + "note": "Do not use (research §3.4). [known from Camera Raw 18.6; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "correction", + "min_pv": null, + "name": "LocalColorGradeShadowSat", + "note": "Do not use (research §3.4). [known from Camera Raw 18.6; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "correction", + "min_pv": null, + "name": "LocalColorGradeShadowLum", + "note": "Do not use (research §3.4). [known from Camera Raw 18.6; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "correction", + "min_pv": null, + "name": "LocalColorGradeHighlightHue", + "note": "Do not use (research §3.4). [known from Camera Raw 18.6; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "correction", + "min_pv": null, + "name": "LocalColorGradeHighlightSat", + "note": "Do not use (research §3.4). [known from Camera Raw 18.6; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "correction", + "min_pv": null, + "name": "LocalColorGradeHighlightLum", + "note": "Do not use (research §3.4). [known from Camera Raw 18.6; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "correction", + "min_pv": null, + "name": "LocalColorGradeMidtoneHue", + "note": "Do not use (research §3.4). [known from Camera Raw 18.6; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "correction", + "min_pv": null, + "name": "LocalColorGradeMidtoneSat", + "note": "Do not use (research §3.4). [known from Camera Raw 18.6; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "correction", + "min_pv": null, + "name": "LocalColorGradeMidtoneLum", + "note": "Do not use (research §3.4). [known from Camera Raw 18.6; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "correction", + "min_pv": null, + "name": "LocalColorGradeGlobalHue", + "note": "Do not use (research §3.4). [known from Camera Raw 18.6; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "correction", + "min_pv": null, + "name": "LocalColorGradeGlobalSat", + "note": "Do not use (research §3.4). [known from Camera Raw 18.6; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "correction", + "min_pv": null, + "name": "LocalColorGradeGlobalLum", + "note": "Do not use (research §3.4). [known from Camera Raw 18.6; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "correction", + "min_pv": null, + "name": "LocalColorGradeBalance", + "note": "Do not use (research §3.4). [known from Camera Raw 18.6; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "correction", + "min_pv": null, + "name": "LocalColorGradeBlending", + "note": "Do not use (research §3.4). [known from Camera Raw 18.6; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "correction", + "min_pv": null, + "name": "LocalToningLuminance", + "note": "Do not use (research §3.4). [known from Camera Raw 18.6; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "correction", + "min_pv": null, + "name": "LocalGlow", + "note": "Do not use (research §3.4). [known from Camera Raw 18.6; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "correction", + "min_pv": null, + "name": "CorrectionColorTable", + "note": "Do not use (research §3.4). [known from Camera Raw 18.6; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "correction", + "min_pv": null, + "name": "CorrectionLUTName", + "note": "Do not use (research §3.4). [known from Camera Raw 18.6; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "correction", + "min_pv": null, + "name": "CorrectionLUTAmount", + "note": "Do not use (research §3.4). [known from Camera Raw 18.6; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Str", + "level": "correction", + "min_pv": null, + "name": "CorrectionID", + "note": "Runtime ids; never write. [Lua/catalog only]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Real", + "level": "correction", + "min_pv": null, + "name": "CorrectionReferenceX", + "note": "Runtime ids; never write. [Lua/catalog only]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Real", + "level": "correction", + "min_pv": null, + "name": "CorrectionReferenceY", + "note": "Runtime ids; never write. [Lua/catalog only]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "Shareable", + "kind": "Enum([\"Mask/Image\", \"Mask/Gradient\", \"Mask/CircularGradient\", \"Mask/RangeMask\", \"Mask/Aggregate\", \"Mask/Paint\", \"Mask/Polygon\", \"Mask/Ellipse\", \"Mask/FlattenedGroup\", \"Mask/Clip\", \"Mask/Combine\"])", + "level": "mask-tool", + "min_pv": null, + "name": "What", + "note": "Policy follows the type: Image = LEARN, Gradient/CircularGradient/Polygon = PHOTO, Paint/Aggregate = NEVER.", + "plus_sign": false, + "policy": { + "class": "LEARN†", + "gate": "ByMaskType" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": true + }, + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Bool(Lower)", + "level": "mask-tool", + "min_pv": null, + "name": "MaskActive", + "note": "", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Str", + "level": "mask-tool", + "min_pv": null, + "name": "MaskName", + "note": "Never key on it.", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "EnumInt([0, 1])", + "level": "mask-tool", + "min_pv": null, + "name": "MaskBlendMode", + "note": "Intersect = 1 + MaskValue 0 + MaskInverted true (105 corpus cases).", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": false + }, + "file_kind": "Both", + "fmt": "Text", + "frame": "Shareable", + "kind": "Bool(Lower)", + "level": "mask-tool", + "min_pv": null, + "name": "MaskInverted", + "note": "", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Hex32", + "level": "mask-tool", + "min_pv": null, + "name": "MaskSyncID", + "note": "", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 1.0 + }, + "file_kind": "Both", + "fmt": "Trim6", + "frame": "Shareable", + "kind": "Real", + "level": "mask-tool", + "min_pv": null, + "name": "MaskValue", + "note": "0 iff MaskBlendMode = 1 (235/235).", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + 0.0, + 1.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Str", + "level": "mask-tool", + "min_pv": null, + "name": "MaskID", + "note": "Runtime id; never write. [Lua/catalog only]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 1 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "NotApplicable", + "kind": "Int", + "level": "mask-tool", + "min_pv": null, + "name": "MaskVersion", + "note": "", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "EnumInt([0, 1, 2, 3])", + "level": "mask-tool", + "min_pv": null, + "name": "MaskSubType", + "note": "Subtype 3 = \"all people\" is unconfirmed; 0 + part id = one specific person. [LrC 11+]", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "mask-tool", + "min_pv": null, + "name": "MaskSubCategoryID", + "note": "Full tables in Dev-AI-Edit-XMP-Findings.md. 20036 generic form unknown. [landscape classes LrC 13+]", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "CompoundFixed6", + "frame": "PerFrameOnly", + "kind": "Str", + "level": "mask-tool", + "min_pv": null, + "name": "ReferencePoint", + "note": "Write the constant 0.5 0.5 for preset-form semantic masks (META); a real point selects a person/object (PHOTO).", + "plus_sign": false, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "NotApplicable", + "kind": "EnumInt([0, 1])", + "level": "mask-tool", + "min_pv": null, + "name": "ErrorReason", + "note": "Adobe presets write 0; read back after an AI update and surface 1 as a warning.", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "PerFrameOnly", + "kind": "StructSeq(Gesture)", + "level": "mask-tool", + "min_pv": null, + "name": "Gesture", + "note": "Frame-dependent (research §4.2). Polygon form only; never paint strokes. [\"Select Object\"]", + "plus_sign": false, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Hex32", + "level": "mask-tool", + "min_pv": null, + "name": "MaskDigest", + "note": "Computed-mask bookkeeping (bitmap lives in .acr / catalog). FullMaskSize ~ \"2880,1920\"; WholeImageArea rationals.", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Hex32", + "level": "mask-tool", + "min_pv": null, + "name": "InputDigest", + "note": "Computed-mask bookkeeping (bitmap lives in .acr / catalog). FullMaskSize ~ \"2880,1920\"; WholeImageArea rationals.", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "NotApplicable", + "kind": "Int", + "level": "mask-tool", + "min_pv": null, + "name": "InputDigestVersion", + "note": "Computed-mask bookkeeping (bitmap lives in .acr / catalog). FullMaskSize ~ \"2880,1920\"; WholeImageArea rationals.", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Hex32", + "level": "mask-tool", + "min_pv": null, + "name": "LocalInputDigest", + "note": "Computed-mask bookkeeping (bitmap lives in .acr / catalog). FullMaskSize ~ \"2880,1920\"; WholeImageArea rationals.", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "NotApplicable", + "kind": "Int", + "level": "mask-tool", + "min_pv": null, + "name": "LocalInputDigestVersion", + "note": "Computed-mask bookkeeping (bitmap lives in .acr / catalog). FullMaskSize ~ \"2880,1920\"; WholeImageArea rationals.", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "NotApplicable", + "kind": "VersionU32", + "level": "mask-tool", + "min_pv": null, + "name": "ModelVersion", + "note": "Computed-mask bookkeeping (bitmap lives in .acr / catalog). FullMaskSize ~ \"2880,1920\"; WholeImageArea rationals.", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Str", + "level": "mask-tool", + "min_pv": null, + "name": "WholeImageArea", + "note": "Computed-mask bookkeeping (bitmap lives in .acr / catalog). FullMaskSize ~ \"2880,1920\"; WholeImageArea rationals.", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Str", + "level": "mask-tool", + "min_pv": null, + "name": "Origin", + "note": "Computed-mask bookkeeping (bitmap lives in .acr / catalog). FullMaskSize ~ \"2880,1920\"; WholeImageArea rationals.", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Str", + "level": "mask-tool", + "min_pv": null, + "name": "FullMaskSize", + "note": "Computed-mask bookkeeping (bitmap lives in .acr / catalog). FullMaskSize ~ \"2880,1920\"; WholeImageArea rationals.", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Bool(Lower)", + "level": "mask-tool", + "min_pv": null, + "name": "DidOverrideInputDigestMismatch", + "note": "Computed-mask bookkeeping (bitmap lives in .acr / catalog). FullMaskSize ~ \"2880,1920\"; WholeImageArea rationals.", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "mask-tool", + "min_pv": null, + "name": "TargetImageArea", + "note": "[known from Camera Raw; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "mask-tool", + "min_pv": null, + "name": "EdgeShift", + "note": "[known from Camera Raw; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "mask-tool", + "min_pv": null, + "name": "SemanticObjectPrompt", + "note": "SemanticObjectPrompt suggests text-prompted object masks; relevant for step 4 (dodge & burn tier 2). [known from Camera Raw 18.6; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "mask-tool", + "min_pv": null, + "name": "ExtendedSemanticName", + "note": "SemanticObjectPrompt suggests text-prompted object masks; relevant for step 4 (dodge & burn tier 2). [known from Camera Raw 18.6; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "mask-tool", + "min_pv": null, + "name": "ExtendedInstanceID", + "note": "SemanticObjectPrompt suggests text-prompted object masks; relevant for step 4 (dodge & burn tier 2). [known from Camera Raw 18.6; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "mask-tool", + "min_pv": null, + "name": "InstanceIDs", + "note": "SemanticObjectPrompt suggests text-prompted object masks; relevant for step 4 (dodge & burn tier 2). [known from Camera Raw 18.6; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "mask-tool", + "min_pv": null, + "name": "InstanceID", + "note": "SemanticObjectPrompt suggests text-prompted object masks; relevant for step 4 (dodge & burn tier 2). [known from Camera Raw 18.6; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "mask-tool", + "min_pv": null, + "name": "InstanceBounds", + "note": "SemanticObjectPrompt suggests text-prompted object masks; relevant for step 4 (dodge & burn tier 2). [known from Camera Raw 18.6; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Trim6", + "frame": "PerFrameOnly", + "kind": "Real", + "level": "mask-tool", + "min_pv": null, + "name": "ZeroX", + "note": "0 % point.", + "plus_sign": false, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Trim6", + "frame": "PerFrameOnly", + "kind": "Real", + "level": "mask-tool", + "min_pv": null, + "name": "ZeroY", + "note": "", + "plus_sign": false, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Trim6", + "frame": "PerFrameOnly", + "kind": "Real", + "level": "mask-tool", + "min_pv": null, + "name": "FullX", + "note": "100 % point.", + "plus_sign": false, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Trim6", + "frame": "PerFrameOnly", + "kind": "Real", + "level": "mask-tool", + "min_pv": null, + "name": "FullY", + "note": "Direction/orthogonality in pixel space is derived, not measured.", + "plus_sign": false, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "mask-tool", + "min_pv": null, + "name": "Bidirectional", + "note": "Newer gradient variants. [known from Camera Raw; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "mask-tool", + "min_pv": null, + "name": "Zero2X", + "note": "Newer gradient variants. [known from Camera Raw; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "mask-tool", + "min_pv": null, + "name": "Zero2Y", + "note": "Newer gradient variants. [known from Camera Raw; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "mask-tool", + "min_pv": null, + "name": "ZeroFeather", + "note": "Newer gradient variants. [known from Camera Raw; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "mask-tool", + "min_pv": null, + "name": "Zero2Feather", + "note": "Newer gradient variants. [known from Camera Raw; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "mask-tool", + "min_pv": null, + "name": "FullPointDistance", + "note": "Newer gradient variants. [known from Camera Raw; never observed in LrC output]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Trim6", + "frame": "PerFrameOnly", + "kind": "Real", + "level": "mask-tool", + "min_pv": null, + "name": "Top", + "note": "", + "plus_sign": false, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Trim6", + "frame": "PerFrameOnly", + "kind": "Real", + "level": "mask-tool", + "min_pv": null, + "name": "Left", + "note": "", + "plus_sign": false, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Trim6", + "frame": "PerFrameOnly", + "kind": "Real", + "level": "mask-tool", + "min_pv": null, + "name": "Bottom", + "note": "", + "plus_sign": false, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Trim6", + "frame": "PerFrameOnly", + "kind": "Real", + "level": "mask-tool", + "min_pv": null, + "name": "Right", + "note": "", + "plus_sign": false, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 0.0 + }, + "file_kind": "Both", + "fmt": "Trim6", + "frame": "PerFrameOnly", + "kind": "Real", + "level": "mask-tool", + "min_pv": null, + "name": "Angle", + "note": "Convention (sign, rotated corners) uncalibrated: write 0 only.", + "plus_sign": false, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 50 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "PerFrameOnly", + "kind": "Int", + "level": "mask-tool", + "min_pv": null, + "name": "Midpoint", + "note": "Always 50.", + "plus_sign": false, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "PerFrameOnly", + "kind": "Int", + "level": "mask-tool", + "min_pv": null, + "name": "Roundness", + "note": "", + "plus_sign": false, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 50 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "PerFrameOnly", + "kind": "Int", + "level": "mask-tool", + "min_pv": null, + "name": "Feather", + "note": "", + "plus_sign": false, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": [ + 0.0, + 100.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "PerFrameOnly", + "kind": "Bool(Lower)", + "level": "mask-tool", + "min_pv": null, + "name": "Flipped", + "note": "Always = !MaskInverted (276 + 186 corpus radials).", + "plus_sign": false, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 2 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "NotApplicable", + "kind": "Int", + "level": "mask-tool", + "min_pv": null, + "name": "Version", + "note": "", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "Shareable", + "kind": "Struct(CorrectionRangeMask)", + "level": "mask-tool", + "min_pv": null, + "name": "CorrectionRangeMask", + "note": "Luminance (Type 2) is LEARN, colour (Type 1) PHOTO.", + "plus_sign": false, + "policy": { + "class": "LEARN†", + "gate": "ByMaskType" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "PerFrameOnly", + "kind": "ComponentSeq", + "level": "mask-tool", + "min_pv": null, + "name": "Masks", + "note": "", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "CompoundFixed6", + "frame": "PerFrameOnly", + "kind": "StrSeq", + "level": "mask-tool", + "min_pv": null, + "name": "Dabs", + "note": "Up to 218 KB per sidecar.", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Trim6", + "frame": "PerFrameOnly", + "kind": "Real", + "level": "mask-tool", + "min_pv": null, + "name": "Radius", + "note": "", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Trim6", + "frame": "PerFrameOnly", + "kind": "Real", + "level": "mask-tool", + "min_pv": null, + "name": "Flow", + "note": "", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "Both", + "range": [ + 0.0, + 1.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Trim6", + "frame": "PerFrameOnly", + "kind": "Real", + "level": "mask-tool", + "min_pv": null, + "name": "CenterWeight", + "note": "", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Hex32", + "level": "mask-tool", + "min_pv": null, + "name": "MaskBrushTable", + "note": "[strokes moved to .acr/catalog]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "NotApplicable", + "kind": "Int", + "level": "mask-tool", + "min_pv": null, + "name": "MaskBrushUncompressedBytes", + "note": "[strokes moved to .acr/catalog]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "Shareable", + "kind": "Str", + "level": "struct:Look", + "min_pv": null, + "name": "Name", + "note": "", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "Shareable", + "kind": "Hex32", + "level": "struct:Look", + "min_pv": null, + "name": "UUID", + "note": "Must match an installed profile.", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 1.0 + }, + "file_kind": "Both", + "fmt": "Trim6", + "frame": "Shareable", + "kind": "Real", + "level": "struct:Look", + "min_pv": null, + "name": "Amount", + "note": "", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + 0.0, + 2.0 + ], + "recipe_alias": null, + "ui": { + "div": 100.0 + } + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Bool(Lower)", + "level": "struct:Look", + "min_pv": null, + "name": "Stubbed", + "note": "Presets carry a stub (no Parameters); whether applyDevelopSettings accepts a stub is E11.", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Settings", + "level": "struct:Look", + "min_pv": null, + "name": "Parameters", + "note": "LrC fills it from its profile library. [sidecars only]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "LangAlt", + "level": "struct:Look", + "min_pv": null, + "name": "Group", + "note": "Copied from the profile.", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "LangAlt", + "level": "struct:Look", + "min_pv": null, + "name": "SortName", + "note": "", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Str", + "level": "struct:Look", + "min_pv": null, + "name": "Cluster", + "note": "", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Str", + "level": "struct:Look", + "min_pv": null, + "name": "Copyright", + "note": "", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Str", + "level": "struct:Look", + "min_pv": null, + "name": "CameraModelRestriction", + "note": "Also the learning gate: never transfer such a Look to another camera.", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Bool(Lower)", + "level": "struct:Look", + "min_pv": null, + "name": "isAdobeAdaptive", + "note": "Marks an Adobe Adaptive profile (needs an AI update, see AILook). [Lua only (getDevelopSettings() of adaptive profiles); never observed in XMP]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Bool(Lower)", + "level": "struct:Look", + "min_pv": null, + "name": "SupportsAmount", + "note": "", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Bool(Lower)", + "level": "struct:Look", + "min_pv": null, + "name": "SupportsMonochrome", + "note": "", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Bool(Lower)", + "level": "struct:Look", + "min_pv": null, + "name": "SupportsOutputReferred", + "note": "", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": false + }, + "file_kind": "Both", + "fmt": "Text", + "frame": "Shareable", + "kind": "Bool(Lower)", + "level": "struct:LensBlur", + "min_pv": null, + "name": "Active", + "note": "Needs a depth map (AI update). [LrC 13+]", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 50 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "struct:LensBlur", + "min_pv": null, + "name": "BlurAmount", + "note": "", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + 0.0, + 100.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "struct:LensBlur", + "min_pv": null, + "name": "BokehShape", + "note": "", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "struct:LensBlur", + "min_pv": null, + "name": "BokehShapeDetail", + "note": "", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "struct:LensBlur", + "min_pv": null, + "name": "BokehAspect", + "note": "", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + -100.0, + 100.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "struct:LensBlur", + "min_pv": null, + "name": "BokehRotation", + "note": "", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "struct:LensBlur", + "min_pv": null, + "name": "CatEyeAmount", + "note": "", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + 0.0, + 100.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 100 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "struct:LensBlur", + "min_pv": null, + "name": "CatEyeScale", + "note": "", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 50 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "struct:LensBlur", + "min_pv": null, + "name": "HighlightsBoost", + "note": "", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + 0.0, + 100.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 50 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "struct:LensBlur", + "min_pv": null, + "name": "HighlightsThreshold", + "note": "", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": [ + 0.0, + 100.0 + ], + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "Int", + "level": "struct:LensBlur", + "min_pv": null, + "name": "SphericalAberration", + "note": "", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 1 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "NotApplicable", + "kind": "Int", + "level": "struct:LensBlur", + "min_pv": null, + "name": "Version", + "note": "", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Str", + "level": "struct:LensBlur", + "min_pv": null, + "name": "FocalRange", + "note": "Preset value vs per-photo focus range; whether LrC re-focuses on the subject when applied from a preset is unverified.", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Int", + "frame": "NotApplicable", + "kind": "Int", + "level": "struct:LensBlur", + "min_pv": null, + "name": "FocalRangeSource", + "note": "", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Str", + "level": "struct:LensBlur", + "min_pv": null, + "name": "SampledArea", + "note": "Per-photo focus sampling.", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Str", + "level": "struct:LensBlur", + "min_pv": null, + "name": "SampledRange", + "note": "Per-photo focus sampling.", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Str", + "level": "struct:LensBlur", + "min_pv": null, + "name": "SubjectRange", + "note": "Per-photo focus sampling.", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "NotApplicable", + "kind": "Int", + "level": "struct:LensBlur", + "min_pv": null, + "name": "ImageOrientation", + "note": "Per-photo focus sampling.", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 3 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "NotApplicable", + "kind": "Int", + "level": "struct:CorrectionRangeMask", + "min_pv": null, + "name": "Version", + "note": "", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "Shareable", + "kind": "EnumInt([1, 2, 3])", + "level": "struct:CorrectionRangeMask", + "min_pv": null, + "name": "Type", + "note": "Type 2 is LEARN; Type 1 is PHOTO (photo-specific samples).", + "plus_sign": false, + "policy": { + "class": "LEARN†", + "gate": "ByMaskType" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": false + }, + "file_kind": "Both", + "fmt": "Text", + "frame": "Shareable", + "kind": "Bool(Lower)", + "level": "struct:CorrectionRangeMask", + "min_pv": null, + "name": "Invert", + "note": "", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": { + "value": 0 + }, + "file_kind": "Both", + "fmt": "Int", + "frame": "NotApplicable", + "kind": "EnumInt([0, 1])", + "level": "struct:CorrectionRangeMask", + "min_pv": null, + "name": "SampleType", + "note": "Meaning unverified.", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "CompoundFixed6", + "frame": "Shareable", + "kind": "Str", + "level": "struct:CorrectionRangeMask", + "min_pv": null, + "name": "LumRange", + "note": "Threshold-only, frame-independent.", + "plus_sign": false, + "policy": { + "class": "LEARN" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "CompoundFixed6", + "frame": "PerFrameOnly", + "kind": "Str", + "level": "struct:CorrectionRangeMask", + "min_pv": null, + "name": "LuminanceDepthSampleInfo", + "note": "", + "plus_sign": false, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Trim6", + "frame": "PerFrameOnly", + "kind": "Real", + "level": "struct:CorrectionRangeMask", + "min_pv": null, + "name": "ColorAmount", + "note": "[colour ranges]", + "plus_sign": false, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": [ + 0.0, + 1.0 + ], + "recipe_alias": null, + "ui": { + "div": 100.0 + } + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "CompoundFixed6", + "frame": "PerFrameOnly", + "kind": "StrSeq", + "level": "struct:CorrectionRangeMask", + "min_pv": null, + "name": "PointModels", + "note": "[colour ranges]", + "plus_sign": false, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "PerFrameOnly", + "kind": "StructSeq(AreaModel)", + "level": "struct:CorrectionRangeMask", + "min_pv": null, + "name": "AreaModels", + "note": "[colour ranges]", + "plus_sign": false, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "PerFrameOnly", + "kind": "StrSeq", + "level": "struct:AreaModel", + "min_pv": null, + "name": "AreaComponents", + "note": "[inside AreaModels]", + "plus_sign": false, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "PerFrameOnly", + "kind": "Str", + "level": "struct:AreaModel", + "min_pv": null, + "name": "ColorRangeMaskAreaSampleInfo", + "note": "[inside AreaModels]", + "plus_sign": false, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "struct:CorrectionRangeMask", + "min_pv": null, + "name": "LumMin", + "note": "[known from Camera Raw; never observed in LrC output (legacy LrC 7 range masks)]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "struct:CorrectionRangeMask", + "min_pv": null, + "name": "LumMax", + "note": "[known from Camera Raw; never observed in LrC output (legacy LrC 7 range masks)]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "struct:CorrectionRangeMask", + "min_pv": null, + "name": "LumFeather", + "note": "[known from Camera Raw; never observed in LrC output (legacy LrC 7 range masks)]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "struct:CorrectionRangeMask", + "min_pv": null, + "name": "DepthMin", + "note": "[known from Camera Raw; never observed in LrC output (legacy LrC 7 range masks)]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "struct:CorrectionRangeMask", + "min_pv": null, + "name": "DepthMax", + "note": "[known from Camera Raw; never observed in LrC output (legacy LrC 7 range masks)]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "struct:CorrectionRangeMask", + "min_pv": null, + "name": "DepthFeather", + "note": "[known from Camera Raw; never observed in LrC output (legacy LrC 7 range masks)]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Any", + "level": "struct:CorrectionRangeMask", + "min_pv": null, + "name": "DepthRange", + "note": "[known from Camera Raw; never observed in LrC output (legacy LrC 7 range masks)]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Unobserved", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "PerFrameOnly", + "kind": "Enum([\"Mask/Polygon\", \"Mask/Paint\"])", + "level": "struct:Gesture", + "min_pv": null, + "name": "What", + "note": "", + "plus_sign": false, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "PerFrameOnly", + "kind": "StructSeq(GesturePoint)", + "level": "struct:Gesture", + "min_pv": null, + "name": "Points", + "note": "The only gesture form we may write (a box as prompt).", + "plus_sign": false, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Trim6", + "frame": "PerFrameOnly", + "kind": "Real", + "level": "struct:GesturePoint", + "min_pv": null, + "name": "X", + "note": "[inside Points]", + "plus_sign": false, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Trim6", + "frame": "PerFrameOnly", + "kind": "Real", + "level": "struct:GesturePoint", + "min_pv": null, + "name": "Y", + "note": "[inside Points]", + "plus_sign": false, + "policy": { + "class": "PHOTO" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "CompoundFixed6", + "frame": "PerFrameOnly", + "kind": "StrSeq", + "level": "struct:Gesture", + "min_pv": null, + "name": "Dabs", + "note": "[paint-stroke gestures]", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Trim6", + "frame": "PerFrameOnly", + "kind": "Real", + "level": "struct:Gesture", + "min_pv": null, + "name": "Radius", + "note": "[paint-stroke gestures]", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Trim6", + "frame": "PerFrameOnly", + "kind": "Real", + "level": "struct:Gesture", + "min_pv": null, + "name": "Flow", + "note": "[paint-stroke gestures]", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Trim6", + "frame": "PerFrameOnly", + "kind": "Real", + "level": "struct:Gesture", + "min_pv": null, + "name": "CenterWeight", + "note": "[paint-stroke gestures]", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "PerFrameOnly", + "kind": "Int", + "level": "struct:Gesture", + "min_pv": null, + "name": "BrushGestureInterpretation", + "note": "[paint-stroke gestures]", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Bool(Lower)", + "level": "struct:Gesture", + "min_pv": null, + "name": "MaskActive", + "note": "", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "NotApplicable", + "kind": "EnumInt([0, 1])", + "level": "struct:Gesture", + "min_pv": null, + "name": "MaskBlendMode", + "note": "", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Bool(Lower)", + "level": "struct:Gesture", + "min_pv": null, + "name": "MaskInverted", + "note": "", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Hex32", + "level": "struct:Gesture", + "min_pv": null, + "name": "MaskSyncID", + "note": "", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Trim6", + "frame": "NotApplicable", + "kind": "Real", + "level": "struct:Gesture", + "min_pv": null, + "name": "MaskValue", + "note": "", + "plus_sign": false, + "policy": { + "class": "META" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Str", + "level": "struct:Gesture", + "min_pv": null, + "name": "MaskID", + "note": "Runtime id; never write. [Lua/catalog only]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Bool(Lower)", + "level": "struct:AILook", + "min_pv": null, + "name": "Active", + "note": "Produced by the AI update.", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Str", + "level": "struct:AILook", + "min_pv": null, + "name": "AILookData", + "note": "Produced by the AI update.", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Hex32", + "level": "struct:AILook", + "min_pv": null, + "name": "InputDigest", + "note": "Produced by the AI update.", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "NotApplicable", + "kind": "Int", + "level": "struct:AILook", + "min_pv": null, + "name": "InputDigestVersion", + "note": "Produced by the AI update.", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "NotApplicable", + "kind": "VersionU32", + "level": "struct:AILook", + "min_pv": null, + "name": "ModelVersion", + "note": "Produced by the AI update.", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "NotApplicable", + "kind": "VersionU32", + "level": "struct:AILook", + "min_pv": null, + "name": "Version", + "note": "Produced by the AI update.", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Struct(RangeMaskMapInfo)", + "level": "struct:RangeMaskMapInfo", + "min_pv": null, + "name": "RangeMaskMapInfo", + "note": "Written as rdf:parseType=\"Resource\" wrapper around an inner RangeMaskMapInfo struct.", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "CompoundFixed6", + "frame": "NotApplicable", + "kind": "Str", + "level": "struct:RangeMaskMapInfo", + "min_pv": null, + "name": "RGBMin", + "note": "Written as rdf:parseType=\"Resource\" wrapper around an inner RangeMaskMapInfo struct.", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "CompoundFixed6", + "frame": "NotApplicable", + "kind": "Str", + "level": "struct:RangeMaskMapInfo", + "min_pv": null, + "name": "RGBMax", + "note": "Written as rdf:parseType=\"Resource\" wrapper around an inner RangeMaskMapInfo struct.", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "CompoundFixed6", + "frame": "NotApplicable", + "kind": "Str", + "level": "struct:RangeMaskMapInfo", + "min_pv": null, + "name": "LabMin", + "note": "Written as rdf:parseType=\"Resource\" wrapper around an inner RangeMaskMapInfo struct.", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "CompoundFixed6", + "frame": "NotApplicable", + "kind": "Str", + "level": "struct:RangeMaskMapInfo", + "min_pv": null, + "name": "LabMax", + "note": "Written as rdf:parseType=\"Resource\" wrapper around an inner RangeMaskMapInfo struct.", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "CompoundFixed6", + "frame": "NotApplicable", + "kind": "StrSeq", + "level": "struct:RangeMaskMapInfo", + "min_pv": null, + "name": "LumEq", + "note": "Written as rdf:parseType=\"Resource\" wrapper around an inner RangeMaskMapInfo struct.", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "NotApplicable", + "kind": "Int", + "level": "struct:DepthMapInfo", + "min_pv": null, + "name": "DepthSource", + "note": "10 keys.", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Hex32", + "level": "struct:DepthMapInfo", + "min_pv": null, + "name": "BaseRawDepthTable", + "note": "10 keys.", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Hex32", + "level": "struct:DepthMapInfo", + "min_pv": null, + "name": "BaseRawDepthInputDigest", + "note": "10 keys.", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "NotApplicable", + "kind": "Int", + "level": "struct:DepthMapInfo", + "min_pv": null, + "name": "BaseRawDepthVersion", + "note": "10 keys.", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Hex32", + "level": "struct:DepthMapInfo", + "min_pv": null, + "name": "BaseLayeredDepthTable", + "note": "10 keys.", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Hex32", + "level": "struct:DepthMapInfo", + "min_pv": null, + "name": "BaseLayeredDepthInputDigest", + "note": "10 keys.", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "NotApplicable", + "kind": "Int", + "level": "struct:DepthMapInfo", + "min_pv": null, + "name": "BaseLayeredDepthVersion", + "note": "10 keys.", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Hex32", + "level": "struct:DepthMapInfo", + "min_pv": null, + "name": "BaseHighlightGuideTable", + "note": "10 keys.", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Hex32", + "level": "struct:DepthMapInfo", + "min_pv": null, + "name": "BaseHighlightGuideInputDigest", + "note": "10 keys.", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "NotApplicable", + "kind": "Int", + "level": "struct:DepthMapInfo", + "min_pv": null, + "name": "BaseHighlightGuideVersion", + "note": "10 keys.", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Int", + "frame": "NotApplicable", + "kind": "Int", + "level": "struct:ISODependent", + "min_pv": null, + "name": "ISO", + "note": "Interpolated by ISO on apply (inferred). Not for v1. [presets only]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Int", + "frame": "NotApplicable", + "kind": "Int", + "level": "struct:ISODependent", + "min_pv": null, + "name": "ColorNoiseReduction", + "note": "Interpolated by ISO on apply (inferred). Not for v1. [presets only]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Int", + "frame": "NotApplicable", + "kind": "Int", + "level": "struct:ISODependent", + "min_pv": null, + "name": "LuminanceSmoothing", + "note": "Interpolated by ISO on apply (inferred). Not for v1. [presets only]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Settings", + "level": "struct:Preset", + "min_pv": null, + "name": "Parameters", + "note": "Kept opaque; never merged into the photo's settings. [sidecars only]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Str", + "level": "struct:Preset", + "min_pv": null, + "name": "Name", + "note": "Written by LrC. Useful as a learning signal (\"which preset did the user start from\"), never written by us. [about a tenth of the sidecars; Parameters subtree excluded from this inventory]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Hex32", + "level": "struct:Preset", + "min_pv": null, + "name": "UUID", + "note": "Written by LrC. Useful as a learning signal (\"which preset did the user start from\"), never written by us. [about a tenth of the sidecars; Parameters subtree excluded from this inventory]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Trim6", + "frame": "NotApplicable", + "kind": "Real", + "level": "struct:Preset", + "min_pv": null, + "name": "Amount", + "note": "Written by LrC. Useful as a learning signal (\"which preset did the user start from\"), never written by us. [about a tenth of the sidecars; Parameters subtree excluded from this inventory]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Trim6", + "frame": "NotApplicable", + "kind": "Real", + "level": "struct:Preset", + "min_pv": null, + "name": "LookAmount", + "note": "Written by LrC. Useful as a learning signal (\"which preset did the user start from\"), never written by us. [about a tenth of the sidecars; Parameters subtree excluded from this inventory]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "LangAlt", + "level": "struct:Preset", + "min_pv": null, + "name": "Group", + "note": "Written by LrC. Useful as a learning signal (\"which preset did the user start from\"), never written by us. [about a tenth of the sidecars; Parameters subtree excluded from this inventory]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Str", + "level": "struct:Preset", + "min_pv": null, + "name": "Cluster", + "note": "Written by LrC. Useful as a learning signal (\"which preset did the user start from\"), never written by us. [about a tenth of the sidecars; Parameters subtree excluded from this inventory]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Str", + "level": "struct:Preset", + "min_pv": null, + "name": "Baseline", + "note": "Written by LrC. Useful as a learning signal (\"which preset did the user start from\"), never written by us. [about a tenth of the sidecars; Parameters subtree excluded from this inventory]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Bool(Lower)", + "level": "struct:Preset", + "min_pv": null, + "name": "SupportsAmount", + "note": "Written by LrC. Useful as a learning signal (\"which preset did the user start from\"), never written by us. [about a tenth of the sidecars; Parameters subtree excluded from this inventory]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Bool(Lower)", + "level": "struct:Preset", + "min_pv": null, + "name": "SupportsMonochrome", + "note": "Written by LrC. Useful as a learning signal (\"which preset did the user start from\"), never written by us. [about a tenth of the sidecars; Parameters subtree excluded from this inventory]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Bool(Lower)", + "level": "struct:Preset", + "min_pv": null, + "name": "SupportsOutputReferred", + "note": "Written by LrC. Useful as a learning signal (\"which preset did the user start from\"), never written by us. [about a tenth of the sidecars; Parameters subtree excluded from this inventory]", + "plus_sign": false, + "policy": { + "class": "COMPUTED" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "PerFrameOnly", + "kind": "Str", + "level": "struct:RetouchArea", + "min_pv": null, + "name": "SpotType", + "note": "Heal/clone/remove spots (Masks = Mask/Ellipse or Mask/Paint).", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "PerFrameOnly", + "kind": "Str", + "level": "struct:RetouchArea", + "min_pv": null, + "name": "Method", + "note": "Heal/clone/remove spots (Masks = Mask/Ellipse or Mask/Paint).", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "PerFrameOnly", + "kind": "Str", + "level": "struct:RetouchArea", + "min_pv": null, + "name": "SourceState", + "note": "Heal/clone/remove spots (Masks = Mask/Ellipse or Mask/Paint).", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Trim6", + "frame": "PerFrameOnly", + "kind": "Real", + "level": "struct:RetouchArea", + "min_pv": null, + "name": "Opacity", + "note": "Heal/clone/remove spots (Masks = Mask/Ellipse or Mask/Paint).", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Trim6", + "frame": "PerFrameOnly", + "kind": "Real", + "level": "struct:RetouchArea", + "min_pv": null, + "name": "Feather", + "note": "Heal/clone/remove spots (Masks = Mask/Ellipse or Mask/Paint).", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "PerFrameOnly", + "kind": "Int", + "level": "struct:RetouchArea", + "min_pv": null, + "name": "Seed", + "note": "Heal/clone/remove spots (Masks = Mask/Ellipse or Mask/Paint).", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Trim6", + "frame": "PerFrameOnly", + "kind": "Real", + "level": "struct:RetouchArea", + "min_pv": null, + "name": "OffsetY", + "note": "Heal/clone/remove spots (Masks = Mask/Ellipse or Mask/Paint).", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Trim6", + "frame": "PerFrameOnly", + "kind": "Real", + "level": "struct:RetouchArea", + "min_pv": null, + "name": "SourceX", + "note": "Heal/clone/remove spots (Masks = Mask/Ellipse or Mask/Paint).", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Int", + "frame": "PerFrameOnly", + "kind": "Int", + "level": "struct:RetouchArea", + "min_pv": null, + "name": "HealVersion", + "note": "Heal/clone/remove spots (Masks = Mask/Ellipse or Mask/Paint).", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "PerFrameOnly", + "kind": "Str", + "level": "struct:RetouchArea", + "min_pv": null, + "name": "IngestInfo", + "note": "Heal/clone/remove spots (Masks = Mask/Ellipse or Mask/Paint).", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "PerFrameOnly", + "kind": "Str", + "level": "struct:RetouchArea", + "min_pv": null, + "name": "fill_method", + "note": "Heal/clone/remove spots (Masks = Mask/Ellipse or Mask/Paint).", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "PerFrameOnly", + "kind": "ComponentSeq", + "level": "struct:RetouchArea", + "min_pv": null, + "name": "Masks", + "note": "Heal/clone/remove spots (Masks = Mask/Ellipse or Mask/Paint).", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "PerFrameOnly", + "kind": "Real", + "level": "struct:RetouchArea", + "min_pv": null, + "name": "centerX", + "note": "Same fields as a RetouchInfo item. [Lua only: legacy spot fields next to the new ones in a few getDevelopSettings() retouch areas]", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "PerFrameOnly", + "kind": "Real", + "level": "struct:RetouchArea", + "min_pv": null, + "name": "centerY", + "note": "Same fields as a RetouchInfo item. [Lua only: legacy spot fields next to the new ones in a few getDevelopSettings() retouch areas]", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "PerFrameOnly", + "kind": "Real", + "level": "struct:RetouchArea", + "min_pv": null, + "name": "opacity", + "note": "Same fields as a RetouchInfo item. [Lua only: legacy spot fields next to the new ones in a few getDevelopSettings() retouch areas]", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "PerFrameOnly", + "kind": "Real", + "level": "struct:RetouchArea", + "min_pv": null, + "name": "radius", + "note": "Same fields as a RetouchInfo item. [Lua only: legacy spot fields next to the new ones in a few getDevelopSettings() retouch areas]", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "PerFrameOnly", + "kind": "Int", + "level": "struct:RetouchArea", + "min_pv": null, + "name": "seed", + "note": "Same fields as a RetouchInfo item. [Lua only: legacy spot fields next to the new ones in a few getDevelopSettings() retouch areas]", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "PerFrameOnly", + "kind": "Str", + "level": "struct:RetouchArea", + "min_pv": null, + "name": "sourceState", + "note": "Same fields as a RetouchInfo item. [Lua only: legacy spot fields next to the new ones in a few getDevelopSettings() retouch areas]", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "PerFrameOnly", + "kind": "Real", + "level": "struct:RetouchArea", + "min_pv": null, + "name": "sourceX", + "note": "Same fields as a RetouchInfo item. [Lua only: legacy spot fields next to the new ones in a few getDevelopSettings() retouch areas]", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "PerFrameOnly", + "kind": "Real", + "level": "struct:RetouchArea", + "min_pv": null, + "name": "sourceY", + "note": "Same fields as a RetouchInfo item. [Lua only: legacy spot fields next to the new ones in a few getDevelopSettings() retouch areas]", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "PerFrameOnly", + "kind": "Str", + "level": "struct:RetouchArea", + "min_pv": null, + "name": "spotType", + "note": "Same fields as a RetouchInfo item. [Lua only: legacy spot fields next to the new ones in a few getDevelopSettings() retouch areas]", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Trim6", + "frame": "PerFrameOnly", + "kind": "Real", + "level": "mask-tool", + "min_pv": null, + "name": "X", + "note": "[heal spot shape]", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Trim6", + "frame": "PerFrameOnly", + "kind": "Real", + "level": "mask-tool", + "min_pv": null, + "name": "Y", + "note": "[heal spot shape]", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Trim6", + "frame": "PerFrameOnly", + "kind": "Real", + "level": "mask-tool", + "min_pv": null, + "name": "SizeX", + "note": "[heal spot shape]", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Trim6", + "frame": "PerFrameOnly", + "kind": "Real", + "level": "mask-tool", + "min_pv": null, + "name": "SizeY", + "note": "[heal spot shape]", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Trim6", + "frame": "PerFrameOnly", + "kind": "Real", + "level": "mask-tool", + "min_pv": null, + "name": "Alpha", + "note": "[heal spot shape]", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Trim6", + "frame": "PerFrameOnly", + "kind": "Real", + "level": "mask-tool", + "min_pv": null, + "name": "CenterValue", + "note": "[heal spot shape]", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Trim6", + "frame": "PerFrameOnly", + "kind": "Real", + "level": "mask-tool", + "min_pv": null, + "name": "PerimeterValue", + "note": "[heal spot shape]", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "Both", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "PerFrameOnly", + "kind": "Str", + "level": "struct:RetouchInfo", + "min_pv": null, + "name": "Method", + "note": "Legacy heal/clone spot. [Lua only (the XMP form is one string per spot)]", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "PerFrameOnly", + "kind": "Real", + "level": "struct:RetouchInfo", + "min_pv": null, + "name": "centerX", + "note": "Legacy heal/clone spot. [Lua only (the XMP form is one string per spot)]", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "PerFrameOnly", + "kind": "Real", + "level": "struct:RetouchInfo", + "min_pv": null, + "name": "centerY", + "note": "Legacy heal/clone spot. [Lua only (the XMP form is one string per spot)]", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "PerFrameOnly", + "kind": "Real", + "level": "struct:RetouchInfo", + "min_pv": null, + "name": "opacity", + "note": "Legacy heal/clone spot. [Lua only (the XMP form is one string per spot)]", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "PerFrameOnly", + "kind": "Real", + "level": "struct:RetouchInfo", + "min_pv": null, + "name": "radius", + "note": "Legacy heal/clone spot. [Lua only (the XMP form is one string per spot)]", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "PerFrameOnly", + "kind": "Str", + "level": "struct:RetouchInfo", + "min_pv": null, + "name": "sourceState", + "note": "Legacy heal/clone spot. [Lua only (the XMP form is one string per spot)]", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "PerFrameOnly", + "kind": "Real", + "level": "struct:RetouchInfo", + "min_pv": null, + "name": "sourceX", + "note": "Legacy heal/clone spot. [Lua only (the XMP form is one string per spot)]", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "PerFrameOnly", + "kind": "Real", + "level": "struct:RetouchInfo", + "min_pv": null, + "name": "sourceY", + "note": "Legacy heal/clone spot. [Lua only (the XMP form is one string per spot)]", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "no_default", + "default_raw": "no_default", + "file_kind": "Both", + "fmt": "Text", + "frame": "PerFrameOnly", + "kind": "Str", + "level": "struct:RetouchInfo", + "min_pv": null, + "name": "spotType", + "note": "Legacy heal/clone spot. [Lua only (the XMP form is one string per spot)]", + "plus_sign": false, + "policy": { + "class": "NEVER" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Real", + "level": "struct:PointColor", + "min_pv": null, + "name": "SrcHue", + "note": "Sampled colour and its shift; values come from one photo. Round-trip only. [Lua only (the XMP form is one string of 19 floats per swatch)]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Real", + "level": "struct:PointColor", + "min_pv": null, + "name": "SrcSat", + "note": "Sampled colour and its shift; values come from one photo. Round-trip only. [Lua only (the XMP form is one string of 19 floats per swatch)]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Real", + "level": "struct:PointColor", + "min_pv": null, + "name": "SrcLum", + "note": "Sampled colour and its shift; values come from one photo. Round-trip only. [Lua only (the XMP form is one string of 19 floats per swatch)]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Real", + "level": "struct:PointColor", + "min_pv": null, + "name": "HueShift", + "note": "Sampled colour and its shift; values come from one photo. Round-trip only. [Lua only (the XMP form is one string of 19 floats per swatch)]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Real", + "level": "struct:PointColor", + "min_pv": null, + "name": "SatScale", + "note": "Sampled colour and its shift; values come from one photo. Round-trip only. [Lua only (the XMP form is one string of 19 floats per swatch)]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Real", + "level": "struct:PointColor", + "min_pv": null, + "name": "LumScale", + "note": "Sampled colour and its shift; values come from one photo. Round-trip only. [Lua only (the XMP form is one string of 19 floats per swatch)]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Real", + "level": "struct:PointColor", + "min_pv": null, + "name": "RangeAmount", + "note": "Sampled colour and its shift; values come from one photo. Round-trip only. [Lua only (the XMP form is one string of 19 floats per swatch)]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Real", + "level": "struct:PointColor", + "min_pv": null, + "name": "Variance", + "note": "Sampled colour and its shift; values come from one photo. Round-trip only. [Lua only (the XMP form is one string of 19 floats per swatch)]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Struct(PointColorRange)", + "level": "struct:PointColor", + "min_pv": null, + "name": "HueRange", + "note": "[Lua only]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Struct(PointColorRange)", + "level": "struct:PointColor", + "min_pv": null, + "name": "SatRange", + "note": "[Lua only]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Struct(PointColorRange)", + "level": "struct:PointColor", + "min_pv": null, + "name": "LumRange", + "note": "[Lua only]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Real", + "level": "struct:PointColorRange", + "min_pv": null, + "name": "LowerNone", + "note": "Range with feathered edges. [Lua only]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Real", + "level": "struct:PointColorRange", + "min_pv": null, + "name": "LowerFull", + "note": "Range with feathered edges. [Lua only]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Real", + "level": "struct:PointColorRange", + "min_pv": null, + "name": "UpperFull", + "note": "Range with feathered edges. [Lua only]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + }, + { + "default_non_raw": "unverified", + "default_raw": "unverified", + "file_kind": "Both", + "fmt": "Text", + "frame": "NotApplicable", + "kind": "Real", + "level": "struct:PointColorRange", + "min_pv": null, + "name": "UpperNone", + "note": "Range with feathered edges. [Lua only]", + "plus_sign": false, + "policy": { + "class": "UNKNOWN" + }, + "presence": "LuaOnly", + "range": null, + "recipe_alias": null, + "ui": "identity" + } + ], + "rows_per_level_and_class": { + "correction": { + "COMPUTED": 8, + "LEARN": 23, + "LEARN†": 2, + "META": 4, + "UNKNOWN": 23 + }, + "global": { + "COMPUTED": 62, + "LEARN": 90, + "LEARN†": 27, + "META": 22, + "NEVER": 7, + "PHOTO": 32, + "UNKNOWN": 27 + }, + "header": { + "META": 22, + "UNKNOWN": 1 + }, + "mask-tool": { + "COMPUTED": 16, + "LEARN": 5, + "LEARN†": 2, + "META": 5, + "NEVER": 12, + "PHOTO": 15, + "UNKNOWN": 12 + }, + "struct:AILook": { + "COMPUTED": 6 + }, + "struct:AreaModel": { + "PHOTO": 2 + }, + "struct:CorrectionRangeMask": { + "LEARN": 2, + "LEARN†": 1, + "META": 1, + "PHOTO": 4, + "UNKNOWN": 8 + }, + "struct:DepthMapInfo": { + "COMPUTED": 10 + }, + "struct:Gesture": { + "COMPUTED": 1, + "META": 5, + "NEVER": 5, + "PHOTO": 2 + }, + "struct:GesturePoint": { + "PHOTO": 2 + }, + "struct:ISODependent": { + "UNKNOWN": 3 + }, + "struct:LensBlur": { + "COMPUTED": 4, + "LEARN": 11, + "META": 1, + "UNKNOWN": 2 + }, + "struct:Look": { + "COMPUTED": 10, + "LEARN": 3, + "META": 1 + }, + "struct:PointColor": { + "UNKNOWN": 11 + }, + "struct:PointColorRange": { + "UNKNOWN": 4 + }, + "struct:Preset": { + "COMPUTED": 11 + }, + "struct:RangeMaskMapInfo": { + "COMPUTED": 6 + }, + "struct:RetouchArea": { + "NEVER": 21 + }, + "struct:RetouchInfo": { + "NEVER": 9 + } + } +} diff --git a/server-rs/testdata/develop/wire/README.md b/server-rs/testdata/develop/wire/README.md new file mode 100644 index 00000000..a3a34ebe --- /dev/null +++ b/server-rs/testdata/develop/wire/README.md @@ -0,0 +1,108 @@ +# testdata/develop/wire — the Lua writer's wire goldens + +> **Not frozen.** These files change when experiments E1, E2, E4 and E11 +> (`plugin/LrGeniusAI.lrdevplugin/TaskDevelopExperiments.lua`) settle the +> write-side forms `photo:applyDevelopSettings()` accepts. They show the +> defaults of `LuaOptions::PROVISIONAL` +> (`server-rs/crates/lrg-develop/src/lua/write.rs`); each default's status +> and evidence is `LuaOptions::EVIDENCE` and one table on the wiki page +> Dev-Develop-Model ("Options and what decides them"). `look_full.json` +> shows the whole-record `Look` alternative. +> +> **Status (2026-10-03):** the first run (Lightroom 15.6, one non-raw TIFF, +> run from the Library module) settled the AI-mask forms for subject, sky +> and background; the goldens were re-blessed for `panel_switches = +> MaskOnly`, the form E2 applied, which adds +> `EnableMaskGroupBasedCorrections = true` to the ten files with +> corrections (supported, not settled: the switch was already on in E2's +> photo). People parts, `int_flag_as`, `look_form` and the raw-side white +> balance are still open. +> +> **Blocking before they are frozen:** the experiments apply hand-built +> tables, never these files. An E2 variant has to decode at least +> `masks_subject_sky.json`, `mask_luminance_intersect.json` and +> `global_basic.json` with JSON.lua and pass them unchanged to +> `applyDevelopSettings` (with and without +> `EnableMaskGroupBasedCorrections`, on a photo where the switch is off), so +> the writer's own output is measured. + +Each `*.json` is one table exactly as the backend hands it to the plugin: +what JSON.lua decodes into the Lua table `photo:applyDevelopSettings()` +takes. Written by `lrg_develop::lua::to_lua_value` in apply mode, keys +sorted, LF line endings (`.gitattributes`). + +## Who reads them + +- `crates/lrg-develop/tests/lua_wire_goldens.rs` writes them and compares + byte for byte, pins what each case skips and how it is applied + (`ApplyCall`), and checks each file both ways: every value it was written + from is in it or reported, and it holds nothing else than the writer's + fixed form. It also writes every case under every single option flip and + checks those the same way, and generates `key_classes.txt`. + `tests/lua_roundtrip.rs` round-trips them (model → Lua → model) under + every encoding. +- `plugin/spec/native_wire_format_spec.lua` decodes them with the plugin's + `JSON.lua` (Lua 5.1 in CI, like Lightroom) and checks the wire rules on + the Lua side: real sequences, every value of its key's registry class + (`key_classes.txt`: numbers are numbers, numeric-looking strings stay + strings, compound strings hold the right count of numbers, booleans), + curve lengths, non-empty `CorrectionMasks`, no never-written or unknown + key, no `Temp`. It compares decoded values, never encoded text: Lua 5.1 + re-encodes numbers with `%.14g`, Lua 5.5 with the shortest exact form. + It accepts both forms of each provisional encoding (mask enums as + integers or integer strings, 0/1 flags as 0/1 or booleans), and the Rust + test checks every single option flip, so a flipped option needs a + re-bless, not a test change. The `Lua tests` workflow + (`.github/workflows/lua-tests.yml`) runs when this folder changes. +- Both fixture-hygiene checks (`extract_fixtures.py --check-only` and + `tests/fixture_hygiene.rs`) cover this folder like every other one. + +## Cases + +| File | Shape | Source | +|---|---|---| +| `global_basic.json` | basic global sliders, 0/1 flags, a boolean; the example's `ProcessVersion` is not written | hand-written table | +| `wb_raw_custom.json` | raw white balance: `Temperature`/`Tint` with `Custom` | hand-written table | +| `wb_non_raw_incremental.json` | non-raw white balance: `IncrementalTemperature`/`IncrementalTint`, `Custom` stamped | hand-written table | +| `tone_curves.json` | global point curves (flat number lists), parametric curve, derived `ToneCurveName2012` | hand-written table | +| `hsl_color_grading.json` | HSL, colour grading, split toning | hand-written table | +| `masks_subject_sky.json` | a subject and a sky correction | builders | +| `masks_linear_radial.json` | a linear and a radial gradient | builders | +| `mask_luminance_intersect.json` | sky, minus subject, intersected with a luminance range | builders | +| `mask_people_part.json` | a people part for every person (`MaskSubType` 3) | builders | +| `mask_local_curves.json` | local point curves (`MainCurve`, `BlueCurve`) as lists of `"x,y"` strings on a subject correction | hand-written table | +| `look_stub.json` | the `Look` as a stub (provisional form) | `lua/mask_range_color_area.json` | +| `look_full.json` | the same `Look` whole (`LookForm::Full`), stub `Parameters` | `lua/mask_range_color_area.json` | +| `no_masks.json` | a source whose only correction is a brush: `MaskGroupBasedCorrections` absent, never `[]` | hand-written table | +| `fixture_mask_ai_sky.json` | a real (scrubbed) sky-mask readback applied to its own photo: digests, runtime ids and computed mask fields gone | `lua/mask_ai_sky.json` | +| `fixture_mask_range_color.json` | a colour range: `PointModels` (a list of compound strings), `ColorAmount`, `CorrectionRangeMask.Type` 1 | `lua/mask_range_color.json` | +| `fixture_mask_range_color_area.json` | AI masks next to an area colour range (`AreaModels`, `ColorRangeMaskAreaSampleInfo`) | `lua/mask_range_color_area.json` | +| `fixture_mask_select_object_polygon.json` | Select Object polygons (`Gesture`/`Points`), untyped AI masks; `AILook` never written | `lua/mask_select_object_polygon.json` | +| `fixture_lensblur_object.json` | the `LensBlur` struct, which asks for the AI update | `lua/lensblur_object.json` | + +`key_classes.txt` is no golden: it is the registry's class of every key +(`number`, `integer`, `flag`, `maskenum`, `compound`, ..., or `never`), +generated by the same test from `lrg-develop`'s registry for the plugin +spec. Key names only. + +Nothing here comes from a private source: the inputs are hand-written +tables, models built in the test with synthetic sync ids (twenty zeros and +twelve hex digits; a derived SHA-256 id cannot be told from a catalog's), +and the committed, scrubbed fixtures in `../lua/`. + +## Re-blessing + +After an intended change to the writer or the registry, or when an +experiment has decided an option (follow the steps on the wiki page first), +regenerate and review the diff: + +```bash +cd server-rs +LRG_BLESS=1 cargo test -p lrg-develop --test lua_wire_goldens +``` + +A file in this folder that no case writes fails the test, so delete a +golden together with its case. The blessing run skips the read-back test +(it would race the files being written); run the command once more without +`LRG_BLESS` to check them. The plugin spec reads whatever is here, so +re-run `busted` from the repository root after re-blessing. diff --git a/server-rs/testdata/develop/wire/fixture_lensblur_object.json b/server-rs/testdata/develop/wire/fixture_lensblur_object.json new file mode 100644 index 00000000..444d4e3a --- /dev/null +++ b/server-rs/testdata/develop/wire/fixture_lensblur_object.json @@ -0,0 +1,287 @@ +{ + "AutoLateralCA": 1, + "Blacks2012": -27, + "BlueHue": 0, + "BlueSaturation": 0, + "CameraProfile": "Adobe Standard", + "Clarity2012": 0, + "ColorGradeBlending": 50, + "ColorGradeGlobalHue": 0, + "ColorGradeGlobalLum": 0, + "ColorGradeGlobalSat": 0, + "ColorGradeHighlightLum": 0, + "ColorGradeMidtoneHue": 0, + "ColorGradeMidtoneLum": 0, + "ColorGradeMidtoneSat": 0, + "ColorGradeShadowLum": 0, + "ColorNoiseReduction": 25, + "ColorNoiseReductionDetail": 50, + "ColorNoiseReductionSmoothness": 50, + "Contrast2012": 20, + "ConvertToGrayscale": false, + "CropAngle": 0.0, + "CropBottom": 0.923333, + "CropConstrainToUnitSquare": 1, + "CropConstrainToWarp": 0, + "CropLeft": 0.214637, + "CropRight": 0.729191, + "CropTop": 0.151503, + "CurveRefineSaturation": 100, + "DefringeGreenAmount": 0, + "DefringeGreenHueHi": 60, + "DefringeGreenHueLo": 40, + "DefringePurpleAmount": 0, + "DefringePurpleHueHi": 70, + "DefringePurpleHueLo": 30, + "Dehaze": 0, + "EnableMaskGroupBasedCorrections": true, + "Exposure2012": 0.15, + "GrainAmount": 0, + "GrainFrequency": 50, + "GrainSize": 25, + "GreenHue": 0, + "GreenSaturation": 0, + "HDREditMode": 0, + "HDRMaxValue": 4.0, + "Highlights2012": -100, + "HueAdjustmentAqua": 0, + "HueAdjustmentBlue": 0, + "HueAdjustmentGreen": 0, + "HueAdjustmentMagenta": 0, + "HueAdjustmentOrange": 0, + "HueAdjustmentPurple": 0, + "HueAdjustmentRed": 0, + "HueAdjustmentYellow": 0, + "LensBlur": { + "Active": false, + "BlurAmount": 50, + "BokehAspect": 0, + "BokehRotation": 0, + "BokehShape": 0, + "BokehShapeDetail": 0, + "CatEyeAmount": 0, + "CatEyeScale": 100, + "HighlightsBoost": 50, + "HighlightsThreshold": 50, + "SphericalAberration": 0, + "Version": 1 + }, + "LensManualDistortionAmount": 0, + "LensProfileDistortionScale": 100, + "LensProfileEnable": 1, + "LensProfileFilename": "Synthetic Lens - RAW.lcp", + "LensProfileIsEmbedded": false, + "LensProfileName": "Adobe (Synthetic Lens)", + "LensProfileSetup": "LensDefaults", + "LensProfileVignettingScale": 100, + "Look": { + "Amount": 1.0, + "Copyright": "Synthetic copyright notice", + "Group": { + "x-default": "Synthetic Group" + }, + "Name": "Adobe Landscape", + "Parameters": { + "ConvertToGrayscale": false, + "ProcessVersion": "15.4" + }, + "SupportsAmount": false, + "SupportsMonochrome": false, + "SupportsOutputReferred": false, + "UUID": "6F9C877E84273F4E8271E6B91BEB36A1" + }, + "LuminanceAdjustmentAqua": 0, + "LuminanceAdjustmentBlue": 0, + "LuminanceAdjustmentGreen": 0, + "LuminanceAdjustmentMagenta": 0, + "LuminanceAdjustmentOrange": 0, + "LuminanceAdjustmentPurple": 0, + "LuminanceAdjustmentRed": 0, + "LuminanceAdjustmentYellow": 0, + "LuminanceNoiseReductionContrast": 0, + "LuminanceNoiseReductionDetail": 50, + "LuminanceSmoothing": 0, + "MaskGroupBasedCorrections": [ + { + "CorrectionActive": true, + "CorrectionAmount": 1.0, + "CorrectionMasks": [ + { + "Angle": -29.0882, + "Bottom": 0.635755, + "Feather": 45, + "Flipped": true, + "Left": 0.15409, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskInverted": false, + "MaskName": "Mask 1", + "MaskSyncID": "000000000000000000000000000000BB", + "MaskValue": 1.0, + "Midpoint": 50, + "Right": 0.739495, + "Roundness": 0, + "Top": 0.478149, + "Version": 2, + "What": "Mask/CircularGradient" + } + ], + "CorrectionName": "Correction 1", + "CorrectionSyncID": "000000000000000000000000000000BC", + "LocalBlacks2012": 0.0, + "LocalBrightness": 0.0, + "LocalClarity": 0.0, + "LocalClarity2012": 0.198313, + "LocalContrast": 0.0, + "LocalContrast2012": 0.0, + "LocalCurveRefineSaturation": 100.0, + "LocalDefringe": 0.0, + "LocalDehaze": 0.0, + "LocalExposure": 0.0, + "LocalExposure2012": 0.214094, + "LocalHighlights2012": -0.708713, + "LocalHue": 0.0, + "LocalLuminanceNoise": 0.0, + "LocalMoire": 0.0, + "LocalSaturation": 0.0, + "LocalShadows2012": 0.0, + "LocalSharpness": 0.0, + "LocalTemperature": 0.072744, + "LocalTexture": 0.158268, + "LocalTint": 0.0, + "LocalToningHue": 0.0, + "LocalToningSaturation": 0.0, + "LocalWhites2012": 0.200797, + "What": "Correction" + }, + { + "CorrectionActive": true, + "CorrectionAmount": 1.0, + "CorrectionMasks": [ + { + "FullX": 0.482977, + "FullY": 0.916184, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskInverted": false, + "MaskName": "Mask 2", + "MaskSyncID": "000000000000000000000000000000BD", + "MaskValue": 1.0, + "What": "Mask/Gradient", + "ZeroX": 0.485755, + "ZeroY": 0.392832 + } + ], + "CorrectionName": "Correction 2", + "CorrectionSyncID": "000000000000000000000000000000BE", + "LocalBlacks2012": 0.0, + "LocalBrightness": 0.0, + "LocalClarity": 0.0, + "LocalClarity2012": 0.0, + "LocalContrast": 0.0, + "LocalContrast2012": 0.0, + "LocalCurveRefineSaturation": 100.0, + "LocalDefringe": 0.0, + "LocalDehaze": 0.0, + "LocalExposure": 0.0, + "LocalExposure2012": 0.154077, + "LocalHighlights2012": 0.0, + "LocalHue": 0.0, + "LocalLuminanceNoise": 0.0, + "LocalMoire": 0.0, + "LocalSaturation": 0.0, + "LocalShadows2012": 0.0, + "LocalSharpness": 0.0, + "LocalTemperature": 0.0, + "LocalTexture": 0.0, + "LocalTint": 0.0, + "LocalToningHue": 0.0, + "LocalToningSaturation": 0.0, + "LocalWhites2012": 0.0, + "What": "Correction" + } + ], + "ParametricDarks": 0, + "ParametricHighlightSplit": 75, + "ParametricHighlights": 0, + "ParametricLights": 0, + "ParametricMidtoneSplit": 50, + "ParametricShadowSplit": 25, + "ParametricShadows": 0, + "PerspectiveAspect": 0, + "PerspectiveHorizontal": 0, + "PerspectiveRotate": 0.0, + "PerspectiveScale": 100, + "PerspectiveUpright": 0, + "PerspectiveVertical": 0, + "PerspectiveX": 0.0, + "PerspectiveY": 0.0, + "PostCropVignetteAmount": 0, + "PostCropVignetteFeather": 50, + "PostCropVignetteHighlightContrast": 0, + "PostCropVignetteMidpoint": 50, + "PostCropVignetteRoundness": 0, + "PostCropVignetteStyle": 1, + "RedHue": 0, + "RedSaturation": 0, + "SDRBlend": 0, + "SDRBrightness": 0, + "SDRClarity": 0, + "SDRContrast": 0, + "SDRHighlights": 0, + "SDRShadows": 0, + "SDRWhites": 0, + "Saturation": 0, + "SaturationAdjustmentAqua": 0, + "SaturationAdjustmentBlue": 0, + "SaturationAdjustmentGreen": 0, + "SaturationAdjustmentMagenta": 0, + "SaturationAdjustmentOrange": 0, + "SaturationAdjustmentPurple": 0, + "SaturationAdjustmentRed": 0, + "SaturationAdjustmentYellow": 0, + "ShadowTint": 0, + "Shadows2012": 100, + "SharpenDetail": 25, + "SharpenEdgeMasking": 0, + "SharpenRadius": 1.0, + "Sharpness": 40, + "SplitToningBalance": 0, + "SplitToningHighlightHue": 0, + "SplitToningHighlightSaturation": 0, + "SplitToningShadowHue": 0, + "SplitToningShadowSaturation": 0, + "Temperature": 5432, + "Texture": 0, + "Tint": 23, + "ToneCurveName2012": "Linear", + "ToneCurvePV2012": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Blue": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Green": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Red": [ + 0, + 0, + 255, + 255 + ], + "Vibrance": 0, + "VignetteAmount": 0, + "VignetteMidpoint": 50, + "WhiteBalance": "Custom", + "Whites2012": 0 +} diff --git a/server-rs/testdata/develop/wire/fixture_mask_ai_sky.json b/server-rs/testdata/develop/wire/fixture_mask_ai_sky.json new file mode 100644 index 00000000..d8a7c767 --- /dev/null +++ b/server-rs/testdata/develop/wire/fixture_mask_ai_sky.json @@ -0,0 +1,201 @@ +{ + "AutoLateralCA": 1, + "Blacks2012": 0, + "BlueHue": 0, + "BlueSaturation": 0, + "CameraProfile": "Adobe Standard", + "Clarity2012": 60, + "ColorGradeBlending": 50, + "ColorGradeGlobalHue": 0, + "ColorGradeGlobalLum": 0, + "ColorGradeGlobalSat": 0, + "ColorGradeHighlightLum": 0, + "ColorGradeMidtoneHue": 0, + "ColorGradeMidtoneLum": 0, + "ColorGradeMidtoneSat": 0, + "ColorGradeShadowLum": 0, + "ColorNoiseReduction": 25, + "ColorNoiseReductionDetail": 50, + "ColorNoiseReductionSmoothness": 50, + "Contrast2012": 41, + "ConvertToGrayscale": true, + "CropAngle": 0.37, + "CropBottom": 1.0, + "CropConstrainAspectRatio": true, + "CropConstrainToUnitSquare": 1, + "CropConstrainToWarp": 0, + "CropLeft": 0.006929, + "CropRight": 0.993071, + "CropTop": 0.0, + "CurveRefineSaturation": 100, + "DefringeGreenAmount": 0, + "DefringeGreenHueHi": 60, + "DefringeGreenHueLo": 40, + "DefringePurpleAmount": 0, + "DefringePurpleHueHi": 70, + "DefringePurpleHueLo": 30, + "Dehaze": 22, + "EnableMaskGroupBasedCorrections": true, + "Exposure2012": -0.25, + "GrainAmount": 63, + "GrainFrequency": 50, + "GrainSize": 25, + "GrayMixerAqua": 0, + "GrayMixerBlue": 0, + "GrayMixerGreen": 0, + "GrayMixerMagenta": 0, + "GrayMixerOrange": 0, + "GrayMixerPurple": 0, + "GrayMixerRed": 0, + "GrayMixerYellow": 0, + "GreenHue": 0, + "GreenSaturation": 0, + "HDREditMode": 0, + "HDRMaxValue": 2.3, + "Highlights2012": -100, + "LensManualDistortionAmount": 0, + "LensProfileDistortionScale": 100, + "LensProfileEnable": 1, + "LensProfileFilename": "Synthetic Lens - RAW.lcp", + "LensProfileIsEmbedded": false, + "LensProfileName": "Adobe (Synthetic Lens)", + "LensProfileSetup": "LensDefaults", + "LensProfileVignettingScale": 100, + "Look": { + "Amount": 1.0, + "Copyright": "Synthetic copyright notice", + "Group": { + "x-default": "Synthetic Group" + }, + "Name": "Adobe Monochrome", + "Parameters": { + "ConvertToGrayscale": true, + "ProcessVersion": "15.4" + }, + "SupportsAmount": false, + "SupportsMonochrome": false, + "SupportsOutputReferred": false, + "UUID": "0CFE8F8AB5F63B2A73CE0B0077D20817" + }, + "LuminanceNoiseReductionContrast": 0, + "LuminanceNoiseReductionDetail": 50, + "LuminanceSmoothing": 0, + "MaskGroupBasedCorrections": [ + { + "CorrectionActive": true, + "CorrectionAmount": 1.0, + "CorrectionMasks": [ + { + "ErrorReason": 0, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskInverted": false, + "MaskName": "Mask 1", + "MaskSubType": 2, + "MaskSyncID": "00000000000000000000000000000027", + "MaskValue": 1.0, + "MaskVersion": 1, + "ReferencePoint": "0.683599 0.180160", + "What": "Mask/Image" + } + ], + "CorrectionName": "Correction 1", + "CorrectionSyncID": "00000000000000000000000000000028", + "LocalBlacks2012": 0.0, + "LocalBrightness": 0.0, + "LocalClarity": 0.0, + "LocalClarity2012": 0.288411, + "LocalContrast": 0.0, + "LocalContrast2012": 0.0, + "LocalCurveRefineSaturation": 100.0, + "LocalDefringe": 0.0, + "LocalDehaze": 0.0, + "LocalExposure": 0.0, + "LocalExposure2012": -0.079613, + "LocalHighlights2012": 0.0, + "LocalHue": 0.0, + "LocalLuminanceNoise": 0.0, + "LocalMoire": 0.0, + "LocalSaturation": 0.0, + "LocalShadows2012": 0.0, + "LocalSharpness": 0.0, + "LocalTemperature": 0.0, + "LocalTexture": 0.253116, + "LocalTint": 0.0, + "LocalToningHue": 0.0, + "LocalToningSaturation": 0.0, + "LocalWhites2012": 0.097842, + "What": "Correction" + } + ], + "ParametricDarks": 0, + "ParametricHighlightSplit": 75, + "ParametricHighlights": 0, + "ParametricLights": 0, + "ParametricMidtoneSplit": 50, + "ParametricShadowSplit": 25, + "ParametricShadows": 0, + "PerspectiveAspect": 0, + "PerspectiveHorizontal": 0, + "PerspectiveRotate": 0.0, + "PerspectiveScale": 100, + "PerspectiveUpright": 0, + "PerspectiveVertical": 0, + "PerspectiveX": 0.0, + "PerspectiveY": 0.0, + "PostCropVignetteAmount": 0, + "PostCropVignetteFeather": 50, + "PostCropVignetteHighlightContrast": 0, + "PostCropVignetteMidpoint": 50, + "PostCropVignetteRoundness": 0, + "PostCropVignetteStyle": 1, + "RedHue": 0, + "RedSaturation": 0, + "SDRBlend": 0, + "SDRBrightness": 0, + "SDRClarity": 0, + "SDRContrast": 0, + "SDRHighlights": 0, + "SDRShadows": 0, + "SDRWhites": 0, + "ShadowTint": 0, + "Shadows2012": 100, + "SharpenDetail": 25, + "SharpenEdgeMasking": 0, + "SharpenRadius": 1.0, + "Sharpness": 40, + "SplitToningBalance": 0, + "SplitToningHighlightHue": 0, + "SplitToningHighlightSaturation": 0, + "SplitToningShadowHue": 0, + "SplitToningShadowSaturation": 0, + "Texture": 66, + "ToneCurveName2012": "Linear", + "ToneCurvePV2012": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Blue": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Green": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Red": [ + 0, + 0, + 255, + 255 + ], + "VignetteAmount": 0, + "VignetteMidpoint": 50, + "Whites2012": 0 +} diff --git a/server-rs/testdata/develop/wire/fixture_mask_range_color.json b/server-rs/testdata/develop/wire/fixture_mask_range_color.json new file mode 100644 index 00000000..25f6e20a --- /dev/null +++ b/server-rs/testdata/develop/wire/fixture_mask_range_color.json @@ -0,0 +1,222 @@ +{ + "AutoLateralCA": 0, + "Blacks2012": -21, + "BlueHue": 0, + "BlueSaturation": 0, + "CameraProfile": "Adobe Standard", + "Clarity2012": 0, + "ColorGradeBlending": 50, + "ColorGradeGlobalHue": 0, + "ColorGradeGlobalLum": 0, + "ColorGradeGlobalSat": 0, + "ColorGradeHighlightLum": 0, + "ColorGradeMidtoneHue": 0, + "ColorGradeMidtoneLum": 0, + "ColorGradeMidtoneSat": 0, + "ColorGradeShadowLum": 0, + "ColorNoiseReduction": 0, + "ColorNoiseReductionDetail": 50, + "ColorNoiseReductionSmoothness": 50, + "Contrast2012": 4, + "ConvertToGrayscale": false, + "CropAngle": 0.0, + "CropBottom": 0.95739, + "CropConstrainToUnitSquare": 1, + "CropConstrainToWarp": 0, + "CropLeft": 0.0, + "CropRight": 0.95739, + "CropTop": 0.0, + "CurveRefineSaturation": 100, + "DefringeGreenAmount": 0, + "DefringeGreenHueHi": 60, + "DefringeGreenHueLo": 40, + "DefringePurpleAmount": 0, + "DefringePurpleHueHi": 70, + "DefringePurpleHueLo": 30, + "Dehaze": 0, + "EnableMaskGroupBasedCorrections": true, + "Exposure2012": 0.6, + "GrainAmount": 0, + "GrainFrequency": 50, + "GrainSize": 25, + "GreenHue": 0, + "GreenSaturation": 0, + "HDREditMode": 0, + "HDRMaxValue": 4.0, + "Highlights2012": -85, + "HueAdjustmentAqua": 0, + "HueAdjustmentBlue": 0, + "HueAdjustmentGreen": 0, + "HueAdjustmentMagenta": 0, + "HueAdjustmentOrange": 0, + "HueAdjustmentPurple": 0, + "HueAdjustmentRed": 0, + "HueAdjustmentYellow": 0, + "LensManualDistortionAmount": 0, + "LensProfileDistortionScale": 100, + "LensProfileEnable": 0, + "LensProfileFilename": "Synthetic Lens - RAW.lcp", + "LensProfileIsEmbedded": false, + "LensProfileName": "Adobe (Synthetic Lens)", + "LensProfileSetup": "LensDefaults", + "LensProfileVignettingScale": 100, + "Look": { + "Amount": 1.0, + "Group": { + "x-default": "Synthetic Group" + }, + "Name": "Adobe Landscape", + "Parameters": { + "ConvertToGrayscale": false, + "ProcessVersion": "15.4" + }, + "SupportsAmount": false, + "SupportsMonochrome": false, + "SupportsOutputReferred": false, + "UUID": "6F9C877E84273F4E8271E6B91BEB36A1" + }, + "LuminanceAdjustmentAqua": 0, + "LuminanceAdjustmentBlue": 0, + "LuminanceAdjustmentGreen": 0, + "LuminanceAdjustmentMagenta": 0, + "LuminanceAdjustmentOrange": 0, + "LuminanceAdjustmentPurple": 0, + "LuminanceAdjustmentRed": 0, + "LuminanceAdjustmentYellow": 0, + "LuminanceNoiseReductionContrast": 0, + "LuminanceNoiseReductionDetail": 50, + "LuminanceSmoothing": 0, + "MaskGroupBasedCorrections": [ + { + "CorrectionActive": true, + "CorrectionAmount": 1.0, + "CorrectionMasks": [ + { + "CorrectionRangeMask": { + "ColorAmount": 0.494949, + "Invert": false, + "PointModels": [ + "0.255405 0.034566 0.419137 0.692175 0.651248 0" + ], + "Type": 1, + "Version": 3 + }, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskInverted": false, + "MaskName": "Mask 1", + "MaskSyncID": "000000000000000000000000000000B0", + "MaskValue": 1.0, + "What": "Mask/RangeMask" + } + ], + "CorrectionName": "Correction 1", + "CorrectionSyncID": "000000000000000000000000000000B1", + "LocalBlacks2012": 0.0, + "LocalBrightness": 0.0, + "LocalClarity": 0.0, + "LocalClarity2012": 0.798791, + "LocalContrast": 0.0, + "LocalContrast2012": 0.0, + "LocalCurveRefineSaturation": 100.0, + "LocalDefringe": 0.0, + "LocalDehaze": 0.0, + "LocalExposure": 0.0, + "LocalExposure2012": 0.0, + "LocalHighlights2012": 0.0, + "LocalHue": 0.0, + "LocalLuminanceNoise": 0.0, + "LocalMoire": 0.0, + "LocalSaturation": 0.0, + "LocalShadows2012": 0.0, + "LocalSharpness": 0.0, + "LocalTemperature": 0.0, + "LocalTexture": 0.0, + "LocalTint": 0.0, + "LocalToningHue": 0.0, + "LocalToningSaturation": 0.0, + "LocalWhites2012": 0.0, + "What": "Correction" + } + ], + "ParametricDarks": 0, + "ParametricHighlightSplit": 75, + "ParametricHighlights": 0, + "ParametricLights": 0, + "ParametricMidtoneSplit": 50, + "ParametricShadowSplit": 25, + "ParametricShadows": 0, + "PerspectiveAspect": 0, + "PerspectiveHorizontal": 0, + "PerspectiveRotate": 0.0, + "PerspectiveScale": 100, + "PerspectiveUpright": 0, + "PerspectiveVertical": 0, + "PerspectiveX": 0.0, + "PerspectiveY": 0.0, + "PostCropVignetteAmount": 0, + "PostCropVignetteFeather": 50, + "PostCropVignetteHighlightContrast": 0, + "PostCropVignetteMidpoint": 50, + "PostCropVignetteRoundness": 0, + "PostCropVignetteStyle": 1, + "RedHue": 0, + "RedSaturation": 0, + "SDRBlend": 0, + "SDRBrightness": 0, + "SDRClarity": 0, + "SDRContrast": 0, + "SDRHighlights": 0, + "SDRShadows": 0, + "SDRWhites": 0, + "Saturation": -1, + "SaturationAdjustmentAqua": 0, + "SaturationAdjustmentBlue": 0, + "SaturationAdjustmentGreen": 0, + "SaturationAdjustmentMagenta": 0, + "SaturationAdjustmentOrange": 0, + "SaturationAdjustmentPurple": 0, + "SaturationAdjustmentRed": 0, + "SaturationAdjustmentYellow": 0, + "ShadowTint": 0, + "Shadows2012": 48, + "SharpenDetail": 25, + "SharpenEdgeMasking": 0, + "SharpenRadius": 1.0, + "Sharpness": 0, + "SplitToningBalance": 0, + "SplitToningHighlightHue": 0, + "SplitToningHighlightSaturation": 0, + "SplitToningShadowHue": 0, + "SplitToningShadowSaturation": 0, + "Texture": 0, + "ToneCurveName2012": "Linear", + "ToneCurvePV2012": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Blue": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Green": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Red": [ + 0, + 0, + 255, + 255 + ], + "Vibrance": 14, + "VignetteAmount": 0, + "VignetteMidpoint": 50, + "Whites2012": 14 +} diff --git a/server-rs/testdata/develop/wire/fixture_mask_range_color_area.json b/server-rs/testdata/develop/wire/fixture_mask_range_color_area.json new file mode 100644 index 00000000..8fba5614 --- /dev/null +++ b/server-rs/testdata/develop/wire/fixture_mask_range_color_area.json @@ -0,0 +1,398 @@ +{ + "AutoLateralCA": 1, + "Blacks2012": 78, + "BlueHue": 0, + "BlueSaturation": 0, + "CameraProfile": "Adobe Standard", + "Clarity2012": -4, + "ColorGradeBlending": 50, + "ColorGradeGlobalHue": 0, + "ColorGradeGlobalLum": 0, + "ColorGradeGlobalSat": 0, + "ColorGradeHighlightLum": 0, + "ColorGradeMidtoneHue": 0, + "ColorGradeMidtoneLum": 0, + "ColorGradeMidtoneSat": 0, + "ColorGradeShadowLum": 0, + "ColorNoiseReduction": 25, + "ColorNoiseReductionDetail": 90, + "ColorNoiseReductionSmoothness": 90, + "Contrast2012": -14, + "ConvertToGrayscale": false, + "CropAngle": 0.0, + "CropBottom": 1.0, + "CropConstrainToUnitSquare": 1, + "CropConstrainToWarp": 0, + "CropLeft": 0.0, + "CropRight": 1.0, + "CropTop": 0.0, + "CurveRefineSaturation": 43, + "DefringeGreenAmount": 0, + "DefringeGreenHueHi": 60, + "DefringeGreenHueLo": 40, + "DefringePurpleAmount": 0, + "DefringePurpleHueHi": 70, + "DefringePurpleHueLo": 30, + "Dehaze": 13, + "EnableMaskGroupBasedCorrections": true, + "Exposure2012": 0.0, + "GrainAmount": 0, + "GrainFrequency": 50, + "GrainSize": 25, + "GreenHue": 0, + "GreenSaturation": 0, + "HDREditMode": 0, + "HDRMaxValue": 4.0, + "Highlights2012": -76, + "HueAdjustmentAqua": 0, + "HueAdjustmentBlue": 0, + "HueAdjustmentGreen": 0, + "HueAdjustmentMagenta": 0, + "HueAdjustmentOrange": 0, + "HueAdjustmentPurple": 0, + "HueAdjustmentRed": 0, + "HueAdjustmentYellow": 0, + "LensManualDistortionAmount": 0, + "LensProfileDistortionScale": 100, + "LensProfileEnable": 1, + "LensProfileSetup": "LensDefaults", + "LensProfileVignettingScale": 100, + "Look": { + "Amount": 1.0, + "Group": { + "x-default": "Synthetic Group" + }, + "Name": "Adobe Color", + "Parameters": { + "ConvertToGrayscale": false, + "ProcessVersion": "15.4" + }, + "SupportsAmount": false, + "SupportsMonochrome": false, + "SupportsOutputReferred": false, + "UUID": "B952C231111CD8E0ECCF14B86BAA7077" + }, + "LuminanceAdjustmentAqua": 0, + "LuminanceAdjustmentBlue": 0, + "LuminanceAdjustmentGreen": 0, + "LuminanceAdjustmentMagenta": 0, + "LuminanceAdjustmentOrange": 0, + "LuminanceAdjustmentPurple": 0, + "LuminanceAdjustmentRed": 0, + "LuminanceAdjustmentYellow": 0, + "LuminanceNoiseReductionContrast": 0, + "LuminanceNoiseReductionDetail": 50, + "LuminanceSmoothing": 0, + "MaskGroupBasedCorrections": [ + { + "CorrectionActive": true, + "CorrectionAmount": 1.0, + "CorrectionMasks": [ + { + "ErrorReason": 0, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskInverted": true, + "MaskName": "Mask 1", + "MaskSubType": 1, + "MaskSyncID": "000000000000000000000000000000B3", + "MaskValue": 1.0, + "MaskVersion": 1, + "ReferencePoint": "0.382812 0.576471", + "What": "Mask/Image" + } + ], + "CorrectionName": "Correction 1", + "CorrectionSyncID": "000000000000000000000000000000B4", + "LocalBlacks2012": 0.0, + "LocalBrightness": 0.0, + "LocalClarity": 0.0, + "LocalClarity2012": 0.221649, + "LocalContrast": 0.0, + "LocalContrast2012": 0.0, + "LocalCurveRefineSaturation": 100.0, + "LocalDefringe": 0.0, + "LocalDehaze": 0.0, + "LocalExposure": 0.0, + "LocalExposure2012": 0.33407, + "LocalHighlights2012": 0.37613, + "LocalHue": 0.0, + "LocalLuminanceNoise": 0.425658, + "LocalMoire": 0.0, + "LocalSaturation": 0.0, + "LocalShadows2012": 0.245725, + "LocalSharpness": 0.144163, + "LocalTemperature": -0.113404, + "LocalTexture": 0.0, + "LocalTint": 0.0, + "LocalToningHue": 54.0, + "LocalToningSaturation": 0.0, + "LocalWhites2012": 0.0, + "What": "Correction" + }, + { + "CorrectionActive": true, + "CorrectionAmount": 1.0, + "CorrectionMasks": [ + { + "ErrorReason": 0, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskInverted": false, + "MaskName": "Mask 2", + "MaskSubType": 1, + "MaskSyncID": "000000000000000000000000000000B5", + "MaskValue": 1.0, + "MaskVersion": 1, + "ReferencePoint": "0.382812 0.576471", + "What": "Mask/Image" + } + ], + "CorrectionName": "Correction 2", + "CorrectionSyncID": "000000000000000000000000000000B6", + "LocalBlacks2012": 0.0, + "LocalBrightness": 0.0, + "LocalClarity": 0.0, + "LocalClarity2012": 0.473762, + "LocalContrast": 0.0, + "LocalContrast2012": 0.0, + "LocalCurveRefineSaturation": 100.0, + "LocalDefringe": 0.0, + "LocalDehaze": 0.0, + "LocalExposure": 0.0, + "LocalExposure2012": 0.0, + "LocalHighlights2012": 0.237814, + "LocalHue": 0.0, + "LocalLuminanceNoise": 0.0, + "LocalMoire": 0.0, + "LocalSaturation": 0.0, + "LocalShadows2012": 0.342767, + "LocalSharpness": 0.0, + "LocalTemperature": 0.0, + "LocalTexture": 0.139986, + "LocalTint": 0.0, + "LocalToningHue": 54.0, + "LocalToningSaturation": 0.0, + "LocalWhites2012": 0.023438, + "What": "Correction" + }, + { + "CorrectionActive": true, + "CorrectionAmount": 1.0, + "CorrectionMasks": [ + { + "ErrorReason": 0, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskInverted": true, + "MaskName": "Mask 3", + "MaskSubType": 1, + "MaskSyncID": "000000000000000000000000000000B5", + "MaskValue": 1.0, + "MaskVersion": 1, + "ReferencePoint": "0.382812 0.576471", + "What": "Mask/Image" + } + ], + "CorrectionName": "Correction 3", + "CorrectionSyncID": "000000000000000000000000000000B7", + "LocalBlacks2012": 0.0, + "LocalBrightness": 0.0, + "LocalClarity": 0.0, + "LocalClarity2012": 0.357557, + "LocalContrast": 0.0, + "LocalContrast2012": 0.0, + "LocalCurveRefineSaturation": 100.0, + "LocalDefringe": 0.0, + "LocalDehaze": 0.032626, + "LocalExposure": 0.0, + "LocalExposure2012": 0.114632, + "LocalHighlights2012": -0.422022, + "LocalHue": 0.0, + "LocalLuminanceNoise": 0.0, + "LocalMoire": 0.0, + "LocalSaturation": 0.0, + "LocalShadows2012": 0.568544, + "LocalSharpness": 0.0, + "LocalTemperature": 0.0, + "LocalTexture": 0.0, + "LocalTint": 0.0, + "LocalToningHue": 54.0, + "LocalToningSaturation": 0.0, + "LocalWhites2012": 0.0, + "What": "Correction" + }, + { + "CorrectionActive": true, + "CorrectionAmount": 1.0, + "CorrectionMasks": [ + { + "CorrectionRangeMask": { + "AreaModels": [ + { + "AreaComponents": [ + "0.496178 0.211467 0.237057 0.021401", + "0.604646 0.228862 0.260861 0.034520", + "0.713115 0.241558 0.297818 0.050960", + "0.821584 0.268116 0.337690 0.071190", + "0.930053 0.294125 0.403296 0.048887" + ], + "ColorRangeMaskAreaSampleInfo": "0.109375 0.247059 0.644477 0.259033 0.688264 0.259033 0.644477 0.485377 0.688264 0.485377 0" + } + ], + "ColorAmount": 0.329945, + "Invert": false, + "Type": 1, + "Version": 3 + }, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskInverted": false, + "MaskName": "Mask 4", + "MaskSyncID": "000000000000000000000000000000B8", + "MaskValue": 1.0, + "What": "Mask/RangeMask" + } + ], + "CorrectionName": "Correction 4", + "CorrectionSyncID": "000000000000000000000000000000B9", + "LocalBlacks2012": 0.0, + "LocalBrightness": 0.0, + "LocalClarity": 0.0, + "LocalClarity2012": 0.312402, + "LocalContrast": 0.0, + "LocalContrast2012": 0.0, + "LocalCurveRefineSaturation": 100.0, + "LocalDefringe": 0.0, + "LocalDehaze": 0.0, + "LocalExposure": 0.0, + "LocalExposure2012": 0.0, + "LocalHighlights2012": 0.061616, + "LocalHue": 0.0, + "LocalLuminanceNoise": 0.0, + "LocalMoire": 0.0, + "LocalSaturation": 0.0, + "LocalShadows2012": 0.158215, + "LocalSharpness": 0.0, + "LocalTemperature": 0.0, + "LocalTexture": 0.0, + "LocalTint": 0.0, + "LocalToningHue": 54.0, + "LocalToningSaturation": 0.0, + "LocalWhites2012": 0.268377, + "What": "Correction" + } + ], + "ParametricDarks": 0, + "ParametricHighlightSplit": 75, + "ParametricHighlights": 0, + "ParametricLights": 0, + "ParametricMidtoneSplit": 50, + "ParametricShadowSplit": 25, + "ParametricShadows": 0, + "PerspectiveAspect": 0, + "PerspectiveHorizontal": 0, + "PerspectiveRotate": 0.0, + "PerspectiveScale": 100, + "PerspectiveUpright": 0, + "PerspectiveVertical": 0, + "PerspectiveX": 0.0, + "PerspectiveY": 0.0, + "PostCropVignetteAmount": 0, + "PostCropVignetteFeather": 50, + "PostCropVignetteHighlightContrast": 0, + "PostCropVignetteMidpoint": 50, + "PostCropVignetteRoundness": 0, + "PostCropVignetteStyle": 1, + "RedHue": 0, + "RedSaturation": 0, + "SDRBlend": 0, + "SDRBrightness": 0, + "SDRClarity": 0, + "SDRContrast": 0, + "SDRHighlights": 0, + "SDRShadows": 0, + "SDRWhites": 0, + "Saturation": 6, + "SaturationAdjustmentAqua": 0, + "SaturationAdjustmentBlue": 0, + "SaturationAdjustmentGreen": 0, + "SaturationAdjustmentMagenta": 0, + "SaturationAdjustmentOrange": -3, + "SaturationAdjustmentPurple": 0, + "SaturationAdjustmentRed": 1, + "SaturationAdjustmentYellow": 0, + "ShadowTint": 0, + "Shadows2012": 58, + "SharpenDetail": 35, + "SharpenEdgeMasking": 74, + "SharpenRadius": 1.0, + "Sharpness": 50, + "SplitToningBalance": 0, + "SplitToningHighlightHue": 0, + "SplitToningHighlightSaturation": 0, + "SplitToningShadowHue": 0, + "SplitToningShadowSaturation": 0, + "Texture": 9, + "ToneCurveName2012": "Custom", + "ToneCurvePV2012": [ + 0, + 31, + 26, + 19, + 62, + 26, + 128, + 128, + 194, + 237, + 255, + 241 + ], + "ToneCurvePV2012Blue": [ + 0, + 0, + 41, + 27, + 63, + 53, + 128, + 128, + 206, + 214, + 255, + 255 + ], + "ToneCurvePV2012Green": [ + 0, + 0, + 41, + 27, + 63, + 53, + 128, + 128, + 206, + 214, + 255, + 255 + ], + "ToneCurvePV2012Red": [ + 0, + 0, + 41, + 27, + 63, + 53, + 128, + 128, + 206, + 214, + 255, + 255 + ], + "Vibrance": 21, + "VignetteAmount": 0, + "VignetteMidpoint": 50, + "Whites2012": -62 +} diff --git a/server-rs/testdata/develop/wire/fixture_mask_select_object_polygon.json b/server-rs/testdata/develop/wire/fixture_mask_select_object_polygon.json new file mode 100644 index 00000000..fcd8c51d --- /dev/null +++ b/server-rs/testdata/develop/wire/fixture_mask_select_object_polygon.json @@ -0,0 +1,549 @@ +{ + "AutoLateralCA": 1, + "Blacks2012": 0, + "BlueHue": 0, + "BlueSaturation": 0, + "CameraProfile": "Adobe Standard", + "Clarity2012": 0, + "ColorGradeBlending": 50, + "ColorGradeGlobalHue": 0, + "ColorGradeGlobalLum": 0, + "ColorGradeGlobalSat": 0, + "ColorGradeHighlightLum": 0, + "ColorGradeMidtoneHue": 0, + "ColorGradeMidtoneLum": 0, + "ColorGradeMidtoneSat": 0, + "ColorGradeShadowLum": 0, + "ColorNoiseReduction": 25, + "ColorNoiseReductionDetail": 50, + "ColorNoiseReductionSmoothness": 50, + "Contrast2012": 0, + "ConvertToGrayscale": false, + "CropAngle": 0.0, + "CropBottom": 1.0, + "CropConstrainToUnitSquare": 1, + "CropConstrainToWarp": 0, + "CropLeft": 0.0, + "CropRight": 1.0, + "CropTop": 0.0, + "CurveRefineSaturation": 100, + "DefringeGreenAmount": 0, + "DefringeGreenHueHi": 60, + "DefringeGreenHueLo": 40, + "DefringePurpleAmount": 0, + "DefringePurpleHueHi": 70, + "DefringePurpleHueLo": 30, + "Dehaze": 0, + "EnableMaskGroupBasedCorrections": true, + "Exposure2012": 0.0, + "GrainAmount": 0, + "GrainFrequency": 50, + "GrainSize": 25, + "GreenHue": 0, + "GreenSaturation": 0, + "HDREditMode": 0, + "HDRMaxValue": 4.0, + "Highlights2012": 0, + "HueAdjustmentAqua": 0, + "HueAdjustmentBlue": 0, + "HueAdjustmentGreen": 0, + "HueAdjustmentMagenta": 0, + "HueAdjustmentOrange": 10, + "HueAdjustmentPurple": 0, + "HueAdjustmentRed": 0, + "HueAdjustmentYellow": 0, + "LensManualDistortionAmount": 0, + "LensProfileDistortionScale": 100, + "LensProfileEnable": 1, + "LensProfileFilename": "Synthetic Lens - RAW.lcp", + "LensProfileIsEmbedded": false, + "LensProfileName": "Adobe (Synthetic Lens)", + "LensProfileSetup": "LensDefaults", + "LensProfileVignettingScale": 100, + "Look": { + "Amount": 1.0, + "Cluster": "Synthetic", + "Copyright": "Synthetic copyright notice", + "Group": { + "x-default": "Synthetic Group" + }, + "Name": "Adaptive Color", + "Parameters": { + "ConvertToGrayscale": false, + "ProcessVersion": "15.4" + }, + "SortName": { + "x-default": "Synthetic" + }, + "SupportsMonochrome": false, + "SupportsOutputReferred": false, + "UUID": "226E6263ED94405B973B41F5A6E67BE9", + "isAdobeAdaptive": true + }, + "LuminanceAdjustmentAqua": 0, + "LuminanceAdjustmentBlue": 0, + "LuminanceAdjustmentGreen": 0, + "LuminanceAdjustmentMagenta": 0, + "LuminanceAdjustmentOrange": -12, + "LuminanceAdjustmentPurple": 0, + "LuminanceAdjustmentRed": 0, + "LuminanceAdjustmentYellow": 10, + "LuminanceNoiseReductionContrast": 0, + "LuminanceNoiseReductionDetail": 50, + "LuminanceSmoothing": 0, + "MaskGroupBasedCorrections": [ + { + "CorrectionActive": true, + "CorrectionAmount": 1.0, + "CorrectionMasks": [ + { + "FullX": 0.145275, + "FullY": 0.883266, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskInverted": false, + "MaskName": "Mask 1", + "MaskSyncID": "00000000000000000000000000000072", + "MaskValue": 1.0, + "What": "Mask/Gradient", + "ZeroX": 0.243963, + "ZeroY": 0.585304 + } + ], + "CorrectionName": "Correction 1", + "CorrectionSyncID": "00000000000000000000000000000073", + "LocalBlacks2012": 0.0, + "LocalBrightness": 0.0, + "LocalClarity": 0.0, + "LocalClarity2012": 0.0, + "LocalContrast": 0.0, + "LocalContrast2012": 0.331126, + "LocalCurveRefineSaturation": 100.0, + "LocalDefringe": 0.0, + "LocalDehaze": 0.0, + "LocalExposure": 0.0, + "LocalExposure2012": -0.047392, + "LocalHighlights2012": 0.0, + "LocalHue": 0.0, + "LocalLuminanceNoise": 0.0, + "LocalMoire": 0.0, + "LocalSaturation": 0.0, + "LocalShadows2012": 0.0, + "LocalSharpness": 0.0, + "LocalTemperature": 0.0, + "LocalTexture": 0.0, + "LocalTint": 0.0, + "LocalToningHue": 0.0, + "LocalToningSaturation": 0.0, + "LocalWhites2012": 0.0, + "What": "Correction" + }, + { + "CorrectionActive": true, + "CorrectionAmount": 1.0, + "CorrectionMasks": [ + { + "FullX": 0.529205, + "FullY": 0.119562, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskInverted": false, + "MaskName": "Mask 2", + "MaskSyncID": "00000000000000000000000000000074", + "MaskValue": 1.0, + "What": "Mask/Gradient", + "ZeroX": 0.531103, + "ZeroY": 0.335766 + } + ], + "CorrectionName": "Correction 2", + "CorrectionSyncID": "00000000000000000000000000000075", + "LocalBlacks2012": 0.0, + "LocalBrightness": 0.0, + "LocalClarity": 0.0, + "LocalClarity2012": 0.0, + "LocalContrast": 0.0, + "LocalContrast2012": 0.0, + "LocalCurveRefineSaturation": 100.0, + "LocalDefringe": 0.0, + "LocalDehaze": 0.0, + "LocalExposure": 0.0, + "LocalExposure2012": -0.132916, + "LocalHighlights2012": 0.0, + "LocalHue": 0.0, + "LocalLuminanceNoise": 0.0, + "LocalMoire": 0.0, + "LocalSaturation": 0.0, + "LocalShadows2012": 0.0, + "LocalSharpness": 0.0, + "LocalTemperature": 0.0, + "LocalTexture": 0.0, + "LocalTint": 0.0, + "LocalToningHue": 0.0, + "LocalToningSaturation": 0.0, + "LocalWhites2012": 0.187759, + "What": "Correction" + }, + { + "CorrectionActive": true, + "CorrectionAmount": 1.0, + "CorrectionMasks": [ + { + "Gesture": [ + { + "MaskActive": true, + "MaskBlendMode": 0, + "MaskInverted": false, + "MaskSyncID": "00000000000000000000000000000076", + "MaskValue": 1.0, + "Points": [ + { + "X": 0.622731, + "Y": 0.463914 + }, + { + "X": 0.752816, + "Y": 0.463794 + }, + { + "X": 0.623224, + "Y": 0.863986 + }, + { + "X": 0.753822, + "Y": 0.865196 + } + ], + "What": "Mask/Polygon" + } + ], + "MaskActive": true, + "MaskBlendMode": 0, + "MaskInverted": false, + "MaskName": "Mask 3", + "MaskSubType": 0, + "MaskSyncID": "00000000000000000000000000000078", + "MaskValue": 1.0, + "MaskVersion": 1, + "ReferencePoint": "0.681846 0.760097", + "What": "Mask/Image" + }, + { + "Angle": 0.0, + "Bottom": 0.678696, + "Feather": 71, + "Flipped": true, + "Left": 0.117562, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskInverted": false, + "MaskName": "Mask 4", + "MaskSyncID": "00000000000000000000000000000079", + "MaskValue": 1.0, + "Midpoint": 50, + "Right": 0.344094, + "Roundness": 0, + "Top": 0.523675, + "Version": 2, + "What": "Mask/CircularGradient" + } + ], + "CorrectionName": "Correction 3", + "CorrectionSyncID": "0000000000000000000000000000007A", + "LocalBlacks2012": 0.0, + "LocalBrightness": 0.0, + "LocalClarity": 0.0, + "LocalClarity2012": 0.127535, + "LocalContrast": 0.0, + "LocalContrast2012": 0.203849, + "LocalCurveRefineSaturation": 100.0, + "LocalDefringe": 0.0, + "LocalDehaze": 0.200952, + "LocalExposure": 0.0, + "LocalExposure2012": 0.145385, + "LocalHighlights2012": 0.0, + "LocalHue": 0.0, + "LocalLuminanceNoise": 0.0, + "LocalMoire": 0.0, + "LocalSaturation": 0.095095, + "LocalShadows2012": 0.0, + "LocalSharpness": 0.0, + "LocalTemperature": 0.084851, + "LocalTexture": 0.096699, + "LocalTint": 0.0, + "LocalToningHue": 0.0, + "LocalToningSaturation": 0.0, + "LocalWhites2012": 0.091474, + "What": "Correction" + }, + { + "CorrectionActive": true, + "CorrectionAmount": 1.0, + "CorrectionMasks": [ + { + "Gesture": [ + { + "MaskActive": true, + "MaskBlendMode": 0, + "MaskInverted": false, + "MaskSyncID": "0000000000000000000000000000007B", + "MaskValue": 1.0, + "Points": [ + { + "X": 0.623535, + "Y": 0.433401 + }, + { + "X": 0.751802, + "Y": 0.433183 + }, + { + "X": 0.623997, + "Y": 0.855748 + }, + { + "X": 0.752735, + "Y": 0.856913 + } + ], + "What": "Mask/Polygon" + } + ], + "MaskActive": true, + "MaskBlendMode": 0, + "MaskInverted": false, + "MaskName": "Mask 5", + "MaskSubType": 0, + "MaskSyncID": "0000000000000000000000000000007D", + "MaskValue": 1.0, + "MaskVersion": 1, + "ReferencePoint": "0.687583 0.748593", + "What": "Mask/Image" + }, + { + "FullX": 0.700736, + "FullY": 0.654146, + "MaskActive": true, + "MaskBlendMode": 1, + "MaskInverted": false, + "MaskName": "Mask 6", + "MaskSyncID": "0000000000000000000000000000007E", + "MaskValue": 0.0, + "What": "Mask/Gradient", + "ZeroX": 0.691912, + "ZeroY": 0.525544 + } + ], + "CorrectionName": "Correction 4", + "CorrectionSyncID": "0000000000000000000000000000007F", + "LocalBlacks2012": 0.0, + "LocalBrightness": 0.0, + "LocalClarity": 0.0, + "LocalClarity2012": 0.0, + "LocalContrast": 0.0, + "LocalContrast2012": 0.0, + "LocalCurveRefineSaturation": 100.0, + "LocalDefringe": 0.0, + "LocalDehaze": 0.0, + "LocalExposure": 0.0, + "LocalExposure2012": 0.08125, + "LocalHighlights2012": 0.0, + "LocalHue": 0.0, + "LocalLuminanceNoise": 0.0, + "LocalMoire": 0.0, + "LocalSaturation": 0.0, + "LocalShadows2012": 0.0, + "LocalSharpness": 0.0, + "LocalTemperature": 0.0, + "LocalTexture": 0.0, + "LocalTint": 0.0, + "LocalToningHue": 0.0, + "LocalToningSaturation": 0.0, + "LocalWhites2012": 0.0, + "What": "Correction" + }, + { + "CorrectionActive": true, + "CorrectionAmount": 1.0, + "CorrectionMasks": [ + { + "Angle": 0.0, + "Bottom": 0.947338, + "Feather": 38, + "Flipped": true, + "Left": 0.317468, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskInverted": false, + "MaskName": "Mask 7", + "MaskSyncID": "00000000000000000000000000000080", + "MaskValue": 1.0, + "Midpoint": 50, + "Right": 1.02402, + "Roundness": 0, + "Top": 0.565817, + "Version": 2, + "What": "Mask/CircularGradient" + } + ], + "CorrectionName": "Correction 5", + "CorrectionSyncID": "00000000000000000000000000000081", + "LocalBlacks2012": 0.0, + "LocalBrightness": 0.0, + "LocalClarity": 0.0, + "LocalClarity2012": 0.103322, + "LocalContrast": 0.0, + "LocalContrast2012": 0.0, + "LocalCurveRefineSaturation": 100.0, + "LocalDefringe": 0.0, + "LocalDehaze": -0.15, + "LocalExposure": 0.0, + "LocalExposure2012": 0.05, + "LocalHighlights2012": 0.0, + "LocalHue": 0.0, + "LocalLuminanceNoise": 0.0, + "LocalMoire": 0.0, + "LocalSaturation": 0.165822, + "LocalShadows2012": 0.0, + "LocalSharpness": 0.0, + "LocalTemperature": 0.119361, + "LocalTexture": -0.107047, + "LocalTint": 0.0, + "LocalToningHue": 0.0, + "LocalToningSaturation": 0.0, + "LocalWhites2012": 0.15, + "What": "Correction" + }, + { + "CorrectionActive": true, + "CorrectionAmount": 1.0, + "CorrectionMasks": [ + { + "CorrectionRangeMask": { + "ColorAmount": 1.0, + "Invert": false, + "PointModels": [ + "0.742481 0.780084 1.000000 0.662277 0.317560 0" + ], + "Type": 1, + "Version": 3 + }, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskInverted": false, + "MaskName": "Mask 8", + "MaskSyncID": "00000000000000000000000000000082", + "MaskValue": 1.0, + "What": "Mask/RangeMask" + } + ], + "CorrectionName": "Correction 6", + "CorrectionSyncID": "00000000000000000000000000000083", + "LocalBlacks2012": 0.0, + "LocalBrightness": 0.0, + "LocalClarity": 0.0, + "LocalClarity2012": 0.0, + "LocalContrast": 0.0, + "LocalContrast2012": 0.0, + "LocalCurveRefineSaturation": 100.0, + "LocalDefringe": 0.0, + "LocalDehaze": 0.0, + "LocalExposure": 0.0, + "LocalExposure2012": 0.0, + "LocalHighlights2012": 0.0, + "LocalHue": 0.0, + "LocalLuminanceNoise": 0.0, + "LocalMoire": 0.0, + "LocalSaturation": 0.610358, + "LocalShadows2012": 0.0, + "LocalSharpness": 0.0, + "LocalTemperature": 0.267332, + "LocalTexture": 0.0, + "LocalTint": 0.0, + "LocalToningHue": 0.0, + "LocalToningSaturation": 0.0, + "LocalWhites2012": 0.0, + "What": "Correction" + } + ], + "ParametricDarks": 0, + "ParametricHighlightSplit": 75, + "ParametricHighlights": 0, + "ParametricLights": 0, + "ParametricMidtoneSplit": 50, + "ParametricShadowSplit": 25, + "ParametricShadows": 0, + "PerspectiveAspect": 0, + "PerspectiveHorizontal": 0, + "PerspectiveRotate": 0.0, + "PerspectiveScale": 100, + "PerspectiveUpright": 0, + "PerspectiveVertical": 0, + "PerspectiveX": 0.0, + "PerspectiveY": 0.0, + "PostCropVignetteAmount": 0, + "PostCropVignetteFeather": 50, + "PostCropVignetteHighlightContrast": 0, + "PostCropVignetteMidpoint": 50, + "PostCropVignetteRoundness": 0, + "PostCropVignetteStyle": 1, + "RedHue": 0, + "RedSaturation": 0, + "SDRBlend": 0, + "SDRBrightness": 0, + "SDRClarity": 0, + "SDRContrast": 0, + "SDRHighlights": 0, + "SDRShadows": 0, + "SDRWhites": 0, + "Saturation": 0, + "SaturationAdjustmentAqua": 0, + "SaturationAdjustmentBlue": 0, + "SaturationAdjustmentGreen": 0, + "SaturationAdjustmentMagenta": 0, + "SaturationAdjustmentOrange": 10, + "SaturationAdjustmentPurple": 0, + "SaturationAdjustmentRed": 0, + "SaturationAdjustmentYellow": 10, + "ShadowTint": 0, + "Shadows2012": 0, + "SharpenDetail": 25, + "SharpenEdgeMasking": 0, + "SharpenRadius": 1.0, + "Sharpness": 40, + "SplitToningBalance": 0, + "SplitToningHighlightHue": 33, + "SplitToningHighlightSaturation": 0, + "SplitToningShadowHue": 218, + "SplitToningShadowSaturation": 0, + "Texture": 0, + "ToneCurveName2012": "Linear", + "ToneCurvePV2012": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Blue": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Green": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Red": [ + 0, + 0, + 255, + 255 + ], + "Vibrance": 20, + "VignetteAmount": 0, + "VignetteMidpoint": 50, + "Whites2012": 0 +} diff --git a/server-rs/testdata/develop/wire/global_basic.json b/server-rs/testdata/develop/wire/global_basic.json new file mode 100644 index 00000000..550d5084 --- /dev/null +++ b/server-rs/testdata/develop/wire/global_basic.json @@ -0,0 +1,23 @@ +{ + "AutoLateralCA": 1, + "Blacks2012": -8, + "CameraProfile": "Adobe Standard", + "Clarity2012": 15, + "Contrast2012": 12, + "ConvertToGrayscale": false, + "Dehaze": 5, + "Exposure2012": 0.35, + "GrainAmount": 15, + "Highlights2012": -40, + "LensProfileEnable": 1, + "LuminanceSmoothing": 10, + "PostCropVignetteAmount": -10, + "Saturation": -5, + "Shadows2012": 30, + "SharpenDetail": 25, + "SharpenRadius": 1.0, + "Sharpness": 40, + "Texture": 10, + "Vibrance": 20, + "Whites2012": 10 +} diff --git a/server-rs/testdata/develop/wire/hsl_color_grading.json b/server-rs/testdata/develop/wire/hsl_color_grading.json new file mode 100644 index 00000000..bb6c1e3a --- /dev/null +++ b/server-rs/testdata/develop/wire/hsl_color_grading.json @@ -0,0 +1,22 @@ +{ + "ColorGradeBlending": 60, + "ColorGradeGlobalHue": 0, + "ColorGradeGlobalSat": 0, + "ColorGradeHighlightLum": 4, + "ColorGradeMidtoneHue": 30, + "ColorGradeMidtoneSat": 6, + "ColorGradeShadowLum": -5, + "HueAdjustmentBlue": 12, + "HueAdjustmentOrange": -8, + "HueAdjustmentRed": 5, + "LuminanceAdjustmentBlue": -20, + "LuminanceAdjustmentOrange": 10, + "SaturationAdjustmentBlue": 15, + "SaturationAdjustmentGreen": -25, + "SaturationAdjustmentOrange": -10, + "SplitToningBalance": 10, + "SplitToningHighlightHue": 45, + "SplitToningHighlightSaturation": 10, + "SplitToningShadowHue": 215, + "SplitToningShadowSaturation": 12 +} diff --git a/server-rs/testdata/develop/wire/key_classes.txt b/server-rs/testdata/develop/wire/key_classes.txt new file mode 100644 index 00000000..c4211b92 --- /dev/null +++ b/server-rs/testdata/develop/wire/key_classes.txt @@ -0,0 +1,501 @@ +# Generated by server-rs/crates/lrg-develop/tests/lua_wire_goldens.rs from +# lrg-develop's key registry; do not edit. Regenerate with +# LRG_BLESS=1 cargo test -p lrg-develop --test lua_wire_goldens +# Read by plugin/spec/native_wire_format_spec.lua: one key name per line, +# then the classes of its values (a name at several levels has several); +# `never`: the Lua writer never writes it, at any level. +AILook never +AILookData string +Active boolean +AllowFilters integer +Alpha number +AlreadyApplied never +Amount number +Angle number +AreaComponents strings +AreaModels list +AsShotTemperature integer +AsShotTint integer +AutoBrightness boolean +AutoContrast boolean +AutoExposure boolean +AutoGrayscaleMix boolean +AutoLateralCA flag +AutoShadows boolean +AutoTone boolean +AutoToneDigest never +AutoToneDigestNoSat never +AutoToneDigestPV2 never +AutoWhiteVersion never +BaseHighlightGuideInputDigest never +BaseHighlightGuideTable string +BaseHighlightGuideVersion integer +BaseLayeredDepthInputDigest never +BaseLayeredDepthTable string +BaseLayeredDepthVersion integer +BaseRawDepthInputDigest never +BaseRawDepthTable string +BaseRawDepthVersion integer +Baseline string +Bidirectional never +Blacks2012 integer +BlueCurve localcurve +BlueHue integer +BlueSaturation integer +BlurAmount integer +BokehAspect integer +BokehRotation integer +BokehShape integer +BokehShapeDetail integer +Bottom number +Brightness integer +BrushGestureInterpretation integer +CameraModelRestriction string +CameraProfile string +CameraProfileDigest never +CatEyeAmount integer +CatEyeScale integer +CenterValue number +CenterWeight number +ChromaticAberrationB integer +ChromaticAberrationR integer +CircularGradientBasedCorrections list +Clarity integer +Clarity2012 integer +Cluster string +ColorAmount number +ColorGradeBlending integer +ColorGradeGlobalHue integer +ColorGradeGlobalLum integer +ColorGradeGlobalSat integer +ColorGradeHighlightLum integer +ColorGradeMidtoneHue integer +ColorGradeMidtoneLum integer +ColorGradeMidtoneSat integer +ColorGradeShadowLum integer +ColorNoiseReduction integer +ColorNoiseReductionDetail integer +ColorNoiseReductionSmoothness integer +ColorRangeMaskAreaSampleInfo string +ColorVariance compounds +CompatibleVersion integer +Contrast integer +Contrast2012 integer +ConvertToGrayscale boolean +Copyright string +CorrectionActive boolean +CorrectionAmount number +CorrectionColorTable never +CorrectionID never +CorrectionLUTAmount never +CorrectionLUTName never +CorrectionMasks list +CorrectionName string +CorrectionRangeMask table +CorrectionReferenceX never +CorrectionReferenceY never +CorrectionSyncID string +CropAngle number +CropBottom number +CropConstrainAspectRatio boolean +CropConstrainToUnitSquare integer +CropConstrainToWarp flag +CropHeight never +CropLeft number +CropRight number +CropTop number +CropUnit never +CropUnits never +CropWidth never +CurveRefineSaturation integer +Dabs never +Defringe integer +DefringeGreenAmount integer +DefringeGreenHueHi integer +DefringeGreenHueLo integer +DefringePurpleAmount integer +DefringePurpleHueHi integer +DefringePurpleHueLo integer +Dehaze integer +DepthBasedCorrections never +DepthFeather never +DepthMapInfo never +DepthMax never +DepthMin never +DepthRange never +DepthSource integer +DidOverrideInputDigestMismatch never +EdgeShift never +EnableCalibration boolean +EnableColorAdjustments boolean +EnableDetail boolean +EnableDistractionRemoval boolean +EnableEffects boolean +EnableGradientBasedCorrections boolean +EnableGrayscaleMix boolean +EnableLensCorrections boolean +EnableMaskGroupBasedCorrections boolean +EnableRedEye boolean +EnableRetouch boolean +EnableSplitToning boolean +EnableToneCurve boolean +EnableTransform boolean +ErrorReason maskenum +Exposure number +Exposure2012 number +ExtendedInstanceID never +ExtendedSemanticName never +ExtendedToneCurveName2012 never +ExtendedToneCurvePV2012 globalcurve +ExtendedToneCurvePV2012Blue globalcurve +ExtendedToneCurvePV2012Green globalcurve +ExtendedToneCurvePV2012Red globalcurve +Feather integer number +FillLight integer +FilterList never +Flipped boolean +Flow number +FocalRange string +FocalRangeSource integer +FullMaskSize never +FullPointDistance never +FullX number +FullY number +Gesture list +Glow any +GlowRange never +GlowSpread never +GlowStyle never +GlowWarmth never +GradientBasedCorrections list +GrainAmount integer +GrainFrequency integer +GrainSeed integer +GrainSize integer +GrayMixerAqua integer +GrayMixerBlue integer +GrayMixerGreen integer +GrayMixerMagenta integer +GrayMixerOrange integer +GrayMixerPurple integer +GrayMixerRed integer +GrayMixerYellow integer +GreenCurve localcurve +GreenHue integer +GreenSaturation integer +Group table +HDREditMode flag +HDRMaxValue number +HasCrop never +HasSettings never +HealVersion integer +HighlightRecovery integer +Highlights2012 integer +HighlightsBoost integer +HighlightsThreshold integer +HueAdjustmentAqua integer +HueAdjustmentBlue integer +HueAdjustmentGreen integer +HueAdjustmentMagenta integer +HueAdjustmentOrange integer +HueAdjustmentPurple integer +HueAdjustmentRed integer +HueAdjustmentYellow integer +HueRange table +HueShift number +ISO integer +ISODependent list +ImageOrientation integer +IncrementalTemperature integer +IncrementalTint integer +IngestInfo string +InputDigest never +InputDigestVersion never +InstanceBounds never +InstanceID never +InstanceIDs never +Invert boolean +LabMax compound +LabMin compound +Left number +LensBlur table +LensManualDistortionAmount integer +LensProfileChromaticAberrationScale never +LensProfileDigest never +LensProfileDistortionScale integer +LensProfileEnable flag +LensProfileFilename string +LensProfileIsEmbedded boolean +LensProfileName string +LensProfileSetup string +LensProfileVignettingScale integer +LocalBlacks2012 number +LocalBrightness number +LocalClarity number +LocalClarity2012 number +LocalColorGradeBalance never +LocalColorGradeBlending never +LocalColorGradeGlobalHue never +LocalColorGradeGlobalLum never +LocalColorGradeGlobalSat never +LocalColorGradeHighlightHue never +LocalColorGradeHighlightLum never +LocalColorGradeHighlightSat never +LocalColorGradeMidtoneHue never +LocalColorGradeMidtoneLum never +LocalColorGradeMidtoneSat never +LocalColorGradeShadowHue never +LocalColorGradeShadowLum never +LocalColorGradeShadowSat never +LocalColorVariance compounds +LocalContrast number +LocalContrast2012 number +LocalCorrectedDepth number +LocalCurveRefineSaturation number +LocalDefringe number +LocalDehaze number +LocalExposure number +LocalExposure2012 number +LocalGlow never +LocalGrain number +LocalHighlights2012 number +LocalHue number +LocalInputDigest never +LocalInputDigestVersion never +LocalLuminanceNoise number +LocalMoire number +LocalPointColors compounds +LocalSaturation number +LocalShadows2012 number +LocalSharpness number +LocalTemperature number +LocalTexture number +LocalTint number +LocalToningHue number +LocalToningLuminance never +LocalToningSaturation number +LocalWhites2012 number +Look table +LookAmount number +LookTable string +LookTableAmount any +LowerFull number +LowerNone number +LumEq compounds +LumFeather never +LumMax never +LumMin never +LumRange compound table +LumScale number +LuminanceAdjustmentAqua integer +LuminanceAdjustmentBlue integer +LuminanceAdjustmentGreen integer +LuminanceAdjustmentMagenta integer +LuminanceAdjustmentOrange integer +LuminanceAdjustmentPurple integer +LuminanceAdjustmentRed integer +LuminanceAdjustmentYellow integer +LuminanceDepthSampleInfo compound +LuminanceNoiseReductionContrast integer +LuminanceNoiseReductionDetail integer +LuminanceSmoothing integer +MainCurve localcurve +MaskActive boolean +MaskBlendMode maskenum +MaskBrushTable never +MaskBrushUncompressedBytes integer +MaskDigest never +MaskGroupBasedCorrections list +MaskID never +MaskInverted boolean +MaskName string +MaskSubCategoryID maskenum +MaskSubType maskenum +MaskSyncID string +MaskValue number +MaskVersion integer +Masks list +Method string +Midpoint integer +MissingCameraProfile any +MissingCameraProfileDigest never +ModelVersion never +Name string +OffsetY number +Opacity number +Origin never +OverrideLookVignette boolean +PaintBasedCorrections list +Parameters table +ParametricDarks integer +ParametricHighlightSplit integer +ParametricHighlights integer +ParametricLights integer +ParametricMidtoneSplit integer +ParametricShadowSplit integer +ParametricShadows integer +PerimeterValue number +PerspectiveAspect integer +PerspectiveHorizontal integer +PerspectiveRotate number +PerspectiveScale integer +PerspectiveUpright integer +PerspectiveVertical integer +PerspectiveX number +PerspectiveY number +PointColors list +PointModels compounds +Points list +PostCropVignetteAmount integer +PostCropVignetteFeather integer +PostCropVignetteHighlightContrast integer +PostCropVignetteMidpoint integer +PostCropVignetteRoundness integer +PostCropVignetteStyle integer +Preset never +ProcessVersion string +ProfileGainTableMap any +ProfileGainTableMap2 any +ProfileToneCurve any +RGBMax compound +RGBMin compound +RGBTable string +RGBTableAmount any +RGBTables any +Radius number +RangeAmount number +RangeMaskMapInfo table +RawFileName never +RedCurve localcurve +RedEyeInfo never +RedHue integer +RedSaturation integer +ReferencePoint compound +RemoveAreas never +RenderVersion never +ReshapeAmount never +RetouchAreas never +RetouchInfo never +Right number +Roundness integer +SDRBlend integer +SDRBrightness integer +SDRClarity integer +SDRContrast integer +SDRHighlights integer +SDRShadows integer +SDRWhites integer +SampleType maskenum +SampledArea string +SampledRange string +SatRange table +SatScale number +Saturation integer +SaturationAdjustmentAqua integer +SaturationAdjustmentBlue integer +SaturationAdjustmentGreen integer +SaturationAdjustmentMagenta integer +SaturationAdjustmentOrange integer +SaturationAdjustmentPurple integer +SaturationAdjustmentRed integer +SaturationAdjustmentYellow integer +Seed integer +SemanticObjectPrompt never +ShadowTint integer +Shadows integer +Shadows2012 integer +SharpenDetail integer +SharpenEdgeMasking integer +SharpenRadius number +Sharpness integer +SizeX number +SizeY number +SortName table +SourceAutoBlacks2012 never +SourceAutoContrast2012 never +SourceAutoExposure2012 never +SourceAutoHighlights2012 never +SourceAutoSaturation never +SourceAutoShadows2012 never +SourceAutoVibrance never +SourceAutoWhites2012 never +SourceState string +SourceX number +SphericalAberration integer +SplitToningBalance integer +SplitToningHighlightHue integer +SplitToningHighlightSaturation integer +SplitToningShadowHue integer +SplitToningShadowSaturation integer +SpotType string +SrcHue number +SrcLum number +SrcSat number +Stubbed boolean +SubjectRange string +SupportsAmount boolean +SupportsAmount2 boolean +SupportsMonochrome boolean +SupportsOutputReferred boolean +TargetImageArea never +Temperature integer +Texture integer +Tint integer +ToggleStyleAmount number +ToggleStyleDigest never +ToneCurve globalcurve +ToneCurveBlue globalcurve +ToneCurveGreen globalcurve +ToneCurveName string +ToneCurveName2012 string +ToneCurvePV2012 globalcurve +ToneCurvePV2012Blue globalcurve +ToneCurvePV2012Green globalcurve +ToneCurvePV2012Red globalcurve +ToneCurveRed globalcurve +ToneMapStrength never +Top number +Type maskenum +UUID string +UpperFull number +UpperNone number +UprightCenterMode integer +UprightCenterNormX number +UprightCenterNormY number +UprightDependentDigest never +UprightFocalLength35mm number +UprightFocalMode integer +UprightFourSegmentsCount integer +UprightGuidedDependentDigest never +UprightPreview boolean +UprightTransformCount integer +UprightVersion integer +Variance number +Version integer string +Vibrance integer +VignetteAmount integer +VignetteMidpoint integer +What string +WhiteBalance string +Whites2012 integer +WholeImageArea never +X number +Y number +Zero2Feather never +Zero2X never +Zero2Y never +ZeroFeather never +ZeroX number +ZeroY number +centerX number +centerY number +fill_method string +isAdobeAdaptive boolean +opacity number +orientation never +radius number +seed integer +sourceState string +sourceX number +sourceY number +spotType string diff --git a/server-rs/testdata/develop/wire/look_full.json b/server-rs/testdata/develop/wire/look_full.json new file mode 100644 index 00000000..3c697038 --- /dev/null +++ b/server-rs/testdata/develop/wire/look_full.json @@ -0,0 +1,17 @@ +{ + "Look": { + "Amount": 1.0, + "Group": { + "x-default": "Synthetic Group" + }, + "Name": "Adobe Color", + "Parameters": { + "ConvertToGrayscale": false, + "ProcessVersion": "15.4" + }, + "SupportsAmount": false, + "SupportsMonochrome": false, + "SupportsOutputReferred": false, + "UUID": "B952C231111CD8E0ECCF14B86BAA7077" + } +} diff --git a/server-rs/testdata/develop/wire/look_stub.json b/server-rs/testdata/develop/wire/look_stub.json new file mode 100644 index 00000000..b814933a --- /dev/null +++ b/server-rs/testdata/develop/wire/look_stub.json @@ -0,0 +1,8 @@ +{ + "Look": { + "Amount": 1.0, + "Name": "Adobe Color", + "Stubbed": true, + "UUID": "B952C231111CD8E0ECCF14B86BAA7077" + } +} diff --git a/server-rs/testdata/develop/wire/mask_local_curves.json b/server-rs/testdata/develop/wire/mask_local_curves.json new file mode 100644 index 00000000..52b3392c --- /dev/null +++ b/server-rs/testdata/develop/wire/mask_local_curves.json @@ -0,0 +1,61 @@ +{ + "EnableMaskGroupBasedCorrections": true, + "MaskGroupBasedCorrections": [ + { + "BlueCurve": [ + "0,8", + "255,248" + ], + "CorrectionActive": true, + "CorrectionAmount": 1.0, + "CorrectionMasks": [ + { + "ErrorReason": 0, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskInverted": false, + "MaskName": "Subject", + "MaskSubType": 1, + "MaskSyncID": "00000000000000000000000000001102", + "MaskValue": 1.0, + "MaskVersion": 1, + "ReferencePoint": "0.500000 0.500000", + "What": "Mask/Image" + } + ], + "CorrectionName": "Curves", + "CorrectionSyncID": "00000000000000000000000000001101", + "LocalBlacks2012": 0.0, + "LocalBrightness": 0.0, + "LocalClarity": 0.0, + "LocalClarity2012": 0.0, + "LocalContrast": 0.0, + "LocalContrast2012": 0.0, + "LocalCurveRefineSaturation": 90.0, + "LocalDefringe": 0.0, + "LocalDehaze": 0.0, + "LocalExposure": 0.0, + "LocalExposure2012": 0.0, + "LocalHighlights2012": 0.0, + "LocalHue": 0.0, + "LocalLuminanceNoise": 0.0, + "LocalMoire": 0.0, + "LocalSaturation": 0.0, + "LocalShadows2012": 0.0, + "LocalSharpness": 0.0, + "LocalTemperature": 0.0, + "LocalTexture": 0.0, + "LocalTint": 0.0, + "LocalToningHue": 0.0, + "LocalToningSaturation": 0.0, + "LocalWhites2012": 0.0, + "MainCurve": [ + "0,0", + "64,56", + "128,136", + "255,255" + ], + "What": "Correction" + } + ] +} diff --git a/server-rs/testdata/develop/wire/mask_luminance_intersect.json b/server-rs/testdata/develop/wire/mask_luminance_intersect.json new file mode 100644 index 00000000..ebba8a5c --- /dev/null +++ b/server-rs/testdata/develop/wire/mask_luminance_intersect.json @@ -0,0 +1,79 @@ +{ + "EnableMaskGroupBasedCorrections": true, + "MaskGroupBasedCorrections": [ + { + "CorrectionActive": true, + "CorrectionAmount": 1.0, + "CorrectionMasks": [ + { + "ErrorReason": 0, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskInverted": false, + "MaskName": "Sky", + "MaskSubType": 2, + "MaskSyncID": "00000000000000000000000000000F02", + "MaskValue": 1.0, + "MaskVersion": 1, + "ReferencePoint": "0.500000 0.500000", + "What": "Mask/Image" + }, + { + "ErrorReason": 0, + "MaskActive": true, + "MaskBlendMode": 1, + "MaskInverted": false, + "MaskName": "Subject", + "MaskSubType": 1, + "MaskSyncID": "00000000000000000000000000000F03", + "MaskValue": 0.0, + "MaskVersion": 1, + "ReferencePoint": "0.500000 0.500000", + "What": "Mask/Image" + }, + { + "CorrectionRangeMask": { + "Invert": true, + "LumRange": "0.300000 0.500000 1.000000 1.000000", + "SampleType": 0, + "Type": 2, + "Version": 3 + }, + "MaskActive": true, + "MaskBlendMode": 1, + "MaskInverted": true, + "MaskName": "Luminance Range", + "MaskSyncID": "00000000000000000000000000000F04", + "MaskValue": 0.0, + "What": "Mask/RangeMask" + } + ], + "CorrectionName": "LrGenius · Bright sky", + "CorrectionSyncID": "00000000000000000000000000000F01", + "LocalBlacks2012": 0.0, + "LocalBrightness": 0.0, + "LocalClarity": 0.0, + "LocalClarity2012": 0.0, + "LocalContrast": 0.0, + "LocalContrast2012": 0.0, + "LocalDefringe": 0.0, + "LocalDehaze": 0.0, + "LocalExposure": 0.0, + "LocalExposure2012": 0.0, + "LocalHighlights2012": -0.3, + "LocalHue": 0.0, + "LocalLuminanceNoise": 0.0, + "LocalMoire": 0.0, + "LocalSaturation": 0.0, + "LocalShadows2012": 0.0, + "LocalSharpness": 0.0, + "LocalTemperature": 0.0, + "LocalTexture": 0.0, + "LocalTint": 0.0, + "LocalToningHue": 0.0, + "LocalToningSaturation": 0.0, + "LocalWhites2012": 0.0, + "What": "Correction" + } + ] +} diff --git a/server-rs/testdata/develop/wire/mask_people_part.json b/server-rs/testdata/develop/wire/mask_people_part.json new file mode 100644 index 00000000..495ba3e6 --- /dev/null +++ b/server-rs/testdata/develop/wire/mask_people_part.json @@ -0,0 +1,51 @@ +{ + "EnableMaskGroupBasedCorrections": true, + "MaskGroupBasedCorrections": [ + { + "CorrectionActive": true, + "CorrectionAmount": 1.0, + "CorrectionMasks": [ + { + "ErrorReason": 0, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskInverted": false, + "MaskName": "Iris and Pupil", + "MaskSubCategoryID": 3, + "MaskSubType": 3, + "MaskSyncID": "00000000000000000000000000001002", + "MaskValue": 1.0, + "MaskVersion": 1, + "ReferencePoint": "0.500000 0.500000", + "What": "Mask/Image" + } + ], + "CorrectionName": "LrGenius · Eyes", + "CorrectionSyncID": "00000000000000000000000000001001", + "LocalBlacks2012": 0.0, + "LocalBrightness": 0.0, + "LocalClarity": 0.0, + "LocalClarity2012": 0.0, + "LocalContrast": 0.0, + "LocalContrast2012": 0.0, + "LocalDefringe": 0.0, + "LocalDehaze": 0.0, + "LocalExposure": 0.0, + "LocalExposure2012": 0.0, + "LocalHighlights2012": 0.0, + "LocalHue": 0.0, + "LocalLuminanceNoise": 0.0, + "LocalMoire": 0.0, + "LocalSaturation": 0.3, + "LocalShadows2012": 0.0, + "LocalSharpness": 0.0, + "LocalTemperature": 0.0, + "LocalTexture": 0.0, + "LocalTint": 0.0, + "LocalToningHue": 0.0, + "LocalToningSaturation": 0.0, + "LocalWhites2012": 0.0, + "What": "Correction" + } + ] +} diff --git a/server-rs/testdata/develop/wire/masks_linear_radial.json b/server-rs/testdata/develop/wire/masks_linear_radial.json new file mode 100644 index 00000000..052582ee --- /dev/null +++ b/server-rs/testdata/develop/wire/masks_linear_radial.json @@ -0,0 +1,101 @@ +{ + "EnableMaskGroupBasedCorrections": true, + "MaskGroupBasedCorrections": [ + { + "CorrectionActive": true, + "CorrectionAmount": 1.0, + "CorrectionMasks": [ + { + "FullX": 0.5, + "FullY": 0.15, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskInverted": false, + "MaskName": "Linear Gradient", + "MaskSyncID": "00000000000000000000000000000E02", + "MaskValue": 1.0, + "What": "Mask/Gradient", + "ZeroX": 0.5, + "ZeroY": 0.55 + } + ], + "CorrectionName": "LrGenius · Darker sky", + "CorrectionSyncID": "00000000000000000000000000000E01", + "LocalBlacks2012": 0.0, + "LocalBrightness": 0.0, + "LocalClarity": 0.0, + "LocalClarity2012": 0.0, + "LocalContrast": 0.0, + "LocalContrast2012": 0.0, + "LocalDefringe": 0.0, + "LocalDehaze": 0.0, + "LocalExposure": 0.0, + "LocalExposure2012": -0.125, + "LocalHighlights2012": 0.0, + "LocalHue": 0.0, + "LocalLuminanceNoise": 0.0, + "LocalMoire": 0.0, + "LocalSaturation": 0.0, + "LocalShadows2012": 0.0, + "LocalSharpness": 0.0, + "LocalTemperature": 0.0, + "LocalTexture": 0.0, + "LocalTint": 0.0, + "LocalToningHue": 0.0, + "LocalToningSaturation": 0.0, + "LocalWhites2012": 0.0, + "What": "Correction" + }, + { + "CorrectionActive": true, + "CorrectionAmount": 1.0, + "CorrectionMasks": [ + { + "Angle": 0.0, + "Bottom": 0.75, + "Feather": 60, + "Flipped": true, + "Left": 0.3, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskInverted": false, + "MaskName": "Radial Gradient", + "MaskSyncID": "00000000000000000000000000000E04", + "MaskValue": 1.0, + "Midpoint": 50, + "Right": 0.7, + "Roundness": 0, + "Top": 0.25, + "Version": 2, + "What": "Mask/CircularGradient" + } + ], + "CorrectionName": "LrGenius · Spotlight", + "CorrectionSyncID": "00000000000000000000000000000E03", + "LocalBlacks2012": 0.0, + "LocalBrightness": 0.0, + "LocalClarity": 0.0, + "LocalClarity2012": 0.0, + "LocalContrast": 0.0, + "LocalContrast2012": 0.0, + "LocalDefringe": 0.0, + "LocalDehaze": 0.0, + "LocalExposure": 0.0, + "LocalExposure2012": 0.0625, + "LocalHighlights2012": 0.0, + "LocalHue": 0.0, + "LocalLuminanceNoise": 0.0, + "LocalMoire": 0.0, + "LocalSaturation": 0.0, + "LocalShadows2012": 0.0, + "LocalSharpness": 0.0, + "LocalTemperature": 0.0, + "LocalTexture": 0.0, + "LocalTint": 0.0, + "LocalToningHue": 0.0, + "LocalToningSaturation": 0.0, + "LocalWhites2012": 0.0, + "What": "Correction" + } + ] +} diff --git a/server-rs/testdata/develop/wire/masks_subject_sky.json b/server-rs/testdata/develop/wire/masks_subject_sky.json new file mode 100644 index 00000000..ea0685ba --- /dev/null +++ b/server-rs/testdata/develop/wire/masks_subject_sky.json @@ -0,0 +1,95 @@ +{ + "EnableMaskGroupBasedCorrections": true, + "MaskGroupBasedCorrections": [ + { + "CorrectionActive": true, + "CorrectionAmount": 1.0, + "CorrectionMasks": [ + { + "ErrorReason": 0, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskInverted": false, + "MaskName": "Subject", + "MaskSubType": 1, + "MaskSyncID": "00000000000000000000000000000D02", + "MaskValue": 1.0, + "MaskVersion": 1, + "ReferencePoint": "0.500000 0.500000", + "What": "Mask/Image" + } + ], + "CorrectionName": "LrGenius · Subject", + "CorrectionSyncID": "00000000000000000000000000000D01", + "LocalBlacks2012": 0.0, + "LocalBrightness": 0.0, + "LocalClarity": 0.0, + "LocalClarity2012": 0.1, + "LocalContrast": 0.0, + "LocalContrast2012": 0.0, + "LocalDefringe": 0.0, + "LocalDehaze": 0.0, + "LocalExposure": 0.0, + "LocalExposure2012": 0.075, + "LocalHighlights2012": 0.0, + "LocalHue": 0.0, + "LocalLuminanceNoise": 0.0, + "LocalMoire": 0.0, + "LocalSaturation": 0.0, + "LocalShadows2012": 0.0, + "LocalSharpness": 0.0, + "LocalTemperature": 0.0, + "LocalTexture": 0.0, + "LocalTint": 0.0, + "LocalToningHue": 0.0, + "LocalToningSaturation": 0.0, + "LocalWhites2012": 0.0, + "What": "Correction" + }, + { + "CorrectionActive": true, + "CorrectionAmount": 1.0, + "CorrectionMasks": [ + { + "ErrorReason": 0, + "MaskActive": true, + "MaskBlendMode": 0, + "MaskInverted": false, + "MaskName": "Sky", + "MaskSubType": 2, + "MaskSyncID": "00000000000000000000000000000D04", + "MaskValue": 1.0, + "MaskVersion": 1, + "ReferencePoint": "0.500000 0.500000", + "What": "Mask/Image" + } + ], + "CorrectionName": "LrGenius · Sky", + "CorrectionSyncID": "00000000000000000000000000000D03", + "LocalBlacks2012": 0.0, + "LocalBrightness": 0.0, + "LocalClarity": 0.0, + "LocalClarity2012": 0.0, + "LocalContrast": 0.0, + "LocalContrast2012": 0.0, + "LocalDefringe": 0.0, + "LocalDehaze": 0.15, + "LocalExposure": 0.0, + "LocalExposure2012": 0.0, + "LocalHighlights2012": -0.4, + "LocalHue": 0.0, + "LocalLuminanceNoise": 0.0, + "LocalMoire": 0.0, + "LocalSaturation": 0.0, + "LocalShadows2012": 0.0, + "LocalSharpness": 0.0, + "LocalTemperature": 0.0, + "LocalTexture": 0.0, + "LocalTint": 0.0, + "LocalToningHue": 0.0, + "LocalToningSaturation": 0.0, + "LocalWhites2012": 0.0, + "What": "Correction" + } + ] +} diff --git a/server-rs/testdata/develop/wire/no_masks.json b/server-rs/testdata/develop/wire/no_masks.json new file mode 100644 index 00000000..1eef8cd5 --- /dev/null +++ b/server-rs/testdata/develop/wire/no_masks.json @@ -0,0 +1,3 @@ +{ + "Exposure2012": 0.5 +} diff --git a/server-rs/testdata/develop/wire/tone_curves.json b/server-rs/testdata/develop/wire/tone_curves.json new file mode 100644 index 00000000..a59c5229 --- /dev/null +++ b/server-rs/testdata/develop/wire/tone_curves.json @@ -0,0 +1,44 @@ +{ + "ParametricDarks": 5, + "ParametricHighlightSplit": 75, + "ParametricHighlights": -12, + "ParametricLights": 8, + "ParametricMidtoneSplit": 50, + "ParametricShadowSplit": 25, + "ParametricShadows": -10, + "ToneCurveName2012": "Custom", + "ToneCurvePV2012": [ + 0, + 0, + 64, + 52, + 128, + 135, + 192, + 210, + 255, + 255 + ], + "ToneCurvePV2012Blue": [ + 0, + 8, + 128, + 124, + 255, + 245 + ], + "ToneCurvePV2012Green": [ + 0, + 0, + 255, + 255 + ], + "ToneCurvePV2012Red": [ + 0, + 0, + 128, + 132, + 255, + 255 + ] +} diff --git a/server-rs/testdata/develop/wire/wb_non_raw_incremental.json b/server-rs/testdata/develop/wire/wb_non_raw_incremental.json new file mode 100644 index 00000000..2b21b1a9 --- /dev/null +++ b/server-rs/testdata/develop/wire/wb_non_raw_incremental.json @@ -0,0 +1,5 @@ +{ + "IncrementalTemperature": 12, + "IncrementalTint": -4, + "WhiteBalance": "Custom" +} diff --git a/server-rs/testdata/develop/wire/wb_raw_custom.json b/server-rs/testdata/develop/wire/wb_raw_custom.json new file mode 100644 index 00000000..4cef3d90 --- /dev/null +++ b/server-rs/testdata/develop/wire/wb_raw_custom.json @@ -0,0 +1,5 @@ +{ + "Temperature": 5600, + "Tint": 6, + "WhiteBalance": "Custom" +} diff --git a/server-rs/testdata/develop/written/handtest_combinations_background.xmp b/server-rs/testdata/develop/written/handtest_combinations_background.xmp new file mode 100644 index 00000000..66182fbc --- /dev/null +++ b/server-rs/testdata/develop/written/handtest_combinations_background.xmp @@ -0,0 +1,138 @@ + + + + + + LrGenius Handtest Combinations+Background + + + + + + + + + + + + + + + LrGeniusAI + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/server-rs/testdata/develop/written/handtest_landscape_people.xmp b/server-rs/testdata/develop/written/handtest_landscape_people.xmp new file mode 100644 index 00000000..62fe6450 --- /dev/null +++ b/server-rs/testdata/develop/written/handtest_landscape_people.xmp @@ -0,0 +1,109 @@ + + + + + + LrGenius Handtest Landscape+People + + + + + + + + + + + + + + + LrGeniusAI + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/server-rs/testdata/develop/written/handtest_subject_sky.xmp b/server-rs/testdata/develop/written/handtest_subject_sky.xmp new file mode 100644 index 00000000..2058865e --- /dev/null +++ b/server-rs/testdata/develop/written/handtest_subject_sky.xmp @@ -0,0 +1,109 @@ + + + + + + LrGenius Handtest Subject+Sky + + + + + + + + + + + + + + + LrGeniusAI + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/server-rs/testdata/develop/xmp/alt_second_language.xmp b/server-rs/testdata/develop/xmp/alt_second_language.xmp new file mode 100644 index 00000000..a3064fee --- /dev/null +++ b/server-rs/testdata/develop/xmp/alt_second_language.xmp @@ -0,0 +1,31 @@ + + + + + + Synthetic Text + Synthetic Text + + + + + Synthetic Group + + + + + Synthetic Text + Synthetic Text + + + + + diff --git a/server-rs/testdata/develop/xmp/bag.xmp b/server-rs/testdata/develop/xmp/bag.xmp new file mode 100644 index 00000000..1965efde --- /dev/null +++ b/server-rs/testdata/develop/xmp/bag.xmp @@ -0,0 +1,25 @@ + + + + + + Synthetic Text + + + + + 0.250000, 0.500000, 0.500000, 10.000000, 0.000000, 0.000000 + 0.750000, 0.400000, 0.600000, -5.000000, 0.000000, 0.000000 + + + + + 0.000000, 0.000000, 0.000000 + + + + + diff --git a/server-rs/testdata/develop/xmp/bom.xmp b/server-rs/testdata/develop/xmp/bom.xmp new file mode 100644 index 00000000..acac8f2c --- /dev/null +++ b/server-rs/testdata/develop/xmp/bom.xmp @@ -0,0 +1,8 @@ + + + + + diff --git a/server-rs/testdata/develop/xmp/coercion_warnings.xmp b/server-rs/testdata/develop/xmp/coercion_warnings.xmp new file mode 100644 index 00000000..8d360699 --- /dev/null +++ b/server-rs/testdata/develop/xmp/coercion_warnings.xmp @@ -0,0 +1,42 @@ + + + + + + 0, 0 + half, 128 + + + + + + + + + + + + + + + + + + diff --git a/server-rs/testdata/develop/xmp/curves.xmp b/server-rs/testdata/develop/xmp/curves.xmp new file mode 100644 index 00000000..880b062d --- /dev/null +++ b/server-rs/testdata/develop/xmp/curves.xmp @@ -0,0 +1,78 @@ + + + + + + 0, 0 + 64, 58 + 192, 200 + 255, 255 + + + + + 0, 12 + 255, 255 + + + + + 0, 0 + 255, 255 + + + + + 0, 0 + 255, 240 + + + + + + + + + + + + + + 0,0 + 32,16 + 128,128 + 255,255 + + + + + 0,0 + 255,240 + + + + + + + + + diff --git a/server-rs/testdata/develop/xmp/element_form.xmp b/server-rs/testdata/develop/xmp/element_form.xmp new file mode 100644 index 00000000..e6018bcd --- /dev/null +++ b/server-rs/testdata/develop/xmp/element_form.xmp @@ -0,0 +1,44 @@ + + + + + 15.4 + + + + + Correction + 1 + true + Correction 1 + 00000000000000000000000000000D01 + -0.125 + 0.2 + + + + + Mask/Gradient + true + Mask 1 + 0 + false + 00000000000000000000000000000D02 + 1 + 0.5 + 0.55 + 0.5 + 0.2 + + + + + + + + + + + + diff --git a/server-rs/testdata/develop/xmp/look_stub.xmp b/server-rs/testdata/develop/xmp/look_stub.xmp new file mode 100644 index 00000000..6c6ebdc6 --- /dev/null +++ b/server-rs/testdata/develop/xmp/look_stub.xmp @@ -0,0 +1,31 @@ + + + + + + + + Synthetic Group + + + + + + + + + + diff --git a/server-rs/testdata/develop/xmp/mask_brush.xmp b/server-rs/testdata/develop/xmp/mask_brush.xmp new file mode 100644 index 00000000..a5d4ef1b --- /dev/null +++ b/server-rs/testdata/develop/xmp/mask_brush.xmp @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + d 0.520898 0.305817 + d 0.530000 0.310000 + + + + + + + + + + + + + + + + + diff --git a/server-rs/testdata/develop/xmp/mask_combine.xmp b/server-rs/testdata/develop/xmp/mask_combine.xmp new file mode 100644 index 00000000..10eb558b --- /dev/null +++ b/server-rs/testdata/develop/xmp/mask_combine.xmp @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/server-rs/testdata/develop/xmp/mask_linear.xmp b/server-rs/testdata/develop/xmp/mask_linear.xmp new file mode 100644 index 00000000..310bc7b5 --- /dev/null +++ b/server-rs/testdata/develop/xmp/mask_linear.xmp @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + diff --git a/server-rs/testdata/develop/xmp/mask_radial.xmp b/server-rs/testdata/develop/xmp/mask_radial.xmp new file mode 100644 index 00000000..112f61aa --- /dev/null +++ b/server-rs/testdata/develop/xmp/mask_radial.xmp @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/server-rs/testdata/develop/xmp/mask_range.xmp b/server-rs/testdata/develop/xmp/mask_range.xmp new file mode 100644 index 00000000..de3720c6 --- /dev/null +++ b/server-rs/testdata/develop/xmp/mask_range.xmp @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0.255405 0.034566 0.419137 0.692175 0.651248 0 + + + + + + + + + + + + + + + diff --git a/server-rs/testdata/develop/xmp/masks_ai.xmp b/server-rs/testdata/develop/xmp/masks_ai.xmp new file mode 100644 index 00000000..f0b3f84e --- /dev/null +++ b/server-rs/testdata/develop/xmp/masks_ai.xmp @@ -0,0 +1,163 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/server-rs/testdata/develop/xmp/multiple_descriptions.xmp b/server-rs/testdata/develop/xmp/multiple_descriptions.xmp new file mode 100644 index 00000000..7872765a --- /dev/null +++ b/server-rs/testdata/develop/xmp/multiple_descriptions.xmp @@ -0,0 +1,28 @@ + + + + + + Synthetic Text + + + + + + + diff --git a/server-rs/testdata/develop/xmp/not_develop.xmp b/server-rs/testdata/develop/xmp/not_develop.xmp new file mode 100644 index 00000000..edcd56da --- /dev/null +++ b/server-rs/testdata/develop/xmp/not_develop.xmp @@ -0,0 +1,15 @@ + + + + + + Synthetic Text + + + + + diff --git a/server-rs/testdata/develop/xmp/number_formats.xmp b/server-rs/testdata/develop/xmp/number_formats.xmp new file mode 100644 index 00000000..45cf0e6b --- /dev/null +++ b/server-rs/testdata/develop/xmp/number_formats.xmp @@ -0,0 +1,52 @@ + + + + + + + + + diff --git a/server-rs/testdata/develop/xmp/parse_type_resource.xmp b/server-rs/testdata/develop/xmp/parse_type_resource.xmp new file mode 100644 index 00000000..9b14ed29 --- /dev/null +++ b/server-rs/testdata/develop/xmp/parse_type_resource.xmp @@ -0,0 +1,74 @@ + + + + + + + Correction + 1 + true + Correction 1 + 00000000000000000000000000000F01 + 0.2 + + + + Mask/RangeMask + true + Mask 1 + 0 + false + 00000000000000000000000000000F02 + 1 + + 3 + 2 + false + 0 + 0.000000 0.100000 0.350000 0.500000 + 0.250000 0.200000 0.300000 + + + + + + + Correction + 1 + true + Correction 2 + 00000000000000000000000000000F03 + -0.3 + + + + Mask/RangeMask + true + Mask 2 + 0 + false + 00000000000000000000000000000F04 + 1 + + 3 + 1 + 0.494949 + false + 0 + + + 0.255405 0.034566 0.419137 0.692175 0.651248 0 + + + + + + + + + + + + diff --git a/server-rs/testdata/develop/xmp/preset_header.xmp b/server-rs/testdata/develop/xmp/preset_header.xmp new file mode 100644 index 00000000..275647ee --- /dev/null +++ b/server-rs/testdata/develop/xmp/preset_header.xmp @@ -0,0 +1,55 @@ + + + + + + Synthetic Text + + + + + + + + + + + + + + + Synthetic Group + + + + + + + + + + diff --git a/server-rs/testdata/develop/xmp/preset_record.xmp b/server-rs/testdata/develop/xmp/preset_record.xmp new file mode 100644 index 00000000..d17222f7 --- /dev/null +++ b/server-rs/testdata/develop/xmp/preset_record.xmp @@ -0,0 +1,33 @@ + + + + + + + + Synthetic Group + + + + + + + + + + diff --git a/server-rs/testdata/develop/xmp/profile.xmp b/server-rs/testdata/develop/xmp/profile.xmp new file mode 100644 index 00000000..51dc42e1 --- /dev/null +++ b/server-rs/testdata/develop/xmp/profile.xmp @@ -0,0 +1,27 @@ + + + + + + Synthetic Profile + + + + + Synthetic Group + + + + + diff --git a/server-rs/testdata/develop/xmp/snapshot_crss.xmp b/server-rs/testdata/develop/xmp/snapshot_crss.xmp new file mode 100644 index 00000000..1d7cb1bc --- /dev/null +++ b/server-rs/testdata/develop/xmp/snapshot_crss.xmp @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + diff --git a/server-rs/testdata/develop/xmp/unknown_keys.xmp b/server-rs/testdata/develop/xmp/unknown_keys.xmp new file mode 100644 index 00000000..f54d0b60 --- /dev/null +++ b/server-rs/testdata/develop/xmp/unknown_keys.xmp @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + +