@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,1668 @@
1
+ local LrApplication = import 'LrApplication'
2
+ local LrFileUtils = import 'LrFileUtils'
3
+ local LrPathUtils = import 'LrPathUtils'
4
+ local LrTasks = import 'LrTasks'
5
+ local LrApplicationView = import 'LrApplicationView'
6
+ local LrDevelopController = import 'LrDevelopController'
7
+
8
+ local PhotoLookup = require 'PhotoLookup'
9
+ local Log = require 'Log'
10
+
11
+ local DevelopHandler = {}
12
+
13
+ local MAX_BULK_PHOTO_IDS = 1000
14
+ local MAX_POINT_CURVE_VALUES = 64
15
+
16
+ local POINT_CURVE_SETTING_KEYS = {
17
+ "ToneCurvePV2012",
18
+ "ToneCurvePV2012Red",
19
+ "ToneCurvePV2012Green",
20
+ "ToneCurvePV2012Blue",
21
+ }
22
+
23
+ local ALLOWED_DEVELOP_SETTING_KEYS = {
24
+ "WhiteBalance",
25
+ "Temperature",
26
+ "Tint",
27
+ "Exposure2012",
28
+ "Contrast2012",
29
+ "Highlights2012",
30
+ "Shadows2012",
31
+ "Whites2012",
32
+ "Blacks2012",
33
+ "Texture",
34
+ "Clarity2012",
35
+ "Dehaze",
36
+ "Vibrance",
37
+ "Saturation",
38
+ "SaturationAdjustmentRed",
39
+ "SaturationAdjustmentOrange",
40
+ "SaturationAdjustmentYellow",
41
+ "SaturationAdjustmentGreen",
42
+ "SaturationAdjustmentAqua",
43
+ "SaturationAdjustmentBlue",
44
+ "SaturationAdjustmentPurple",
45
+ "SaturationAdjustmentMagenta",
46
+ "HueAdjustmentRed",
47
+ "HueAdjustmentOrange",
48
+ "HueAdjustmentYellow",
49
+ "HueAdjustmentGreen",
50
+ "HueAdjustmentAqua",
51
+ "HueAdjustmentBlue",
52
+ "HueAdjustmentPurple",
53
+ "HueAdjustmentMagenta",
54
+ "LuminanceAdjustmentRed",
55
+ "LuminanceAdjustmentOrange",
56
+ "LuminanceAdjustmentYellow",
57
+ "LuminanceAdjustmentGreen",
58
+ "LuminanceAdjustmentAqua",
59
+ "LuminanceAdjustmentBlue",
60
+ "LuminanceAdjustmentPurple",
61
+ "LuminanceAdjustmentMagenta",
62
+ "ParametricShadows",
63
+ "ParametricDarks",
64
+ "ParametricLights",
65
+ "ParametricHighlights",
66
+ "ParametricShadowSplit",
67
+ "ParametricMidtoneSplit",
68
+ "ParametricHighlightSplit",
69
+ "ToneCurvePV2012",
70
+ "ToneCurvePV2012Red",
71
+ "ToneCurvePV2012Green",
72
+ "ToneCurvePV2012Blue",
73
+ "ToneCurveName2012",
74
+ "ConvertToGrayscale",
75
+ "Sharpness",
76
+ "SharpenRadius",
77
+ "SharpenDetail",
78
+ "SharpenEdgeMasking",
79
+ "LuminanceSmoothing",
80
+ "LuminanceNoiseReductionDetail",
81
+ "LuminanceNoiseReductionContrast",
82
+ "ColorNoiseReduction",
83
+ "ColorNoiseReductionDetail",
84
+ "ColorNoiseReductionSmoothness",
85
+ "LensProfileEnable",
86
+ "LensManualDistortionAmount",
87
+ "PerspectiveVertical",
88
+ "PerspectiveHorizontal",
89
+ "PerspectiveRotate",
90
+ "PerspectiveScale",
91
+ "PerspectiveAspect",
92
+ "PerspectiveUpright",
93
+ "PostCropVignetteAmount",
94
+ "PostCropVignetteMidpoint",
95
+ "PostCropVignetteRoundness",
96
+ "PostCropVignetteFeather",
97
+ "PostCropVignetteStyle",
98
+ "GrainAmount",
99
+ "GrainSize",
100
+ "GrainFrequency",
101
+ "CropTop",
102
+ "CropLeft",
103
+ "CropBottom",
104
+ "CropRight",
105
+ "CropAngle",
106
+ }
107
+
108
+ local ALLOWED_DEVELOP_SETTING_LOOKUP = {}
109
+ for _, key in ipairs(ALLOWED_DEVELOP_SETTING_KEYS) do
110
+ ALLOWED_DEVELOP_SETTING_LOOKUP[key] = true
111
+ end
112
+
113
+ local POINT_CURVE_SETTING_LOOKUP = {}
114
+ for _, key in ipairs(POINT_CURVE_SETTING_KEYS) do
115
+ POINT_CURVE_SETTING_LOOKUP[key] = true
116
+ end
117
+
118
+ local function requireString(value, name)
119
+ if type(value) ~= "string" or value == "" then
120
+ error(name .. " is required")
121
+ end
122
+ end
123
+
124
+ -- Photo ids leave the catalog as numbers (localIdentifier), so a caller feeding
125
+ -- search/selection output back in sends numbers. PhotoLookup normalizes with
126
+ -- tostring; only these validators used to reject them, which surfaced as a
127
+ -- misleading "photo_id is required".
128
+ local function requirePhotoId(value, name)
129
+ if type(value) == "number" then
130
+ return tostring(value)
131
+ end
132
+ requireString(value, name)
133
+ return value
134
+ end
135
+
136
+ local function requireStringArray(value, name, maxItems)
137
+ if type(value) ~= "table" then
138
+ error(name .. " is required")
139
+ end
140
+
141
+ local count = 0
142
+ for key, item in pairs(value) do
143
+ if type(key) ~= "number" or key < 1 or key ~= math.floor(key) then
144
+ error(name .. " must be an array")
145
+ end
146
+ if type(item) ~= "string" or item == "" then
147
+ error(name .. "[" .. tostring(key) .. "] must be a non-empty string")
148
+ end
149
+ count = count + 1
150
+ end
151
+
152
+ if count == 0 then
153
+ error(name .. " is required")
154
+ end
155
+ if count ~= #value then
156
+ error(name .. " must be an array")
157
+ end
158
+ if maxItems and count > maxItems then
159
+ error(name .. " must contain at most " .. tostring(maxItems) .. " items")
160
+ end
161
+ end
162
+
163
+ local function requirePhotoIdArray(value, name, maxItems)
164
+ if type(value) ~= "table" then
165
+ error(name .. " is required")
166
+ end
167
+
168
+ local normalized = {}
169
+ local count = 0
170
+ local maxIndex = 0
171
+ for key, item in pairs(value) do
172
+ if type(key) ~= "number" or key < 1 or key ~= math.floor(key) then
173
+ error(name .. " must be an array")
174
+ end
175
+ if type(item) == "number" then
176
+ normalized[key] = tostring(item)
177
+ elseif type(item) == "string" and item ~= "" then
178
+ normalized[key] = item
179
+ else
180
+ error(name .. "[" .. tostring(key) .. "] must be a photo id or file path")
181
+ end
182
+ count = count + 1
183
+ if key > maxIndex then maxIndex = key end
184
+ end
185
+
186
+ if count == 0 then
187
+ error(name .. " is required")
188
+ end
189
+ if count ~= maxIndex then
190
+ error(name .. " must be an array")
191
+ end
192
+ if maxItems and count > maxItems then
193
+ error(name .. " must contain at most " .. tostring(maxItems) .. " items")
194
+ end
195
+
196
+ return normalized
197
+ end
198
+
199
+ local function requireAllowedDevelopSettingKey(key)
200
+ if not ALLOWED_DEVELOP_SETTING_LOOKUP[key] then
201
+ error("Unsupported develop setting key: " .. tostring(key))
202
+ end
203
+ end
204
+
205
+ local function requirePointCurve(value, name)
206
+ if type(value) ~= "table" then
207
+ error(name .. " must be an array of input/output number pairs")
208
+ end
209
+
210
+ local count = 0
211
+ for index, item in pairs(value) do
212
+ if type(index) ~= "number" or index < 1 or index ~= math.floor(index) then
213
+ error(name .. " must be an array")
214
+ end
215
+ if type(item) ~= "number" or item < 0 or item > 255 or item ~= math.floor(item) then
216
+ error(name .. "[" .. tostring(index) .. "] must be an integer from 0 to 255")
217
+ end
218
+ count = count + 1
219
+ end
220
+
221
+ if count ~= #value then
222
+ error(name .. " must be an array")
223
+ end
224
+ if count < 4 or count > MAX_POINT_CURVE_VALUES or count % 2 ~= 0 then
225
+ error(name .. " must contain 2 to 32 input/output pairs")
226
+ end
227
+ if value[1] ~= 0 or value[count - 1] ~= 255 then
228
+ error(name .. " must start at input 0 and end at input 255")
229
+ end
230
+
231
+ local previousInput = nil
232
+ for index = 1, count, 2 do
233
+ local input = value[index]
234
+ if previousInput ~= nil and input <= previousInput then
235
+ error(name .. " input values must be strictly increasing")
236
+ end
237
+ previousInput = input
238
+ end
239
+ end
240
+
241
+ local function requireDevelopSettingValue(key, value)
242
+ if POINT_CURVE_SETTING_LOOKUP[key] then
243
+ requirePointCurve(value, key)
244
+ return
245
+ end
246
+
247
+ local valueType = type(value)
248
+ if valueType ~= "number" and valueType ~= "string" and valueType ~= "boolean" then
249
+ error("Unsupported value for develop setting key: " .. tostring(key))
250
+ end
251
+ end
252
+
253
+ local function requireCapturedSettingValue(key, value)
254
+ local valueType = type(value)
255
+ if valueType == "number" or valueType == "string" or valueType == "boolean" then
256
+ return
257
+ end
258
+ if POINT_CURVE_SETTING_LOOKUP[key] and valueType == "table" then
259
+ local count = 0
260
+ for index, item in pairs(value) do
261
+ if type(index) ~= "number" or index < 1 or index ~= math.floor(index) then
262
+ error("Develop setting " .. tostring(key) .. " is not a capturable array")
263
+ end
264
+ if type(item) ~= "number" then
265
+ error("Develop setting " .. tostring(key) .. " must contain only numbers")
266
+ end
267
+ count = count + 1
268
+ end
269
+ if count ~= #value or count % 2 ~= 0 or count < 4 or count > MAX_POINT_CURVE_VALUES then
270
+ error("Develop setting " .. tostring(key) .. " is not a capturable point curve")
271
+ end
272
+ return
273
+ end
274
+ error("Unsupported value for develop setting key: " .. tostring(key))
275
+ end
276
+
277
+ local function requireDevelopSettingsObject(settings)
278
+ if type(settings) ~= "table" then
279
+ error("settings is required")
280
+ end
281
+
282
+ local count = 0
283
+ for key, value in pairs(settings) do
284
+ if type(key) ~= "string" then
285
+ error("settings keys must be strings")
286
+ end
287
+ requireAllowedDevelopSettingKey(key)
288
+ requireDevelopSettingValue(key, value)
289
+ count = count + 1
290
+ end
291
+
292
+ if count == 0 then
293
+ error("settings is required")
294
+ end
295
+ end
296
+
297
+ local function requireDevelopSettingWhitelist(settings)
298
+ if settings == nil then
299
+ return
300
+ end
301
+
302
+ requireStringArray(settings, "settings", #ALLOWED_DEVELOP_SETTING_KEYS)
303
+ for _, key in ipairs(settings) do
304
+ requireAllowedDevelopSettingKey(key)
305
+ end
306
+ end
307
+
308
+ local function callPresetMethod(preset, methodName)
309
+ local method = preset and preset[methodName]
310
+ if type(method) ~= "function" then
311
+ return nil
312
+ end
313
+ local ok, value = pcall(method, preset)
314
+ if not ok then
315
+ return nil
316
+ end
317
+ return value
318
+ end
319
+
320
+ local function presetEntry(preset, folder, scope)
321
+ return {
322
+ preset = preset,
323
+ name = callPresetMethod(preset, "getName"),
324
+ folder = folder,
325
+ scope = scope,
326
+ uuid = callPresetMethod(preset, "getUuid"),
327
+ file = callPresetMethod(preset, "getFile"),
328
+ }
329
+ end
330
+
331
+ local function allDevelopPresetEntries()
332
+ local entries = {}
333
+ for _, folder in ipairs(LrApplication.developPresetFolders()) do
334
+ for _, preset in ipairs(folder:getDevelopPresets()) do
335
+ table.insert(entries, presetEntry(preset, folder:getName(), "lightroom"))
336
+ end
337
+ end
338
+
339
+ if _PLUGIN and type(LrApplication.getDevelopPresetsForPlugin) == "function" then
340
+ local pluginPresets = LrApplication.getDevelopPresetsForPlugin(_PLUGIN) or {}
341
+ for _, preset in ipairs(pluginPresets) do
342
+ table.insert(entries, presetEntry(preset, "Plugin Develop Presets", "plugin"))
343
+ end
344
+ end
345
+
346
+ return entries
347
+ end
348
+
349
+ local function presetSummary(entry)
350
+ return {
351
+ name = entry.name,
352
+ folder = entry.folder,
353
+ scope = entry.scope,
354
+ uuid = entry.uuid,
355
+ file = entry.file,
356
+ }
357
+ end
358
+
359
+ local function requirePresetSelector(args)
360
+ if type(args) ~= "table" then
361
+ error("preset selector is required")
362
+ end
363
+ local hasName = type(args.preset_name) == "string" and args.preset_name ~= ""
364
+ local hasUuid = type(args.preset_uuid) == "string" and args.preset_uuid ~= ""
365
+ if not hasName and not hasUuid then
366
+ error("preset_name or preset_uuid is required")
367
+ end
368
+ if args.preset_folder ~= nil then requireString(args.preset_folder, "preset_folder") end
369
+ if args.preset_scope ~= nil and args.preset_scope ~= "lightroom" and args.preset_scope ~= "plugin" then
370
+ error("preset_scope must be lightroom or plugin")
371
+ end
372
+ end
373
+
374
+ local function sharesOneUuid(matches)
375
+ local uuid = matches[1].uuid
376
+ if uuid == nil then
377
+ return false
378
+ end
379
+ for index = 2, #matches do
380
+ if matches[index].uuid ~= uuid then
381
+ return false
382
+ end
383
+ end
384
+ return true
385
+ end
386
+
387
+ local function findPreset(args)
388
+ requirePresetSelector(args)
389
+ local matches = {}
390
+ for _, entry in ipairs(allDevelopPresetEntries()) do
391
+ local uuidMatches = args.preset_uuid == nil or entry.uuid == args.preset_uuid
392
+ local nameMatches = args.preset_name == nil or entry.name == args.preset_name
393
+ local folderMatches = args.preset_folder == nil or entry.folder == args.preset_folder
394
+ local scopeMatches = args.preset_scope == nil or entry.scope == args.preset_scope
395
+ if uuidMatches and nameMatches and folderMatches and scopeMatches then
396
+ table.insert(matches, entry)
397
+ end
398
+ end
399
+ if #matches == 0 then
400
+ error("Preset not found")
401
+ end
402
+ -- Lightroom can expose the same preset more than once (for example, as a
403
+ -- favourite and in its original group). Those aliases share one UUID, so
404
+ -- a UUID selector is still exact even when the flattened folder list has
405
+ -- multiple entries for it.
406
+ if #matches > 1 and args.preset_uuid == nil and not sharesOneUuid(matches) then
407
+ error("Preset selector is ambiguous; provide preset_uuid or preset_folder")
408
+ end
409
+ return matches[1]
410
+ end
411
+
412
+ local function cloneSerializable(value, depth, seen)
413
+ local valueType = type(value)
414
+ if valueType == "number" or valueType == "string" or valueType == "boolean" then
415
+ return value, true
416
+ end
417
+ if valueType ~= "table" or depth >= 6 or seen[value] then
418
+ return nil, false
419
+ end
420
+
421
+ seen[value] = true
422
+ local out = {}
423
+ local count = 0
424
+ for key, item in pairs(value) do
425
+ if type(key) ~= "string" and type(key) ~= "number" then
426
+ seen[value] = nil
427
+ return nil, false
428
+ end
429
+ count = count + 1
430
+ if count > 2000 then
431
+ seen[value] = nil
432
+ return nil, false
433
+ end
434
+ local cloned, supported = cloneSerializable(item, depth + 1, seen)
435
+ if not supported then
436
+ seen[value] = nil
437
+ return nil, false
438
+ end
439
+ out[key] = cloned
440
+ end
441
+ seen[value] = nil
442
+ return out, true
443
+ end
444
+
445
+ local function normalizedPresetSettings(preset)
446
+ local settings = callPresetMethod(preset, "getSetting")
447
+ if type(settings) ~= "table" then
448
+ error("Preset settings are unavailable")
449
+ end
450
+ local normalized, supported = cloneSerializable(settings, 0, {})
451
+ if not supported then
452
+ error("Preset settings contain unsupported nested values")
453
+ end
454
+ return normalized
455
+ end
456
+
457
+ -- Lightroom mints a fresh CorrectionID/MaskID on every getSetting() call, so
458
+ -- they identify a read rather than the preset. Comparing them made every
459
+ -- masked preset (all the Adaptive/AI ones) differ from itself.
460
+ local VOLATILE_SETTING_KEYS = {
461
+ CorrectionID = true,
462
+ MaskID = true,
463
+ }
464
+
465
+ local function withoutVolatileIds(value)
466
+ if type(value) ~= "table" then return value end
467
+ local out = {}
468
+ for key, item in pairs(value) do
469
+ if not VOLATILE_SETTING_KEYS[key] then
470
+ out[key] = withoutVolatileIds(item)
471
+ end
472
+ end
473
+ return out
474
+ end
475
+
476
+ local function deepEqual(left, right)
477
+ if type(left) ~= type(right) then return false end
478
+ if type(left) ~= "table" then return left == right end
479
+ for key, value in pairs(left) do
480
+ if not deepEqual(value, right[key]) then return false end
481
+ end
482
+ for key, _ in pairs(right) do
483
+ if left[key] == nil then return false end
484
+ end
485
+ return true
486
+ end
487
+
488
+ local function sortedSettingKeys(settings)
489
+ local keys = {}
490
+ for key, _ in pairs(settings) do table.insert(keys, key) end
491
+ table.sort(keys)
492
+ return keys
493
+ end
494
+
495
+ function DevelopHandler.listDevelopPresets(_)
496
+ local out = {}
497
+ for _, entry in ipairs(allDevelopPresetEntries()) do
498
+ table.insert(out, presetSummary(entry))
499
+ end
500
+
501
+ Log.info(string.format("Listed %d develop presets", #out))
502
+
503
+ return {
504
+ success = true,
505
+ presets = out,
506
+ count = #out,
507
+ }
508
+ end
509
+
510
+ function DevelopHandler.getDevelopPreset(args)
511
+ local entry = findPreset(args)
512
+ local settings = normalizedPresetSettings(entry.preset)
513
+ local result = presetSummary(entry)
514
+ result.success = true
515
+ result.settings = settings
516
+ result.setting_count = #sortedSettingKeys(settings)
517
+ return result
518
+ end
519
+
520
+ function DevelopHandler.compareDevelopPresets(args)
521
+ if type(args.base) ~= "table" or type(args.candidate) ~= "table" then
522
+ error("base and candidate preset selectors are required")
523
+ end
524
+ local base = findPreset(args.base)
525
+ local candidate = findPreset(args.candidate)
526
+ local baseSettings = normalizedPresetSettings(base.preset)
527
+ local candidateSettings = normalizedPresetSettings(candidate.preset)
528
+ local keys = {}
529
+ for key, _ in pairs(baseSettings) do keys[key] = true end
530
+ for key, _ in pairs(candidateSettings) do keys[key] = true end
531
+
532
+ local keyList = {}
533
+ for key, _ in pairs(keys) do table.insert(keyList, key) end
534
+ table.sort(keyList)
535
+
536
+ local changes = {}
537
+ for _, key in ipairs(keyList) do
538
+ local before = withoutVolatileIds(baseSettings[key])
539
+ local after = withoutVolatileIds(candidateSettings[key])
540
+ if not deepEqual(before, after) then
541
+ local change = {
542
+ key = key,
543
+ before_present = baseSettings[key] ~= nil,
544
+ after_present = candidateSettings[key] ~= nil,
545
+ }
546
+ if before ~= nil then change.before = before end
547
+ if after ~= nil then change.after = after end
548
+ table.insert(changes, change)
549
+ end
550
+ end
551
+
552
+ return {
553
+ success = true,
554
+ base = presetSummary(base),
555
+ candidate = presetSummary(candidate),
556
+ changes = changes,
557
+ changed_count = #changes,
558
+ }
559
+ end
560
+
561
+ function DevelopHandler.createDevelopPreset(args)
562
+ args.photo_id = requirePhotoId(args.photo_id, "photo_id")
563
+ requireString(args.preset_name, "preset_name")
564
+ requireStringArray(args.settings, "settings", #ALLOWED_DEVELOP_SETTING_KEYS)
565
+ requireDevelopSettingWhitelist(args.settings)
566
+
567
+ if not _PLUGIN or type(LrApplication.addDevelopPresetForPlugin) ~= "function" then
568
+ error("Plugin preset creation is unavailable")
569
+ end
570
+ for _, entry in ipairs(allDevelopPresetEntries()) do
571
+ if entry.scope == "plugin" and entry.name == args.preset_name then
572
+ error("Plugin preset already exists; use a versioned preset_name")
573
+ end
574
+ end
575
+
576
+ local catalog = LrApplication.activeCatalog()
577
+ local sourceSettings
578
+ catalog:withReadAccessDo(function()
579
+ local photo = PhotoLookup.resolveOne(catalog, args.photo_id)
580
+ if not photo then
581
+ error("Photo not found: " .. args.photo_id)
582
+ end
583
+ sourceSettings = photo:getDevelopSettings()
584
+ end)
585
+
586
+ local presetSettings = {}
587
+ for _, key in ipairs(args.settings) do
588
+ local value = sourceSettings[key]
589
+ if value == nil then
590
+ error("Source photo has no develop setting: " .. key)
591
+ end
592
+ requireCapturedSettingValue(key, value)
593
+ presetSettings[key] = value
594
+ end
595
+
596
+ local preset = LrApplication.addDevelopPresetForPlugin(_PLUGIN, args.preset_name, presetSettings)
597
+ if not preset then error("Lightroom did not create the plugin preset") end
598
+ local entry = presetEntry(preset, "Plugin Develop Presets", "plugin")
599
+ local result = presetSummary(entry)
600
+ result.success = true
601
+ result.source_photo_id = args.photo_id
602
+ result.settings = args.settings
603
+ result.visible_in_develop = false
604
+ result.message = "Created plugin-managed Develop preset checkpoint"
605
+ Log.info(string.format("Created plugin preset %s from photo %s", args.preset_name, args.photo_id))
606
+ return result
607
+ end
608
+
609
+ local function requireLeafFilename(filename)
610
+ requireString(filename, "filename")
611
+ if filename == "." or filename == ".." or LrPathUtils.leafName(filename) ~= filename then
612
+ error("filename must be a leaf filename without path separators")
613
+ end
614
+ end
615
+
616
+ function DevelopHandler.exportDevelopPreset(args)
617
+ requireString(args.destination_dir, "destination_dir")
618
+ local entry = findPreset(args)
619
+ local source = entry.file
620
+ if type(source) ~= "string" or source == "" or LrFileUtils.exists(source) ~= "file" then
621
+ error("Preset has no exportable backing file")
622
+ end
623
+
624
+ local sourceExtension = LrPathUtils.extension(source)
625
+ if type(sourceExtension) ~= "string" or sourceExtension == "" then
626
+ error("Preset backing file has no extension")
627
+ end
628
+ local filename = args.filename or LrPathUtils.leafName(source)
629
+ requireLeafFilename(filename)
630
+ local requestedExtension = LrPathUtils.extension(filename)
631
+ if requestedExtension == "" then
632
+ filename = filename .. "." .. sourceExtension
633
+ elseif requestedExtension:lower() ~= sourceExtension:lower() then
634
+ error("filename extension must match preset backing file: ." .. sourceExtension)
635
+ end
636
+
637
+ if LrFileUtils.exists(args.destination_dir) == false then
638
+ LrFileUtils.createAllDirectories(args.destination_dir)
639
+ end
640
+ if LrFileUtils.exists(args.destination_dir) ~= "directory" then
641
+ error("destination_dir is not a directory")
642
+ end
643
+
644
+ local destination = LrPathUtils.child(args.destination_dir, filename)
645
+ if LrFileUtils.exists(destination) then
646
+ error("destination preset already exists; choose a new filename")
647
+ end
648
+ local copied, copyError = LrFileUtils.copy(source, destination)
649
+ if not copied then
650
+ error("Preset export failed: " .. tostring(copyError))
651
+ end
652
+
653
+ local result = presetSummary(entry)
654
+ result.success = true
655
+ result.destination = destination
656
+ result.message = "Exported Develop preset without overwriting existing files"
657
+ Log.info(string.format("Exported preset %s to %s", tostring(entry.name), destination))
658
+ return result
659
+ end
660
+
661
+ function DevelopHandler.applyDevelopPreset(args)
662
+ args.photo_ids = requirePhotoIdArray(args.photo_ids, "photo_ids", MAX_BULK_PHOTO_IDS)
663
+ local selectedPreset = findPreset(args)
664
+
665
+ local catalog = LrApplication.activeCatalog()
666
+ local appliedCount = 0
667
+
668
+ local missingIds = {}
669
+ local missingCount = 0
670
+
671
+ catalog:withWriteAccessDo("Apply Develop Preset", function()
672
+ local resolved = PhotoLookup.resolveMany(catalog, args.photo_ids)
673
+ for _, resolvedEntry in ipairs(resolved) do
674
+ if resolvedEntry.photo then
675
+ if selectedPreset.scope == "plugin" then
676
+ resolvedEntry.photo:applyDevelopPreset(selectedPreset.preset, _PLUGIN)
677
+ else
678
+ resolvedEntry.photo:applyDevelopPreset(selectedPreset.preset)
679
+ end
680
+ appliedCount = appliedCount + 1
681
+ else
682
+ missingCount = missingCount + 1
683
+ missingIds[missingCount] = tostring(resolvedEntry.id)
684
+ end
685
+ end
686
+ end)
687
+
688
+ Log.info(string.format("Applied preset %s to %d photos", selectedPreset.name, appliedCount))
689
+
690
+ return {
691
+ success = true,
692
+ applied = appliedCount,
693
+ preset = selectedPreset.name,
694
+ folder = selectedPreset.folder,
695
+ scope = selectedPreset.scope,
696
+ uuid = selectedPreset.uuid,
697
+ missing = missingIds,
698
+ message = string.format("Applied preset %s to %d photos (%d ids not found)",
699
+ selectedPreset.name, appliedCount, missingCount),
700
+ }
701
+ end
702
+
703
+ function DevelopHandler.copyDevelopSettings(args)
704
+ args.source_id = requirePhotoId(args.source_id, "source_id")
705
+ args.target_ids = requirePhotoIdArray(args.target_ids, "target_ids", MAX_BULK_PHOTO_IDS)
706
+ requireDevelopSettingWhitelist(args.settings)
707
+
708
+ local catalog = LrApplication.activeCatalog()
709
+ local sourceSettings
710
+
711
+ catalog:withReadAccessDo(function()
712
+ local source = PhotoLookup.resolveOne(catalog, args.source_id)
713
+ if not source then
714
+ error("Source photo not found: " .. args.source_id)
715
+ end
716
+ sourceSettings = source:getDevelopSettings()
717
+ end)
718
+
719
+ local toApply = sourceSettings
720
+ if args.settings then
721
+ toApply = {}
722
+ for _, key in ipairs(args.settings) do
723
+ toApply[key] = sourceSettings[key]
724
+ end
725
+ end
726
+
727
+ local copiedCount = 0
728
+ local missingIds = {}
729
+ local missingCount = 0
730
+
731
+ catalog:withWriteAccessDo("Copy Develop Settings", function()
732
+ local resolved = PhotoLookup.resolveMany(catalog, args.target_ids)
733
+ for _, entry in ipairs(resolved) do
734
+ if entry.photo then
735
+ entry.photo:applyDevelopSettings(toApply)
736
+ copiedCount = copiedCount + 1
737
+ else
738
+ missingCount = missingCount + 1
739
+ missingIds[missingCount] = tostring(entry.id)
740
+ end
741
+ end
742
+ end)
743
+
744
+ Log.info(string.format("Copied develop settings from %s to %d photos", args.source_id, copiedCount))
745
+
746
+ return {
747
+ success = true,
748
+ copied = copiedCount,
749
+ source = args.source_id,
750
+ missing = missingIds,
751
+ message = string.format("Copied develop settings from %s to %d photos (%d ids not found)",
752
+ args.source_id, copiedCount, missingCount),
753
+ }
754
+ end
755
+
756
+ function DevelopHandler.setDevelopSettings(args)
757
+ args.photo_id = requirePhotoId(args.photo_id, "photo_id")
758
+ requireDevelopSettingsObject(args.settings)
759
+
760
+ local catalog = LrApplication.activeCatalog()
761
+ local applied = false
762
+
763
+ catalog:withWriteAccessDo("Set Develop Settings", function()
764
+ local photo = PhotoLookup.resolveOne(catalog, args.photo_id)
765
+ if not photo then
766
+ error("Photo not found: " .. args.photo_id)
767
+ end
768
+ photo:applyDevelopSettings(args.settings)
769
+ applied = true
770
+ end)
771
+
772
+ Log.info(string.format("Set develop settings on photo %s", args.photo_id))
773
+
774
+ return {
775
+ success = applied,
776
+ photo_id = args.photo_id,
777
+ }
778
+ end
779
+
780
+ -- White balance presets accepted by the WhiteBalance develop setting. "Auto"
781
+ -- is resolved immediately thanks to applyDevelopSettings' optFlattenAutoNow
782
+ -- (SDK 13+; older versions resolve lazily on the next Develop render).
783
+ local WHITE_BALANCE_PRESETS = {
784
+ ["As Shot"] = true,
785
+ ["Auto"] = true,
786
+ ["Daylight"] = true,
787
+ ["Cloudy"] = true,
788
+ ["Shade"] = true,
789
+ ["Tungsten"] = true,
790
+ ["Fluorescent"] = true,
791
+ ["Flash"] = true,
792
+ }
793
+
794
+ function DevelopHandler.setWhiteBalance(args)
795
+ if not args.photo_ids or #args.photo_ids == 0 then
796
+ error("photo_ids is required")
797
+ end
798
+
799
+ local settings = {}
800
+
801
+ if args.preset ~= nil then
802
+ if not WHITE_BALANCE_PRESETS[args.preset] then
803
+ error("preset must be one of: As Shot, Auto, Daylight, Cloudy, Shade, Tungsten, Fluorescent, Flash")
804
+ end
805
+ settings.WhiteBalance = args.preset
806
+ end
807
+
808
+ if args.temperature ~= nil or args.tint ~= nil then
809
+ settings.WhiteBalance = "Custom"
810
+ if args.temperature ~= nil then
811
+ if type(args.temperature) ~= "number" or args.temperature < 2000 or args.temperature > 50000 then
812
+ error("temperature must be a Kelvin number between 2000 and 50000")
813
+ end
814
+ settings.Temperature = args.temperature
815
+ end
816
+ if args.tint ~= nil then
817
+ if type(args.tint) ~= "number" or args.tint < -150 or args.tint > 150 then
818
+ error("tint must be a number between -150 (green) and 150 (magenta)")
819
+ end
820
+ settings.Tint = args.tint
821
+ end
822
+ end
823
+
824
+ if next(settings) == nil then
825
+ error("provide a preset, or temperature and/or tint")
826
+ end
827
+
828
+ local catalog = LrApplication.activeCatalog()
829
+ local updatedCount = 0
830
+ local nonRaw = 0
831
+ local missingIds = {}
832
+ local missingCount = 0
833
+
834
+ catalog:withWriteAccessDo("Set White Balance", function()
835
+ local resolved = PhotoLookup.resolveMany(catalog, args.photo_ids)
836
+ for _, entry in ipairs(resolved) do
837
+ local photo = entry.photo
838
+ if photo then
839
+ -- optFlattenAutoNow resolves "Auto" synchronously when the
840
+ -- SDK supports it (3rd parameter, SDK 13+).
841
+ photo:applyDevelopSettings(settings, "MCP White Balance", true)
842
+ updatedCount = updatedCount + 1
843
+ local format = photo:getRawMetadata('fileFormat')
844
+ if format ~= "RAW" and format ~= "DNG" then
845
+ nonRaw = nonRaw + 1
846
+ end
847
+ else
848
+ missingCount = missingCount + 1
849
+ missingIds[missingCount] = tostring(entry.id)
850
+ end
851
+ end
852
+ end)
853
+
854
+ -- Read back the effective values of the first photo so the caller can
855
+ -- verify what Lightroom actually stored.
856
+ local verified = nil
857
+ if updatedCount > 0 then
858
+ catalog:withReadAccessDo(function()
859
+ local resolved = PhotoLookup.resolveMany(catalog, { args.photo_ids[1] })
860
+ if resolved[1] and resolved[1].photo then
861
+ local current = resolved[1].photo:getDevelopSettings()
862
+ verified = {
863
+ WhiteBalance = current.WhiteBalance,
864
+ Temperature = current.Temperature,
865
+ Tint = current.Tint,
866
+ }
867
+ end
868
+ end)
869
+ end
870
+
871
+ Log.info(string.format("Set white balance for %d photos", updatedCount))
872
+
873
+ local result = {
874
+ success = true,
875
+ updated = updatedCount,
876
+ missing = missingIds,
877
+ verified = verified,
878
+ message = string.format("Set white balance on %d photos (%d ids not found)",
879
+ updatedCount, missingCount),
880
+ }
881
+ if nonRaw > 0 then
882
+ result.warning = string.format(
883
+ "%d photo(s) are not RAW/DNG: white balance has limited effect on JPEG/TIFF/PSD files.", nonRaw)
884
+ end
885
+ return result
886
+ end
887
+
888
+ -- =====================================================================
889
+ -- Tone curve (PV2012 point curves)
890
+ -- =====================================================================
891
+ --
892
+ -- Lightroom stores each point curve as a flat array of x,y pairs in
893
+ -- 0..255 input/output space: { 0,0, 128,140, 255,255 }. The dedicated
894
+ -- set_tone_curve / get_tone_curve tools wrap that flat format with a
895
+ -- friendlier [[x,y], ...] shape, endpoint normalization, monotonic-x
896
+ -- validation and read-back verification.
897
+
898
+ local TONE_CURVE_CHANNELS = {
899
+ main = "ToneCurvePV2012",
900
+ red = "ToneCurvePV2012Red",
901
+ green = "ToneCurvePV2012Green",
902
+ blue = "ToneCurvePV2012Blue",
903
+ }
904
+
905
+ -- Approximations of the built-in Lightroom curve presets; the UI name is
906
+ -- reported alongside so Lightroom shows the matching dropdown entry.
907
+ local TONE_CURVE_PRESETS = {
908
+ linear = {
909
+ name = "Linear",
910
+ points = { { 0, 0 }, { 255, 255 } },
911
+ },
912
+ medium_contrast = {
913
+ name = "Medium Contrast",
914
+ points = { { 0, 0 }, { 64, 56 }, { 128, 128 }, { 192, 202 }, { 255, 255 } },
915
+ },
916
+ strong_contrast = {
917
+ name = "Strong Contrast",
918
+ points = { { 0, 0 }, { 64, 42 }, { 128, 128 }, { 192, 216 }, { 255, 255 } },
919
+ },
920
+ }
921
+
922
+ local MAX_CURVE_POINTS = 32
923
+
924
+ local function parseCurveToPoints(curveArray)
925
+ local points = {}
926
+ if type(curveArray) == "table" then
927
+ for i = 1, #curveArray, 2 do
928
+ table.insert(points, { curveArray[i], curveArray[i + 1] })
929
+ end
930
+ end
931
+ return points
932
+ end
933
+
934
+ local function buildCurveArray(points)
935
+ local flat = {}
936
+ for _, p in ipairs(points) do
937
+ table.insert(flat, p[1])
938
+ table.insert(flat, p[2])
939
+ end
940
+ return flat
941
+ end
942
+
943
+ -- Validate + normalize caller-supplied points. Lightroom expects the curve
944
+ -- to start at (0,0) and end at (255,255) with strictly increasing x, so
945
+ -- missing endpoints are inserted (and reported) rather than rejected.
946
+ local function normalizeCurvePoints(points)
947
+ if type(points) ~= "table" or #points < 1 then
948
+ error("points must be an array of at least 1 [x, y] pair")
949
+ end
950
+ if #points > MAX_CURVE_POINTS then
951
+ error("points must contain at most " .. MAX_CURVE_POINTS .. " points")
952
+ end
953
+
954
+ local normalized = {}
955
+ local endpointsAdded = false
956
+ for i, pair in ipairs(points) do
957
+ if type(pair) ~= "table" or #pair ~= 2
958
+ or type(pair[1]) ~= "number" or type(pair[2]) ~= "number" then
959
+ error("points[" .. i .. "] must be an [x, y] pair of numbers")
960
+ end
961
+ local x = math.floor(pair[1] + 0.5)
962
+ local y = math.floor(pair[2] + 0.5)
963
+ if x < 0 or x > 255 or y < 0 or y > 255 then
964
+ error("points[" .. i .. "] coordinates must be between 0 and 255")
965
+ end
966
+ if #normalized > 0 and x <= normalized[#normalized][1] then
967
+ error("points must have strictly increasing x values")
968
+ end
969
+ table.insert(normalized, { x, y })
970
+ end
971
+
972
+ if normalized[1][1] ~= 0 or normalized[1][2] ~= 0 then
973
+ table.insert(normalized, 1, { 0, 0 })
974
+ endpointsAdded = true
975
+ end
976
+ if normalized[#normalized][1] ~= 255 or normalized[#normalized][2] ~= 255 then
977
+ if normalized[#normalized][1] >= 255 then
978
+ error("the last point must end at (255, 255)")
979
+ end
980
+ table.insert(normalized, { 255, 255 })
981
+ endpointsAdded = true
982
+ end
983
+ return normalized, endpointsAdded
984
+ end
985
+
986
+ function DevelopHandler.setToneCurve(args)
987
+ if args.photo_id == nil or args.photo_id == "" then
988
+ error("photo_id is required")
989
+ end
990
+
991
+ local channel = args.channel or "main"
992
+ local key = TONE_CURVE_CHANNELS[channel]
993
+ if not key then
994
+ error("channel must be one of: main, red, green, blue")
995
+ end
996
+
997
+ local points, endpointsAdded, curveName
998
+ if args.preset ~= nil then
999
+ local preset = TONE_CURVE_PRESETS[args.preset]
1000
+ if not preset then
1001
+ error("preset must be one of: linear, medium_contrast, strong_contrast")
1002
+ end
1003
+ curveName = preset.name
1004
+ endpointsAdded = false
1005
+ points = {}
1006
+ for i, p in ipairs(preset.points) do points[i] = { p[1], p[2] } end
1007
+ else
1008
+ points, endpointsAdded = normalizeCurvePoints(args.points)
1009
+ curveName = "Custom"
1010
+ end
1011
+
1012
+ local catalog = LrApplication.activeCatalog()
1013
+ local updated = false
1014
+ local missing = true
1015
+
1016
+ catalog:withWriteAccessDo("Set Tone Curve", function()
1017
+ local resolved = PhotoLookup.resolveMany(catalog, { args.photo_id })
1018
+ if resolved[1] and resolved[1].photo then
1019
+ missing = false
1020
+ resolved[1].photo:applyDevelopSettings({
1021
+ [key] = buildCurveArray(points),
1022
+ ToneCurveName2012 = curveName,
1023
+ }, "MCP Tone Curve")
1024
+ updated = true
1025
+ end
1026
+ end)
1027
+
1028
+ if missing then
1029
+ error("No photo matched photo_id")
1030
+ end
1031
+
1032
+ -- Read back the stored curve so the caller can verify what Lightroom
1033
+ -- actually kept (values may be snapped by the tone engine).
1034
+ local verified = nil
1035
+ catalog:withReadAccessDo(function()
1036
+ local resolved = PhotoLookup.resolveMany(catalog, { args.photo_id })
1037
+ if resolved[1] and resolved[1].photo then
1038
+ local current = resolved[1].photo:getDevelopSettings()
1039
+ verified = {
1040
+ curve_name = current.ToneCurveName2012,
1041
+ points = parseCurveToPoints(current[key]),
1042
+ raw = current[key],
1043
+ }
1044
+ end
1045
+ end)
1046
+
1047
+ Log.info(string.format("Set %s tone curve on photo %s",
1048
+ channel, tostring(args.photo_id)))
1049
+
1050
+ local result = {
1051
+ success = updated,
1052
+ channel = channel,
1053
+ curve_name = curveName,
1054
+ endpoints_added = endpointsAdded or nil,
1055
+ verified = verified,
1056
+ message = string.format("Set %s tone curve to '%s' (%d points)",
1057
+ channel, curveName, #points),
1058
+ }
1059
+ if endpointsAdded then
1060
+ result.message = result.message
1061
+ .. "; (0,0)/(255,255) endpoints were added automatically"
1062
+ end
1063
+ return result
1064
+ end
1065
+
1066
+ function DevelopHandler.getToneCurve(args)
1067
+ if args.photo_id == nil or args.photo_id == "" then
1068
+ error("photo_id is required")
1069
+ end
1070
+
1071
+ local catalog = LrApplication.activeCatalog()
1072
+ local out = nil
1073
+
1074
+ catalog:withReadAccessDo(function()
1075
+ local resolved = PhotoLookup.resolveMany(catalog, { args.photo_id })
1076
+ if resolved[1] and resolved[1].photo then
1077
+ local s = resolved[1].photo:getDevelopSettings()
1078
+ local channels = {}
1079
+ for channel, key in pairs(TONE_CURVE_CHANNELS) do
1080
+ channels[channel] = {
1081
+ points = parseCurveToPoints(s[key]),
1082
+ raw = s[key],
1083
+ }
1084
+ end
1085
+ out = {
1086
+ curve_name = s.ToneCurveName2012,
1087
+ channels = channels,
1088
+ }
1089
+ end
1090
+ end)
1091
+
1092
+ if not out then
1093
+ error("No photo matched photo_id")
1094
+ end
1095
+
1096
+ out.success = true
1097
+ out.message = string.format("Tone curves for photo %s (curve name: %s)",
1098
+ tostring(args.photo_id), tostring(out.curve_name))
1099
+ return out
1100
+ end
1101
+
1102
+ -- =====================================================================
1103
+ -- apply_auto — official Auto Tone / Auto White Balance commands
1104
+ -- =====================================================================
1105
+ --
1106
+ -- LrDevelopController.setAutoTone() / setAutoWhiteBalance() are the same
1107
+ -- commands as the Auto button in the Develop panel: they run Lightroom's
1108
+ -- own analysis on the photo loaded in the Develop module. Because they
1109
+ -- drive the running UI, the handler switches to Develop once, selects
1110
+ -- each photo (outside any catalog gate), fires the commands, and diffs the
1111
+ -- main sliders before/after so the caller sees what actually changed.
1112
+
1113
+ local AUTO_READBACK_KEYS = {
1114
+ "Exposure2012", "Contrast2012", "Highlights2012", "Shadows2012",
1115
+ "Whites2012", "Blacks2012", "Temperature", "Tint",
1116
+ }
1117
+
1118
+ local AUTO_SETTLE_AFTER_MODULE_SWITCH_S = 0.5
1119
+ local AUTO_SETTLE_BETWEEN_PHOTOS_S = 0.3
1120
+ local AUTO_SETTLE_AFTER_COMMAND_S = 0.5
1121
+
1122
+ function DevelopHandler.applyAuto(args)
1123
+ if not args.photo_ids or #args.photo_ids == 0 then
1124
+ error("photo_ids is required")
1125
+ end
1126
+
1127
+ local operations = args.operations
1128
+ if operations == nil or #operations == 0 then
1129
+ operations = { "tone", "white_balance" }
1130
+ end
1131
+ local doTone, doWB = false, false
1132
+ for _, op in ipairs(operations) do
1133
+ if op == "tone" then
1134
+ doTone = true
1135
+ elseif op == "white_balance" then
1136
+ doWB = true
1137
+ else
1138
+ error("operations must contain only 'tone' and/or 'white_balance'")
1139
+ end
1140
+ end
1141
+ if not doTone and not doWB then
1142
+ error("operations must include at least one of 'tone', 'white_balance'")
1143
+ end
1144
+
1145
+ local catalog = LrApplication.activeCatalog()
1146
+
1147
+ local photos = {}
1148
+ catalog:withReadAccessDo(function()
1149
+ local resolved = PhotoLookup.resolveMany(catalog, args.photo_ids)
1150
+ for _, entry in ipairs(resolved) do
1151
+ if entry.photo then table.insert(photos, entry.photo) end
1152
+ end
1153
+ end)
1154
+ if #photos == 0 then
1155
+ error("No photos matched photo_ids")
1156
+ end
1157
+
1158
+ -- switchToModule and setSelectedPhotos both yield to the UI thread, so
1159
+ -- they must run OUTSIDE any catalog access gate (#134/#124).
1160
+ local switchOk, switchErr = pcall(function()
1161
+ LrApplicationView.switchToModule("develop")
1162
+ end)
1163
+ if not switchOk then
1164
+ error("could not switch Lightroom to the Develop module: "
1165
+ .. tostring(switchErr))
1166
+ end
1167
+ LrTasks.sleep(AUTO_SETTLE_AFTER_MODULE_SWITCH_S)
1168
+
1169
+ local results = {}
1170
+ local succeeded = 0
1171
+
1172
+ for i, photo in ipairs(photos) do
1173
+ if i > 1 then LrTasks.sleep(AUTO_SETTLE_BETWEEN_PHOTOS_S) end
1174
+
1175
+ local entry = {
1176
+ photo = {
1177
+ id = photo.localIdentifier,
1178
+ path = photo:getRawMetadata('path'),
1179
+ filename = photo:getFormattedMetadata('fileName'),
1180
+ },
1181
+ applied = {},
1182
+ }
1183
+ table.insert(results, entry)
1184
+
1185
+ local selectOk = pcall(function()
1186
+ catalog:setSelectedPhotos(photo, { photo })
1187
+ end)
1188
+ if not selectOk then
1189
+ entry.error = "could not select the photo in Lightroom"
1190
+ else
1191
+ LrTasks.sleep(AUTO_SETTLE_BETWEEN_PHOTOS_S)
1192
+
1193
+ local before = nil
1194
+ catalog:withReadAccessDo(function()
1195
+ before = photo:getDevelopSettings()
1196
+ end)
1197
+
1198
+ if doTone then
1199
+ local ok, err = pcall(function()
1200
+ catalog:withWriteAccessDo("Auto Tone", function()
1201
+ LrDevelopController.setAutoTone()
1202
+ end)
1203
+ end)
1204
+ entry.applied.tone = ok
1205
+ if not ok then entry.tone_error = tostring(err) end
1206
+ end
1207
+ if doWB then
1208
+ local ok, err = pcall(function()
1209
+ catalog:withWriteAccessDo("Auto White Balance", function()
1210
+ LrDevelopController.setAutoWhiteBalance()
1211
+ end)
1212
+ end)
1213
+ entry.applied.white_balance = ok
1214
+ if not ok then entry.white_balance_error = tostring(err) end
1215
+ end
1216
+
1217
+ -- The Auto commands commit through the Develop pipeline; give
1218
+ -- them a beat before reading back, then diff the main sliders so
1219
+ -- the caller sees exactly what Lightroom changed.
1220
+ LrTasks.sleep(AUTO_SETTLE_AFTER_COMMAND_S)
1221
+ local after = nil
1222
+ catalog:withReadAccessDo(function()
1223
+ after = photo:getDevelopSettings()
1224
+ end)
1225
+
1226
+ if type(before) == "table" and type(after) == "table" then
1227
+ local changed = {}
1228
+ for _, k in ipairs(AUTO_READBACK_KEYS) do
1229
+ if before[k] ~= after[k] then
1230
+ changed[k] = { before = before[k], after = after[k] }
1231
+ end
1232
+ end
1233
+ if next(changed) ~= nil then
1234
+ entry.changed = changed
1235
+ else
1236
+ entry.note = "Auto made no slider changes (settings may already be optimal, or the file type does not support Auto)"
1237
+ end
1238
+ end
1239
+
1240
+ if (not doTone or entry.applied.tone)
1241
+ and (not doWB or entry.applied.white_balance) then
1242
+ succeeded = succeeded + 1
1243
+ end
1244
+ end
1245
+ end
1246
+
1247
+ Log.info(string.format("applyAuto(%s): %d/%d photos",
1248
+ table.concat(operations, "+"), succeeded, #photos))
1249
+
1250
+ local result = {
1251
+ success = succeeded == #photos,
1252
+ requested = #photos,
1253
+ succeeded = succeeded,
1254
+ failed = #photos - succeeded,
1255
+ operations = operations,
1256
+ results = results,
1257
+ method = "LrDevelopController.setAutoTone/setAutoWhiteBalance",
1258
+ message = string.format("Applied Auto (%s) to %d of %d photos",
1259
+ table.concat(operations, "+"), succeeded, #photos),
1260
+ }
1261
+ if succeeded < #photos then
1262
+ result.message = result.message
1263
+ .. ". Check each photo's 'error'/'tone_error'/'white_balance_error' entry."
1264
+ end
1265
+ return result
1266
+ end
1267
+
1268
+ -- =====================================================================
1269
+ -- get_develop_settings — read the develop settings of a photo
1270
+ -- =====================================================================
1271
+ --
1272
+ -- The read-only counterpart of set_develop_settings: photo:getDevelopSettings()
1273
+ -- inside a read gate returns the full stored settings table. "basic" filters
1274
+ -- to the common develop sliders (plus curves) so the response stays small
1275
+ -- and LLM-friendly; "all" passes everything through cloneSerializable
1276
+ -- (depth-limited, handles the nested mask/retouch structures safely).
1277
+ -- Ported from lightroom-cli getSettings, which instead polls
1278
+ -- LrDevelopController per parameter; getDevelopSettings is one catalog read
1279
+ -- and works without switching modules.
1280
+
1281
+ local BASIC_DEVELOP_READ_KEYS = {
1282
+ "WhiteBalance", "Temperature", "Tint",
1283
+ "Exposure2012", "Contrast2012", "Highlights2012", "Shadows2012",
1284
+ "Whites2012", "Blacks2012", "Texture", "Clarity2012", "Dehaze",
1285
+ "Vibrance", "Saturation",
1286
+ "Sharpness", "LuminanceSmoothing", "ColorNoiseReduction",
1287
+ "ParametricShadows", "ParametricDarks", "ParametricLights",
1288
+ "ParametricHighlights",
1289
+ "ToneCurvePV2012", "ToneCurvePV2012Red", "ToneCurvePV2012Green",
1290
+ "ToneCurvePV2012Blue", "ToneCurveName2012",
1291
+ "ConvertToGrayscale", "PostCropVignetteAmount", "GrainAmount",
1292
+ "CropAngle", "ProcessVersion",
1293
+ }
1294
+
1295
+ function DevelopHandler.getDevelopSettings(args)
1296
+ if args.photo_id == nil or args.photo_id == "" then
1297
+ error("photo_id is required")
1298
+ end
1299
+
1300
+ local fields = args.fields or "basic"
1301
+ if fields ~= "basic" and fields ~= "all" then
1302
+ error("fields must be 'basic' (default) or 'all'")
1303
+ end
1304
+
1305
+ local catalog = LrApplication.activeCatalog()
1306
+
1307
+ local photo = nil
1308
+ catalog:withReadAccessDo(function()
1309
+ local resolved = PhotoLookup.resolveMany(catalog, { args.photo_id })
1310
+ if resolved[1] then photo = resolved[1].photo end
1311
+ end)
1312
+ if not photo then
1313
+ error("No photo matched photo_id")
1314
+ end
1315
+
1316
+ local settings = nil
1317
+ catalog:withReadAccessDo(function()
1318
+ settings = photo:getDevelopSettings()
1319
+ end)
1320
+ if type(settings) ~= "table" then
1321
+ error("getDevelopSettings returned no settings table")
1322
+ end
1323
+
1324
+ local out = {}
1325
+ local skipped = {}
1326
+ local skippedCount = 0
1327
+ local function copySetting(key, value)
1328
+ local cloned, supported = cloneSerializable(value, 0, {})
1329
+ if supported then
1330
+ out[key] = cloned
1331
+ else
1332
+ skippedCount = skippedCount + 1
1333
+ table.insert(skipped, key)
1334
+ end
1335
+ end
1336
+
1337
+ if fields == "all" then
1338
+ for key, value in pairs(settings) do
1339
+ copySetting(key, value)
1340
+ end
1341
+ else
1342
+ for _, key in ipairs(BASIC_DEVELOP_READ_KEYS) do
1343
+ if settings[key] ~= nil then
1344
+ copySetting(key, settings[key])
1345
+ end
1346
+ end
1347
+ end
1348
+
1349
+ local count = 0
1350
+ for _ in pairs(out) do count = count + 1 end
1351
+
1352
+ Log.info(string.format("getDevelopSettings: %d field(s) (fields=%s, %d skipped)",
1353
+ count, fields, skippedCount))
1354
+
1355
+ local result = {
1356
+ success = true,
1357
+ photo = {
1358
+ id = photo.localIdentifier,
1359
+ path = photo:getRawMetadata('path'),
1360
+ filename = photo:getFormattedMetadata('fileName'),
1361
+ },
1362
+ fields = fields,
1363
+ settings = out,
1364
+ setting_count = count,
1365
+ message = string.format("Read %d develop setting(s)", count),
1366
+ }
1367
+ if skippedCount > 0 then
1368
+ result.skipped_fields = skipped
1369
+ result.message = result.message
1370
+ .. string.format(" (%d non-serializable field(s) skipped)", skippedCount)
1371
+ end
1372
+ return result
1373
+ end
1374
+
1375
+ -- =====================================================================
1376
+ -- reset_develop — reset all adjustments, per tool, or per parameter
1377
+ -- =====================================================================
1378
+ --
1379
+ -- LrDevelopController exposes three reset surfaces (all reverse-engineered
1380
+ -- by lightroom-cli and verified against LrC 12/13):
1381
+ -- resetAllDevelopAdjustments() — everything, like the Reset button
1382
+ -- resetCrop()/resetTransforms()/... — per Develop tool
1383
+ -- resetToDefault(param) — one named parameter
1384
+ -- Like every LrDevelopController call they act on the photo loaded in the
1385
+ -- Develop module, so the handler switches modules and selects the photo
1386
+ -- first (outside any gate), then runs the resets inside write gates and
1387
+ -- verifies through a before/after settings diff.
1388
+
1389
+ local RESET_TOOLS = {
1390
+ crop = function() LrDevelopController.resetCrop() end,
1391
+ transforms = function() LrDevelopController.resetTransforms() end,
1392
+ spot_removal = function() LrDevelopController.resetSpotRemoval() end,
1393
+ redeye = function() LrDevelopController.resetRedeye() end,
1394
+ healing = function() LrDevelopController.resetHealing() end,
1395
+ masking = function() LrDevelopController.resetMasking() end,
1396
+ gradient = function() LrDevelopController.resetGradient() end,
1397
+ circular_gradient = function() LrDevelopController.resetCircularGradient() end,
1398
+ brushing = function() LrDevelopController.resetBrushing() end,
1399
+ }
1400
+
1401
+ local function switchToDevelopAndSelectPhoto(catalog, photo)
1402
+ local switchOk, switchErr = pcall(function()
1403
+ LrApplicationView.switchToModule("develop")
1404
+ end)
1405
+ if not switchOk then
1406
+ error("could not switch Lightroom to the Develop module: " .. tostring(switchErr))
1407
+ end
1408
+ LrTasks.sleep(AUTO_SETTLE_AFTER_MODULE_SWITCH_S)
1409
+
1410
+ local selectOk = pcall(function()
1411
+ catalog:setSelectedPhotos(photo, { photo })
1412
+ end)
1413
+ if not selectOk then
1414
+ error("could not select the photo in Lightroom")
1415
+ end
1416
+ LrTasks.sleep(AUTO_SETTLE_BETWEEN_PHOTOS_S)
1417
+ end
1418
+
1419
+ function DevelopHandler.resetDevelop(args)
1420
+ if args.photo_id == nil or args.photo_id == "" then
1421
+ error("photo_id is required")
1422
+ end
1423
+
1424
+ local scope = args.scope or "all"
1425
+ if scope ~= "all" and scope ~= "tools" and scope ~= "params" then
1426
+ error("scope must be 'all' (default), 'tools' or 'params'")
1427
+ end
1428
+
1429
+ local tools = args.tools
1430
+ local params = args.params
1431
+
1432
+ if scope == "tools" then
1433
+ if type(tools) ~= "table" or #tools == 0 then
1434
+ error("tools array is required when scope is 'tools' (crop, transforms, spot_removal, redeye, healing, masking, gradient, circular_gradient, brushing)")
1435
+ end
1436
+ for i, tool in ipairs(tools) do
1437
+ if not RESET_TOOLS[tool] then
1438
+ error("tools[" .. i .. "] '" .. tostring(tool) .. "' is not a resettable tool")
1439
+ end
1440
+ end
1441
+ elseif scope == "params" then
1442
+ if type(params) ~= "table" or #params == 0 then
1443
+ error("params array is required when scope is 'params'")
1444
+ end
1445
+ for i, key in ipairs(params) do
1446
+ requireAllowedDevelopSettingKey(key)
1447
+ end
1448
+ end
1449
+
1450
+ local catalog = LrApplication.activeCatalog()
1451
+
1452
+ local photo = nil
1453
+ catalog:withReadAccessDo(function()
1454
+ local resolved = PhotoLookup.resolveMany(catalog, { args.photo_id })
1455
+ if resolved[1] then photo = resolved[1].photo end
1456
+ end)
1457
+ if not photo then
1458
+ error("No photo matched photo_id")
1459
+ end
1460
+
1461
+ switchToDevelopAndSelectPhoto(catalog, photo)
1462
+
1463
+ local before = nil
1464
+ catalog:withReadAccessDo(function()
1465
+ before = photo:getDevelopSettings()
1466
+ end)
1467
+
1468
+ local applied = {}
1469
+ local errors = {}
1470
+
1471
+ if scope == "all" then
1472
+ local ok, err = pcall(function()
1473
+ catalog:withWriteAccessDo("Reset All Develop Adjustments", function()
1474
+ LrDevelopController.resetAllDevelopAdjustments()
1475
+ end)
1476
+ end)
1477
+ if ok then
1478
+ applied.all = true
1479
+ else
1480
+ errors.all = tostring(err)
1481
+ end
1482
+ elseif scope == "tools" then
1483
+ for _, tool in ipairs(tools) do
1484
+ local ok, err = pcall(function()
1485
+ catalog:withWriteAccessDo("Reset " .. tool, function()
1486
+ RESET_TOOLS[tool]()
1487
+ end)
1488
+ end)
1489
+ if ok then
1490
+ applied[tool] = true
1491
+ else
1492
+ errors[tool] = tostring(err)
1493
+ end
1494
+ end
1495
+ else
1496
+ for _, key in ipairs(params) do
1497
+ local ok, err = pcall(function()
1498
+ catalog:withWriteAccessDo("Reset Parameter", function()
1499
+ LrDevelopController.resetToDefault(key)
1500
+ end)
1501
+ end)
1502
+ if ok then
1503
+ applied[key] = true
1504
+ else
1505
+ errors[key] = tostring(err)
1506
+ end
1507
+ end
1508
+ end
1509
+
1510
+ -- Give the Develop pipeline a beat, then diff the main sliders so the
1511
+ -- caller sees what actually changed (same read-back as applyAuto).
1512
+ LrTasks.sleep(AUTO_SETTLE_AFTER_COMMAND_S)
1513
+ local after = nil
1514
+ catalog:withReadAccessDo(function()
1515
+ after = photo:getDevelopSettings()
1516
+ end)
1517
+
1518
+ local changed = {}
1519
+ if type(before) == "table" and type(after) == "table" then
1520
+ for _, k in ipairs(BASIC_DEVELOP_READ_KEYS) do
1521
+ if before[k] ~= nil and before[k] ~= after[k] then
1522
+ changed[k] = { before = before[k], after = after[k] }
1523
+ end
1524
+ end
1525
+ end
1526
+
1527
+ local failedCount = 0
1528
+ for _ in pairs(errors) do failedCount = failedCount + 1 end
1529
+
1530
+ Log.info(string.format("resetDevelop(%s): %d reset(s), %d error(s)",
1531
+ scope, (scope == "all" and 1 or (scope == "tools" and #tools or #params)), failedCount))
1532
+
1533
+ local result = {
1534
+ success = failedCount == 0,
1535
+ scope = scope,
1536
+ applied = applied,
1537
+ errors = (next(errors) ~= nil) and errors or nil,
1538
+ changed = (next(changed) ~= nil) and changed or nil,
1539
+ message = string.format("Reset develop settings (scope=%s)%s",
1540
+ scope, failedCount > 0 and (" — " .. failedCount .. " error(s)") or ""),
1541
+ }
1542
+ if next(changed) == nil and failedCount == 0 then
1543
+ result.note = "No slider changed — the settings may already be at defaults."
1544
+ end
1545
+ return result
1546
+ end
1547
+
1548
+ -- =====================================================================
1549
+ -- set_process_version — process/engine version of the loaded photo
1550
+ -- =====================================================================
1551
+ --
1552
+ -- LrDevelopController.setProcessVersion("Version N") switches the process
1553
+ -- (calibration engine) of the photo loaded in Develop: Version 3 is
1554
+ -- Process 2012, higher numbers are newer engines (LrC 13+ = Version 6).
1555
+ -- Older photos can be modernized (then AI masking/Denoise unlock) at the
1556
+ -- cost of a rendering change. Read-back via getProcessVersion confirms.
1557
+
1558
+ function DevelopHandler.setProcessVersion(args)
1559
+ if args.photo_id == nil or args.photo_id == "" then
1560
+ error("photo_id is required")
1561
+ end
1562
+
1563
+ local version = args.version
1564
+ if version == nil then
1565
+ error("version is required (e.g. 'Version 3' = Process 2012, 'Version 6' = newest)")
1566
+ end
1567
+
1568
+ local catalog = LrApplication.activeCatalog()
1569
+
1570
+ local photo = nil
1571
+ catalog:withReadAccessDo(function()
1572
+ local resolved = PhotoLookup.resolveMany(catalog, { args.photo_id })
1573
+ if resolved[1] then photo = resolved[1].photo end
1574
+ end)
1575
+ if not photo then
1576
+ error("No photo matched photo_id")
1577
+ end
1578
+
1579
+ switchToDevelopAndSelectPhoto(catalog, photo)
1580
+
1581
+ local beforeVersion = nil
1582
+ pcall(function() beforeVersion = LrDevelopController.getProcessVersion() end)
1583
+
1584
+ local ok, err = pcall(function()
1585
+ catalog:withWriteAccessDo("Set Process Version", function()
1586
+ LrDevelopController.setProcessVersion(version)
1587
+ end)
1588
+ end)
1589
+ if not ok then
1590
+ error("setProcessVersion failed: " .. tostring(err)
1591
+ .. " (the photo may already be on this engine, or the LrC version does not accept it)")
1592
+ end
1593
+
1594
+ LrTasks.sleep(AUTO_SETTLE_AFTER_COMMAND_S)
1595
+ local afterVersion = nil
1596
+ pcall(function() afterVersion = LrDevelopController.getProcessVersion() end)
1597
+
1598
+ Log.info(string.format("setProcessVersion: %s -> %s (requested %s)",
1599
+ tostring(beforeVersion), tostring(afterVersion), version))
1600
+
1601
+ local result = {
1602
+ success = true,
1603
+ version = version,
1604
+ before = beforeVersion,
1605
+ after = afterVersion,
1606
+ verified = (afterVersion ~= nil and afterVersion == version),
1607
+ message = string.format("Process version: %s -> %s",
1608
+ tostring(beforeVersion), tostring(afterVersion or version)),
1609
+ }
1610
+ if afterVersion ~= nil and afterVersion ~= version then
1611
+ result.warning = "Lightroom reports a different process version than requested: "
1612
+ .. tostring(afterVersion)
1613
+ end
1614
+ return result
1615
+ end
1616
+
1617
+ -- =====================================================================
1618
+ -- create_snapshot — named develop snapshot (undo checkpoint)
1619
+ -- =====================================================================
1620
+ --
1621
+ -- photo:createDevelopSnapshot(name) records the current develop state as
1622
+ -- a Snapshots panel entry. The SDK exposes no list/apply/delete for
1623
+ -- snapshots, so this is create-only — reported honestly instead of
1624
+ -- pretending a read-back. Ported from lightroom-cli createDevelopSnapshot.
1625
+
1626
+ function DevelopHandler.createSnapshot(args)
1627
+ if args.photo_id == nil or args.photo_id == "" then
1628
+ error("photo_id is required")
1629
+ end
1630
+
1631
+ local name = args.name
1632
+ if type(name) ~= "string" or name == "" then
1633
+ error("name is required")
1634
+ end
1635
+ if #name > 255 then
1636
+ error("name must be at most 255 characters")
1637
+ end
1638
+
1639
+ local catalog = LrApplication.activeCatalog()
1640
+
1641
+ local photo = nil
1642
+ catalog:withReadAccessDo(function()
1643
+ local resolved = PhotoLookup.resolveMany(catalog, { args.photo_id })
1644
+ if resolved[1] then photo = resolved[1].photo end
1645
+ end)
1646
+ if not photo then
1647
+ error("No photo matched photo_id")
1648
+ end
1649
+
1650
+ catalog:withWriteAccessDo("Create Develop Snapshot", function()
1651
+ photo:createDevelopSnapshot(name)
1652
+ end)
1653
+
1654
+ Log.info(string.format("createSnapshot('%s') on photo %s", name, tostring(photo.localIdentifier)))
1655
+
1656
+ return {
1657
+ success = true,
1658
+ photo = {
1659
+ id = photo.localIdentifier,
1660
+ path = photo:getRawMetadata('path'),
1661
+ },
1662
+ name = name,
1663
+ message = string.format("Created develop snapshot '%s'", name),
1664
+ note = "The SDK cannot list snapshots back; verify visually in Lightroom's Snapshots panel.",
1665
+ }
1666
+ end
1667
+
1668
+ return DevelopHandler