@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
@@ -0,0 +1,549 @@
1
+ local LrApplication = import 'LrApplication'
2
+ local LrTasks = import 'LrTasks'
3
+ local LrApplicationView = import 'LrApplicationView'
4
+ local LrDevelopController = import 'LrDevelopController'
5
+
6
+ local PhotoLookup = require 'PhotoLookup'
7
+ local Log = require 'Log'
8
+
9
+ local AIMaskHandler = {}
10
+
11
+ -- =====================================================================
12
+ -- add_ai_mask — AI subject/sky/background selection masks
13
+ -- =====================================================================
14
+ --
15
+ -- Lightroom Classic's AI masking (Select Subject / Select Sky / ...) is
16
+ -- exposed to the SDK through LrDevelopController:
17
+ --
18
+ -- maskId = LrDevelopController.createNewMask("aiSelection", subtype)
19
+ --
20
+ -- Unlike the catalog write APIs used by most handlers, LrDevelopController
21
+ -- drives the Develop module of the running Lightroom UI: the target photo
22
+ -- must be selected in the filmstrip and the Develop module active, and the
23
+ -- AI computation itself runs inside Lightroom (seconds on fast machines).
24
+ -- The handler therefore, per photo:
25
+ --
26
+ -- 1. selects the photo (outside any catalog gate — it yields, #134/#124);
27
+ -- 2. switches to the Develop module (once, before the batch);
28
+ -- 3. engages the masking tool if another tool is active;
29
+ -- 4. creates the AI selection mask;
30
+ -- 5. applies the requested adjustment sliders to the new mask.
31
+ --
32
+ -- Everything is pcall-wrapped and reported honestly per photo: if a
33
+ -- particular subtype is unavailable in the installed Lightroom version,
34
+ -- or the photo has no detectable subject, the error surfaces in that
35
+ -- photo's entry instead of failing the whole call.
36
+ --
37
+ -- Best verified visually afterwards with get_photo_preview.
38
+
39
+ -- Subtypes accepted by createNewMask("aiSelection", ...). Availability of
40
+ -- people/objects/landscape depends on the Lightroom Classic version.
41
+ local SELECTION_TYPES = {
42
+ subject = true,
43
+ sky = true,
44
+ background = true,
45
+ objects = true,
46
+ people = true,
47
+ landscape = true,
48
+ }
49
+
50
+ -- Friendly arg name -> LrDevelopController setValue param + valid range.
51
+ -- Temperature/Tint are deliberately NOT offered here: in the masking
52
+ -- context those sliders are relative (-100..100) while the global Develop
53
+ -- parameters are Kelvin/-150..150, and the SDK docs do not pin down which
54
+ -- one setValue targets while a mask is active. Rather than risk silently
55
+ -- overwriting the global white balance, local color work goes through
56
+ -- add_local_adjustment (LocalTemperature2012/LocalTint2012, verified).
57
+ local ADJUSTMENTS = {
58
+ exposure = { key = "Exposure2012", min = -5, max = 5 },
59
+ contrast = { key = "Contrast2012", min = -100, max = 100 },
60
+ highlights = { key = "Highlights2012", min = -100, max = 100 },
61
+ shadows = { key = "Shadows2012", min = -100, max = 100 },
62
+ whites = { key = "Whites2012", min = -100, max = 100 },
63
+ blacks = { key = "Blacks2012", min = -100, max = 100 },
64
+ texture = { key = "Texture", min = -100, max = 100 },
65
+ clarity = { key = "Clarity2012", min = -100, max = 100 },
66
+ dehaze = { key = "Dehaze", min = -100, max = 100 },
67
+ vibrance = { key = "Vibrance", min = -100, max = 100 },
68
+ saturation = { key = "Saturation", min = -100, max = 100 },
69
+ sharpness = { key = "Sharpness", min = 0, max = 150 },
70
+ }
71
+
72
+ local SUPPORTED_ADJUSTMENT_NAMES = (function()
73
+ local names = {}
74
+ for k in pairs(ADJUSTMENTS) do table.insert(names, k) end
75
+ table.sort(names)
76
+ return table.concat(names, ", ")
77
+ end)()
78
+
79
+ -- Named adjustment recipes for AI/range masks (ported from lightroom-cli's
80
+ -- presets.py, minus 'warm-skin': it relies on Temp/Tint, which this handler
81
+ -- deliberately does not expose — see the ADJUSTMENTS note above).
82
+ local ADJUSTMENT_PRESETS = {
83
+ darken_sky = { exposure = -0.7, highlights = -30, saturation = 15 },
84
+ brighten_subject = { exposure = 0.5, shadows = 20, clarity = 10 },
85
+ blur_background = { sharpness = -80, clarity = -40 },
86
+ enhance_landscape = { clarity = 30, vibrance = 25, dehaze = 15 },
87
+ }
88
+
89
+ local SETTLE_AFTER_MODULE_SWITCH_S = 0.5
90
+ local SETTLE_BETWEEN_PHOTOS_S = 0.3
91
+
92
+ local function buildAdjustments(input)
93
+ if input == nil then return {} end
94
+ if type(input) ~= "table" then
95
+ error("adjustments must be an object")
96
+ end
97
+ local out = {}
98
+ for name, value in pairs(input) do
99
+ local spec = ADJUSTMENTS[name]
100
+ if not spec then
101
+ error("unknown adjustment '" .. tostring(name)
102
+ .. "' (supported: " .. SUPPORTED_ADJUSTMENT_NAMES .. ")")
103
+ end
104
+ if type(value) ~= "number" then
105
+ error("adjustment '" .. tostring(name) .. "' must be a number")
106
+ end
107
+ if value < spec.min or value > spec.max then
108
+ error(string.format("adjustment '%s' must be between %s and %s",
109
+ name, tostring(spec.min), tostring(spec.max)))
110
+ end
111
+ table.insert(out, { name = name, key = spec.key, value = value })
112
+ end
113
+ return out
114
+ end
115
+
116
+ local function resolveAdjustments(args)
117
+ if args.adjustments ~= nil and args.adjustment_preset ~= nil then
118
+ error("pass either adjustments or adjustment_preset, not both")
119
+ end
120
+ if args.adjustment_preset ~= nil then
121
+ local preset = ADJUSTMENT_PRESETS[args.adjustment_preset]
122
+ if not preset then
123
+ error("adjustment_preset must be one of: darken_sky, brighten_subject, blur_background, enhance_landscape")
124
+ end
125
+ return buildAdjustments(preset), args.adjustment_preset
126
+ end
127
+ return buildAdjustments(args.adjustments), nil
128
+ end
129
+
130
+ -- Shared per-photo batch loop: selects each photo in Develop, engages the
131
+ -- masking tool if needed, creates the mask through `createMask()`, then
132
+ -- applies the requested adjustment sliders. Returns (results, succeeded).
133
+ local function runMaskCreationBatch(catalog, photos, createMask, adjustments)
134
+ local results = {}
135
+ local succeeded = 0
136
+ local failed = 0
137
+
138
+ for i, photo in ipairs(photos) do
139
+ if i > 1 then LrTasks.sleep(SETTLE_BETWEEN_PHOTOS_S) end
140
+
141
+ local entry = {
142
+ photo = {
143
+ id = photo.localIdentifier,
144
+ path = photo:getRawMetadata('path'),
145
+ filename = photo:getFormattedMetadata('fileName'),
146
+ },
147
+ }
148
+ table.insert(results, entry)
149
+
150
+ local ok = pcall(function()
151
+ catalog:setSelectedPhotos(photo, { photo })
152
+ end)
153
+ if not ok then
154
+ entry.error = "could not select the photo in Lightroom"
155
+ failed = failed + 1
156
+ else
157
+ -- Engage the masking tool only if another tool is active; a
158
+ -- fresh createNewMask usually does this itself, but the guard
159
+ -- mirrors how the Develop UI behaves when a tool is stuck open.
160
+ local currentToolOk, currentTool = pcall(function()
161
+ return LrDevelopController.getSelectedTool()
162
+ end)
163
+ if currentToolOk and currentTool ~= "masking" then
164
+ pcall(function() LrDevelopController.selectTool("masking") end)
165
+ end
166
+
167
+ local maskOk, maskId = pcall(createMask)
168
+ if not maskOk or maskId == nil then
169
+ entry.error = "createNewMask failed: " .. tostring(maskId)
170
+ failed = failed + 1
171
+ else
172
+ entry.mask_id = maskId
173
+ entry.applied = {}
174
+ entry.adjustment_errors = {}
175
+
176
+ for _, adj in ipairs(adjustments) do
177
+ local applied, err = pcall(function()
178
+ LrDevelopController.setValue(adj.key, adj.value)
179
+ end)
180
+ if applied then
181
+ entry.applied[adj.name] = adj.value
182
+ else
183
+ entry.adjustment_errors[adj.name] = tostring(err)
184
+ end
185
+ end
186
+ if next(entry.adjustment_errors) == nil then
187
+ entry.adjustment_errors = nil
188
+ end
189
+ succeeded = succeeded + 1
190
+ end
191
+ end
192
+ end
193
+
194
+ return results, succeeded, failed
195
+ end
196
+
197
+ local function resolveSinglePhoto(catalog, photoId)
198
+ local photo = nil
199
+ catalog:withReadAccessDo(function()
200
+ local resolved = PhotoLookup.resolveMany(catalog, { photoId })
201
+ if resolved[1] and resolved[1].photo then
202
+ photo = resolved[1].photo
203
+ end
204
+ end)
205
+ return photo
206
+ end
207
+
208
+ local function switchToDevelopAndSelect(catalog, photo)
209
+ -- switchToModule and setSelectedPhotos both yield to the UI thread, so
210
+ -- they must run OUTSIDE any catalog access gate (#134/#124).
211
+ local switchOk, switchErr = pcall(function()
212
+ LrApplicationView.switchToModule("develop")
213
+ end)
214
+ if not switchOk then
215
+ error("could not switch Lightroom to the Develop module: "
216
+ .. tostring(switchErr))
217
+ end
218
+ LrTasks.sleep(SETTLE_AFTER_MODULE_SWITCH_S)
219
+
220
+ local selectOk = pcall(function()
221
+ catalog:setSelectedPhotos(photo, { photo })
222
+ end)
223
+ if not selectOk then
224
+ error("could not select the photo in Lightroom")
225
+ end
226
+ LrTasks.sleep(SETTLE_BETWEEN_PHOTOS_S)
227
+ end
228
+
229
+ function AIMaskHandler.addAIMask(args)
230
+ args = args or {}
231
+ if not args.photo_ids or #args.photo_ids == 0 then
232
+ error("photo_ids is required")
233
+ end
234
+
235
+ local selectionType = args.selection_type or "subject"
236
+ if not SELECTION_TYPES[selectionType] then
237
+ error("selection_type must be one of: subject, sky, background, objects, people, landscape")
238
+ end
239
+
240
+ local adjustments, presetName = resolveAdjustments(args)
241
+
242
+ local catalog = LrApplication.activeCatalog()
243
+
244
+ local photos = {}
245
+ catalog:withReadAccessDo(function()
246
+ local resolved = PhotoLookup.resolveMany(catalog, args.photo_ids)
247
+ for _, entry in ipairs(resolved) do
248
+ if entry.photo then table.insert(photos, entry.photo) end
249
+ end
250
+ end)
251
+
252
+ if #photos == 0 then
253
+ error("No photos matched photo_ids")
254
+ end
255
+
256
+ -- switchToModule and setSelectedPhotos both yield to the UI thread, so
257
+ -- they must run OUTSIDE any catalog access gate (#134/#124).
258
+ local switchOk, switchErr = pcall(function()
259
+ LrApplicationView.switchToModule("develop")
260
+ end)
261
+ if not switchOk then
262
+ error("could not switch Lightroom to the Develop module: "
263
+ .. tostring(switchErr))
264
+ end
265
+ LrTasks.sleep(SETTLE_AFTER_MODULE_SWITCH_S)
266
+
267
+ local results, succeeded, failed = runMaskCreationBatch(catalog, photos, function()
268
+ return LrDevelopController.createNewMask("aiSelection", selectionType)
269
+ end, adjustments)
270
+
271
+ Log.info(string.format("addAIMask('%s'): %d/%d photos",
272
+ selectionType, succeeded, #photos))
273
+
274
+ local result = {
275
+ success = failed == 0,
276
+ selection_type = selectionType,
277
+ requested = #photos,
278
+ succeeded = succeeded,
279
+ failed = failed,
280
+ results = results,
281
+ method = "LrDevelopController.createNewMask('aiSelection')",
282
+ message = string.format("AI '%s' mask applied to %d of %d photos",
283
+ selectionType, succeeded, #photos),
284
+ }
285
+ if presetName then
286
+ result.adjustment_preset = presetName
287
+ end
288
+ if failed > 0 then
289
+ result.message = result.message
290
+ .. ". Check each photo's 'error' entry; verify masks visually with get_photo_preview."
291
+ end
292
+ return result
293
+ end
294
+
295
+ -- =====================================================================
296
+ -- add_range_mask — luminance / color / depth range masks
297
+ -- =====================================================================
298
+ --
299
+ -- LrDevelopController.createNewMask("rangeMask", "luminance"|"color"|"depth")
300
+ -- creates a range mask on the photo loaded in Develop, then setValue
301
+ -- applies the adjustment sliders to it (same flow as the AI masks).
302
+ -- HONEST LIMITATION (also present in lightroom-cli): the SDK cannot set
303
+ -- the range bounds or sample points, so a fresh range mask covers the
304
+ -- full range — refine the bounds in the Lightroom UI afterwards, or use
305
+ -- the adjustment sliders from here. 'depth' requires a photo that has
306
+ -- depth data (iPhone portrait etc.).
307
+
308
+ local RANGE_TYPES = {
309
+ luminance = true,
310
+ color = true,
311
+ depth = true,
312
+ }
313
+
314
+ function AIMaskHandler.addRangeMask(args)
315
+ args = args or {}
316
+ if not args.photo_ids or #args.photo_ids == 0 then
317
+ error("photo_ids is required")
318
+ end
319
+
320
+ local rangeType = args.range_type or "luminance"
321
+ if not RANGE_TYPES[rangeType] then
322
+ error("range_type must be one of: luminance, color, depth")
323
+ end
324
+
325
+ local adjustments, presetName = resolveAdjustments(args)
326
+
327
+ local catalog = LrApplication.activeCatalog()
328
+
329
+ local photos = {}
330
+ catalog:withReadAccessDo(function()
331
+ local resolved = PhotoLookup.resolveMany(catalog, args.photo_ids)
332
+ for _, entry in ipairs(resolved) do
333
+ if entry.photo then table.insert(photos, entry.photo) end
334
+ end
335
+ end)
336
+
337
+ if #photos == 0 then
338
+ error("No photos matched photo_ids")
339
+ end
340
+
341
+ local switchOk, switchErr = pcall(function()
342
+ LrApplicationView.switchToModule("develop")
343
+ end)
344
+ if not switchOk then
345
+ error("could not switch Lightroom to the Develop module: "
346
+ .. tostring(switchErr))
347
+ end
348
+ LrTasks.sleep(SETTLE_AFTER_MODULE_SWITCH_S)
349
+
350
+ local results, succeeded, failed = runMaskCreationBatch(catalog, photos, function()
351
+ return LrDevelopController.createNewMask("rangeMask", rangeType)
352
+ end, adjustments)
353
+
354
+ Log.info(string.format("addRangeMask('%s'): %d/%d photos",
355
+ rangeType, succeeded, #photos))
356
+
357
+ local result = {
358
+ success = failed == 0,
359
+ range_type = rangeType,
360
+ requested = #photos,
361
+ succeeded = succeeded,
362
+ failed = failed,
363
+ results = results,
364
+ method = "LrDevelopController.createNewMask('rangeMask')",
365
+ message = string.format("Range '%s' mask applied to %d of %d photos",
366
+ rangeType, succeeded, #photos),
367
+ note = "The SDK cannot set range bounds or sample points: the mask "
368
+ .. "covers the full range. Refine the bounds in Lightroom's UI, or "
369
+ .. "drive the look through the adjustment sliders.",
370
+ }
371
+ if presetName then
372
+ result.adjustment_preset = presetName
373
+ end
374
+ if failed > 0 then
375
+ result.message = result.message
376
+ .. ". Check each photo's 'error' entry."
377
+ end
378
+ return result
379
+ end
380
+
381
+ -- =====================================================================
382
+ -- toggle_mask_overlay — show/hide mask overlays in the Develop UI
383
+ -- =====================================================================
384
+ --
385
+ -- LrDevelopController.toggleOverlay() flips the red mask overlay in the
386
+ -- Develop module, the fastest way for a human to eyeball what add_ai_mask /
387
+ -- add_local_adjustment actually selected. Acts on the photo loaded in
388
+ -- Develop; this handler selects the requested photo first so the overlay
389
+ -- belongs to it deterministically.
390
+
391
+ function AIMaskHandler.toggleMaskOverlay(args)
392
+ if args.photo_id == nil or args.photo_id == "" then
393
+ error("photo_id is required")
394
+ end
395
+
396
+ local catalog = LrApplication.activeCatalog()
397
+ local photo = resolveSinglePhoto(catalog, args.photo_id)
398
+ if not photo then
399
+ error("No photo matched photo_id")
400
+ end
401
+
402
+ switchToDevelopAndSelect(catalog, photo)
403
+
404
+ local ok, err = pcall(function()
405
+ LrDevelopController.toggleOverlay()
406
+ end)
407
+ if not ok then
408
+ error("toggleOverlay failed: " .. tostring(err)
409
+ .. " (requires the Develop module to be showing this photo)")
410
+ end
411
+
412
+ Log.info(string.format("toggleMaskOverlay: photo %s", tostring(photo.localIdentifier)))
413
+
414
+ return {
415
+ success = true,
416
+ photo = {
417
+ id = photo.localIdentifier,
418
+ path = photo:getRawMetadata('path'),
419
+ },
420
+ message = "Mask overlay toggled — check the Lightroom Develop view "
421
+ .. "(red overlay shows what is masked).",
422
+ }
423
+ end
424
+
425
+ -- =====================================================================
426
+ -- list_masks / remove_mask — mask inventory management
427
+ -- =====================================================================
428
+ --
429
+ -- LrDevelopController.getAllMasks() returns the masks of the photo loaded
430
+ -- in the Develop module. The exact per-entry shape varies between Lightroom
431
+ -- versions (name/id fields), so entries are passed through verbatim — pair
432
+ -- list_masks with read_local_adjustments for the full stored structure.
433
+ -- removeMask verifies through the mask count before/after, which is stable
434
+ -- regardless of entry shape.
435
+
436
+ local function currentMaskCount()
437
+ local ok, masks = pcall(function()
438
+ return LrDevelopController.getAllMasks()
439
+ end)
440
+ if not ok or type(masks) ~= "table" then return -1 end
441
+ return #masks
442
+ end
443
+
444
+ function AIMaskHandler.listMasks(args)
445
+ if args.photo_id == nil or args.photo_id == "" then
446
+ error("photo_id is required")
447
+ end
448
+
449
+ local catalog = LrApplication.activeCatalog()
450
+ local photo = resolveSinglePhoto(catalog, args.photo_id)
451
+ if not photo then
452
+ error("No photo matched photo_id")
453
+ end
454
+
455
+ switchToDevelopAndSelect(catalog, photo)
456
+
457
+ local masksOk, masks = pcall(function()
458
+ return LrDevelopController.getAllMasks()
459
+ end)
460
+ if not masksOk then
461
+ error("getAllMasks() failed: " .. tostring(masks))
462
+ end
463
+ masks = masks or {}
464
+
465
+ Log.info(string.format("listMasks: %d mask(s)", #masks))
466
+
467
+ return {
468
+ success = true,
469
+ photo = {
470
+ id = photo.localIdentifier,
471
+ path = photo:getRawMetadata('path'),
472
+ },
473
+ masks = masks,
474
+ count = #masks,
475
+ message = string.format("Photo has %d mask(s)", #masks),
476
+ }
477
+ end
478
+
479
+ function AIMaskHandler.removeMask(args)
480
+ if args.photo_id == nil or args.photo_id == "" then
481
+ error("photo_id is required")
482
+ end
483
+
484
+ local removeAll = args.remove_all == true
485
+
486
+ if not removeAll and args.mask_id == nil then
487
+ error("mask_id is required (or pass remove_all=true to clear every mask)")
488
+ end
489
+ if removeAll and args.confirm ~= true then
490
+ error("remove_all strips every mask from the photo: pass confirm=true to proceed")
491
+ end
492
+
493
+ local catalog = LrApplication.activeCatalog()
494
+ local photo = resolveSinglePhoto(catalog, args.photo_id)
495
+ if not photo then
496
+ error("No photo matched photo_id")
497
+ end
498
+
499
+ switchToDevelopAndSelect(catalog, photo)
500
+
501
+ local beforeCount = currentMaskCount()
502
+
503
+ if removeAll then
504
+ -- resetMasking() is the engine's "remove all masks" command
505
+ -- (lightroom-cli's `ai reset`).
506
+ local resetOk, resetErr = pcall(function()
507
+ LrDevelopController.resetMasking()
508
+ end)
509
+ if not resetOk then
510
+ error("resetMasking failed: " .. tostring(resetErr))
511
+ end
512
+ else
513
+ local delOk, delErr = pcall(function()
514
+ LrDevelopController.deleteMask(args.mask_id)
515
+ end)
516
+ if not delOk then
517
+ error("deleteMask failed: " .. tostring(delErr)
518
+ .. " (the mask id must belong to the current photo — check list_masks)")
519
+ end
520
+ end
521
+
522
+ -- Give the Develop module a beat to commit the deletion, then verify
523
+ -- through the mask count: the per-entry shape of getAllMasks() varies
524
+ -- between Lightroom versions, but a decrease is unambiguous.
525
+ LrTasks.sleep(SETTLE_BETWEEN_PHOTOS_S)
526
+ local afterCount = currentMaskCount()
527
+ local verified = beforeCount >= 0 and afterCount >= 0 and afterCount < beforeCount
528
+
529
+ Log.info(string.format("removeMask(%s): %d -> %d masks",
530
+ removeAll and "all" or tostring(args.mask_id), beforeCount, afterCount))
531
+
532
+ local result = {
533
+ success = true,
534
+ remove_all = removeAll,
535
+ masks_before = beforeCount,
536
+ masks_after = afterCount,
537
+ verified = verified,
538
+ message = string.format("%s (masks %d -> %d)",
539
+ removeAll and "Removed all masks" or ("Removed mask '" .. tostring(args.mask_id) .. "'"),
540
+ beforeCount, afterCount),
541
+ }
542
+ if not verified then
543
+ result.warning = "Mask count did not decrease — the photo may have "
544
+ .. "had no masks, or the id did not match. Run list_masks to inspect."
545
+ end
546
+ return result
547
+ end
548
+
549
+ return AIMaskHandler