@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.
- package/LICENSE +21 -0
- package/README.md +70 -0
- package/dist/LightroomMCP.lrplugin/HandlerAI.lua +466 -0
- package/dist/LightroomMCP.lrplugin/HandlerAIMasks.lua +549 -0
- package/dist/LightroomMCP.lrplugin/HandlerCatalog.lua +256 -0
- package/dist/LightroomMCP.lrplugin/HandlerCollections.lua +337 -0
- package/dist/LightroomMCP.lrplugin/HandlerDevelop.lua +1668 -0
- package/dist/LightroomMCP.lrplugin/HandlerExport.lua +149 -0
- package/dist/LightroomMCP.lrplugin/HandlerImport.lua +108 -0
- package/dist/LightroomMCP.lrplugin/HandlerLocalAdjustments.lua +330 -0
- package/dist/LightroomMCP.lrplugin/HandlerMetadata.lua +169 -0
- package/dist/LightroomMCP.lrplugin/HandlerOrganization.lua +783 -0
- package/dist/LightroomMCP.lrplugin/HandlerPreview.lua +189 -0
- package/dist/LightroomMCP.lrplugin/HandlerSearch.lua +185 -0
- package/dist/LightroomMCP.lrplugin/HandlerSelection.lua +302 -0
- package/dist/LightroomMCP.lrplugin/HandlerSpots.lua +313 -0
- package/dist/LightroomMCP.lrplugin/HandlerWatermark.lua +59 -0
- package/dist/LightroomMCP.lrplugin/Info.lua +25 -0
- package/dist/LightroomMCP.lrplugin/JSON.lua +270 -0
- package/dist/LightroomMCP.lrplugin/Log.lua +92 -0
- package/dist/LightroomMCP.lrplugin/MenuShowStatus.lua +22 -0
- package/dist/LightroomMCP.lrplugin/PhotoLookup.lua +39 -0
- package/dist/LightroomMCP.lrplugin/PluginInfoProvider.lua +865 -0
- package/dist/LightroomMCP.lrplugin/PluginInit.lua +48 -0
- package/dist/cli.d.ts +6 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +31 -0
- package/dist/cli.js.map +1 -0
- package/dist/configs/claude-code.mcp.json +9 -0
- package/dist/configs/claude-desktop.json +12 -0
- package/dist/configs/generic-stdio-client.json +10 -0
- package/dist/configs/mcp-proxy-http.json +9 -0
- package/dist/create-server.d.ts +12 -0
- package/dist/create-server.d.ts.map +1 -0
- package/dist/create-server.js +17 -0
- package/dist/create-server.js.map +1 -0
- package/dist/deepseek-harness/README.md +112 -0
- package/dist/deepseek-harness/deepseek-mcp-harness.mjs +292 -0
- package/dist/dispatcher.d.ts +34 -0
- package/dist/dispatcher.d.ts.map +1 -0
- package/dist/dispatcher.js +72 -0
- package/dist/dispatcher.js.map +1 -0
- package/dist/heartbeat.d.ts +17 -0
- package/dist/heartbeat.d.ts.map +1 -0
- package/dist/heartbeat.js +36 -0
- package/dist/heartbeat.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +255 -0
- package/dist/index.js.map +1 -0
- package/dist/install-plugin.d.ts +16 -0
- package/dist/install-plugin.d.ts.map +1 -0
- package/dist/install-plugin.js +125 -0
- package/dist/install-plugin.js.map +1 -0
- package/dist/instance-lock.d.ts +5 -0
- package/dist/instance-lock.d.ts.map +1 -0
- package/dist/instance-lock.js +77 -0
- package/dist/instance-lock.js.map +1 -0
- package/dist/list-tools-handler.d.ts +16 -0
- package/dist/list-tools-handler.d.ts.map +1 -0
- package/dist/list-tools-handler.js +50 -0
- package/dist/list-tools-handler.js.map +1 -0
- package/dist/plugin-liveness.d.ts +48 -0
- package/dist/plugin-liveness.d.ts.map +1 -0
- package/dist/plugin-liveness.js +75 -0
- package/dist/plugin-liveness.js.map +1 -0
- package/dist/plugin-socket.d.ts +32 -0
- package/dist/plugin-socket.d.ts.map +1 -0
- package/dist/plugin-socket.js +97 -0
- package/dist/plugin-socket.js.map +1 -0
- package/dist/ports.d.ts +3 -0
- package/dist/ports.d.ts.map +1 -0
- package/dist/ports.js +22 -0
- package/dist/ports.js.map +1 -0
- package/dist/token.d.ts +3 -0
- package/dist/token.d.ts.map +1 -0
- package/dist/token.js +23 -0
- package/dist/token.js.map +1 -0
- package/dist/tool-contracts.d.ts +13 -0
- package/dist/tool-contracts.d.ts.map +1 -0
- package/dist/tool-contracts.js +1422 -0
- package/dist/tool-contracts.js.map +1 -0
- package/dist/tool-handler.d.ts +23 -0
- package/dist/tool-handler.d.ts.map +1 -0
- package/dist/tool-handler.js +96 -0
- package/dist/tool-handler.js.map +1 -0
- package/dist/validate-args.d.ts +7 -0
- package/dist/validate-args.d.ts.map +1 -0
- package/dist/validate-args.js +37 -0
- package/dist/validate-args.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +4 -0
- package/dist/version.js.map +1 -0
- package/dist/wait-until.d.ts +7 -0
- package/dist/wait-until.d.ts.map +1 -0
- package/dist/wait-until.js +15 -0
- package/dist/wait-until.js.map +1 -0
- 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
|