@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
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
local LrApplication = import 'LrApplication'
|
|
2
|
+
|
|
3
|
+
local PhotoLookup = require 'PhotoLookup'
|
|
4
|
+
local Log = require 'Log'
|
|
5
|
+
|
|
6
|
+
local SpotsHandler = {}
|
|
7
|
+
|
|
8
|
+
-- =====================================================================
|
|
9
|
+
-- Spot removal (the Develop module's spot heal/clone tool)
|
|
10
|
+
-- =====================================================================
|
|
11
|
+
--
|
|
12
|
+
-- Lightroom stores spot removal in the `RetouchInfo` develop setting. The
|
|
13
|
+
-- SDK returns it as a table that is a LIST of spot strings; each string is
|
|
14
|
+
-- the classic ACR flat format:
|
|
15
|
+
--
|
|
16
|
+
-- ( x, y ), ( type ), ( srcX, srcY ), ( radius ), ( f ), ( o ), ( flags )
|
|
17
|
+
--
|
|
18
|
+
-- where x/y/srcX/srcY are normalized coordinates (0..1), type is "heal" or
|
|
19
|
+
-- "clone", radius is normalized, and the trailing groups are preserved
|
|
20
|
+
-- verbatim when round-tripping. Because Adobe does not document this format,
|
|
21
|
+
-- this handler never rewrites existing entries (they are appended to
|
|
22
|
+
-- byte-identical) and verifies the write by reading the photo back.
|
|
23
|
+
|
|
24
|
+
local MAX_SPOTS_PER_REQUEST = 50
|
|
25
|
+
|
|
26
|
+
local function clamp01(v)
|
|
27
|
+
if v == nil then return 0 end
|
|
28
|
+
if v < 0 then return 0 end
|
|
29
|
+
if v > 1 then return 1 end
|
|
30
|
+
return v
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
SpotsHandler.clamp01 = clamp01
|
|
34
|
+
|
|
35
|
+
local function requirePhotoId(args)
|
|
36
|
+
if args.photo_id == nil or args.photo_id == "" then
|
|
37
|
+
error("photo_id is required")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
-- Extract every "( ... )" group from a flat spot string.
|
|
42
|
+
local function extractGroups(s)
|
|
43
|
+
local groups = {}
|
|
44
|
+
for group in s:gmatch("%b()") do
|
|
45
|
+
table.insert(groups, group:sub(2, -2))
|
|
46
|
+
end
|
|
47
|
+
return groups
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
local function parseNumberList(group)
|
|
51
|
+
local numbers = {}
|
|
52
|
+
for token in group:gmatch("[-+]?%d+%.?%d*") do
|
|
53
|
+
table.insert(numbers, tonumber(token))
|
|
54
|
+
end
|
|
55
|
+
return numbers
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
-- Parse one flat spot string into a descriptive table. Unknown/extra groups
|
|
59
|
+
-- are preserved raw so callers always see the original data.
|
|
60
|
+
function SpotsHandler.parseSpotString(s)
|
|
61
|
+
if type(s) ~= "string" or s == "" then return nil end
|
|
62
|
+
local groups = extractGroups(s)
|
|
63
|
+
if #groups < 4 then return nil end
|
|
64
|
+
|
|
65
|
+
local center = parseNumberList(groups[1])
|
|
66
|
+
local kind = groups[2] and groups[2]:gsub("^%s+", ""):gsub("%s+$", "") or nil
|
|
67
|
+
local source = parseNumberList(groups[3])
|
|
68
|
+
local radius = tonumber(groups[4]:match("[-+]?%d+%.?%d*"))
|
|
69
|
+
|
|
70
|
+
local rawTail = {}
|
|
71
|
+
for i = 5, #groups do
|
|
72
|
+
-- Trim so round-trips keep the canonical "( 1 ), ( 1 ), ( 0 )" spacing
|
|
73
|
+
-- regardless of how much whitespace the source string carried.
|
|
74
|
+
table.insert(rawTail, (groups[i]:gsub("^%s+", ""):gsub("%s+$", "")))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
x = center[1],
|
|
79
|
+
y = center[2],
|
|
80
|
+
type = (kind == "clone") and "clone" or "heal",
|
|
81
|
+
source_x = source[1],
|
|
82
|
+
source_y = source[2],
|
|
83
|
+
radius = radius,
|
|
84
|
+
tail = rawTail,
|
|
85
|
+
raw = s,
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
-- Serialize one spot back into the flat format. The tail groups are kept
|
|
90
|
+
-- verbatim from the original entry when available.
|
|
91
|
+
local function buildSpotString(spot)
|
|
92
|
+
local fmt = string.format
|
|
93
|
+
local function num(v)
|
|
94
|
+
return fmt("%.6f", v)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
local x = clamp01(spot.x)
|
|
98
|
+
local y = clamp01(spot.y)
|
|
99
|
+
local radius = tonumber(spot.radius) or 0.05
|
|
100
|
+
if radius < 0 then radius = 0.05 end
|
|
101
|
+
if radius > 1 then radius = 1 end
|
|
102
|
+
|
|
103
|
+
local kind = (spot.type == "clone") and "clone" or "heal"
|
|
104
|
+
|
|
105
|
+
-- Source defaults to an offset left of the spot; Lightroom will usually
|
|
106
|
+
-- recompute a better one automatically, but a deterministic fallback
|
|
107
|
+
-- guarantees the heal never samples the spot itself.
|
|
108
|
+
local srcX = tonumber(spot.source_x)
|
|
109
|
+
local srcY = tonumber(spot.source_y)
|
|
110
|
+
if srcX == nil or srcY == nil then
|
|
111
|
+
srcX = x - (radius * 3)
|
|
112
|
+
if srcX < 0 then srcX = x + (radius * 3) end
|
|
113
|
+
if srcX > 1 then srcX = clamp01(x) end
|
|
114
|
+
srcY = y
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
local tail
|
|
118
|
+
if type(spot.tail) == "table" and #spot.tail >= 3 then
|
|
119
|
+
tail = { spot.tail[1], spot.tail[2], spot.tail[3] }
|
|
120
|
+
else
|
|
121
|
+
tail = { "1", "1", "0" }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
return "( " .. num(x) .. ", " .. num(y) .. " ), ( " .. kind .. " ), ( "
|
|
125
|
+
.. num(srcX) .. ", " .. num(srcY) .. " ), ( " .. num(radius)
|
|
126
|
+
.. " ), ( " .. tail[1] .. " ), ( " .. tail[2] .. " ), ( " .. tail[3] .. " )"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
function SpotsHandler.clamp01(v)
|
|
130
|
+
return clamp01(v)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
-- Normalize whatever getDevelopSettings() returned into an array of strings.
|
|
134
|
+
-- Observed shapes: nil, {}, { "spot1" }, { "spot1", "spot2" }, or a single
|
|
135
|
+
-- string (defensive). Tables of tables (newer formats) are passed through
|
|
136
|
+
-- untouched so data is never corrupted by a guess.
|
|
137
|
+
local function retouchEntriesToTable(retouchInfo)
|
|
138
|
+
if retouchInfo == nil then return {}, nil end
|
|
139
|
+
if type(retouchInfo) == "string" then
|
|
140
|
+
return { retouchInfo }, "string"
|
|
141
|
+
end
|
|
142
|
+
if type(retouchInfo) ~= "table" then
|
|
143
|
+
return {}, nil
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
local strings = {}
|
|
147
|
+
local others = {}
|
|
148
|
+
for _, entry in ipairs(retouchInfo) do
|
|
149
|
+
if type(entry) == "string" then
|
|
150
|
+
table.insert(strings, entry)
|
|
151
|
+
else
|
|
152
|
+
table.insert(others, entry)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
return strings, (next(others) ~= nil) and others or nil
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
local function readRetouchInfo(photo)
|
|
159
|
+
local settings = photo:getDevelopSettings()
|
|
160
|
+
return retouchEntriesToTable(settings.RetouchInfo)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
local function applyRetouchInfo(photo, entries, historyName)
|
|
164
|
+
photo:applyDevelopSettings({
|
|
165
|
+
EnableRetouch = (#entries > 0),
|
|
166
|
+
RetouchInfo = entries,
|
|
167
|
+
}, historyName)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
local function resolveOnePhoto(catalog, photoId)
|
|
171
|
+
local photo = nil
|
|
172
|
+
catalog:withReadAccessDo(function()
|
|
173
|
+
photo = PhotoLookup.resolveOne(catalog, photoId)
|
|
174
|
+
end)
|
|
175
|
+
if not photo then
|
|
176
|
+
error("Photo not found: " .. tostring(photoId))
|
|
177
|
+
end
|
|
178
|
+
return photo
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
function SpotsHandler.getSpots(args)
|
|
182
|
+
args = args or {}
|
|
183
|
+
requirePhotoId(args)
|
|
184
|
+
|
|
185
|
+
local catalog = LrApplication.activeCatalog()
|
|
186
|
+
local photo = resolveOnePhoto(catalog, args.photo_id)
|
|
187
|
+
|
|
188
|
+
local entries, others = readRetouchInfo(photo)
|
|
189
|
+
local spots = {}
|
|
190
|
+
for _, entry in ipairs(entries) do
|
|
191
|
+
local parsed = SpotsHandler.parseSpotString(entry)
|
|
192
|
+
if parsed then
|
|
193
|
+
table.insert(spots, parsed)
|
|
194
|
+
else
|
|
195
|
+
-- Not the classic flat format; surface it raw instead of hiding it.
|
|
196
|
+
table.insert(spots, { raw = entry, unparsed = true })
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
Log.info(string.format("get_spots: %d spots on photo %s", #spots, tostring(args.photo_id)))
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
success = true,
|
|
204
|
+
photo_id = photo.localIdentifier,
|
|
205
|
+
count = #spots,
|
|
206
|
+
spots = spots,
|
|
207
|
+
additional_raw_entries = others,
|
|
208
|
+
}
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
function SpotsHandler.addSpots(args)
|
|
212
|
+
args = args or {}
|
|
213
|
+
requirePhotoId(args)
|
|
214
|
+
|
|
215
|
+
local newSpots = args.spots
|
|
216
|
+
if type(newSpots) ~= "table" or #newSpots == 0 then
|
|
217
|
+
error("spots is required (array of {x, y, radius, type, source_x?, source_y?})")
|
|
218
|
+
end
|
|
219
|
+
if #newSpots > MAX_SPOTS_PER_REQUEST then
|
|
220
|
+
error("spots must contain at most " .. MAX_SPOTS_PER_REQUEST .. " entries")
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
for i, spot in ipairs(newSpots) do
|
|
224
|
+
if type(spot) ~= "table" or spot.x == nil or spot.y == nil then
|
|
225
|
+
error("spots[" .. i .. "] must include numeric x and y (normalized 0..1)")
|
|
226
|
+
end
|
|
227
|
+
if spot.type ~= nil and spot.type ~= "heal" and spot.type ~= "clone" then
|
|
228
|
+
error("spots[" .. i .. "].type must be 'heal' or 'clone'")
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
local catalog = LrApplication.activeCatalog()
|
|
233
|
+
local photo = resolveOnePhoto(catalog, args.photo_id)
|
|
234
|
+
|
|
235
|
+
-- Existing entries are preserved byte-identical.
|
|
236
|
+
local entries = readRetouchInfo(photo)
|
|
237
|
+
local beforeCount = #entries
|
|
238
|
+
|
|
239
|
+
for _, spot in ipairs(newSpots) do
|
|
240
|
+
table.insert(entries, buildSpotString(spot))
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
local appliedSettings
|
|
244
|
+
catalog:withWriteAccessDo("Add Spot Removal", function()
|
|
245
|
+
applyRetouchInfo(photo, entries, "MCP Add Spots")
|
|
246
|
+
appliedSettings = true
|
|
247
|
+
end)
|
|
248
|
+
|
|
249
|
+
-- Verify by reading back: if this Lightroom version refuses RetouchInfo
|
|
250
|
+
-- writes, say so instead of reporting success.
|
|
251
|
+
local afterEntries, _ = readRetouchInfo(photo)
|
|
252
|
+
local verified = #afterEntries
|
|
253
|
+
local took = verified > beforeCount
|
|
254
|
+
|
|
255
|
+
Log.info(string.format("add_spots: %d before, %d after on photo %s",
|
|
256
|
+
beforeCount, verified, tostring(args.photo_id)))
|
|
257
|
+
|
|
258
|
+
if not took then
|
|
259
|
+
return {
|
|
260
|
+
success = false,
|
|
261
|
+
photo_id = photo.localIdentifier,
|
|
262
|
+
before_count = beforeCount,
|
|
263
|
+
after_count = verified,
|
|
264
|
+
applied = false,
|
|
265
|
+
message = "Lightroom did not accept the RetouchInfo write on this version. "
|
|
266
|
+
.. "Draw one spot manually on the photo, then retry: existing entries are "
|
|
267
|
+
.. "used as a template and round-trip safely.",
|
|
268
|
+
}
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
success = true,
|
|
273
|
+
applied = true,
|
|
274
|
+
photo_id = photo.localIdentifier,
|
|
275
|
+
before_count = beforeCount,
|
|
276
|
+
after_count = verified,
|
|
277
|
+
added = #newSpots,
|
|
278
|
+
message = string.format("Added %d spot(s); photo now has %d.", #newSpots, verified),
|
|
279
|
+
}
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
function SpotsHandler.clearSpots(args)
|
|
283
|
+
args = args or {}
|
|
284
|
+
requirePhotoId(args)
|
|
285
|
+
|
|
286
|
+
local catalog = LrApplication.activeCatalog()
|
|
287
|
+
local photo = resolveOnePhoto(catalog, args.photo_id)
|
|
288
|
+
|
|
289
|
+
local entries, _ = readRetouchInfo(photo)
|
|
290
|
+
local beforeCount = #entries
|
|
291
|
+
|
|
292
|
+
catalog:withWriteAccessDo("Clear Spot Removal", function()
|
|
293
|
+
applyRetouchInfo(photo, {}, "MCP Clear Spots")
|
|
294
|
+
end)
|
|
295
|
+
|
|
296
|
+
local afterEntries, _ = readRetouchInfo(photo)
|
|
297
|
+
local cleared = #afterEntries == 0
|
|
298
|
+
|
|
299
|
+
Log.info(string.format("clear_spots: %d before, %d after on photo %s",
|
|
300
|
+
beforeCount, #afterEntries, tostring(args.photo_id)))
|
|
301
|
+
|
|
302
|
+
return {
|
|
303
|
+
success = cleared,
|
|
304
|
+
photo_id = photo.localIdentifier,
|
|
305
|
+
before_count = beforeCount,
|
|
306
|
+
after_count = #afterEntries,
|
|
307
|
+
message = cleared
|
|
308
|
+
and string.format("Removed all %d spot(s).", beforeCount)
|
|
309
|
+
or string.format("Clear was not verified (%d spots still present); this Lightroom version may require removing them from the Develop panel.", #afterEntries),
|
|
310
|
+
}
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
return SpotsHandler
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
local LrPathUtils = import 'LrPathUtils'
|
|
2
|
+
local LrFileUtils = import 'LrFileUtils'
|
|
3
|
+
|
|
4
|
+
local Log = require 'Log'
|
|
5
|
+
|
|
6
|
+
local WatermarkHandler = {}
|
|
7
|
+
|
|
8
|
+
-- =====================================================================
|
|
9
|
+
-- Watermark presets
|
|
10
|
+
-- =====================================================================
|
|
11
|
+
--
|
|
12
|
+
-- Lightroom stores watermark presets as files under
|
|
13
|
+
-- <appPrefs>/Watermark Presets/<preset name>.watermark, where <appPrefs> is
|
|
14
|
+
-- the root of Lightroom's own preferences folder (getStandardFilePath
|
|
15
|
+
-- ("appPrefs"), i.e. %APPDATA%\Adobe\Lightroom on Windows). Enumerating that
|
|
16
|
+
-- directory gives the exact names the export watermarking option expects.
|
|
17
|
+
|
|
18
|
+
function WatermarkHandler.listWatermarks(_args)
|
|
19
|
+
local appPrefs = LrPathUtils.getStandardFilePath("appPrefs")
|
|
20
|
+
if not appPrefs then
|
|
21
|
+
error("Could not resolve Lightroom's preferences folder on this system")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
local presetsDir = LrPathUtils.child(appPrefs, "Watermark Presets")
|
|
25
|
+
|
|
26
|
+
local names = {}
|
|
27
|
+
local ok, entries = pcall(function() return LrFileUtils.directoryContents(presetsDir) end)
|
|
28
|
+
if ok and type(entries) == "table" then
|
|
29
|
+
for _, entry in ipairs(entries) do
|
|
30
|
+
if type(entry) == "string" then
|
|
31
|
+
local name = entry:match("^(.*)%.watermark$")
|
|
32
|
+
if name and name ~= "" then
|
|
33
|
+
table.insert(names, name)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
table.sort(names, function(a, b) return a:lower() < b:lower() end)
|
|
40
|
+
|
|
41
|
+
Log.info(string.format("list_watermarks: %d presets found", #names))
|
|
42
|
+
|
|
43
|
+
local result = {
|
|
44
|
+
success = true,
|
|
45
|
+
watermarks = names,
|
|
46
|
+
count = #names,
|
|
47
|
+
presets_dir = presetsDir,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if #names == 0 then
|
|
51
|
+
result.message = "No watermark presets found. Create them in Lightroom via "
|
|
52
|
+
.. "Edit > Edit Watermarks..., then this tool (and export_photos' watermark option) "
|
|
53
|
+
.. "can reference them by name."
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
return result
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
return WatermarkHandler
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
return {
|
|
2
|
+
LrSdkVersion = 8.0,
|
|
3
|
+
LrSdkMinimumVersion = 8.0,
|
|
4
|
+
|
|
5
|
+
LrToolkitIdentifier = 'com.lightroom.mcp',
|
|
6
|
+
LrPluginName = "Lightroom MCP AI",
|
|
7
|
+
|
|
8
|
+
LrPluginInfoUrl = "https://github.com/pired/lightroom-mcp",
|
|
9
|
+
|
|
10
|
+
VERSION = { major=3, minor=1, revision=0, build=0 },
|
|
11
|
+
|
|
12
|
+
LrPluginInfoProvider = 'PluginInfoProvider.lua',
|
|
13
|
+
LrInitPlugin = 'PluginInit.lua',
|
|
14
|
+
-- LrForceInitPlugin forces eager load on Lr launch, but ONLY if the
|
|
15
|
+
-- plugin also exposes at least one menu item — see LrLibraryMenuItems
|
|
16
|
+
-- below. Adobe's own remote_control_socket sample uses this pattern.
|
|
17
|
+
LrForceInitPlugin = true,
|
|
18
|
+
|
|
19
|
+
LrLibraryMenuItems = {
|
|
20
|
+
{
|
|
21
|
+
title = "Lightroom MCP — Show Status",
|
|
22
|
+
file = "MenuShowStatus.lua",
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
-- Simple JSON encoder/decoder for Lightroom
|
|
2
|
+
local JSON = {}
|
|
3
|
+
|
|
4
|
+
local SHORT_ESCAPES = {
|
|
5
|
+
['\n'] = '\\n',
|
|
6
|
+
['\r'] = '\\r',
|
|
7
|
+
['\t'] = '\\t',
|
|
8
|
+
['\b'] = '\\b',
|
|
9
|
+
['\f'] = '\\f',
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
-- Control characters are illegal raw inside a JSON string, so anything without
|
|
13
|
+
-- a short escape has to go out as \u00XX. Emitting them raw produced responses
|
|
14
|
+
-- the MCP server could not parse.
|
|
15
|
+
local function escapeString(s)
|
|
16
|
+
local escaped = s:gsub('[\\"]', '\\%0'):gsub('%c', function(c)
|
|
17
|
+
return SHORT_ESCAPES[c] or string.format('\\u%04x', c:byte())
|
|
18
|
+
end)
|
|
19
|
+
return escaped
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
-- Lightroom runs Lua 5.1: no utf8 library, so encode the code point by hand.
|
|
23
|
+
local function codepointToUtf8(cp)
|
|
24
|
+
if cp < 0x80 then
|
|
25
|
+
return string.char(cp)
|
|
26
|
+
elseif cp < 0x800 then
|
|
27
|
+
return string.char(0xC0 + math.floor(cp / 0x40), 0x80 + (cp % 0x40))
|
|
28
|
+
elseif cp < 0x10000 then
|
|
29
|
+
return string.char(
|
|
30
|
+
0xE0 + math.floor(cp / 0x1000),
|
|
31
|
+
0x80 + (math.floor(cp / 0x40) % 0x40),
|
|
32
|
+
0x80 + (cp % 0x40))
|
|
33
|
+
end
|
|
34
|
+
return string.char(
|
|
35
|
+
0xF0 + math.floor(cp / 0x40000),
|
|
36
|
+
0x80 + (math.floor(cp / 0x1000) % 0x40),
|
|
37
|
+
0x80 + (math.floor(cp / 0x40) % 0x40),
|
|
38
|
+
0x80 + (cp % 0x40))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
-- Reads one \uXXXX escape at `pos` (which points at the backslash), pairing a
|
|
42
|
+
-- high surrogate with the low surrogate that follows. A lone surrogate becomes
|
|
43
|
+
-- U+FFFD rather than an error: JSON.stringify emits those for unpaired code
|
|
44
|
+
-- units, and erroring would drop the whole request.
|
|
45
|
+
local function decodeUnicodeEscape(str, pos)
|
|
46
|
+
local hex = str:sub(pos + 2, pos + 5)
|
|
47
|
+
if not hex:match('^%x%x%x%x$') then
|
|
48
|
+
error('Invalid unicode escape: \\u' .. hex)
|
|
49
|
+
end
|
|
50
|
+
local cp = tonumber(hex, 16)
|
|
51
|
+
local nextPos = pos + 6
|
|
52
|
+
|
|
53
|
+
if cp >= 0xD800 and cp <= 0xDBFF then
|
|
54
|
+
if str:sub(nextPos, nextPos + 1) == '\\u' then
|
|
55
|
+
local lowHex = str:sub(nextPos + 2, nextPos + 5)
|
|
56
|
+
local low = lowHex:match('^%x%x%x%x$') and tonumber(lowHex, 16)
|
|
57
|
+
if low and low >= 0xDC00 and low <= 0xDFFF then
|
|
58
|
+
cp = 0x10000 + (cp - 0xD800) * 0x400 + (low - 0xDC00)
|
|
59
|
+
nextPos = nextPos + 6
|
|
60
|
+
else
|
|
61
|
+
cp = 0xFFFD
|
|
62
|
+
end
|
|
63
|
+
else
|
|
64
|
+
cp = 0xFFFD
|
|
65
|
+
end
|
|
66
|
+
elseif cp >= 0xDC00 and cp <= 0xDFFF then
|
|
67
|
+
cp = 0xFFFD
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
return codepointToUtf8(cp), nextPos
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
function JSON:encode(obj)
|
|
74
|
+
local function encode_value(v)
|
|
75
|
+
local t = type(v)
|
|
76
|
+
if t == "string" then
|
|
77
|
+
return '"' .. escapeString(v) .. '"'
|
|
78
|
+
elseif t == "number" or t == "boolean" then
|
|
79
|
+
return tostring(v)
|
|
80
|
+
elseif t == "table" then
|
|
81
|
+
-- An empty Lua table is ambiguous. Encoding it as {} broke every
|
|
82
|
+
-- client iterating an empty result list (changes.map is not a
|
|
83
|
+
-- function); an empty object rendered as [] is harmless by
|
|
84
|
+
-- comparison, so empty means array here.
|
|
85
|
+
local is_array = next(v) == nil or #v > 0
|
|
86
|
+
if is_array then
|
|
87
|
+
local values = {}
|
|
88
|
+
for i, item in ipairs(v) do
|
|
89
|
+
table.insert(values, encode_value(item))
|
|
90
|
+
end
|
|
91
|
+
return "[" .. table.concat(values, ",") .. "]"
|
|
92
|
+
else
|
|
93
|
+
local key_value_pairs = {}
|
|
94
|
+
for k, val in pairs(v) do
|
|
95
|
+
table.insert(key_value_pairs, encode_value(tostring(k)) .. ":" .. encode_value(val))
|
|
96
|
+
end
|
|
97
|
+
return "{" .. table.concat(key_value_pairs, ",") .. "}"
|
|
98
|
+
end
|
|
99
|
+
elseif t == "nil" then
|
|
100
|
+
return "null"
|
|
101
|
+
else
|
|
102
|
+
return '"' .. tostring(v) .. '"'
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
return encode_value(obj)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
function JSON:decode(str)
|
|
109
|
+
local pos = 1
|
|
110
|
+
|
|
111
|
+
local function skip_whitespace()
|
|
112
|
+
while pos <= #str and str:sub(pos, pos):match("%s") do
|
|
113
|
+
pos = pos + 1
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
local function decode_value()
|
|
118
|
+
skip_whitespace()
|
|
119
|
+
local char = str:sub(pos, pos)
|
|
120
|
+
|
|
121
|
+
if char == '"' then
|
|
122
|
+
-- String
|
|
123
|
+
pos = pos + 1
|
|
124
|
+
local chars = {}
|
|
125
|
+
while pos <= #str do
|
|
126
|
+
local current = str:sub(pos, pos)
|
|
127
|
+
if current == '"' then
|
|
128
|
+
pos = pos + 1
|
|
129
|
+
return table.concat(chars)
|
|
130
|
+
elseif current == '\\' then
|
|
131
|
+
local escaped = str:sub(pos + 1, pos + 1)
|
|
132
|
+
if escaped == 'u' then
|
|
133
|
+
local text, nextPos = decodeUnicodeEscape(str, pos)
|
|
134
|
+
table.insert(chars, text)
|
|
135
|
+
pos = nextPos
|
|
136
|
+
else
|
|
137
|
+
if escaped == '"' or escaped == '\\' or escaped == '/' then
|
|
138
|
+
table.insert(chars, escaped)
|
|
139
|
+
elseif escaped == 'n' then
|
|
140
|
+
table.insert(chars, '\n')
|
|
141
|
+
elseif escaped == 'r' then
|
|
142
|
+
table.insert(chars, '\r')
|
|
143
|
+
elseif escaped == 't' then
|
|
144
|
+
table.insert(chars, '\t')
|
|
145
|
+
elseif escaped == 'b' then
|
|
146
|
+
table.insert(chars, string.char(8))
|
|
147
|
+
elseif escaped == 'f' then
|
|
148
|
+
table.insert(chars, string.char(12))
|
|
149
|
+
else
|
|
150
|
+
error("Invalid escape sequence: \\" .. tostring(escaped))
|
|
151
|
+
end
|
|
152
|
+
pos = pos + 2
|
|
153
|
+
end
|
|
154
|
+
else
|
|
155
|
+
table.insert(chars, current)
|
|
156
|
+
pos = pos + 1
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
error("Unterminated string")
|
|
160
|
+
elseif char == '{' then
|
|
161
|
+
-- Object
|
|
162
|
+
pos = pos + 1
|
|
163
|
+
local obj = {}
|
|
164
|
+
skip_whitespace()
|
|
165
|
+
if str:sub(pos, pos) == '}' then
|
|
166
|
+
pos = pos + 1
|
|
167
|
+
return obj
|
|
168
|
+
end
|
|
169
|
+
while true do
|
|
170
|
+
skip_whitespace()
|
|
171
|
+
local key = decode_value()
|
|
172
|
+
skip_whitespace()
|
|
173
|
+
if str:sub(pos, pos) ~= ':' then
|
|
174
|
+
error("Expected ':'")
|
|
175
|
+
end
|
|
176
|
+
pos = pos + 1
|
|
177
|
+
local value = decode_value()
|
|
178
|
+
obj[key] = value
|
|
179
|
+
skip_whitespace()
|
|
180
|
+
char = str:sub(pos, pos)
|
|
181
|
+
if char == '}' then
|
|
182
|
+
pos = pos + 1
|
|
183
|
+
break
|
|
184
|
+
elseif char == ',' then
|
|
185
|
+
pos = pos + 1
|
|
186
|
+
else
|
|
187
|
+
error("Expected ',' or '}'")
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
return obj
|
|
191
|
+
elseif char == '[' then
|
|
192
|
+
-- Array
|
|
193
|
+
pos = pos + 1
|
|
194
|
+
local arr = {}
|
|
195
|
+
skip_whitespace()
|
|
196
|
+
if str:sub(pos, pos) == ']' then
|
|
197
|
+
pos = pos + 1
|
|
198
|
+
return arr
|
|
199
|
+
end
|
|
200
|
+
while true do
|
|
201
|
+
local value = decode_value()
|
|
202
|
+
table.insert(arr, value)
|
|
203
|
+
skip_whitespace()
|
|
204
|
+
char = str:sub(pos, pos)
|
|
205
|
+
if char == ']' then
|
|
206
|
+
pos = pos + 1
|
|
207
|
+
break
|
|
208
|
+
elseif char == ',' then
|
|
209
|
+
pos = pos + 1
|
|
210
|
+
else
|
|
211
|
+
error("Expected ',' or ']'")
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
return arr
|
|
215
|
+
elseif char == 't' and str:sub(pos, pos + 3) == 'true' then
|
|
216
|
+
pos = pos + 4
|
|
217
|
+
return true
|
|
218
|
+
elseif char == 'f' and str:sub(pos, pos + 4) == 'false' then
|
|
219
|
+
pos = pos + 5
|
|
220
|
+
return false
|
|
221
|
+
elseif char == 'n' and str:sub(pos, pos + 3) == 'null' then
|
|
222
|
+
pos = pos + 4
|
|
223
|
+
return nil
|
|
224
|
+
elseif char:match("[%-0-9]") then
|
|
225
|
+
-- Number
|
|
226
|
+
local start = pos
|
|
227
|
+
if char == '-' then
|
|
228
|
+
pos = pos + 1
|
|
229
|
+
end
|
|
230
|
+
while pos <= #str and str:sub(pos, pos):match("[0-9]") do
|
|
231
|
+
pos = pos + 1
|
|
232
|
+
end
|
|
233
|
+
if pos <= #str and str:sub(pos, pos) == '.' then
|
|
234
|
+
pos = pos + 1
|
|
235
|
+
while pos <= #str and str:sub(pos, pos):match("[0-9]") do
|
|
236
|
+
pos = pos + 1
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
-- Exponent form is core JSON and JSON.stringify emits it for
|
|
240
|
+
-- magnitudes past 1e21. Without this the scanner stopped at the
|
|
241
|
+
-- 'e', and the whole request failed to decode.
|
|
242
|
+
local exponent = str:sub(pos, pos)
|
|
243
|
+
if exponent == 'e' or exponent == 'E' then
|
|
244
|
+
pos = pos + 1
|
|
245
|
+
local sign = str:sub(pos, pos)
|
|
246
|
+
if sign == '+' or sign == '-' then
|
|
247
|
+
pos = pos + 1
|
|
248
|
+
end
|
|
249
|
+
while str:sub(pos, pos):match("[0-9]") do
|
|
250
|
+
pos = pos + 1
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
-- tonumber returns nil for a malformed number such as "1e", "1e+"
|
|
254
|
+
-- or a lone "-". Returning that nil would drop the field silently
|
|
255
|
+
-- and read as null; a malformed number is a parse error.
|
|
256
|
+
local numberText = str:sub(start, pos - 1)
|
|
257
|
+
local value = tonumber(numberText)
|
|
258
|
+
if value == nil then
|
|
259
|
+
error("Invalid number: " .. numberText)
|
|
260
|
+
end
|
|
261
|
+
return value
|
|
262
|
+
else
|
|
263
|
+
error("Unexpected character: " .. char)
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
return decode_value()
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
return JSON
|