@pired/lightroom-mcp 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +70 -0
  3. package/dist/LightroomMCP.lrplugin/HandlerAI.lua +466 -0
  4. package/dist/LightroomMCP.lrplugin/HandlerAIMasks.lua +549 -0
  5. package/dist/LightroomMCP.lrplugin/HandlerCatalog.lua +256 -0
  6. package/dist/LightroomMCP.lrplugin/HandlerCollections.lua +337 -0
  7. package/dist/LightroomMCP.lrplugin/HandlerDevelop.lua +1668 -0
  8. package/dist/LightroomMCP.lrplugin/HandlerExport.lua +149 -0
  9. package/dist/LightroomMCP.lrplugin/HandlerImport.lua +108 -0
  10. package/dist/LightroomMCP.lrplugin/HandlerLocalAdjustments.lua +330 -0
  11. package/dist/LightroomMCP.lrplugin/HandlerMetadata.lua +169 -0
  12. package/dist/LightroomMCP.lrplugin/HandlerOrganization.lua +783 -0
  13. package/dist/LightroomMCP.lrplugin/HandlerPreview.lua +189 -0
  14. package/dist/LightroomMCP.lrplugin/HandlerSearch.lua +185 -0
  15. package/dist/LightroomMCP.lrplugin/HandlerSelection.lua +302 -0
  16. package/dist/LightroomMCP.lrplugin/HandlerSpots.lua +313 -0
  17. package/dist/LightroomMCP.lrplugin/HandlerWatermark.lua +59 -0
  18. package/dist/LightroomMCP.lrplugin/Info.lua +25 -0
  19. package/dist/LightroomMCP.lrplugin/JSON.lua +270 -0
  20. package/dist/LightroomMCP.lrplugin/Log.lua +92 -0
  21. package/dist/LightroomMCP.lrplugin/MenuShowStatus.lua +22 -0
  22. package/dist/LightroomMCP.lrplugin/PhotoLookup.lua +39 -0
  23. package/dist/LightroomMCP.lrplugin/PluginInfoProvider.lua +865 -0
  24. package/dist/LightroomMCP.lrplugin/PluginInit.lua +48 -0
  25. package/dist/cli.d.ts +6 -0
  26. package/dist/cli.d.ts.map +1 -0
  27. package/dist/cli.js +31 -0
  28. package/dist/cli.js.map +1 -0
  29. package/dist/configs/claude-code.mcp.json +9 -0
  30. package/dist/configs/claude-desktop.json +12 -0
  31. package/dist/configs/generic-stdio-client.json +10 -0
  32. package/dist/configs/mcp-proxy-http.json +9 -0
  33. package/dist/create-server.d.ts +12 -0
  34. package/dist/create-server.d.ts.map +1 -0
  35. package/dist/create-server.js +17 -0
  36. package/dist/create-server.js.map +1 -0
  37. package/dist/deepseek-harness/README.md +112 -0
  38. package/dist/deepseek-harness/deepseek-mcp-harness.mjs +292 -0
  39. package/dist/dispatcher.d.ts +34 -0
  40. package/dist/dispatcher.d.ts.map +1 -0
  41. package/dist/dispatcher.js +72 -0
  42. package/dist/dispatcher.js.map +1 -0
  43. package/dist/heartbeat.d.ts +17 -0
  44. package/dist/heartbeat.d.ts.map +1 -0
  45. package/dist/heartbeat.js +36 -0
  46. package/dist/heartbeat.js.map +1 -0
  47. package/dist/index.d.ts +3 -0
  48. package/dist/index.d.ts.map +1 -0
  49. package/dist/index.js +255 -0
  50. package/dist/index.js.map +1 -0
  51. package/dist/install-plugin.d.ts +16 -0
  52. package/dist/install-plugin.d.ts.map +1 -0
  53. package/dist/install-plugin.js +125 -0
  54. package/dist/install-plugin.js.map +1 -0
  55. package/dist/instance-lock.d.ts +5 -0
  56. package/dist/instance-lock.d.ts.map +1 -0
  57. package/dist/instance-lock.js +77 -0
  58. package/dist/instance-lock.js.map +1 -0
  59. package/dist/list-tools-handler.d.ts +16 -0
  60. package/dist/list-tools-handler.d.ts.map +1 -0
  61. package/dist/list-tools-handler.js +50 -0
  62. package/dist/list-tools-handler.js.map +1 -0
  63. package/dist/plugin-liveness.d.ts +48 -0
  64. package/dist/plugin-liveness.d.ts.map +1 -0
  65. package/dist/plugin-liveness.js +75 -0
  66. package/dist/plugin-liveness.js.map +1 -0
  67. package/dist/plugin-socket.d.ts +32 -0
  68. package/dist/plugin-socket.d.ts.map +1 -0
  69. package/dist/plugin-socket.js +97 -0
  70. package/dist/plugin-socket.js.map +1 -0
  71. package/dist/ports.d.ts +3 -0
  72. package/dist/ports.d.ts.map +1 -0
  73. package/dist/ports.js +22 -0
  74. package/dist/ports.js.map +1 -0
  75. package/dist/token.d.ts +3 -0
  76. package/dist/token.d.ts.map +1 -0
  77. package/dist/token.js +23 -0
  78. package/dist/token.js.map +1 -0
  79. package/dist/tool-contracts.d.ts +13 -0
  80. package/dist/tool-contracts.d.ts.map +1 -0
  81. package/dist/tool-contracts.js +1422 -0
  82. package/dist/tool-contracts.js.map +1 -0
  83. package/dist/tool-handler.d.ts +23 -0
  84. package/dist/tool-handler.d.ts.map +1 -0
  85. package/dist/tool-handler.js +96 -0
  86. package/dist/tool-handler.js.map +1 -0
  87. package/dist/validate-args.d.ts +7 -0
  88. package/dist/validate-args.d.ts.map +1 -0
  89. package/dist/validate-args.js +37 -0
  90. package/dist/validate-args.js.map +1 -0
  91. package/dist/version.d.ts +2 -0
  92. package/dist/version.d.ts.map +1 -0
  93. package/dist/version.js +4 -0
  94. package/dist/version.js.map +1 -0
  95. package/dist/wait-until.d.ts +7 -0
  96. package/dist/wait-until.d.ts.map +1 -0
  97. package/dist/wait-until.js +15 -0
  98. package/dist/wait-until.js.map +1 -0
  99. package/package.json +73 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Marcin Skalski
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,70 @@
1
+ # @pired/lightroom-mcp
2
+
3
+ MCP server bridging Claude / DeepSeek / Codex / Cursor to Adobe Lightroom Classic via a bundled Lua plugin — with hybrid AI Denoise, spot removal, local + AI + range masks, tone curves, Auto commands, scoped resets, process version, snapshots, inline JPEG previews, selection & navigation and batch IPTC.
4
+
5
+ Published on npm as **`@pired/lightroom-mcp`**. This is the `server/` half of the
6
+ **pired-lightroom-mcp** repository (a fork of
7
+ [Automaat/lightroom-mcp](https://github.com/Automaat/lightroom-mcp) that keeps all
8
+ 18 original tools and adds 37 more, 55 in total). The full documentation — installation,
9
+ tool reference and troubleshooting — lives in the repository's main `README.md`.
10
+ The npm tarball is self-contained: `dist/` carries the compiled server, the
11
+ `LightroomMCP.lrplugin` Lua plugin (so `install-plugin` works out of the box), the
12
+ zero-dependency DeepSeek harness (`dist/deepseek-harness/`) and ready-made client
13
+ configs (`dist/configs/`).
14
+
15
+ ## Quick start (npm)
16
+
17
+ ```bash
18
+ npm install -g @pired/lightroom-mcp
19
+ pired-lightroom-mcp install-plugin # copies the .lrplugin into Lightroom's Modules folder
20
+ ```
21
+
22
+ Then restart Lightroom Classic and click **Start Server** in
23
+ File → Plug-in Manager → Lightroom MCP AI.
24
+
25
+ Point your MCP client at the package (no global install needed):
26
+
27
+ ```json
28
+ {
29
+ "mcpServers": {
30
+ "lightroom": {
31
+ "command": "npx",
32
+ "args": ["-y", "@pired/lightroom-mcp"]
33
+ }
34
+ }
35
+ }
36
+ ```
37
+
38
+ On Windows with Claude Desktop use the `cmd` wrapper (see `dist/configs/claude-desktop.json`).
39
+
40
+ ## Quick start (from source)
41
+
42
+ ```bash
43
+ cd server
44
+ npm install
45
+ npm run build
46
+ node dist/index.js install-plugin # copies the .lrplugin into Lightroom's Modules folder
47
+ ```
48
+
49
+ Then point your client at `server/dist/index.js` (absolute path).
50
+
51
+ ## Commands
52
+
53
+ ```
54
+ pired-lightroom-mcp [stdio] Run MCP over stdio (default)
55
+ pired-lightroom-mcp install-plugin Copy plugin into Lightroom Modules folder
56
+ pired-lightroom-mcp --help | --version
57
+ ```
58
+
59
+ ## Environment
60
+
61
+ | Var | Default | Purpose |
62
+ | --- | --- | --- |
63
+ | `LIGHTROOM_MCP_REQUEST_PORT` | `58763` | Plugin request port. |
64
+ | `LIGHTROOM_MCP_RESPONSE_PORT` | `58764` | Plugin response port. |
65
+ | `LIGHTROOM_MCP_TOKEN_PATH` | `~/.config/lightroom-mcp/token` | Auth token file. |
66
+ | `LIGHTROOM_MCP_TOOLS` | `all` | Comma-separated subset of tools to expose (token-constrained clients); unknown names warn on stderr, no-match falls back to all. |
67
+
68
+ ## License
69
+
70
+ MIT
@@ -0,0 +1,466 @@
1
+ local LrApplication = import 'LrApplication'
2
+ local LrTasks = import 'LrTasks'
3
+ local LrPathUtils = import 'LrPathUtils'
4
+ local LrFileUtils = import 'LrFileUtils'
5
+
6
+ local PhotoLookup = require 'PhotoLookup'
7
+ local Log = require 'Log'
8
+
9
+ local AIHandler = {}
10
+
11
+ -- =====================================================================
12
+ -- AI Denoise (hybrid)
13
+ -- =====================================================================
14
+ --
15
+ -- Adobe does not expose AI Denoise (Photo > Enhance) through the
16
+ -- Lightroom Classic SDK, so this handler drives it in a best-effort way:
17
+ --
18
+ -- 1. select the target photo in the Lightroom UI (the Enhance menu
19
+ -- acts on the selection, not on an arbitrary catalog photo);
20
+ -- 2. run a small PowerShell helper that activates the Lightroom
21
+ -- window and replays a configurable SendKeys sequence to open
22
+ -- Photo > Enhance... and confirm the dialog (defaults target the
23
+ -- English menu; every key is overridable per call);
24
+ -- 3. poll the photo's folder for a brand-new DNG next to the source
25
+ -- file (that is where AI Denoise writes its result) and wait for
26
+ -- it to show up in the catalog;
27
+ -- 4. if any of the above fails (shortcut mismatch, focus stolen,
28
+ -- dialog not confirmed, timeout), fall back to a smart manual
29
+ -- noise reduction via the official SDK and clearly report which
30
+ -- method was used.
31
+ --
32
+ -- The PowerShell helper communicates through a result file because
33
+ -- LrTasks.execute() does not capture child-process stdout.
34
+
35
+ local DEFAULT_WINDOW_TITLE = "Lightroom Classic"
36
+ local DEFAULT_MENU_KEYS = "%pe" -- Alt+P (Photo menu), then E (Enhance...)
37
+ local DEFAULT_CONFIRM_KEYS = "{ENTER}" -- confirm the dialog with defaults
38
+ local DEFAULT_KEY_DELAY_MS = 600
39
+ local DEFAULT_VERIFY_TIMEOUT_S = 90
40
+ local MIN_VERIFY_TIMEOUT_S = 10
41
+ local MAX_VERIFY_TIMEOUT_S = 240
42
+ local CATALOG_SETTLE_TIMEOUT_S = 30
43
+ local POLL_INTERVAL_S = 2
44
+ local MAX_SPOTS_PER_REQUEST = 50 -- shared cap style with other handlers
45
+
46
+ -- File formats AI Denoise accepts (as returned by getRawMetadata('fileFormat')).
47
+ local DENOISABLE_FORMATS = {
48
+ RAW = true,
49
+ DNG = true,
50
+ }
51
+
52
+ local MANUAL_NOISE_KEYS = {
53
+ luminance = "LuminanceSmoothing",
54
+ color = "ColorNoiseReduction",
55
+ luminance_detail = "LuminanceNoiseReductionDetail",
56
+ luminance_contrast = "LuminanceNoiseReductionContrast",
57
+ color_detail = "ColorNoiseReductionDetail",
58
+ color_smoothness = "ColorNoiseReductionSmoothness",
59
+ sharpness = "Sharpness",
60
+ sharpen_radius = "SharpenRadius",
61
+ sharpen_detail = "SharpenDetail",
62
+ sharpen_edge_masking = "SharpenEdgeMasking",
63
+ }
64
+
65
+ local function clampNumber(value, min, max, name)
66
+ if type(value) ~= "number" then
67
+ error(name .. " must be a number")
68
+ end
69
+ if value < min or value > max then
70
+ error(string.format("%s must be between %s and %s", name, tostring(min), tostring(max)))
71
+ end
72
+ return value
73
+ end
74
+
75
+ local function clamp01(value)
76
+ if value < 0 then return 0 end
77
+ if value > 1 then return 1 end
78
+ return value
79
+ end
80
+
81
+ -- ISO-aware manual noise reduction defaults. The LrC noise slider is 0-100;
82
+ -- these starting points are deliberately conservative.
83
+ local function smartManualDefaults(photo)
84
+ local iso = tonumber(photo:getFormattedMetadata('isoSpeedRating')) or 0
85
+ local luminance = 35
86
+ if iso >= 12800 then
87
+ luminance = 65
88
+ elseif iso >= 6400 then
89
+ luminance = 55
90
+ elseif iso >= 3200 then
91
+ luminance = 45
92
+ end
93
+ return {
94
+ luminance = luminance,
95
+ color = 25,
96
+ }
97
+ end
98
+
99
+ local function buildManualSettings(photo, manualSettings)
100
+ manualSettings = manualSettings or {}
101
+ local defaults = smartManualDefaults(photo)
102
+ local settings = {}
103
+
104
+ for argName, sdkKey in pairs(MANUAL_NOISE_KEYS) do
105
+ local value = manualSettings[argName]
106
+ if value == nil and (argName == "luminance" or argName == "color") then
107
+ value = defaults[argName]
108
+ end
109
+ if value ~= nil then
110
+ if argName == "sharpen_radius" then
111
+ settings[sdkKey] = clampNumber(value, 0.5, 3, argName)
112
+ else
113
+ settings[sdkKey] = clampNumber(value, 0, 100, argName)
114
+ end
115
+ end
116
+ end
117
+
118
+ if next(settings) == nil then
119
+ settings.LuminanceSmoothing = defaults.luminance
120
+ settings.ColorNoiseReduction = defaults.color
121
+ end
122
+
123
+ return settings
124
+ end
125
+
126
+ -- Write the SendKeys helper script next to the plugin's temp area and run it
127
+ -- synchronously on this task. Returns a status table:
128
+ -- { ok = true } -- keys were sent
129
+ -- { ok = false, error = "..." } -- activate/send failure
130
+ local function sendNativeDenoiseKeys(automation)
131
+ if WIN_ENV == nil and MAC_ENV == nil then
132
+ -- Test environment (busted): no OS to automate. Report failure so the
133
+ -- hybrid path exercises its manual fallback.
134
+ return { ok = false, error = "OS automation unavailable in this environment" }
135
+ end
136
+
137
+ local windowTitle = automation.window_title or DEFAULT_WINDOW_TITLE
138
+ local menuKeys = automation.menu_keys or DEFAULT_MENU_KEYS
139
+ local confirmKeys = automation.confirm_keys or DEFAULT_CONFIRM_KEYS
140
+ local keyDelay = automation.key_delay_ms or DEFAULT_KEY_DELAY_MS
141
+ local preDelay = automation.pre_delay_ms or 400
142
+
143
+ local tempDir = LrPathUtils.getStandardFilePath("temp")
144
+ local scriptPath = LrPathUtils.child(tempDir, "lightroom-mcp-ai-denoise.ps1")
145
+ local resultPath = LrPathUtils.child(tempDir, "lightroom-mcp-ai-denoise.result")
146
+
147
+ pcall(function() LrFileUtils.delete(resultPath) end)
148
+
149
+ -- Escape single quotes for PowerShell single-quoted literals ('' is a
150
+ -- single quote inside one). Key sequences themselves are SendKeys syntax
151
+ -- (e.g. "%pe" = Alt+P then E) and must NOT be escaped further.
152
+ local function psQuote(s)
153
+ return (s:gsub("'", "''"))
154
+ end
155
+
156
+ local script = table.concat({
157
+ "$ErrorActionPreference = 'Stop'",
158
+ "Add-Type -AssemblyName System.Windows.Forms",
159
+ "$shell = New-Object -ComObject WScript.Shell",
160
+ "try {",
161
+ " $activated = $shell.AppActivate('" .. psQuote(windowTitle) .. "')",
162
+ " if (-not $activated) { 'activate-failed' | Out-File -Encoding ascii '" .. psQuote(resultPath) .. "'; exit 2 }",
163
+ " Start-Sleep -Milliseconds " .. tostring(math.floor(preDelay)),
164
+ " $shell.SendKeys('" .. psQuote(menuKeys) .. "')",
165
+ " Start-Sleep -Milliseconds " .. tostring(math.floor(keyDelay)),
166
+ " $shell.SendKeys('" .. psQuote(confirmKeys) .. "')",
167
+ " 'keys-sent' | Out-File -Encoding ascii '" .. psQuote(resultPath) .. "'",
168
+ "} catch {",
169
+ " ('error: ' + $_.Exception.Message) | Out-File -Encoding ascii '" .. psQuote(resultPath) .. "'",
170
+ "}",
171
+ }, "\r\n")
172
+
173
+ local fh, openErr = io.open(scriptPath, "w")
174
+ if not fh then
175
+ return { ok = false, error = "failed to write helper script: " .. tostring(openErr) }
176
+ end
177
+ fh:write(script)
178
+ fh:close()
179
+
180
+ local command = 'powershell -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File "'
181
+ .. scriptPath .. '"'
182
+ local execOk, execResult = pcall(function() return LrTasks.execute(command) end)
183
+
184
+ -- Give the filesystem a beat to flush the result file before reading it.
185
+ local resultContent = nil
186
+ for _ = 1, 10 do
187
+ local rf = io.open(resultPath, "r")
188
+ if rf then
189
+ resultContent = rf:read("*a") or ""
190
+ rf:close()
191
+ break
192
+ end
193
+ LrTasks.sleep(0.2)
194
+ end
195
+
196
+ pcall(function() LrFileUtils.delete(scriptPath) end)
197
+
198
+ if resultContent == nil then
199
+ return {
200
+ ok = false,
201
+ error = "SendKeys helper produced no result (execute ok="
202
+ .. tostring(execOk) .. ", status " .. tostring(execResult) .. ")",
203
+ }
204
+ end
205
+
206
+ resultContent = resultContent:gsub("^%s+", ""):gsub("%s+$", "")
207
+ if resultContent == "keys-sent" then
208
+ return { ok = true }
209
+ end
210
+ if resultContent == "activate-failed" then
211
+ return { ok = false, error = "could not activate the Lightroom window (title: '" .. windowTitle .. "')" }
212
+ end
213
+ return { ok = false, error = "SendKeys helper failed: " .. resultContent }
214
+ end
215
+
216
+ -- Returns the names (not paths) of every file in `folder`, or nil on error.
217
+ local function listFolderNames(folder)
218
+ local ok, names = pcall(function() return LrFileUtils.directoryContents(folder) end)
219
+ if not ok or type(names) ~= "table" then return nil end
220
+ return names
221
+ end
222
+
223
+ -- Poll `folder` for a DNG that did not exist in `before` and whose name starts
224
+ -- with the source file's base name. Returns the new file name or nil.
225
+ local function findNewDngName(folder, sourceBase, before)
226
+ local names = listFolderNames(folder)
227
+ if not names then return nil end
228
+ local lowerBase = sourceBase:lower()
229
+ for _, name in ipairs(names) do
230
+ if not before[name] then
231
+ local lowerName = name:lower()
232
+ if lowerName:sub(1, #lowerBase) == lowerBase and lowerName:find("%.dng$") then
233
+ return name
234
+ end
235
+ end
236
+ end
237
+ return nil
238
+ end
239
+
240
+ local function photoIsDenoisable(photo)
241
+ local format = photo:getRawMetadata('fileFormat')
242
+ return format ~= nil and DENOISABLE_FORMATS[format] == true
243
+ end
244
+
245
+ function AIHandler.aiDenoise(args)
246
+ args = args or {}
247
+ if args.photo_id == nil or args.photo_id == "" then
248
+ error("photo_id is required")
249
+ end
250
+
251
+ local fallback = args.fallback or "manual"
252
+ if fallback ~= "manual" and fallback ~= "none" then
253
+ error("fallback must be 'manual' or 'none'")
254
+ end
255
+
256
+ local automation = args.native_automation or {}
257
+ local verifyTimeout = tonumber(automation.verify_timeout_s) or DEFAULT_VERIFY_TIMEOUT_S
258
+ if verifyTimeout < MIN_VERIFY_TIMEOUT_S then verifyTimeout = MIN_VERIFY_TIMEOUT_S end
259
+ if verifyTimeout > MAX_VERIFY_TIMEOUT_S then verifyTimeout = MAX_VERIFY_TIMEOUT_S end
260
+
261
+ local catalog = LrApplication.activeCatalog()
262
+
263
+ local photo, sourcePath, sourceFolder, sourceBase
264
+ catalog:withReadAccessDo(function()
265
+ photo = PhotoLookup.resolveOne(catalog, args.photo_id)
266
+ if not photo then
267
+ error("Photo not found: " .. tostring(args.photo_id))
268
+ end
269
+ sourcePath = photo:getRawMetadata('path')
270
+ end)
271
+
272
+ if not sourcePath then
273
+ error("Photo has no path on disk: " .. tostring(args.photo_id))
274
+ end
275
+
276
+ sourceFolder = LrPathUtils.parent(sourcePath)
277
+ sourceBase = LrPathUtils.removeExtension(LrPathUtils.leafName(sourcePath))
278
+
279
+ -- Photos the user did not pick must not silently degrade to sliders: if
280
+ -- the source is not RAW/DNG the native path cannot work at all.
281
+ if not photoIsDenoisable(photo) then
282
+ local reason = "AI Denoise requires a RAW or DNG file (this photo is "
283
+ .. tostring(photo:getRawMetadata('fileFormat')) .. ")"
284
+ if fallback == "none" then
285
+ error(reason)
286
+ end
287
+ local manualSettings = buildManualSettings(photo, args.manual_settings)
288
+ catalog:withWriteAccessDo("AI Denoise Manual Fallback", function()
289
+ photo:applyDevelopSettings(manualSettings, "MCP AI Denoise (manual fallback)")
290
+ end)
291
+ return {
292
+ success = true,
293
+ method = "manual_fallback",
294
+ photo_id = photo.localIdentifier,
295
+ reason = reason,
296
+ applied_settings = manualSettings,
297
+ message = reason
298
+ .. ". Applied smart manual noise reduction instead. Convert/export to DNG first if you need the real AI Denoise.",
299
+ }
300
+ end
301
+
302
+ -- Snapshot the folder so the new DNG can be told apart from older ones.
303
+ local before = {}
304
+ for _, name in ipairs(listFolderNames(sourceFolder) or {}) do
305
+ before[name] = true
306
+ end
307
+
308
+ -- 1. Select the photo so the Photo > Enhance command targets it.
309
+ -- Yields to the UI thread: MUST stay outside any catalog access gate.
310
+ Log.info("ai_denoise: selecting photo " .. tostring(photo.localIdentifier))
311
+ catalog:setSelectedPhotos(photo, { photo })
312
+
313
+ -- 2. Fire the menu automation.
314
+ local sendResult = sendNativeDenoiseKeys(automation)
315
+ if not sendResult.ok then
316
+ Log.warn("ai_denoise: native trigger failed: " .. tostring(sendResult.error))
317
+ end
318
+
319
+ -- 3. Poll for the new DNG regardless; a mis-reported SendKeys status can
320
+ -- still have delivered the keys, and vice versa.
321
+ local newPhoto = nil
322
+ local newPhotoPath = nil
323
+ do
324
+ local waited = 0
325
+ while waited < verifyTimeout do
326
+ LrTasks.sleep(POLL_INTERVAL_S)
327
+ waited = waited + POLL_INTERVAL_S
328
+
329
+ local newName = findNewDngName(sourceFolder, sourceBase, before)
330
+ if newName then
331
+ newPhotoPath = LrPathUtils.child(sourceFolder, newName)
332
+ -- The file exists; wait for the catalog to register it.
333
+ local settleWaited = 0
334
+ while settleWaited < CATALOG_SETTLE_TIMEOUT_S do
335
+ local resolved = nil
336
+ catalog:withReadAccessDo(function()
337
+ resolved = PhotoLookup.resolveOne(catalog, newPhotoPath)
338
+ end)
339
+ if resolved then
340
+ newPhoto = resolved
341
+ break
342
+ end
343
+ LrTasks.sleep(POLL_INTERVAL_S)
344
+ settleWaited = settleWaited + POLL_INTERVAL_S
345
+ end
346
+ if newPhoto then break end
347
+ end
348
+ end
349
+ end
350
+
351
+ if newPhoto and newPhotoPath then
352
+ Log.info("ai_denoise: native Denoise produced " .. newPhotoPath)
353
+ return {
354
+ success = true,
355
+ method = "native",
356
+ photo_id = photo.localIdentifier,
357
+ new_photo_id = newPhoto.localIdentifier,
358
+ new_photo_path = newPhotoPath,
359
+ message = "Adobe AI Denoise applied. A new DNG was created next to the original and stacked in the catalog.",
360
+ }
361
+ end
362
+
363
+ local nativeError = sendResult.ok
364
+ and ("no new DNG appeared within " .. verifyTimeout .. "s (check that the Enhance dialog opened and was confirmed)")
365
+ or tostring(sendResult.error)
366
+
367
+ if fallback == "none" then
368
+ error("Native AI Denoise failed and fallback is disabled: " .. nativeError)
369
+ end
370
+
371
+ -- 4. Manual fallback with a clear report of what happened.
372
+ local manualSettings = buildManualSettings(photo, args.manual_settings)
373
+ catalog:withWriteAccessDo("AI Denoise Manual Fallback", function()
374
+ photo:applyDevelopSettings(manualSettings, "MCP AI Denoise (manual fallback)")
375
+ end)
376
+
377
+ Log.warn("ai_denoise: falling back to manual noise reduction")
378
+
379
+ return {
380
+ success = true,
381
+ method = "manual_fallback",
382
+ photo_id = photo.localIdentifier,
383
+ native_error = nativeError,
384
+ applied_settings = manualSettings,
385
+ message = "Native AI Denoise could not be verified (" .. nativeError
386
+ .. "). Applied smart manual noise reduction instead. "
387
+ .. "Verify the SendKeys sequence and the Lightroom window title, and keep Lightroom in the foreground.",
388
+ }
389
+ end
390
+
391
+ -- =====================================================================
392
+ -- Manual noise reduction (SDK-native sliders)
393
+ -- =====================================================================
394
+
395
+ function AIHandler.setNoiseReduction(args)
396
+ args = args or {}
397
+ if not args.photo_ids or #args.photo_ids == 0 then
398
+ error("photo_ids is required")
399
+ end
400
+ if #args.photo_ids > MAX_SPOTS_PER_REQUEST * 20 then
401
+ error("photo_ids must contain at most 1000 photos")
402
+ end
403
+
404
+ local settings = {}
405
+ for argName, sdkKey in pairs(MANUAL_NOISE_KEYS) do
406
+ local value = args[argName]
407
+ if value ~= nil then
408
+ if argName == "sharpen_radius" then
409
+ settings[sdkKey] = clampNumber(value, 0.5, 3, argName)
410
+ else
411
+ settings[sdkKey] = clampNumber(value, 0, 100, argName)
412
+ end
413
+ end
414
+ end
415
+
416
+ if next(settings) == nil then
417
+ error("at least one noise reduction parameter is required")
418
+ end
419
+
420
+ local catalog = LrApplication.activeCatalog()
421
+ local updatedCount = 0
422
+ local missingIds = {}
423
+
424
+ catalog:withWriteAccessDo("Set Noise Reduction", function()
425
+ local resolved = PhotoLookup.resolveMany(catalog, args.photo_ids)
426
+ for _, entry in ipairs(resolved) do
427
+ if entry.photo then
428
+ entry.photo:applyDevelopSettings(settings, "MCP Noise Reduction")
429
+ updatedCount = updatedCount + 1
430
+ else
431
+ table.insert(missingIds, tostring(entry.id))
432
+ end
433
+ end
434
+ end)
435
+
436
+ -- Read back the effective noise settings of the first photo so the caller
437
+ -- can confirm what Lightroom actually stored.
438
+ local appliedSettings = nil
439
+ if updatedCount > 0 then
440
+ catalog:withReadAccessDo(function()
441
+ local resolved = PhotoLookup.resolveMany(catalog, { args.photo_ids[1] })
442
+ if resolved[1] and resolved[1].photo then
443
+ local current = resolved[1].photo:getDevelopSettings()
444
+ appliedSettings = {
445
+ LuminanceSmoothing = current.LuminanceSmoothing,
446
+ ColorNoiseReduction = current.ColorNoiseReduction,
447
+ Sharpness = current.Sharpness,
448
+ }
449
+ end
450
+ end)
451
+ end
452
+
453
+ Log.info(string.format("Set noise reduction on %d photos", updatedCount))
454
+
455
+ return {
456
+ success = true,
457
+ updated = updatedCount,
458
+ missing = missingIds,
459
+ applied = settings,
460
+ verified = appliedSettings,
461
+ message = string.format("Applied noise reduction to %d photos (%d ids not found)",
462
+ updatedCount, #missingIds),
463
+ }
464
+ end
465
+
466
+ return AIHandler