Skip to content

AI Edit: an AI retoucher that looks at Lightroom's render and corrects it - #481

Merged
bmachek merged 42 commits into
mainfrom
feat/ai-edit-llm-loop
Nov 13, 2026
Merged

bmachek merged 42 commits into
mainfrom
feat/ai-edit-llm-loop

Conversation

@bmachek

@bmachek bmachek commented Nov 13, 2026 •

Copy link
Copy Markdown
Member

AI Edit gets an AI retoucher: a vision model edits, Lightroom applies and renders the edit, the model looks at the render next to the original with measurements of its tones, and corrects — round by round, the way a person works at the Develop panel. The style engine stays as My style only (offline, no model), and can be the retoucher's starting point.

Why

Both existing paths stop short of good edits. The single-shot LLM edit (/v1/edit/recipe, unreachable from the plugin since #274) guessed absolute slider values for a file and profile it had never seen rendered — what vision models are worst at. The style engine never looks at the photo at all; only the guardrails do. Vision models are good at the step in between: judging a picture and saying what is wrong with it. The loop gives them that step.

What the branch contains

1. Merge of feature/ai-edit-xmp (a3726ea): lrg-develop (key registry, typed develop model, Lua/XMP readers and writers, mask builders), the style engine's typed white balance and the develop-settings experiments, onto current main. Conflicts resolved in Info.lua, Util.lua, style_edit.rs (the branch's warning list with main's guardrail result), Cargo.toml/lock, the golden-tests workflow. The retoucher is the first production caller of lrg-develop's Lua writer, applying through applyDevelopSettings + updateAISettings as experiment E2 settled.

2. The retoucher (1f46a97, and 930b7f2 from the live test):

Piece What
lrg-api edit_loop.rs, routes/edit_session.rs POST /v1/edit/sessions, POST /v1/edit/sessions/{id}/render, DELETE /v1/edit/sessions/{id}. The backend holds the conversation (in memory, 30 min idle expiry); the plugin drives it. Runs on lrg_providers::chat::ChatModel, so every provider the agent chat runs on works, local engines included. Every edit passes the existing guardrails
lrg-providers edit_loop.rs System prompt, seven looks, the apply_edit/finish tools with a schema generated from the develop registry (Lightroom's own ranges, the photo's white-balance family), the opening and render texts
lrg-develop build/recipe.rs Recipe → DevelopSettings: recipe aliases, colour-grading midtones/luminances/blending, black-and-white mix, white balance per family (a value on the other scale is dropped, never clamped), AI masks for subject, sky, background, ten people parts and seven landscape classes with sync ids derived from the target. And starting_values, the way back
lrg-imaging tone.rs Luminance percentiles, histogram bands, clipping, the colour of the near-neutral tones, midtone saturation — what the model is told to trust over its eye
lrg-analysis style_engine white_balance_into_global, so the photographer's style can be round 0
EditLoop.lua Applies each round from the photo's starting point (keys an earlier round set go back; masks merge by CorrectionSyncID, the photo's own masks passed back raw), computes AI masks, renders at 1536 px, reports a mask that found nothing, reverts on discard
TaskAiEditPhotos.lua Two editors. Retoucher: model (defaults to Analyze & Index's), look, own instructions, edits per photo (3), start from my style (≥ 5 examples), AI masks (LrC 15.3+). Review is Keep / Discard / Keep all on the finished edit. My style only: the previous flow, moved into runStyleEdits unchanged

Every edit is the complete edit measured from the starting point, so rounds are idempotent and the model can go back to an earlier, better round (best_round).

Errors surface (reviewed against Errors must surface): what did not reach the photo (clamped values, unknown mask targets, masks beyond six, keys the process version lacks, the style's white balance from the other file kind), a kept round's mask that found nothing, a failed record of the edit in the catalog or the index, edits the model sent that Lightroom could not use (named as the cause instead of "no tool calling") — all reach the review dialog and the run summary. persistEditRecipe now returns whether it recorded; both editors report a failure.

Test plan

  • cargo fmt, cargo clippy --workspace --all-targets (zero warnings), cargo test --workspace (1534 passed). New: 6 end-to-end loop tests on a scripted model (edit → render → refine → finish with best_round; style as round 0 with both images; nudge then refuse a model without tool calls; edit budget; JPEG offset white balance; unusable edits named as the cause), recipe builder (incl. writing an apply table with a mask), tone measurement, tool schema
  • busted (10 new specs for composing rounds, merging masks by sync id, revert, mask states; the one failure, aesthetic_refine_spec.lua, predates this branch — Lua 5.5 prints 1.0)
  • luacheck, stylua, scripts/check-docs.py, pre-commit hooks
  • Live against the dev binary over HTTP, with a scripted OpenAI-compatible server through the real openai_compatible adapter: start, two renders with Lightroom notes, a guardrail cap, back to round 2, the recipe stored on the photo's record, 404 after the end, DELETE
  • Live in Lightroom Classic 16.0, test catalog, plugin from the checkout, driven through the agent bridge (EditLoop.run via eval_lua), seven CR3 photos:
    • Gemini 3.1 Pro: landscape with sky, backlit forest (twice), person in a forest, B&W portrait (look Black & white); 2–3 rounds, 25–50 s and 20–38k input tokens per photo. Dark backlit frames come out well exposed and natural; the model ends early when satisfied
    • Hybrid with 13 of the photographer's edits saved as training examples: style as round 0, then the model's corrections — which led to 930b7f2 (edits now merge onto the style instead of dropping its values)
    • Local Gemma 4 E4B (MLX): tool calls work through schema decoding, 3 rounds in ~50 s, but the edit stays timid and it dropped values in later rounds (renders now read the edit back to the model)
    • AI masks computed for subject, sky, background, face skin, iris and pupil and the landscape class vegetation; the photo's own masks kept and computed; same-sync-id corrections replaced in place; discard restores every written key and, with no own masks, an empty list removes the loop's — recorded in Dev-AI-Edit-XMP-Findings.md
    • Failure path: Gemini without credit (HTTP 402) reported in plain words, photo untouched
  • A non-raw file (the test catalog had none), the dialog itself (Keep / Discard / Keep all, catalog_photo), virtual copies

Known behaviour

  • The backend stores the kept recipe on the photo's index record when the session ends, before the user's Keep/Discard — as /v1/edit/style stores the recipe it generates.
  • With a cloud model, renders of every edited photo go to that provider (stated in the help page).

🤖 Generated with Claude Code

https://claude.ai/code/session_01HaMMvswyTLENR7Gdygjt41

bmachek and others added 30 commits September 26, 2026 09:05
Research into writing crs: XMP for AI Edit (docs/wiki/Dev-AI-Edit-XMP-Findings.md)
settled most questions from real files: the crs: key space is the
getDevelopSettings() table, semantic AI masks carry no geometry, and crop
and geometric masks live in the uncropped sensor-oriented frame. Four
questions decide the architecture and only Lightroom can answer them, so
this adds a developer task (Help > Developer: Run Develop-Settings
Experiments...) that runs them in a test catalog:

- E13 (read-only): runtime orientation of dimensions/croppedDimensions and
  the develop setting `orientation`. It fits the crop model and says
  "ambiguous" when sensor and display frames tie.
- E1: does applyDevelopSettings accept `Temp` (what AI Edit writes today)
  or only Temperature / IncrementalTemperature? Each variant runs on a
  fresh virtual copy.
- E2: can applyDevelopSettings add a digest-less AI mask, and is it
  computed on its own or only after updateAISettings()?
- E4: plugin preset lifecycle. Covers same-name adds, file location and
  content, merge vs replace against a seeded gradient, re-apply
  idempotency, and amount with and without SupportsAmount.

All writes go to "LrG Exp ..." virtual copies. The report is Markdown
plus JSON, with verdicts, manual checks and raw readbacks. Failures are
recorded and shown, never counted as results. The pure helpers are
covered by busted.

Docs-Reviewed: Credits.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds what the step-1 plan needs from the experiments, before the task is
run in a test catalog:

- E1: white-balance mode only ({WhiteBalance="Daylight"/"Auto"}, and
  "Auto" on non-raw files). Each copy starts from a distinctive Custom
  white balance, and a short poll catches a late recompute. A flattened
  Auto reads as "flattened to Custom".
- E11 (new): Look transfer via applyDevelopSettings on raw files, in
  three forms: the preset form of a stub (Stubbed=true), a bare stub, and
  a full Look. Installed presets never carry Look.Parameters, so the full
  Look comes from another selected photo, from the E11a readback, or from
  the photo's own Look after a prepare step; presets are the last resort.
  Camera-restricted Looks are only used on the same camera model.
- E13: full top-level readback of getDevelopSettings(), for the raw and
  non-raw defaults. It records whether the photo has develop edits
  (hasAdjustments search, editCount, lastEditTime).
- E2: timing of the updateAISettings() call and of the time until the
  mask is ready, measured from the call itself. A lock before the update
  marks the time as a lower bound.

Pure helpers are in DevelopExperiments.lua with busted specs (388 pass).
All branches were exercised through a mock SDK harness (15 scenarios).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…xtures

PR 0 of the step-1 plan for the native Lightroom develop model
(lrg-develop).

- server-rs/scripts/develop_registry/: a stdlib-only uv project with the
  tools the research used, cleaned up as CLIs:
  - parse_xmp.py: crs: RDF parser;
  - scan_corpus.py, sample.py, structform.py: inventory and sampling;
  - gen.py + spec.py: registry generator. It seeds table.rs in PR 1a and
    reads no ExifTool data;
  - dump_training.py: optional 'lance' group;
  - extract_fixtures.py: the fixture extractor.
  No private paths are hard-coded, and every tool refuses to write
  output inside the repo; only the extractor may write, and only to the
  fixture folder.
- server-rs/testdata/develop/lua/: 17 getDevelopSettings() fixtures plus a
  manifest. They cover every shape the 1a Lua reader needs: all mask
  kinds, intersect/subtract, Look variants, LensBlur/FilterList in both
  forms, PV 11.0 and 15.4, and a top-level []. They are derived from the
  maintainer's own edits and scrubbed for a public repo:
  - only develop settings, no metadata;
  - synthetic ids, digests and GUIDs;
  - generic mask names;
  - synthetic lens and camera strings;
  - Look.Parameters replaced by a registry-key-only stub, and non-Adobe
    profiles stubbed;
  - no lookup-table blobs;
  - GrainSeed neutralised.
- The extractor's hygiene check runs as a pre-commit hook and in the
  lint-format CI job, so fixtures with private data or Adobe content fail
  the build. Staging plus the check means a failed run never touches the
  committed fixtures.

Docs-Reviewed: Dev-Build-Environment-Setup.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… reader

PR 1a of the step-1 plan: the native Lightroom develop model as a leaf
crate. It has no image, network or async dependencies, so
`cargo test -p lrg-develop` takes seconds.

- Registry: 562 crs: keys plus 6 pattern families, one KeySpec each:
  - type, range in stored units, UI scale, number format;
  - raw and non-raw defaults (non-raw Unverified until the E1 readback);
  - policy class (LEARN, LEARN†, PHOTO, COMPUTED, NEVER, META, UNKNOWN);
  - frame scope, file kind, presence and min_pv.
  table.rs is generated from scripts/develop_registry/spec.py by
  gen_table_rs.py, byte-reproducible. A JSON snapshot turns registry edits
  into readable diffs (LRG_BLESS=1). ui.rs and coerce.rs are the only
  places for UI scaling and int/real conversion. `Temp` is deliberately
  not a key.
- Model:
  - DevelopSettings keeps a private invariant, with values keyed by
    registry order.
  - Finite rejects NaN and infinities.
  - Corrections and mask components carry typed tools: semantic AI masks
    incl. people parts and landscape classes, linear, radial with Angle 0,
    luminance range, or opaque.
  - Look is taken whole.
  - White-balance types derive the family from the keys.
  - The policy filter checks nested structs at their own registry rows,
    drops global META values for presets and photo targets, and lets only
    portable camera profiles into presets.
- Lua reader: reads getDevelopSettings() blobs as JSON.lua encodes them:
  - an empty table anywhere means the key is absent;
  - int/real and bool/0-1 are normalised;
  - unknown keys pass through losslessly with warnings;
  - the file kind comes from the white-balance keys;
  - PV < 6.7 is flagged;
  - output order is deterministic whether or not serde_json's
    preserve_order is on.
- Tests (104 unit + integration):
  - all generated and 7 new hand_*.json fixtures read with 0 unknown keys;
  - a Rust fixture-hygiene test mirrors extract_fixtures.py, and both
    share 12 crafted self-test cases;
  - a local-only sweep over the maintainer's 1,294 training rows passes
    with 0 errors and 0 warnings.
- Shared golden gate: LOCAL_ONLY families (`training-dump`). `all` now
  leaves out data that can never be in CI; naming such a family still
  requires it.
- .gitattributes keeps server-rs/testdata/develop LF for the byte-exact
  snapshot on Windows runners.
- Docs: new Dev-Develop-Model.md; crate lists in CLAUDE.md, AGENTS.md and
  server-rs/README.md; the regenerated Dev-Server-README.md.

No behaviour change in any other crate: nothing consumes lrg-develop yet
(PR 1d is the first consumer).

Docs-Reviewed: Credits.md
Docs-Reviewed: Dev-Project-README.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PR 1d of the step-1 plan, and the first consumer of lrg-develop.

AI Edit read and wrote the develop key "Temp", which Lightroom never uses.
It appears in 0 of 17,814 catalog rows. White balance was therefore never
learned from training examples, and the averaged Tint was applied as-is to
every file, which is wrong for non-raw files.

Backend:
- Training reads getDevelopSettings() blobs through lrg_develop::lua.
  The white balance of each example becomes a typed WbSetting (mode,
  family, numbers only for Custom).
- "[]" is accepted.
- is_raw follows the white-balance key family, and a contradiction is
  logged.
- A canonical_version is stored. Older rows are re-read from their blob,
  for the at most 20 candidates of a request, with no write-back.
- style_engine::blend_white_balance votes over the whole candidate pool,
  weighted by score:
  - As Shot: the photo keeps its own white balance.
  - Auto or a named mode: a note, nothing applied (EMIT_NAMED_WB_MODES
    stays off until experiment E1).
  - Custom: Kelvin/Tint from up to 3 Custom examples of the photo's own
    family, only if there are at least 2 and they are within 500 K.
  - Raw examples never give a JPEG target Kelvin values; the user gets a
    note instead.
- The generic blend renormalises per key and rounds with registry
  precision. temperature/tint left it.
- The style response carries edit.white_balance at recipe level. There is
  no global.temperature/tint any more, and the LLM alias
  global.white_balance is untouched.
- Warnings are a Vec. Responses carry a top-level `warnings` array plus
  the legacy joined `warning` for installed plugins. Warning texts are
  constant per cause, so they merge across photos. Examples whose blob
  cannot be learned are reported, not dropped silently.
- The LLM path: TEMPERATURE_KEYS became the whole white-balance family,
  the prompt key names are fixed, and the local exposure range is +-4 with
  a drift test against the registry.

Plugin:
- DevelopEditManager.whiteBalanceFamily() decides by the photo's own
  settings, so a DNG converted from a JPEG counts as non-raw.
- edit.white_balance maps to Temperature/Tint or IncrementalTemperature/
  IncrementalTint, each with WhiteBalance="Custom". The old
  global.temperature/tint form maps the same way.
- The style request sends is_raw from that family.
- AI Edit and Save-as-Training collect `warnings` per run: deduplicated,
  counted, and the most frequent shown first. The review dialog lists why
  white balance was not transferred.
- No "Temp" literal is left. tests/no_temp_key.rs guards this, and it
  runs in the unfiltered format-lint-rust CI job. The develop experiments
  keep writing Temp on purpose.

Docs: Dev-Backend-API, Help-AI-Edit (incl. the release note text),
Help-Train-From-Edits, Dev-Develop-Model and Dev-AI-Edit-XMP-Findings
are updated.

Docs-Reviewed: Credits.md
Docs-Reviewed: Dev-Server-README.md
Docs-Reviewed: Help-Analyze-and-Index.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lies

PR 1f of the step-1 plan. The style engine blended 20 keys, but the
installed plugin already knows how to apply many more. 1f adds 45 recipe
aliases in the lrg-develop registry (65 in total). This needs no plugin
update and no change to the wire format.

- Detail: sharpen radius/detail/masking, luminance NR detail/contrast,
  colour NR detail/smoothness.
- Effects: post-crop vignette midpoint/roundness/feather/highlights, grain
  size/roughness.
- Colour mixer: hsl.<colour>.{hue,saturation,luminance} for all 8 colours.
- Colour grading: shadows/highlights hue and saturation, plus balance.
  Midtones, global, luminance and blending are left out, because the
  installed plugin warns about them.
- Parametric curve: the three region splits, blended as one group so their
  order holds. If rounding collides, a constant note is sent instead.

Toning hues use a circular mean (350 and 10 give 0). Saturation is the
length of the score-weighted mean colour vector, so tints that disagree
fade towards neutral instead of blending into a colour no example used.
The run summary says when a zone was toned down. Black-and-white examples
contribute no toning or colour-mixer values. Blend warnings reach the
response's `warnings`.

CANONICAL_VERSION is 3, so stored examples are re-read from their blob.
Guardrails are unchanged: the frame budget covers only tone and curve
strength, which the new keys do not affect (reasoning in Dev-Backend-API).

These values now replace the photo's own mixer, grading, splits and
detail/effects settings. Earlier versions left them alone; Help-AI-Edit
says so and points to the virtual-copy option.

Tests: 9 blend unit tests; the real Lua fixtures blended end to end; a
contract test for every new field; a busted spec run against this
branch's plugin and against origin/main's; and a local-only check over the
1,294 training rows (every key present, 0 blend notes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… file

Answers whether AI Edit can use a throwaway plugin preset per photo:
write it, apply it, delete it. The SDK has no call to import an .xmp
preset or to delete a preset. The programmatic equivalent is
addDevelopPresetForPlugin + applyDevelopPreset(updateAI) + deleting the
file that preset:getFile() returns.

E4f creates "LrGenius Experiment E4f" (a contrast value plus a subject
mask), applies it to a fresh copy and reads it back. It then deletes the
preset's file, under a pure path-safety check: absolute path, no relative
components, .xmp/.lrtemplate only, file name matching the preset, and a
file inside the Plugin Develop Presets folder or next to the other E4
preset files. After the delete it records:
- whether the copy keeps its settings and its computed mask;
- whether Lightroom still lists the preset, and whether its settings can
  still be read;
- what applying the deleted preset object to a second copy does;
- whether re-adding the same name recreates the file, and with which
  content.

Before creating its preset, E4f records the presets of that name already
listed, which answers the after-restart question on a second run. A
manual check names the plugin-preset folder to inspect after a restart.

Verdicts are VIABLE / NOT viable (qualified: within this session; for the
settings only while the mask is pending) or inconclusive when the preset
never took effect or the delete was refused. The outcome always appears
in the final dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PR 1c of the step-1 plan. lrg_develop::xmp::parse reads Camera Raw /
Lightroom XMP (sidecars, develop presets, profiles) into the same
DevelopSettings model the Lua reader produces. Classification, typing,
the white-balance family and process-version handling are shared through
a crate-private reader module.

- Forms: attribute and element form, property elements with attributes,
  nested rdf:Description, parseType="Resource", rdf:Seq, rdf:Alt.
  x-default goes into the model; rdf:Bag, other languages and qualifiers
  are kept whole and counted as kept_whole.
  - crss: snapshots are skipped and counted.
  - crs:Preset/Parameters and Look/Parameters stay opaque.
  - The namespace is matched by URI.
  - Top-level names resolve at global level first, then header
    (Baseline).
- Kinds: Sidecar, Preset (with a typed PresetHeader), Profile,
  NotDevelop.
- Errors: hard only for broken XML, size, encoding, a missing rdf:RDF and
  limits; everything else is a ParseWarning with a path. Duplicates keep
  their first occurrence.
- Untrusted input:
  - a 4 MiB cap;
  - DTDs refused;
  - a non-recursive depth pre-scan before roxmltree runs (a deeply nested
    file would otherwise abort the process);
  - node, attribute, namespace and URI-length limits;
  - namespace URIs shared via Arc, so a small file cannot amplify into
    gigabytes.
- Fixtures: 22 self-authored XMP files under
  server-rs/testdata/develop/xmp/ covering every form. An equivalence
  test shows the XMP and Lua readers build equal models for equal
  content. The hygiene checks (Python and Rust, kept in step) now also
  parse the XMP fixtures.
- Local-only sweeps (gate families lrc-presets, xmp-corpus, acr-presets
  in LOCAL_ONLY):
  - the LrC 15 bundle: 1,045 files with 0 errors and 0 unknown keys (only
    Adobe's own duplicate crs:Group in 6 profiles);
  - the maintainer's sidecar corpus: 10,814 sidecars with 0 errors and 0
    warnings;
  - Camera Raw's user folder: clean.
  The sweeps print counts only.
- roxmltree (MIT/Apache-2.0) added; Credits.md, Dev-Develop-Model.md,
  CLAUDE.md/AGENTS.md goldens paragraph and server-rs/README.md updated.

Docs-Reviewed: Dev-Build-Environment-Setup.md
Docs-Reviewed: Dev-Project-README.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	server-rs/crates/lrg-api/tests/contract.rs
#	server-rs/crates/lrg-providers/Cargo.toml
Merging main brought provider-level edit warnings (Gemini's thinking
fallback, an OpenAI-compatible server ignoring the answer format, Claude's
fallback). generate_edit_recipe_for_photo appends the style-training note
to that same slot on a new line, and the `warnings` list was built from
the slot as a single entry, so both notes reached the plugin as one text
that its tally could not group with the same note on other photos.

Split the slot into one entry per line where /v1/edit/recipe and the
LLM fallback of /v1/edit/style build the list.

Docs-Reviewed: Dev-Backend-API.md
Docs-Reviewed: Help-AI-Edit.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014fnt8Bs3rCjBvABRokgmoM
lrg-develop can now write a DevelopSettings model back out as a crs:
develop preset, and build the masks AI Edit will need without going
through Lightroom's UI.

- xmp::write (WriteMode::Preset with a PresetSpec header, file-kind and
  process-version gating) plus xmp::format for value spelling. The
  writer reports every value it drops in Written::skipped; nothing is
  lost silently. Opaque (kept-whole) values never reach a preset, the
  Look keeps only Name/UUID/Amount/Stubbed, Temperature/Tint next to a
  non-Custom white balance mode are dropped with their own reason.
- CompatibleVersion is derived from the features used (TARGET_ENGINE
  18.5); rows not yet confirmed in Lightroom are marked unverified.
- build: semantic (subject, sky, background, landscape, people parts),
  linear/radial gradients and luminance ranges, combined with add,
  subtract, intersect and add-inverted the way Lightroom writes them
  (range Invert mirrors MaskInverted; duplicate roles are refused).
- A test-roundtrip feature (self dev-dependency, never in the server
  build) exercises write -> parse -> compare.

Local sweeps (LOCAL_ONLY, private inputs never committed): training
dump, the Lightroom preset bundle, the Camera Raw presets and the XMP
corpus all round-trip with 0 differences against the filtered
settings; builder output matches Adobe's adaptive presets exactly.

Three hand-test presets (Subject+Sky, Landscape+People,
Combinations+Background) are committed as byte goldens; their import
into Lightroom is still open.

Docs-Reviewed: Dev-Develop-Model.md
Docs-Reviewed: Dev-AI-Edit-XMP-Findings.md
Docs-Reviewed: Credits.md
Docs-Reviewed: Dev-Server-README.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…de/develop-xmpw-1b

# Conflicts:
#	AGENTS.md
#	CLAUDE.md
#	server-rs/Cargo.lock
lrg-develop can now write a DevelopSettings model as the Lua table
photo:applyDevelopSettings takes, sent as JSON and decoded by the
plugin's JSON.lua. Nothing calls it yet; that is step 2.

- lua::to_lua_value(settings, LuaMode::Apply(PhotoContext), options)
  returns the table, every dropped value as Skipped, and the call flags
  (updateAISettings, flattenAutoNow). check_wire guards the JSON.lua
  hazards: no null, no empty containers (MaskGroupBasedCorrections = {}
  would delete every mask), no mixed or sparse tables. Temp and
  ProcessVersion are never written, white balance always as a family,
  computed mask fields and digests never.
- LuaOptions::PROVISIONAL holds every choice the experiments decide
  (mask enum encoding, flag encoding, panel switches, mask and local
  form, WB forms, AI update, Look form, preset amount flags), each with
  the experiment that settles it and the evidence for its default.
- 18 wire goldens in testdata/develop/wire/ plus key_classes.txt, both
  generated from the registry (LRG_BLESS=1); not frozen until E1/E2/E4.
  plugin/spec/native_wire_format_spec.lua decodes them with JSON.lua and
  type-checks every value; lua-tests.yml now runs on wire/ changes.
- Round trip 2 (model -> Lua -> model) is lossless and round trip 3
  (Lua -> model -> XMP preset -> model -> Lua) agrees after quantisation
  over the fixtures, the goldens under every option flip, and locally
  over the training dump, the Lightroom bundle, Camera Raw presets and
  the sidecar corpus.
- Registry: new unknown-policy row Glow (seen in the corpus).

Docs-Reviewed: Dev-Develop-Model.md
Docs-Reviewed: Dev-AI-Edit-XMP-Findings.md
Docs-Reviewed: Dev-Server-README.md
Docs-Reviewed: Dev-Backend-API.md
Docs-Reviewed: Help-AI-Edit.md
Docs-Reviewed: Help-Train-From-Edits.md
Docs-Reviewed: Credits.md
Docs-Reviewed: README.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…esets-1a845a

# Conflicts:
#	docs/doc-sources.toml
…o claude/develop-model-1a

# Conflicts:
#	AGENTS.md
#	CLAUDE.md
…aude/develop-xmp-1c

# Conflicts:
#	AGENTS.md
#	CLAUDE.md
…de/develop-xmpw-1b

# Conflicts:
#	AGENTS.md
#	CLAUDE.md
…registry

One run on Lightroom Classic 15.6 (2026-10-03), from the Library module,
on one non-raw TIFF virtual copy without develop edits; no raw file, and
the one people-part mask found nothing.

- LuaOptions::EVIDENCE: one typed row per option (settled / supported /
  open, experiments, scope, what is still open); a test requires a row per
  field and pins the first run's statuses. Settled in that scope:
  mask_enum_as, mask_form and local_form for subject/sky/background,
  ai_update (E2b vs E2c), wb_mode_only and flatten_auto_now on non-raw
  (E1f/E1g), preset_amount_flags (E4e: the amount has no effect).
  Supported only: wb_custom_with_numbers (E1b/E1d) and panel_switches.
  Open: int_flag_as, look_form, people parts, every raw-side question.
- panel_switches defaults to MaskOnly, the form E2 applied (None was
  never applied); ten wire goldens gain the mask switch.
- A mode alone is never written for a non-raw photo, in the writer and
  in the style engine: the Auto/named-mode note for JPEG/TIFF targets now
  says the mode does not apply to non-raw files.
- Registry: non-raw defaults from E13's readback, committed scrubbed as
  testdata/develop/defaults/non_raw_lrc15.6.json (key -> value plus
  Lightroom version, process version, file kind) and read by
  gen_table_rs.py; 145 rows filled, HDRMaxValue held back (one photo),
  NoDefault rows kept, nothing marked absent. spec.py now mirrors
  table.rs, so the generator reproduces it byte for byte. Effect: removed
  values equal to a non-raw default are no longer reported for non-raw
  sources (tested).
- Findings: dated results section; step 2 applies AI masks with
  applyDevelopSettings + updateAISettings(), not plugin presets (they
  replace the photo's masks, their updateAI flag left the mask pending in
  the Library module, the amount is ignored).

Docs-Reviewed: Dev-Develop-Model.md
Docs-Reviewed: Dev-AI-Edit-XMP-Findings.md
Docs-Reviewed: Dev-Backend-API.md
Docs-Reviewed: Help-AI-Edit.md
Docs-Reviewed: Help-Train-From-Edits.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ude/develop-luaw-1g

# Conflicts:
#	AGENTS.md
#	CLAUDE.md
catalog:createVirtualCopies() files a copy of a virtual copy under that
copy's master, as Photo > Create Virtual Copy does. AI Edit compared the
new copy's master with the source photo itself, so with "Apply the edit
to a new virtual copy" every source that was itself a virtual copy failed
with "Lightroom copied a different photo", after the copy had already
been created.

- Util.isVirtualCopyOf: pure check. For a virtual-copy source the new
  copy's master must equal the source's master; for a master source it
  must equal the source. A copy of a different master is never accepted.
- Check again, inside the write gate and right before copying, that the
  source alone is selected: waiting for write access can take up to the
  gate's timeout, and a changed selection would copy another photo.
- Count every "AI Edit" copy a failed attempt leaves behind (failed
  check, more than one copy returned, recipe not saved, recipe not
  applied) and say once in the run summary how many to remove with
  Photo > Remove Photo; the SDK cannot delete photos.
- Log catalog-local ids and isVirtualCopy when the check fails.
- Help-AI-Edit: virtual-copy sources work; stray copies after a failure.

Docs-Reviewed: Dev-Plugin-README.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	docs/wiki/Dev-Server-README.md
#	server-rs/README.md
bmachek and others added 12 commits October 23, 2026 06:22
# Conflicts:
#	docs/wiki/Dev-Backend-API.md
#	docs/wiki/Dev-Server-README.md
#	plugin/LrGeniusAI.lrdevplugin/APISearchIndex.lua
#	plugin/LrGeniusAI.lrdevplugin/DevelopEditManager.lua
#	plugin/LrGeniusAI.lrdevplugin/TaskAiEditPhotos.lua
#	plugin/LrGeniusAI.lrdevplugin/TaskTrainFromEdits.lua
#	plugin/LrGeniusAI.lrdevplugin/Util.lua
#	server-rs/Cargo.toml
#	server-rs/README.md
#	server-rs/crates/lrg-api/Cargo.toml
#	server-rs/crates/lrg-api/src/routes/edit.rs
#	server-rs/crates/lrg-api/src/routes/style_edit.rs
#	server-rs/crates/lrg-api/tests/contract.rs
# Conflicts:
#	docs/wiki/Dev-Server-README.md
#	server-rs/README.md
# Conflicts:
#	docs/wiki/Dev-Server-README.md
#	server-rs/README.md
# Conflicts:
#	docs/wiki/Dev-Server-README.md
#	server-rs/README.md
…i-edit-xmp

# Conflicts:
#	plugin/LrGeniusAI.lrdevplugin/TaskAiEditPhotos.lua
#	plugin/LrGeniusAI.lrdevplugin/Util.lua
Raw runs on LrC 15.6 (E1, E2, E4, E11, E13): E11b shows a complete Look
with Parameters is applied while a stub sets none, so look_form defaults
to Full; a Look without Parameters is reported as Look.Parameters. The
evidence rows, the wire goldens with a Look and the wiki carry the results.

Docs-Reviewed: Dev-AI-Edit-XMP-Findings.md
Docs-Reviewed: Dev-Develop-Model.md

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013a7JYahnNttgNMVgEDLLDJ
…M loop

Brings lrg-develop (key registry, typed model, Lua/XMP readers and
writers, mask builders), the style engine's typed white balance and the
develop-settings experiments onto current main, as the base for applying
LLM edits directly through applyDevelopSettings + updateAISettings.

Conflicts: Info.lua keeps main's Help-menu entries and adds the
experiments; Util.lua keeps both sides' helpers; style_edit.rs takes the
branch's warning list with main's guardrail result (the face readers and
its own warning), and drops the temperature sanitising the branch's
typed white balance made unnecessary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HaMMvswyTLENR7Gdygjt41
…s it

The single-shot LLM edit guessed absolute slider values for a file it had
never seen rendered, and the style engine never looks at the photo at all.
The retoucher works the way a person at the Develop panel does: a vision
model edits, Lightroom applies and renders the edit, the model sees the
render next to the original with measurements of its tones and corrects,
until it finishes or runs out of rounds (default 3, at most 5).

Backend
- lrg-api edit_loop + routes/edit_session: POST /v1/edit/sessions, POST
  /v1/edit/sessions/{id}/render, DELETE /v1/edit/sessions/{id}. The
  conversation runs on lrg_providers::chat::ChatModel, so every provider the
  agent chat runs on works, local engines included. Each edit passes the
  existing guardrails, becomes DevelopSettings through
  lrg_develop::build::recipe and an applyDevelopSettings table through the
  Lua writer — its first production caller.
- lrg-providers edit_loop: the system prompt, looks, the apply_edit/finish
  tools with a schema built from the develop registry (Lightroom's own
  ranges, the photo's white-balance family), opening and render texts.
- lrg-develop build::recipe: recipe -> settings (aliases, colour-grading
  midtones, black-and-white mix, white balance per family, AI masks for
  subject, sky, background, ten people parts and seven landscape classes
  with sync ids derived from the target) and a photo's starting values.
- lrg-imaging tone: luminance percentiles, histogram bands, clipping, the
  colour of the neutral tones, midtone saturation — what the model is told
  to trust over its eye.
- lrg-analysis style_engine::white_balance_into_global, so the
  photographer's style can be round 0.

Plugin
- EditLoop.lua: applies each round from the photo's starting point (keys an
  earlier round set go back; masks merge by CorrectionSyncID, the photo's
  own kept raw), computes AI masks with updateAISettings (LrC 15.3+),
  renders at 1536 px, reports masks that found nothing, reverts on discard.
- TaskAiEditPhotos: two editors, AI retoucher (default) and My style only
  (the style engine as before, now runStyleEdits). Model, look, own
  instructions, edits per photo, start from my style, AI masks; review is a
  Keep / Discard / Keep all dialog on the finished edit.
- persistEditRecipe returns whether it recorded, and both editors report a
  failure as a warning.

Live-tested against the dev binary over HTTP with a scripted
OpenAI-compatible server (start, two renders, back to round 2, persist);
not yet in Lightroom or with a real model.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HaMMvswyTLENR7Gdygjt41
…ay what they show

Found in the live test in Lightroom (LrC 16.0, test catalog):

- Started from the photographer's style, the model never saw the style's
  values, and since every edit is complete from the starting point,
  whatever it did not repeat — the colour mix, the grading, the detail
  settings — fell back to the photo's own. Edits are now merged onto the
  style (`merge_onto`): the model sends what it changes, the style keeps
  the rest, and round 0's text lists the style's values.
- A small local model (Gemma 4 E4B on MLX) dropped the values it meant to
  keep in later rounds. Every render now comes with the values of the edit
  it shows (`recipe_text`), so the model has the numbers in front of it.

The live run's evidence goes into Dev-AI-Edit-XMP-Findings: people-part
(face skin, iris) and landscape (vegetation) masks computed through
applyDevelopSettings + updateAISettings, own masks passed back raw kept,
same-sync-id corrections replaced in place, and an empty
MaskGroupBasedCorrections removing the loop's masks on discard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HaMMvswyTLENR7Gdygjt41
@bmachek
bmachek merged commit 31e8f89 into main Nov 13, 2026
5 checks passed
@bmachek
bmachek deleted the feat/ai-edit-llm-loop branch November 13, 2026 14:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants