@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,149 @@
1
+ local LrApplication = import 'LrApplication'
2
+ local LrExportSession = import 'LrExportSession'
3
+ local LrFileUtils = import 'LrFileUtils'
4
+ local LrPathUtils = import 'LrPathUtils'
5
+
6
+ local PhotoLookup = require 'PhotoLookup'
7
+ local Log = require 'Log'
8
+
9
+ local ExportHandler = {}
10
+
11
+ -- Lightroom's default collision handling is "ask", which opens a modal
12
+ -- ("The following files already exist") and blocks the export task until a
13
+ -- human clicks. Over the bridge that hangs the request until the server
14
+ -- timeout and queues every later request behind it, so re-exporting the same
15
+ -- photo to the same folder wedged the plugin. Never prompt.
16
+ local COLLISION_HANDLING = {
17
+ rename = 'rename',
18
+ overwrite = 'overwrite',
19
+ skip = 'skip',
20
+ }
21
+ local DEFAULT_COLLISION_HANDLING = 'rename'
22
+
23
+ local EXPORT_FORMATS = {
24
+ jpeg = 'JPEG',
25
+ png = 'PNG',
26
+ tiff = 'TIFF',
27
+ original = 'ORIGINAL',
28
+ }
29
+
30
+ function ExportHandler.exportPhotos(args)
31
+ if not args.photo_ids or #args.photo_ids == 0 then
32
+ error("photo_ids is required")
33
+ end
34
+
35
+ if not args.destination then
36
+ error("destination is required")
37
+ end
38
+
39
+ local catalog = LrApplication.activeCatalog()
40
+
41
+ -- Resolve photos under read access, then RELEASE the lock before
42
+ -- exporting. doExportOnCurrentTask() can run for minutes on a large
43
+ -- batch; holding catalog read access for that whole span blocks every
44
+ -- other handler (list_collections, get_selected_photos, ...) and on
45
+ -- macOS wedged the bridge until a manual restart (issue #128).
46
+ -- LrExportSession acquires its own catalog access during rendering, so
47
+ -- the lock is only needed for the lookup itself.
48
+ local photosToExport = {}
49
+ catalog:withReadAccessDo(function()
50
+ local resolved = PhotoLookup.resolveMany(catalog, args.photo_ids)
51
+ for _, entry in ipairs(resolved) do
52
+ if entry.photo then
53
+ table.insert(photosToExport, entry.photo)
54
+ end
55
+ end
56
+ end)
57
+
58
+ if #photosToExport == 0 then
59
+ error("No photos found to export")
60
+ end
61
+
62
+ -- destinationType=specificFolder makes LR honour
63
+ -- LR_export_destinationPathPrefix; sourceFolder ignores it and
64
+ -- writes next to the original. LR_format is set in the
65
+ -- format-specific block below.
66
+ local collisionHandling = args.on_existing or DEFAULT_COLLISION_HANDLING
67
+ if not COLLISION_HANDLING[collisionHandling] then
68
+ error("on_existing must be one of: rename, overwrite, skip")
69
+ end
70
+
71
+ local exportSettings = {
72
+ LR_export_destinationType = 'specificFolder',
73
+ LR_export_destinationPathPrefix = args.destination,
74
+ LR_export_useSubfolder = false,
75
+ LR_jpeg_quality = args.quality or 90,
76
+ LR_collisionHandling = COLLISION_HANDLING[collisionHandling],
77
+ }
78
+
79
+ -- Set dimensions if specified
80
+ if args.width or args.height then
81
+ exportSettings.LR_size_doConstrain = true
82
+ exportSettings.LR_size_maxWidth = args.width
83
+ exportSettings.LR_size_maxHeight = args.height
84
+ exportSettings.LR_size_resizeType = 'longEdge'
85
+ end
86
+
87
+ -- Handle different formats
88
+ local requestedFormat = args.format
89
+ if requestedFormat ~= nil and type(requestedFormat) ~= "string" then
90
+ error("format must be one of: jpeg, png, tiff, original")
91
+ end
92
+
93
+ local formatKey = requestedFormat and requestedFormat:lower() or 'jpeg'
94
+ local resolvedFormat = EXPORT_FORMATS[formatKey]
95
+ if not resolvedFormat then
96
+ error("format must be one of: jpeg, png, tiff, original")
97
+ end
98
+
99
+ exportSettings.LR_format = resolvedFormat
100
+ if resolvedFormat == 'JPEG' then
101
+ exportSettings.LR_export_colorSpace = 'sRGB'
102
+ elseif resolvedFormat == 'TIFF' then
103
+ exportSettings.LR_tiff_compressionMethod = 'compressionMethod_LZW'
104
+ end
105
+
106
+ -- Watermarking: the export dialog's Watermarking section binds a table
107
+ -- with watermarkEnabled + watermarkSelection (preset name, as listed by
108
+ -- the list_watermarks tool). Watermarking is only applied to JPEG/PNG/
109
+ -- TIFF exports, never to 'original'.
110
+ if args.watermark then
111
+ if type(args.watermark) ~= "string" or args.watermark == "" then
112
+ error("watermark must be the name of a watermark preset")
113
+ end
114
+ if resolvedFormat == 'ORIGINAL' then
115
+ error("watermark cannot be applied when exporting the original format")
116
+ end
117
+ exportSettings.watermarking = {
118
+ watermarkEnabled = true,
119
+ watermarkSelection = args.watermark,
120
+ }
121
+ end
122
+
123
+ -- Create export session
124
+ local exportSession = LrExportSession {
125
+ photosToExport = photosToExport,
126
+ exportSettings = exportSettings,
127
+ }
128
+
129
+ -- Execute export (outside the read-access block above)
130
+ exportSession:doExportOnCurrentTask()
131
+ local exportedCount = #photosToExport
132
+
133
+ Log.info(string.format("Exported %d photos to: %s", exportedCount, args.destination))
134
+
135
+ local result = {
136
+ success = true,
137
+ exported = exportedCount,
138
+ destination = args.destination,
139
+ message = string.format("Exported %d photos to %s", exportedCount, args.destination)
140
+ }
141
+ if args.watermark then
142
+ result.watermark = args.watermark
143
+ result.message = result.message .. string.format(" with watermark '%s'", args.watermark)
144
+ end
145
+
146
+ return result
147
+ end
148
+
149
+ return ExportHandler
@@ -0,0 +1,108 @@
1
+ local LrApplication = import 'LrApplication'
2
+ local LrTasks = import 'LrTasks'
3
+ local LrFileUtils = import 'LrFileUtils'
4
+ local LrPathUtils = import 'LrPathUtils'
5
+
6
+ local Log = require 'Log'
7
+
8
+ local ImportHandler = {}
9
+
10
+ function ImportHandler.importPhotos(args)
11
+ if not args.source_path then
12
+ error("source_path is required")
13
+ end
14
+
15
+ -- LrFileUtils.exists returns 'file' / 'directory' / false. Use it for the
16
+ -- directory check too: LrFileUtils.isDirectory is absent on some Lightroom
17
+ -- Classic runtimes (nil there, e.g. 15.3.1 -- issue #129), whereas exists
18
+ -- is stable across supported versions.
19
+ local sourceKind = LrFileUtils.exists(args.source_path)
20
+ if not sourceKind then
21
+ error("Source path does not exist: " .. args.source_path)
22
+ end
23
+
24
+ local catalog = LrApplication.activeCatalog()
25
+ local importedCount = 0
26
+
27
+ -- Enumerate source files OUTSIDE any catalog lock; the filesystem walk
28
+ -- needs no catalog access.
29
+ local photosToImport = {}
30
+ if sourceKind == 'directory' then
31
+ -- Import all photos from directory, including nested subfolders.
32
+ -- LrFileUtils.files lists only the immediate directory, so a source
33
+ -- organized as shoot/roll subfolders (the common Lightroom layout)
34
+ -- enumerated nothing and failed with "No photos found to import".
35
+ for file in LrFileUtils.recursiveFiles(args.source_path) do
36
+ -- extension() is on LrPathUtils; LrFileUtils has no such field, so
37
+ -- the previous call was nil and raised "attempt to call field
38
+ -- 'extension' (a nil value)" on every directory import.
39
+ local ext = LrPathUtils.extension(file):lower()
40
+ if ext == 'jpg' or ext == 'jpeg' or ext == 'png' or
41
+ ext == 'tif' or ext == 'tiff' or ext == 'dng' or
42
+ ext == 'cr2' or ext == 'nef' or ext == 'arw' then
43
+ table.insert(photosToImport, file)
44
+ end
45
+ end
46
+ else
47
+ -- Import single photo
48
+ table.insert(photosToImport, args.source_path)
49
+ end
50
+
51
+ if #photosToImport == 0 then
52
+ error("No photos found to import")
53
+ end
54
+
55
+ -- Add each photo in its OWN write transaction rather than holding the
56
+ -- exclusive catalog write lock across the whole batch. A large import
57
+ -- runs for minutes, and one batch-wide withWriteAccessDo would block
58
+ -- every other handler (reads included) for that entire span -- the same
59
+ -- bridge wedge the export handler was restructured to avoid (issue #128),
60
+ -- and now longer-lived since import_photos was given a 5-minute server
61
+ -- timeout. addPhoto cannot acquire its own isolated access the way
62
+ -- LrExportSession does, so a per-photo lock is the bounded-hold
63
+ -- equivalent: the lock is released between photos, letting queued
64
+ -- handlers interleave.
65
+ local addedPhotos = {}
66
+ for _, filePath in ipairs(photosToImport) do
67
+ catalog:withWriteAccessDo("Import Photo", function()
68
+ local photo = catalog:addPhoto(filePath)
69
+ if photo then
70
+ table.insert(addedPhotos, photo)
71
+ importedCount = importedCount + 1
72
+ end
73
+ end)
74
+ end
75
+
76
+ -- Add to collection if specified, in its own short write transaction.
77
+ if args.collection_name and #addedPhotos > 0 then
78
+ catalog:withWriteAccessDo("Add Imported Photos to Collection", function()
79
+ local targetCollection = nil
80
+ local collections = catalog:getChildCollections()
81
+
82
+ for _, collection in ipairs(collections) do
83
+ if collection:getName() == args.collection_name then
84
+ targetCollection = collection
85
+ break
86
+ end
87
+ end
88
+
89
+ if targetCollection then
90
+ targetCollection:addPhotos(addedPhotos)
91
+ Log.info(string.format("Added %d imported photos to collection: %s",
92
+ #addedPhotos, args.collection_name))
93
+ else
94
+ Log.warn("Collection not found: " .. args.collection_name)
95
+ end
96
+ end)
97
+ end
98
+
99
+ Log.info(string.format("Imported %d photos from: %s", importedCount, args.source_path))
100
+
101
+ return {
102
+ success = true,
103
+ imported = importedCount,
104
+ message = string.format("Imported %d photos", importedCount)
105
+ }
106
+ end
107
+
108
+ return ImportHandler
@@ -0,0 +1,330 @@
1
+ local LrApplication = import 'LrApplication'
2
+
3
+ local PhotoLookup = require 'PhotoLookup'
4
+ local Log = require 'Log'
5
+
6
+ local LocalAdjustmentsHandler = {}
7
+
8
+ -- =====================================================================
9
+ -- Local adjustments (linear and radial gradient masks)
10
+ -- =====================================================================
11
+ --
12
+ -- Lightroom Classic 11+ stores local adjustments in the MaskGroupBasedCorrections
13
+ -- develop setting (a table). Adobe does not document the table's internal
14
+ -- shape, but it mirrors the public XMP schema:
15
+ --
16
+ -- {
17
+ -- What = "Correction",
18
+ -- CorrectionAmount = 1.0,
19
+ -- CorrectionActive = true,
20
+ -- LocalExposure2012 = <EV>, -- fractions -1..1 except exposure
21
+ -- LocalContrast2012 = <-1..1>,
22
+ -- ... further Local* sliders ...
23
+ -- CorrectionMasks = {
24
+ -- { What = "Mask/Gradient",
25
+ -- MaskValue = 1.0,
26
+ -- ZeroX = .., ZeroY = .., FullX = .., FullY = .. }, -- linear
27
+ -- -- or, for radial:
28
+ -- { What = "Mask/CircularGradient",
29
+ -- MaskValue = 1.0, Top = .., Left = .., Bottom = .., Right = ..,
30
+ -- Angle = 0, Feather = 50, Flipped = false,
31
+ -- CenterValue = 1, PerimeterValue = 0 },
32
+ -- },
33
+ -- CorrectionRangeMask = { ... default range mask ... },
34
+ -- }
35
+ --
36
+ -- To stay robust across Lightroom versions this handler:
37
+ -- * never rewrites existing corrections (the new one is appended);
38
+ -- * when the photo already has corrections, clones the structure of one as
39
+ -- a template so unknown per-version fields survive the round-trip;
40
+ -- * verifies the write by reading the corrections back and reports
41
+ -- honestly when a version refuses it.
42
+
43
+ local function clamp01(v)
44
+ if v == nil then return 0 end
45
+ if v < 0 then return 0 end
46
+ if v > 1 then return 1 end
47
+ return v
48
+ end
49
+
50
+ local function clampRange(v, min, max)
51
+ if v == nil then return 0 end
52
+ if v < min then return min end
53
+ if v > max then return max end
54
+ return v
55
+ end
56
+
57
+ -- UI units (-100..100) to the XMP fraction (-1..1) the corrections use.
58
+ local function sliderToFraction(v)
59
+ return clampRange(v, -100, 100) / 100
60
+ end
61
+
62
+ local function deepCopy(v)
63
+ if type(v) ~= "table" then return v end
64
+ local copy = {}
65
+ for k, val in pairs(v) do
66
+ copy[k] = deepCopy(val)
67
+ end
68
+ return copy
69
+ end
70
+
71
+ local function resolveOnePhoto(catalog, photoId)
72
+ local photo = nil
73
+ catalog:withReadAccessDo(function()
74
+ photo = PhotoLookup.resolveOne(catalog, photoId)
75
+ end)
76
+ if not photo then
77
+ error("Photo not found: " .. tostring(photoId))
78
+ end
79
+ return photo
80
+ end
81
+
82
+ -- MaskGroupBasedCorrections as a table; tolerates nil, string and weird shapes.
83
+ local function readCorrections(photo)
84
+ local settings = photo:getDevelopSettings()
85
+ local value = settings.MaskGroupBasedCorrections
86
+ if type(value) == "table" then return value end
87
+ return {}
88
+ end
89
+
90
+ -- Pick a correction to clone the structure from: prefer one whose masks array
91
+ -- holds a mask of the wanted type so geometry keys stay consistent.
92
+ local function findTemplate(corrections, maskWhat)
93
+ for _, correction in ipairs(corrections) do
94
+ if type(correction) == "table" and type(correction.CorrectionMasks) == "table" then
95
+ for _, mask in ipairs(correction.CorrectionMasks) do
96
+ if type(mask) == "table" and mask.What == maskWhat then
97
+ return correction
98
+ end
99
+ end
100
+ end
101
+ end
102
+ return nil
103
+ end
104
+
105
+ local LINEAR_MASK = "Mask/Gradient"
106
+ local RADIAL_MASK = "Mask/CircularGradient"
107
+
108
+ -- Geometry for a linear gradient from center + angle + span.
109
+ -- Angle 0 means the effect grows upward (bottom -> top); 90 grows to the
110
+ -- right; both endpoints are clamped to the image.
111
+ local function linearMaskGeometry(args)
112
+ local cx = clamp01(tonumber(args.center_x) or 0.5)
113
+ local cy = clamp01(tonumber(args.center_y) or 0.5)
114
+ local angle = clampRange(tonumber(args.angle) or 0, -360, 360)
115
+ local span = clampRange(tonumber(args.span) or 0.6, 0.02, 2)
116
+
117
+ local radians = math.rad(angle)
118
+ local dx = math.sin(radians) * span / 2
119
+ local dy = -math.cos(radians) * span / 2
120
+
121
+ return {
122
+ ZeroX = clamp01(cx - dx),
123
+ ZeroY = clamp01(cy - dy),
124
+ FullX = clamp01(cx + dx),
125
+ FullY = clamp01(cy + dy),
126
+ }
127
+ end
128
+
129
+ local function radialMaskGeometry(args)
130
+ local cx = clamp01(tonumber(args.center_x) or 0.5)
131
+ local cy = clamp01(tonumber(args.center_y) or 0.5)
132
+ local rx = clampRange(tonumber(args.radius_x) or tonumber(args.radius) or 0.3, 0.01, 1)
133
+ local ry = clampRange(tonumber(args.radius_y) or tonumber(args.radius) or 0.3, 0.01, 1)
134
+
135
+ return {
136
+ Top = clamp01(cy - ry),
137
+ Left = clamp01(cx - rx),
138
+ Bottom = clamp01(cy + ry),
139
+ Right = clamp01(cx + rx),
140
+ Angle = 0,
141
+ Feather = clampRange(tonumber(args.feather) or 50, 0, 100),
142
+ Flipped = args.invert == true,
143
+ }
144
+ end
145
+
146
+ -- The full set of local sliders Lightroom understands, with their XMP names.
147
+ -- Exposure is in EV like the global slider; everything else is -1..1.
148
+ local LOCAL_SLIDERS = {
149
+ { arg = "exposure", key = "LocalExposure2012", ev = true },
150
+ { arg = "contrast", key = "LocalContrast2012" },
151
+ { arg = "highlights", key = "LocalHighlights2012" },
152
+ { arg = "shadows", key = "LocalShadows2012" },
153
+ { arg = "whites", key = "LocalWhites2012" },
154
+ { arg = "blacks", key = "LocalBlacks2012" },
155
+ { arg = "clarity", key = "LocalClarity2012" },
156
+ { arg = "dehaze", key = "LocalDehaze" },
157
+ { arg = "saturation", key = "LocalSaturation" },
158
+ { arg = "sharpness", key = "LocalSharpness" },
159
+ { arg = "noise_reduction", key = "LocalLuminanceNoise" },
160
+ { arg = "temperature", key = "LocalTemperature" },
161
+ { arg = "tint", key = "LocalTint" },
162
+ }
163
+
164
+ local function hasAnySlider(args)
165
+ for _, slider in ipairs(LOCAL_SLIDERS) do
166
+ if args[slider.arg] ~= nil then return true end
167
+ end
168
+ return false
169
+ end
170
+
171
+ local function applyLocalSliders(correction, args)
172
+ for _, slider in ipairs(LOCAL_SLIDERS) do
173
+ local value = args[slider.arg]
174
+ if value ~= nil then
175
+ if slider.ev then
176
+ correction[slider.key] = clampRange(value, -5, 5)
177
+ else
178
+ correction[slider.key] = sliderToFraction(value)
179
+ end
180
+ end
181
+ end
182
+ end
183
+
184
+ function LocalAdjustmentsHandler.readLocalAdjustments(args)
185
+ args = args or {}
186
+ if args.photo_id == nil or args.photo_id == "" then
187
+ error("photo_id is required")
188
+ end
189
+
190
+ local catalog = LrApplication.activeCatalog()
191
+ local photo = resolveOnePhoto(catalog, args.photo_id)
192
+
193
+ local corrections = readCorrections(photo)
194
+
195
+ Log.info(string.format("read_local_adjustments: %d corrections on photo %s",
196
+ #corrections, tostring(args.photo_id)))
197
+
198
+ return {
199
+ success = true,
200
+ photo_id = photo.localIdentifier,
201
+ count = #corrections,
202
+ corrections = corrections,
203
+ note = "MaskGroupBasedCorrections as stored by this Lightroom version. "
204
+ .. "Use add_local_adjustment to append linear/radial masks; existing corrections are never modified.",
205
+ }
206
+ end
207
+
208
+ function LocalAdjustmentsHandler.addLocalAdjustment(args)
209
+ args = args or {}
210
+ if args.photo_id == nil or args.photo_id == "" then
211
+ error("photo_id is required")
212
+ end
213
+
214
+ local maskType = args.mask_type
215
+ if maskType ~= "linear" and maskType ~= "radial" then
216
+ error("mask_type must be 'linear' or 'radial'")
217
+ end
218
+
219
+ if not hasAnySlider(args) then
220
+ error("at least one adjustment setting is required (e.g. exposure, contrast, saturation)")
221
+ end
222
+
223
+ local catalog = LrApplication.activeCatalog()
224
+ local photo = resolveOnePhoto(catalog, args.photo_id)
225
+
226
+ local corrections = readCorrections(photo)
227
+ local beforeCount = #corrections
228
+
229
+ local maskWhat = (maskType == "linear") and LINEAR_MASK or RADIAL_MASK
230
+ local flow = clampRange(tonumber(args.flow) or 1, 0, 1)
231
+
232
+ -- Build on a template cloned from an existing correction of the same mask
233
+ -- type when present, so per-version fields survive; otherwise use the
234
+ -- canonical structure observed in Lightroom-written XMP.
235
+ local template = findTemplate(corrections, maskWhat)
236
+ local correction
237
+ if template then
238
+ correction = deepCopy(template)
239
+ else
240
+ correction = {
241
+ What = "Correction",
242
+ CorrectionAmount = 1.0,
243
+ CorrectionActive = true,
244
+ -- Legacy PV2003/2010 sliders (always zero in modern edits) kept
245
+ -- for structural parity with Lightroom-written files.
246
+ LocalExposure = 0,
247
+ LocalSaturation = 0,
248
+ LocalContrast = 0,
249
+ LocalClarity = 0,
250
+ LocalSharpness = 0,
251
+ LocalBrightness = 0,
252
+ LocalToningHue = 240,
253
+ LocalToningSaturation = 0,
254
+ LocalDefringe = 0,
255
+ LocalMoire = 0,
256
+ CorrectionRangeMask = {
257
+ ColorAmount = 0.5,
258
+ DepthFeather = 0.5,
259
+ DepthMax = 1.0,
260
+ DepthMin = 0.0,
261
+ LumFeather = 0.5,
262
+ LumMax = 1.0,
263
+ LumMin = 0.0,
264
+ Type = 0,
265
+ Version = "+2",
266
+ },
267
+ }
268
+ end
269
+
270
+ applyLocalSliders(correction, args)
271
+
272
+ local geometry = (maskType == "linear")
273
+ and linearMaskGeometry(args)
274
+ or radialMaskGeometry(args)
275
+
276
+ local maskEntry = {
277
+ What = maskWhat,
278
+ MaskValue = flow,
279
+ }
280
+ for k, v in pairs(geometry) do
281
+ maskEntry[k] = v
282
+ end
283
+
284
+ -- A new adjustment carries exactly one mask; drop any masks the template
285
+ -- carried over from a multi-mask correction.
286
+ correction.CorrectionMasks = { maskEntry }
287
+
288
+ table.insert(corrections, correction)
289
+
290
+ catalog:withWriteAccessDo("Add Local Adjustment", function()
291
+ photo:applyDevelopSettings({
292
+ EnableMaskGroupBasedCorrections = true,
293
+ MaskGroupBasedCorrections = corrections,
294
+ }, "MCP Add Local Adjustment")
295
+ end)
296
+
297
+ -- Verify by reading back.
298
+ local afterCorrections = readCorrections(photo)
299
+ local took = #afterCorrections > beforeCount
300
+
301
+ Log.info(string.format("add_local_adjustment: %d before, %d after on photo %s",
302
+ beforeCount, #afterCorrections, tostring(args.photo_id)))
303
+
304
+ if not took then
305
+ return {
306
+ success = false,
307
+ applied = false,
308
+ photo_id = photo.localIdentifier,
309
+ before_count = beforeCount,
310
+ after_count = #afterCorrections,
311
+ message = "Lightroom did not accept the MaskGroupBasedCorrections write on this version. "
312
+ .. "Draw one gradient/radial mask manually on the photo, then retry: the existing "
313
+ .. "correction is then used as a structure template and round-trips safely.",
314
+ }
315
+ end
316
+
317
+ return {
318
+ success = true,
319
+ applied = true,
320
+ photo_id = photo.localIdentifier,
321
+ mask_type = maskType,
322
+ before_count = beforeCount,
323
+ after_count = #afterCorrections,
324
+ geometry = geometry,
325
+ message = string.format("Added %s local adjustment (photo now has %d).",
326
+ maskType, #afterCorrections),
327
+ }
328
+ end
329
+
330
+ return LocalAdjustmentsHandler