@pired/lightroom-mcp 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +70 -0
- package/dist/LightroomMCP.lrplugin/HandlerAI.lua +466 -0
- package/dist/LightroomMCP.lrplugin/HandlerAIMasks.lua +549 -0
- package/dist/LightroomMCP.lrplugin/HandlerCatalog.lua +256 -0
- package/dist/LightroomMCP.lrplugin/HandlerCollections.lua +337 -0
- package/dist/LightroomMCP.lrplugin/HandlerDevelop.lua +1668 -0
- package/dist/LightroomMCP.lrplugin/HandlerExport.lua +149 -0
- package/dist/LightroomMCP.lrplugin/HandlerImport.lua +108 -0
- package/dist/LightroomMCP.lrplugin/HandlerLocalAdjustments.lua +330 -0
- package/dist/LightroomMCP.lrplugin/HandlerMetadata.lua +169 -0
- package/dist/LightroomMCP.lrplugin/HandlerOrganization.lua +783 -0
- package/dist/LightroomMCP.lrplugin/HandlerPreview.lua +189 -0
- package/dist/LightroomMCP.lrplugin/HandlerSearch.lua +185 -0
- package/dist/LightroomMCP.lrplugin/HandlerSelection.lua +302 -0
- package/dist/LightroomMCP.lrplugin/HandlerSpots.lua +313 -0
- package/dist/LightroomMCP.lrplugin/HandlerWatermark.lua +59 -0
- package/dist/LightroomMCP.lrplugin/Info.lua +25 -0
- package/dist/LightroomMCP.lrplugin/JSON.lua +270 -0
- package/dist/LightroomMCP.lrplugin/Log.lua +92 -0
- package/dist/LightroomMCP.lrplugin/MenuShowStatus.lua +22 -0
- package/dist/LightroomMCP.lrplugin/PhotoLookup.lua +39 -0
- package/dist/LightroomMCP.lrplugin/PluginInfoProvider.lua +865 -0
- package/dist/LightroomMCP.lrplugin/PluginInit.lua +48 -0
- package/dist/cli.d.ts +6 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +31 -0
- package/dist/cli.js.map +1 -0
- package/dist/configs/claude-code.mcp.json +9 -0
- package/dist/configs/claude-desktop.json +12 -0
- package/dist/configs/generic-stdio-client.json +10 -0
- package/dist/configs/mcp-proxy-http.json +9 -0
- package/dist/create-server.d.ts +12 -0
- package/dist/create-server.d.ts.map +1 -0
- package/dist/create-server.js +17 -0
- package/dist/create-server.js.map +1 -0
- package/dist/deepseek-harness/README.md +112 -0
- package/dist/deepseek-harness/deepseek-mcp-harness.mjs +292 -0
- package/dist/dispatcher.d.ts +34 -0
- package/dist/dispatcher.d.ts.map +1 -0
- package/dist/dispatcher.js +72 -0
- package/dist/dispatcher.js.map +1 -0
- package/dist/heartbeat.d.ts +17 -0
- package/dist/heartbeat.d.ts.map +1 -0
- package/dist/heartbeat.js +36 -0
- package/dist/heartbeat.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +255 -0
- package/dist/index.js.map +1 -0
- package/dist/install-plugin.d.ts +16 -0
- package/dist/install-plugin.d.ts.map +1 -0
- package/dist/install-plugin.js +125 -0
- package/dist/install-plugin.js.map +1 -0
- package/dist/instance-lock.d.ts +5 -0
- package/dist/instance-lock.d.ts.map +1 -0
- package/dist/instance-lock.js +77 -0
- package/dist/instance-lock.js.map +1 -0
- package/dist/list-tools-handler.d.ts +16 -0
- package/dist/list-tools-handler.d.ts.map +1 -0
- package/dist/list-tools-handler.js +50 -0
- package/dist/list-tools-handler.js.map +1 -0
- package/dist/plugin-liveness.d.ts +48 -0
- package/dist/plugin-liveness.d.ts.map +1 -0
- package/dist/plugin-liveness.js +75 -0
- package/dist/plugin-liveness.js.map +1 -0
- package/dist/plugin-socket.d.ts +32 -0
- package/dist/plugin-socket.d.ts.map +1 -0
- package/dist/plugin-socket.js +97 -0
- package/dist/plugin-socket.js.map +1 -0
- package/dist/ports.d.ts +3 -0
- package/dist/ports.d.ts.map +1 -0
- package/dist/ports.js +22 -0
- package/dist/ports.js.map +1 -0
- package/dist/token.d.ts +3 -0
- package/dist/token.d.ts.map +1 -0
- package/dist/token.js +23 -0
- package/dist/token.js.map +1 -0
- package/dist/tool-contracts.d.ts +13 -0
- package/dist/tool-contracts.d.ts.map +1 -0
- package/dist/tool-contracts.js +1422 -0
- package/dist/tool-contracts.js.map +1 -0
- package/dist/tool-handler.d.ts +23 -0
- package/dist/tool-handler.d.ts.map +1 -0
- package/dist/tool-handler.js +96 -0
- package/dist/tool-handler.js.map +1 -0
- package/dist/validate-args.d.ts +7 -0
- package/dist/validate-args.d.ts.map +1 -0
- package/dist/validate-args.js +37 -0
- package/dist/validate-args.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +4 -0
- package/dist/version.js.map +1 -0
- package/dist/wait-until.d.ts +7 -0
- package/dist/wait-until.d.ts.map +1 -0
- package/dist/wait-until.js +15 -0
- package/dist/wait-until.js.map +1 -0
- package/package.json +73 -0
|
@@ -0,0 +1,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
|