@mthines/reaper-mcp 0.19.0 → 0.70.2
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/README.md +19 -570
- package/bridge-ui/mcp_bridge.html +129 -0
- package/bridge-ui/mcp_bridge.webmanifest +16 -0
- package/bridge-ui/mcp_bridge_icon_192.png +0 -0
- package/bridge-ui/mcp_bridge_icon_512.png +0 -0
- package/bridge-ui/mcp_bridge_sw.js +35 -0
- package/claude/agents/plugin-researcher.md +188 -0
- package/claude/knowledge/reference/aesthetics-scoring.md +48 -0
- package/{knowledge → claude/knowledge}/reference/common-mistakes.md +1 -1
- package/claude/knowledge/reference/lorekit-setup.md +36 -0
- package/claude/knowledge/reference/memory-protocol.md +377 -0
- package/{knowledge → claude/knowledge}/reference/metering.md +5 -5
- package/claude/knowledge/reference/perceived-loudness.md +182 -0
- package/{knowledge → claude/knowledge}/workflows/editing.md +4 -0
- package/{knowledge → claude/knowledge}/workflows/gain-staging.md +21 -23
- package/{knowledge → claude/knowledge}/workflows/learn-plugin.md +15 -48
- package/{knowledge → claude/knowledge}/workflows/master-bus.md +13 -1
- package/claude/knowledge/workflows/section-levels.md +138 -0
- package/{knowledge → claude/knowledge}/workflows/session-prep.md +4 -0
- package/claude/knowledge/workflows/transient-lighting.md +52 -0
- package/claude/skills/critique/SKILL.md +131 -0
- package/claude/skills/editing/SKILL.md +281 -0
- package/claude/skills/learn-plugin/SKILL.md +210 -0
- package/claude/skills/mastering/SKILL.md +151 -0
- package/claude/skills/mix-memory/SKILL.md +140 -0
- package/claude/skills/mixer/SKILL.md +253 -0
- package/claude/skills/new-extension/SKILL.md +324 -0
- package/main.js +7843 -1940
- package/package.json +17 -15
- package/reaper/CLAUDE.md +22 -4
- package/reaper/mcp_actions_lib.lua +375 -0
- package/reaper/mcp_analyzer.jsfx +27 -12
- package/reaper/mcp_arrange_lib.lua +949 -0
- package/reaper/mcp_bridge.lua +2321 -758
- package/reaper/mcp_edit_views_lib.lua +1378 -0
- package/reaper/mcp_envelopes_lib.lua +748 -0
- package/reaper/mcp_extensions_lib.lua +55 -0
- package/reaper/mcp_fxstate_lib.lua +144 -0
- package/reaper/mcp_gain_trace.jsfx +126 -0
- package/reaper/mcp_items_lib.lua +877 -0
- package/reaper/mcp_lufs_meter.jsfx +16 -13
- package/reaper/mcp_midi_lib.lua +555 -0
- package/reaper/mcp_mixing_lib.lua +1021 -0
- package/reaper/mcp_plan_lib.lua +801 -0
- package/reaper/mcp_prefs_lib.lua +127 -0
- package/reaper/mcp_project_lib.lua +1088 -0
- package/reaper/mcp_recording_lib.lua +595 -0
- package/reaper/mcp_render_lib.lua +492 -0
- package/reaper/mcp_snapshot_lib.lua +547 -120
- package/reaper/mcp_snapshot_next.lua +10 -4
- package/reaper/mcp_snapshot_prev.lua +11 -4
- package/reaper/mcp_snapshot_quick_save.lua +7 -32
- package/reaper/mcp_snapshots_ui_lib.lua +258 -0
- package/reaper/mcp_tracks_lib.lua +409 -0
- package/sidecar/analysis.py +661 -0
- package/sidecar/chords.py +96 -0
- package/sidecar/mixcheck.py +295 -0
- package/sidecar/requirements-chords.txt +6 -0
- package/sidecar/requirements-separation.txt +3 -0
- package/sidecar/requirements.txt +17 -0
- package/sidecar/separation.py +142 -0
- package/sidecar/server.py +622 -0
- package/sidecar/tests/test_analysis.py +249 -0
- package/sidecar/tests/test_chords.py +117 -0
- package/sidecar/tests/test_mixcheck.py +163 -0
- package/sidecar/tests/test_separation.py +73 -0
- package/sidecar/tests/test_server.py +464 -0
- package/sidecar/tests/test_verify.py +243 -0
- package/sidecar/tests/test_voice_render.py +268 -0
- package/sidecar/tests/test_voice_sketch.py +140 -0
- package/sidecar/verify.py +224 -0
- package/sidecar/voice_render.py +295 -0
- package/sidecar/voice_sketch.py +280 -0
- package/claude-agents/critique.md +0 -147
- package/claude-agents/editor.md +0 -184
- package/claude-agents/levels.md +0 -107
- package/claude-agents/mastering.md +0 -129
- package/claude-agents/mixer.md +0 -248
- package/claude-agents/preflight.md +0 -149
- package/claude-agents/producer.md +0 -159
- package/claude-agents/setup.md +0 -150
- package/claude-agents/stems.md +0 -135
- package/claude-rules/architecture.md +0 -39
- package/claude-rules/development.md +0 -54
- package/claude-rules/lua-bridge.md +0 -50
- package/claude-rules/testing.md +0 -42
- package/claude-skills/learn-plugin.md +0 -123
- package/knowledge/CLAUDE.md +0 -71
- package/knowledge/plugins/_template.md +0 -82
- package/knowledge/plugins/fabfilter/pro-c-2.md +0 -117
- package/knowledge/plugins/fabfilter/pro-l-2.md +0 -95
- package/knowledge/plugins/fabfilter/pro-q-3.md +0 -112
- package/knowledge/plugins/neural-dsp/helix-native.md +0 -104
- package/knowledge/plugins/stock-reaper/js-1175-compressor.md +0 -94
- package/knowledge/plugins/stock-reaper/rea-comp.md +0 -100
- package/knowledge/plugins/stock-reaper/rea-delay.md +0 -95
- package/knowledge/plugins/stock-reaper/rea-eq.md +0 -103
- package/knowledge/plugins/stock-reaper/rea-gate.md +0 -99
- package/knowledge/plugins/stock-reaper/rea-limit.md +0 -75
- package/knowledge/plugins/stock-reaper/rea-verb.md +0 -76
- package/knowledge/reference/perceived-loudness.md +0 -122
- package/reaper/mcp_snapshot_manager.lua +0 -1001
- /package/{knowledge → claude/knowledge}/genres/_template.md +0 -0
- /package/{knowledge → claude/knowledge}/genres/electronic.md +0 -0
- /package/{knowledge → claude/knowledge}/genres/hip-hop.md +0 -0
- /package/{knowledge → claude/knowledge}/genres/metal.md +0 -0
- /package/{knowledge → claude/knowledge}/genres/orchestral.md +0 -0
- /package/{knowledge → claude/knowledge}/genres/pop.md +0 -0
- /package/{knowledge → claude/knowledge}/genres/rock.md +0 -0
- /package/{knowledge → claude/knowledge}/reference/compression.md +0 -0
- /package/{knowledge → claude/knowledge}/reference/frequencies.md +0 -0
- /package/{knowledge → claude/knowledge}/workflows/delivery.md +0 -0
- /package/{knowledge → claude/knowledge}/workflows/drum-bus.md +0 -0
- /package/{knowledge → claude/knowledge}/workflows/low-end.md +0 -0
- /package/{knowledge → claude/knowledge}/workflows/stem-prep.md +0 -0
- /package/{knowledge → claude/knowledge}/workflows/vocal-chain.md +0 -0
|
@@ -0,0 +1,801 @@
|
|
|
1
|
+
-- =============================================================================
|
|
2
|
+
-- MCP Plan Library — executes a typed list of ops as one all-or-nothing edit
|
|
3
|
+
-- =============================================================================
|
|
4
|
+
-- Loaded via dofile() by mcp_bridge.lua (handler: run_plan). Every function
|
|
5
|
+
-- takes the ReaScript API as `api` instead of reading the `reaper` global, so
|
|
6
|
+
-- the whole executor runs outside REAPER against a fake API in unit tests.
|
|
7
|
+
--
|
|
8
|
+
-- A plan runs in two passes:
|
|
9
|
+
-- 1. validate — resolve every target and check every op; no writes. Any
|
|
10
|
+
-- conflict rejects the whole plan (dryRun stops here and reports).
|
|
11
|
+
-- 2. apply — all ops inside one undo block. If an op fails after writing,
|
|
12
|
+
-- the block is closed and undone, so no partial edit is left behind.
|
|
13
|
+
--
|
|
14
|
+
-- Target references:
|
|
15
|
+
-- track: 3 (0-based index) | "Kick" (exact name, then case-insensitive)
|
|
16
|
+
-- | "{GUID}" | "$ref" (bound by an earlier add_track `as`)
|
|
17
|
+
-- | "3" (digit string: name first, index fallback)
|
|
18
|
+
-- fx: 0 (index on the op's track) | "ReaComp" (unique substring match)
|
|
19
|
+
-- | "$ref" (bound by an earlier add_fx `as`; carries its track)
|
|
20
|
+
-- =============================================================================
|
|
21
|
+
|
|
22
|
+
local M = {}
|
|
23
|
+
|
|
24
|
+
local MAX_REMEMBERED_KEYS = 100
|
|
25
|
+
local REVERT_LABEL = "MCP: run_plan (failed — reverting)"
|
|
26
|
+
|
|
27
|
+
-- -----------------------------------------------------------------------------
|
|
28
|
+
-- Pure helpers (no API access)
|
|
29
|
+
-- -----------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
local function from_db(db)
|
|
32
|
+
return 10 ^ (db / 20)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
local B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
|
36
|
+
|
|
37
|
+
function M.base64_decode(data)
|
|
38
|
+
data = data:gsub("[^%w%+/=]", "")
|
|
39
|
+
return (data:gsub(".", function(ch)
|
|
40
|
+
if ch == "=" then return "" end
|
|
41
|
+
local n = B64:find(ch, 1, true) - 1
|
|
42
|
+
local bits = ""
|
|
43
|
+
for i = 6, 1, -1 do
|
|
44
|
+
bits = bits .. (((n % 2 ^ i) - (n % 2 ^ (i - 1)) > 0) and "1" or "0")
|
|
45
|
+
end
|
|
46
|
+
return bits
|
|
47
|
+
end):gsub("%d%d%d?%d?%d?%d?%d?%d?", function(byte)
|
|
48
|
+
if #byte ~= 8 then return "" end
|
|
49
|
+
local c = 0
|
|
50
|
+
for i = 1, 8 do
|
|
51
|
+
if byte:sub(i, i) == "1" then c = c + 2 ^ (8 - i) end
|
|
52
|
+
end
|
|
53
|
+
return string.char(math.floor(c))
|
|
54
|
+
end))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
--- Minimal JSON decoder. REAPER and SWS ship no JSON parser (there is no
|
|
58
|
+
--- CF_Json_Parse in SWS 2.14), so the plan's nested ops are decoded here.
|
|
59
|
+
--- Returns value, or nil + error. JSON null decodes to nil.
|
|
60
|
+
function M.json_decode(str)
|
|
61
|
+
local pos = 1
|
|
62
|
+
local decode_value
|
|
63
|
+
|
|
64
|
+
local function fail(msg) error({ json = msg .. " at position " .. pos }, 0) end
|
|
65
|
+
local function skip_ws() pos = str:find("[^ \t\r\n]", pos) or (#str + 1) end
|
|
66
|
+
|
|
67
|
+
local function utf8_char(cp)
|
|
68
|
+
if cp < 0x80 then return string.char(cp) end
|
|
69
|
+
if cp < 0x800 then return string.char(0xC0 | (cp >> 6), 0x80 | (cp & 0x3F)) end
|
|
70
|
+
return string.char(0xE0 | (cp >> 12), 0x80 | ((cp >> 6) & 0x3F), 0x80 | (cp & 0x3F))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
local ESC = { ['"'] = '"', ["\\"] = "\\", ["/"] = "/", b = "\b", f = "\f", n = "\n", r = "\r", t = "\t" }
|
|
74
|
+
|
|
75
|
+
local function decode_string()
|
|
76
|
+
local out = {}
|
|
77
|
+
pos = pos + 1 -- opening quote
|
|
78
|
+
while true do
|
|
79
|
+
local c = str:sub(pos, pos)
|
|
80
|
+
if c == "" then fail("unterminated string") end
|
|
81
|
+
if c == '"' then pos = pos + 1; return table.concat(out) end
|
|
82
|
+
if c == "\\" then
|
|
83
|
+
local e = str:sub(pos + 1, pos + 1)
|
|
84
|
+
if e == "u" then
|
|
85
|
+
local hex = str:sub(pos + 2, pos + 5)
|
|
86
|
+
if not hex:match("^%x%x%x%x$") then fail("bad \\u escape") end
|
|
87
|
+
out[#out + 1] = utf8_char(tonumber(hex, 16))
|
|
88
|
+
pos = pos + 6
|
|
89
|
+
elseif ESC[e] then
|
|
90
|
+
out[#out + 1] = ESC[e]
|
|
91
|
+
pos = pos + 2
|
|
92
|
+
else
|
|
93
|
+
fail("bad escape")
|
|
94
|
+
end
|
|
95
|
+
else
|
|
96
|
+
local s, e = str:find('^[^"\\]+', pos)
|
|
97
|
+
out[#out + 1] = str:sub(s, e)
|
|
98
|
+
pos = e + 1
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
function decode_value()
|
|
104
|
+
skip_ws()
|
|
105
|
+
local c = str:sub(pos, pos)
|
|
106
|
+
if c == "{" then
|
|
107
|
+
local obj = {}
|
|
108
|
+
pos = pos + 1
|
|
109
|
+
skip_ws()
|
|
110
|
+
if str:sub(pos, pos) == "}" then pos = pos + 1; return obj end
|
|
111
|
+
while true do
|
|
112
|
+
skip_ws()
|
|
113
|
+
if str:sub(pos, pos) ~= '"' then fail("expected object key") end
|
|
114
|
+
local k = decode_string()
|
|
115
|
+
skip_ws()
|
|
116
|
+
if str:sub(pos, pos) ~= ":" then fail("expected ':'") end
|
|
117
|
+
pos = pos + 1
|
|
118
|
+
obj[k] = decode_value()
|
|
119
|
+
skip_ws()
|
|
120
|
+
local d = str:sub(pos, pos)
|
|
121
|
+
pos = pos + 1
|
|
122
|
+
if d == "}" then return obj end
|
|
123
|
+
if d ~= "," then fail("expected ',' or '}'") end
|
|
124
|
+
end
|
|
125
|
+
elseif c == "[" then
|
|
126
|
+
local arr, n = {}, 0
|
|
127
|
+
pos = pos + 1
|
|
128
|
+
skip_ws()
|
|
129
|
+
if str:sub(pos, pos) == "]" then pos = pos + 1; return arr end
|
|
130
|
+
while true do
|
|
131
|
+
n = n + 1
|
|
132
|
+
arr[n] = decode_value()
|
|
133
|
+
skip_ws()
|
|
134
|
+
local d = str:sub(pos, pos)
|
|
135
|
+
pos = pos + 1
|
|
136
|
+
if d == "]" then return arr end
|
|
137
|
+
if d ~= "," then fail("expected ',' or ']'") end
|
|
138
|
+
end
|
|
139
|
+
elseif c == '"' then
|
|
140
|
+
return decode_string()
|
|
141
|
+
elseif str:sub(pos, pos + 3) == "true" then
|
|
142
|
+
pos = pos + 4; return true
|
|
143
|
+
elseif str:sub(pos, pos + 4) == "false" then
|
|
144
|
+
pos = pos + 5; return false
|
|
145
|
+
elseif str:sub(pos, pos + 3) == "null" then
|
|
146
|
+
pos = pos + 4; return nil
|
|
147
|
+
else
|
|
148
|
+
local num = str:match("^-?%d+%.?%d*[eE]?[-+]?%d*", pos)
|
|
149
|
+
if not num or num == "" or num == "-" then fail("unexpected character '" .. c .. "'") end
|
|
150
|
+
pos = pos + #num
|
|
151
|
+
return tonumber(num) or fail("bad number")
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
local ok, res = pcall(function()
|
|
156
|
+
local v = decode_value()
|
|
157
|
+
skip_ws()
|
|
158
|
+
if pos <= #str then fail("trailing characters") end
|
|
159
|
+
return v
|
|
160
|
+
end)
|
|
161
|
+
if ok then return res end
|
|
162
|
+
if type(res) == "table" and res.json then return nil, res.json end
|
|
163
|
+
return nil, tostring(res)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
local function split_lines(chunk)
|
|
167
|
+
local lines = {}
|
|
168
|
+
for line in (chunk .. "\n"):gmatch("(.-)\r?\n") do
|
|
169
|
+
lines[#lines + 1] = line
|
|
170
|
+
end
|
|
171
|
+
-- A chunk ending in "\n" yields one trailing empty line; drop it.
|
|
172
|
+
if lines[#lines] == "" then lines[#lines] = nil end
|
|
173
|
+
return lines
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
local function is_block_open(line, tag)
|
|
177
|
+
local trimmed = line:match("^%s*(.-)%s*$")
|
|
178
|
+
if tag then
|
|
179
|
+
-- "<FXCHAIN" must not also match "<FXCHAIN_REC" (input FX).
|
|
180
|
+
return trimmed == "<" .. tag or trimmed:sub(1, #tag + 2) == "<" .. tag .. " "
|
|
181
|
+
end
|
|
182
|
+
return trimmed:sub(1, 1) == "<"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
local function is_block_close(line)
|
|
186
|
+
return line:match("^%s*>%s*$") ~= nil
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
--- Find a block that is a DIRECT child of the chunk's outer block.
|
|
190
|
+
--- Returns first_line, last_line (1-based, inclusive) or nil.
|
|
191
|
+
function M.find_block(lines, tag)
|
|
192
|
+
local depth = 0
|
|
193
|
+
local start = nil
|
|
194
|
+
for i, line in ipairs(lines) do
|
|
195
|
+
if is_block_open(line) then
|
|
196
|
+
depth = depth + 1
|
|
197
|
+
if depth == 2 and is_block_open(line, tag) then start = i end
|
|
198
|
+
elseif is_block_close(line) then
|
|
199
|
+
if depth == 2 and start then return start, i end
|
|
200
|
+
depth = depth - 1
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
return nil
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
--- The FX names in an FXCHAIN block, lowercased: the first quoted field of
|
|
207
|
+
--- each FX header (`<VST "VST: ReaComp (Cockos)" ...`), or its first word
|
|
208
|
+
--- (`<JS utility/volume ""`).
|
|
209
|
+
function M.fxchain_names(fxchain)
|
|
210
|
+
local names = {}
|
|
211
|
+
for _, line in ipairs(split_lines(fxchain or "")) do
|
|
212
|
+
local tag, rest = line:match("^%s*<(%u[%u_%d]*)%s+(.*)$")
|
|
213
|
+
if tag and tag ~= "FXCHAIN" and tag ~= "PROGRAMENV" and tag ~= "PARMENV" and tag ~= "JS_SER" then
|
|
214
|
+
local name = rest:match('^"([^"]*)"') or rest:match("^(%S+)")
|
|
215
|
+
names[#names + 1] = (name or ""):lower()
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
return names
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
--- Extract the FXCHAIN block text from a track state chunk, or nil.
|
|
222
|
+
function M.extract_fxchain(chunk)
|
|
223
|
+
local lines = split_lines(chunk)
|
|
224
|
+
local s, e = M.find_block(lines, "FXCHAIN")
|
|
225
|
+
if not s then return nil end
|
|
226
|
+
return table.concat(lines, "\n", s, e)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
--- Replace every "FXID {...}" line with a fresh GUID from gen_guid().
|
|
230
|
+
function M.regenerate_fxids(block, gen_guid)
|
|
231
|
+
local lines = split_lines(block)
|
|
232
|
+
for i, line in ipairs(lines) do
|
|
233
|
+
local indent = line:match("^(%s*)FXID%s+%b{}%s*$")
|
|
234
|
+
if indent then lines[i] = indent .. "FXID " .. gen_guid() end
|
|
235
|
+
end
|
|
236
|
+
return table.concat(lines, "\n")
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
--- Put `fxchain` into a track chunk: replace its FXCHAIN if it has one,
|
|
240
|
+
--- otherwise insert it before the first ITEM block (or before the track's
|
|
241
|
+
--- closing ">"). Everything else in the target chunk is kept as is.
|
|
242
|
+
function M.splice_fxchain(target_chunk, fxchain)
|
|
243
|
+
local lines = split_lines(target_chunk)
|
|
244
|
+
local new_lines = split_lines(fxchain)
|
|
245
|
+
local s, e = M.find_block(lines, "FXCHAIN")
|
|
246
|
+
local out = {}
|
|
247
|
+
if s then
|
|
248
|
+
for i = 1, s - 1 do out[#out + 1] = lines[i] end
|
|
249
|
+
for _, l in ipairs(new_lines) do out[#out + 1] = l end
|
|
250
|
+
for i = e + 1, #lines do out[#out + 1] = lines[i] end
|
|
251
|
+
else
|
|
252
|
+
local insert_at = M.find_block(lines, "ITEM") or #lines
|
|
253
|
+
for i = 1, insert_at - 1 do out[#out + 1] = lines[i] end
|
|
254
|
+
for _, l in ipairs(new_lines) do out[#out + 1] = l end
|
|
255
|
+
for i = insert_at, #lines do out[#out + 1] = lines[i] end
|
|
256
|
+
end
|
|
257
|
+
return table.concat(out, "\n") .. "\n"
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
-- -----------------------------------------------------------------------------
|
|
261
|
+
-- Target resolution (reads only)
|
|
262
|
+
-- -----------------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
local function track_label(ref)
|
|
265
|
+
return type(ref) == "number" and ("#" .. ref) or ("'" .. tostring(ref) .. "'")
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
local function find_tracks_by_name(api, name, insensitive)
|
|
269
|
+
local hits = {}
|
|
270
|
+
local want = insensitive and name:lower() or name
|
|
271
|
+
for i = 0, api.CountTracks(0) - 1 do
|
|
272
|
+
local tr = api.GetTrack(0, i)
|
|
273
|
+
local _, tname = api.GetTrackName(tr)
|
|
274
|
+
tname = tname or ""
|
|
275
|
+
if (insensitive and tname:lower() or tname) == want then hits[#hits + 1] = tr end
|
|
276
|
+
end
|
|
277
|
+
return hits
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
--- Resolve a track reference to { track = MediaTrack } or { ref = name }
|
|
281
|
+
--- (a track the plan creates). Returns nil, err when unresolvable/ambiguous.
|
|
282
|
+
local function resolve_track(api, ref, refs)
|
|
283
|
+
if ref == -1 or ref == "-1" or (type(ref) == "string" and ref:lower() == "master") then
|
|
284
|
+
return { track = api.GetMasterTrack(0) }
|
|
285
|
+
end
|
|
286
|
+
if type(ref) == "number" then
|
|
287
|
+
local tr = api.GetTrack(0, math.floor(ref))
|
|
288
|
+
if not tr then return nil, "track " .. track_label(ref) .. " not found" end
|
|
289
|
+
return { track = tr }
|
|
290
|
+
end
|
|
291
|
+
if type(ref) ~= "string" or ref == "" then
|
|
292
|
+
return nil, "track reference missing"
|
|
293
|
+
end
|
|
294
|
+
if ref:sub(1, 1) == "$" then
|
|
295
|
+
local bound = refs[ref]
|
|
296
|
+
if not bound then return nil, "reference " .. ref .. " is not defined by an earlier op" end
|
|
297
|
+
if bound.kind ~= "track" then return nil, "reference " .. ref .. " is an FX, not a track" end
|
|
298
|
+
return { ref = ref }
|
|
299
|
+
end
|
|
300
|
+
if ref:sub(1, 1) == "{" then
|
|
301
|
+
for i = 0, api.CountTracks(0) - 1 do
|
|
302
|
+
local tr = api.GetTrack(0, i)
|
|
303
|
+
if api.GetTrackGUID(tr) == ref then return { track = tr } end
|
|
304
|
+
end
|
|
305
|
+
return nil, "track with GUID " .. ref .. " not found"
|
|
306
|
+
end
|
|
307
|
+
local hits = find_tracks_by_name(api, ref, false)
|
|
308
|
+
if #hits == 0 then hits = find_tracks_by_name(api, ref, true) end
|
|
309
|
+
if #hits == 1 then return { track = hits[1] } end
|
|
310
|
+
if #hits > 1 then
|
|
311
|
+
return nil, "track name '" .. ref .. "' is ambiguous (" .. #hits .. " tracks match)"
|
|
312
|
+
end
|
|
313
|
+
if ref:match("^%d+$") then return resolve_track(api, tonumber(ref), refs) end
|
|
314
|
+
return nil, "track '" .. ref .. "' not found"
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
local function fx_installed(api, name)
|
|
318
|
+
if not api.EnumInstalledFX then return true end
|
|
319
|
+
local want = name:lower()
|
|
320
|
+
local i = 0
|
|
321
|
+
while true do
|
|
322
|
+
local ok, fx_name = api.EnumInstalledFX(i)
|
|
323
|
+
if not ok then return false end
|
|
324
|
+
if fx_name and fx_name:lower():find(want, 1, true) then return true end
|
|
325
|
+
i = i + 1
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
--- Find one FX on a track by unique name substring. A digit-only ref (MCP
|
|
330
|
+
--- clients send numbers as strings) matches an exact name only, then falls back
|
|
331
|
+
--- to the FX index — a substring match would turn "0" into "Ozone 10".
|
|
332
|
+
local function find_fx(api, track, ref)
|
|
333
|
+
local want = ref:lower()
|
|
334
|
+
local digits = ref:match("^%d+$") ~= nil
|
|
335
|
+
local found
|
|
336
|
+
for j = 0, api.TrackFX_GetCount(track) - 1 do
|
|
337
|
+
local _, name = api.TrackFX_GetFXName(track, j)
|
|
338
|
+
name = (name or ""):lower()
|
|
339
|
+
if (digits and name == want) or (not digits and name:find(want, 1, true)) then
|
|
340
|
+
if found then return nil, "fx name '" .. ref .. "' matches more than one FX" end
|
|
341
|
+
found = j
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
if found then return found end
|
|
345
|
+
local n = digits and tonumber(ref)
|
|
346
|
+
if n and n < api.TrackFX_GetCount(track) then return n end
|
|
347
|
+
return nil, "fx '" .. ref .. "' not found"
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
--- Validate-time version of find_fx, against `chain`: the lowercased FX
|
|
351
|
+
--- names the track will hold when this op runs (its own FX, plus what earlier
|
|
352
|
+
--- ops add or graft). Returns an error string or nil.
|
|
353
|
+
local function check_fx_target(chain, ref)
|
|
354
|
+
if type(ref) == "number" then
|
|
355
|
+
if ref >= #chain then return "fx index " .. ref .. " out of range" end
|
|
356
|
+
return nil
|
|
357
|
+
end
|
|
358
|
+
local want = ref:lower()
|
|
359
|
+
local digits = ref:match("^%d+$") ~= nil
|
|
360
|
+
local hits = 0
|
|
361
|
+
-- A requested fxName ("ReaComp") is usually shorter than the name REAPER
|
|
362
|
+
-- gives the instance ("VST: ReaComp (Cockos)"), so match either way round.
|
|
363
|
+
for _, name in ipairs(chain) do
|
|
364
|
+
if (digits and name == want)
|
|
365
|
+
or (not digits and (name:find(want, 1, true) or want:find(name, 1, true))) then
|
|
366
|
+
hits = hits + 1
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
if hits > 1 then return "fx name '" .. ref .. "' matches more than one FX" end
|
|
370
|
+
if hits == 1 then return nil end
|
|
371
|
+
if digits and tonumber(ref) < #chain then return nil end
|
|
372
|
+
return "fx '" .. ref .. "' not found"
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
-- -----------------------------------------------------------------------------
|
|
376
|
+
-- Validation pass
|
|
377
|
+
-- -----------------------------------------------------------------------------
|
|
378
|
+
|
|
379
|
+
local KNOWN_OPS = {
|
|
380
|
+
add_track = true, set_track = true, add_fx = true,
|
|
381
|
+
set_fx_params = true, add_send = true, graft_fxchain = true,
|
|
382
|
+
run_action = true, select_tracks = true,
|
|
383
|
+
copy_items = true, insert_time = true, remove_time = true,
|
|
384
|
+
}
|
|
385
|
+
-- Ops that add or move items, or add tracks: item and track indexes after them differ from
|
|
386
|
+
-- the project the plan was checked against
|
|
387
|
+
local SHIFTS_ITEMS = { add_track = true, copy_items = true, insert_time = true, remove_time = true }
|
|
388
|
+
|
|
389
|
+
--- Validate every op without writing. Returns resolved ops and conflicts.
|
|
390
|
+
--- ctx.actions (mcp_actions_lib.lua) is required only for run_action and select_tracks ops;
|
|
391
|
+
--- ctx.kb_text (reaper-kb.ini) lets it check the steps of a Custom: macro.
|
|
392
|
+
--- ctx.arrange (mcp_arrange_lib.lua) is required only for copy_items, insert_time and remove_time.
|
|
393
|
+
function M.validate(api, ops, ctx)
|
|
394
|
+
ctx = ctx or {}
|
|
395
|
+
local refs = {} -- "$name" -> { kind = "track" | "fx", op = i }
|
|
396
|
+
local resolved = {}
|
|
397
|
+
local conflicts = {}
|
|
398
|
+
local warnings = {}
|
|
399
|
+
local macros -- Custom: macros from ctx.kb_text, parsed on first run_action
|
|
400
|
+
local action_at -- index of the first run_action: later ops can't trust resolved targets
|
|
401
|
+
local shifted_at -- index of the first op that adds tracks or adds/moves items
|
|
402
|
+
-- Track (or "$ref") -> the lowercased FX names it will hold at this point
|
|
403
|
+
-- of the plan: its own chain, then whatever earlier add_fx / graft ops change.
|
|
404
|
+
local chains = {}
|
|
405
|
+
local function chain_of(res)
|
|
406
|
+
local key = res and (res.track or res.ref)
|
|
407
|
+
if not key then return nil end
|
|
408
|
+
if not chains[key] then
|
|
409
|
+
local names = {}
|
|
410
|
+
if res.track then
|
|
411
|
+
for j = 0, api.TrackFX_GetCount(res.track) - 1 do
|
|
412
|
+
local _, name = api.TrackFX_GetFXName(res.track, j)
|
|
413
|
+
names[#names + 1] = (name or ""):lower()
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
chains[key] = names
|
|
417
|
+
end
|
|
418
|
+
return chains[key]
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
local function conflict(i, op, msg)
|
|
422
|
+
conflicts[#conflicts + 1] = { op = i - 1, type = op.op, error = msg }
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
for i, op in ipairs(ops) do
|
|
426
|
+
local r = { op = op, index = i - 1 }
|
|
427
|
+
local t = op.op
|
|
428
|
+
if not KNOWN_OPS[t] then
|
|
429
|
+
conflict(i, op, "unknown op type '" .. tostring(t) .. "'")
|
|
430
|
+
elseif action_at and t ~= "run_action" then
|
|
431
|
+
-- Targets resolve before anything runs; an action may add, delete or
|
|
432
|
+
-- reorder tracks and FX, so a later target could point at the wrong one.
|
|
433
|
+
conflict(i, op, "only run_action may follow a run_action (op " .. (action_at - 1)
|
|
434
|
+
.. "): an action can add, delete or reorder tracks; send the rest as a new plan")
|
|
435
|
+
else
|
|
436
|
+
local function need_track(field)
|
|
437
|
+
local res, err = resolve_track(api, op[field], refs)
|
|
438
|
+
if not res then conflict(i, op, field .. ": " .. err) end
|
|
439
|
+
return res
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
local fx_is_ref = type(op.fx) == "string" and op.fx:sub(1, 1) == "$"
|
|
443
|
+
if t == "set_track" or t == "add_fx" or t == "graft_fxchain"
|
|
444
|
+
or (t == "set_fx_params" and not fx_is_ref) then
|
|
445
|
+
r.track = need_track("track")
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
if t == "add_fx" then
|
|
449
|
+
local chain = chain_of(r.track)
|
|
450
|
+
if chain and type(op.fxName) == "string" then chain[#chain + 1] = op.fxName:lower() end
|
|
451
|
+
if type(op.fxName) ~= "string" or op.fxName == "" then
|
|
452
|
+
conflict(i, op, "fxName required")
|
|
453
|
+
elseif not fx_installed(api, op.fxName) then
|
|
454
|
+
-- A warning, not a conflict: AddByName accepts names (JS paths,
|
|
455
|
+
-- prefixes) that the installed-FX list spells differently.
|
|
456
|
+
warnings[#warnings + 1] = { op = i - 1, type = t, warning = "FX '" .. op.fxName .. "' not found in the installed FX list" }
|
|
457
|
+
end
|
|
458
|
+
elseif t == "set_fx_params" then
|
|
459
|
+
if type(op.params) ~= "table" or #op.params == 0 then
|
|
460
|
+
conflict(i, op, "params array required")
|
|
461
|
+
end
|
|
462
|
+
if fx_is_ref then
|
|
463
|
+
local bound = refs[op.fx]
|
|
464
|
+
if not bound then
|
|
465
|
+
conflict(i, op, "reference " .. op.fx .. " is not defined by an earlier op")
|
|
466
|
+
elseif bound.kind ~= "fx" then
|
|
467
|
+
conflict(i, op, "reference " .. op.fx .. " is a track, not an FX")
|
|
468
|
+
end
|
|
469
|
+
elseif type(op.fx) ~= "number" and type(op.fx) ~= "string" then
|
|
470
|
+
conflict(i, op, "fx reference missing")
|
|
471
|
+
elseif chain_of(r.track) then
|
|
472
|
+
local err = check_fx_target(chain_of(r.track), op.fx)
|
|
473
|
+
if err then conflict(i, op, err) end
|
|
474
|
+
end
|
|
475
|
+
elseif t == "add_send" then
|
|
476
|
+
r.from = need_track("from")
|
|
477
|
+
r.to = need_track("to")
|
|
478
|
+
-- REAPER creates nothing for a send into or out of the master.
|
|
479
|
+
local master = api.GetMasterTrack(0)
|
|
480
|
+
if (r.from and r.from.track == master) or (r.to and r.to.track == master) then
|
|
481
|
+
conflict(i, op, "a send cannot involve the master track: use set_track_info masterSend")
|
|
482
|
+
end
|
|
483
|
+
elseif t == "graft_fxchain" then
|
|
484
|
+
local grafted
|
|
485
|
+
if op.fromChunk then
|
|
486
|
+
local ok, chunk = pcall(M.base64_decode, op.fromChunk)
|
|
487
|
+
r.fxchain = ok and M.extract_fxchain(chunk) or nil
|
|
488
|
+
if r.fxchain then grafted = M.fxchain_names(r.fxchain) end
|
|
489
|
+
if not r.fxchain then conflict(i, op, "fromChunk holds no FXCHAIN block") end
|
|
490
|
+
elseif op.from ~= nil then
|
|
491
|
+
r.source = need_track("from")
|
|
492
|
+
local key = r.source and (r.source.track or r.source.ref)
|
|
493
|
+
if key and chains[key] then
|
|
494
|
+
-- An earlier op changed (or created) the source: copy what it will hold.
|
|
495
|
+
grafted = { table.unpack(chains[key]) }
|
|
496
|
+
elseif r.source and r.source.track then
|
|
497
|
+
local _, chunk = api.GetTrackStateChunk(r.source.track, "", false)
|
|
498
|
+
local block = M.extract_fxchain(chunk or "")
|
|
499
|
+
if block then grafted = M.fxchain_names(block)
|
|
500
|
+
else conflict(i, op, "source track has no FX chain") end
|
|
501
|
+
end
|
|
502
|
+
else
|
|
503
|
+
conflict(i, op, "graft_fxchain needs `from` or `fromChunk`")
|
|
504
|
+
end
|
|
505
|
+
-- Later ops on the target see the grafted chain, not the old one.
|
|
506
|
+
local key = r.track and (r.track.track or r.track.ref)
|
|
507
|
+
if grafted and key then chains[key] = grafted end
|
|
508
|
+
elseif t == "select_tracks" then
|
|
509
|
+
if not ctx.actions then
|
|
510
|
+
conflict(i, op, "mcp_actions_lib.lua is not loaded")
|
|
511
|
+
elseif type(op.tracks) ~= "table" or #op.tracks == 0 then
|
|
512
|
+
conflict(i, op, "tracks array required")
|
|
513
|
+
else
|
|
514
|
+
r.tracks = {}
|
|
515
|
+
for k, ref in ipairs(op.tracks) do
|
|
516
|
+
local res, err = resolve_track(api, ref, refs)
|
|
517
|
+
if not res then conflict(i, op, "tracks[" .. (k - 1) .. "]: " .. err) end
|
|
518
|
+
r.tracks[k] = res
|
|
519
|
+
end
|
|
520
|
+
end
|
|
521
|
+
elseif ctx.arrange == nil and (t == "copy_items" or t == "insert_time" or t == "remove_time") then
|
|
522
|
+
conflict(i, op, "mcp_arrange_lib.lua is not loaded")
|
|
523
|
+
elseif t == "copy_items" and shifted_at then
|
|
524
|
+
-- Item indexes are read from the project as it is now
|
|
525
|
+
conflict(i, op, "copy_items names items by index, and op " .. (shifted_at - 1) .. " (" .. ops[shifted_at].op
|
|
526
|
+
.. ") changes them first; put copy_items earlier or send it as a new plan")
|
|
527
|
+
elseif t == "copy_items" or t == "insert_time" or t == "remove_time" then
|
|
528
|
+
local err = ctx.arrange.check_op(api, op)
|
|
529
|
+
if err then conflict(i, op, err) end
|
|
530
|
+
elseif t == "run_action" then
|
|
531
|
+
action_at = action_at or i
|
|
532
|
+
local actions = ctx.actions
|
|
533
|
+
if not actions then
|
|
534
|
+
conflict(i, op, "mcp_actions_lib.lua is not loaded")
|
|
535
|
+
else
|
|
536
|
+
local ok, sec = pcall(actions.section_id, op.section)
|
|
537
|
+
if not ok then
|
|
538
|
+
conflict(i, op, sec)
|
|
539
|
+
else
|
|
540
|
+
local id, name = actions.resolve(api, op.action, sec)
|
|
541
|
+
if not id then
|
|
542
|
+
conflict(i, op, name)
|
|
543
|
+
else
|
|
544
|
+
macros = macros or actions.parse_kb(ctx.kb_text)
|
|
545
|
+
local refused = actions.check_runnable(api, id, name, sec, op.allowDialog == true, macros)
|
|
546
|
+
if refused then conflict(i, op, refused) end
|
|
547
|
+
r.action = { id = id, name = name, section = sec }
|
|
548
|
+
end
|
|
549
|
+
end
|
|
550
|
+
end
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
if SHIFTS_ITEMS[t] then shifted_at = shifted_at or i end
|
|
554
|
+
|
|
555
|
+
if op.as ~= nil then
|
|
556
|
+
if type(op.as) ~= "string" or op.as:sub(1, 1) ~= "$" then
|
|
557
|
+
conflict(i, op, "`as` must start with '$'")
|
|
558
|
+
elseif refs[op.as] then
|
|
559
|
+
conflict(i, op, "reference " .. op.as .. " is already defined")
|
|
560
|
+
elseif t == "add_track" then
|
|
561
|
+
refs[op.as] = { kind = "track", op = i }
|
|
562
|
+
elseif t == "add_fx" then
|
|
563
|
+
refs[op.as] = { kind = "fx", op = i }
|
|
564
|
+
else
|
|
565
|
+
conflict(i, op, "`as` is only allowed on add_track and add_fx")
|
|
566
|
+
end
|
|
567
|
+
end
|
|
568
|
+
end
|
|
569
|
+
resolved[i] = r
|
|
570
|
+
end
|
|
571
|
+
return resolved, conflicts, warnings
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
-- -----------------------------------------------------------------------------
|
|
575
|
+
-- Apply pass
|
|
576
|
+
-- -----------------------------------------------------------------------------
|
|
577
|
+
|
|
578
|
+
local function fx_index_by_guid(api, track, guid)
|
|
579
|
+
for j = 0, api.TrackFX_GetCount(track) - 1 do
|
|
580
|
+
if api.TrackFX_GetFXGUID(track, j) == guid then return j end
|
|
581
|
+
end
|
|
582
|
+
return nil
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
local function apply_op(api, r, bound, ctx)
|
|
586
|
+
local op = r.op
|
|
587
|
+
local t = op.op
|
|
588
|
+
|
|
589
|
+
local function track_of(res)
|
|
590
|
+
if res.track then return res.track end
|
|
591
|
+
return bound[res.ref].track
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
if t == "add_track" then
|
|
595
|
+
-- REAPER clamps an index past the end; read the new track back at the same place.
|
|
596
|
+
local idx = math.min(op.index or api.CountTracks(0), api.CountTracks(0))
|
|
597
|
+
api.InsertTrackAtIndex(idx, true)
|
|
598
|
+
local tr = api.GetTrack(0, idx)
|
|
599
|
+
if op.name then api.GetSetMediaTrackInfo_String(tr, "P_NAME", op.name, true) end
|
|
600
|
+
if op.as then bound[op.as] = { track = tr } end
|
|
601
|
+
return { track = idx, guid = api.GetTrackGUID(tr) }
|
|
602
|
+
|
|
603
|
+
elseif t == "set_track" then
|
|
604
|
+
local tr = track_of(r.track)
|
|
605
|
+
if op.volume ~= nil then api.SetMediaTrackInfo_Value(tr, "D_VOL", from_db(op.volume)) end
|
|
606
|
+
if op.pan ~= nil then api.SetMediaTrackInfo_Value(tr, "D_PAN", op.pan) end
|
|
607
|
+
if op.mute ~= nil then api.SetMediaTrackInfo_Value(tr, "B_MUTE", op.mute) end
|
|
608
|
+
if op.solo ~= nil then api.SetMediaTrackInfo_Value(tr, "I_SOLO", op.solo) end
|
|
609
|
+
if op.recordArm ~= nil then api.SetMediaTrackInfo_Value(tr, "I_RECARM", op.recordArm) end
|
|
610
|
+
if op.phase ~= nil then api.SetMediaTrackInfo_Value(tr, "B_PHASE", op.phase) end
|
|
611
|
+
if op.input ~= nil then api.SetMediaTrackInfo_Value(tr, "I_RECINPUT", op.input) end
|
|
612
|
+
if op.name ~= nil then api.GetSetMediaTrackInfo_String(tr, "P_NAME", op.name, true) end
|
|
613
|
+
return {}
|
|
614
|
+
|
|
615
|
+
elseif t == "add_fx" then
|
|
616
|
+
local tr = track_of(r.track)
|
|
617
|
+
-- TrackFX_AddByName: -1 appends; -1000 - n inserts at position n.
|
|
618
|
+
local pos = op.position ~= nil and (-1000 - op.position) or -1
|
|
619
|
+
local fx = api.TrackFX_AddByName(tr, op.fxName, false, pos)
|
|
620
|
+
if fx < 0 then error("FX not found: " .. op.fxName, 0) end
|
|
621
|
+
if op.enabled ~= nil then api.TrackFX_SetEnabled(tr, fx, op.enabled == 1 or op.enabled == true) end
|
|
622
|
+
for _, p in ipairs(op.params or {}) do
|
|
623
|
+
if not api.TrackFX_SetParam(tr, fx, p.index, p.value) then
|
|
624
|
+
error("failed to set param " .. p.index .. " on " .. op.fxName, 0)
|
|
625
|
+
end
|
|
626
|
+
end
|
|
627
|
+
local guid = api.TrackFX_GetFXGUID(tr, fx)
|
|
628
|
+
if op.as then bound[op.as] = { track = tr, fxGuid = guid } end
|
|
629
|
+
local _, name = api.TrackFX_GetFXName(tr, fx)
|
|
630
|
+
return { fxIndex = fx, fxName = name }
|
|
631
|
+
|
|
632
|
+
elseif t == "set_fx_params" then
|
|
633
|
+
local tr, fx
|
|
634
|
+
if type(op.fx) == "string" and op.fx:sub(1, 1) == "$" then
|
|
635
|
+
tr = bound[op.fx].track
|
|
636
|
+
fx = fx_index_by_guid(api, tr, bound[op.fx].fxGuid)
|
|
637
|
+
else
|
|
638
|
+
tr = track_of(r.track)
|
|
639
|
+
if type(op.fx) == "number" then
|
|
640
|
+
fx = op.fx
|
|
641
|
+
else
|
|
642
|
+
local err
|
|
643
|
+
fx, err = find_fx(api, tr, op.fx)
|
|
644
|
+
if not fx then error(err, 0) end
|
|
645
|
+
end
|
|
646
|
+
end
|
|
647
|
+
if not fx or fx >= api.TrackFX_GetCount(tr) then error("fx " .. tostring(op.fx) .. " not found", 0) end
|
|
648
|
+
for _, p in ipairs(op.params) do
|
|
649
|
+
if not api.TrackFX_SetParam(tr, fx, p.index, p.value) then
|
|
650
|
+
error("failed to set param " .. p.index .. " on fx " .. fx, 0)
|
|
651
|
+
end
|
|
652
|
+
end
|
|
653
|
+
return { fxIndex = fx, updated = #op.params }
|
|
654
|
+
|
|
655
|
+
elseif t == "add_send" then
|
|
656
|
+
local src, dst = track_of(r.from), track_of(r.to)
|
|
657
|
+
local s = api.CreateTrackSend(src, dst)
|
|
658
|
+
if s < 0 then error("could not create send", 0) end
|
|
659
|
+
if op.volume ~= nil then api.SetTrackSendInfo_Value(src, 0, s, "D_VOL", from_db(op.volume)) end
|
|
660
|
+
if op.pan ~= nil then api.SetTrackSendInfo_Value(src, 0, s, "D_PAN", op.pan) end
|
|
661
|
+
if op.mute ~= nil then api.SetTrackSendInfo_Value(src, 0, s, "B_MUTE", op.mute) end
|
|
662
|
+
return { sendIndex = s }
|
|
663
|
+
|
|
664
|
+
elseif t == "graft_fxchain" then
|
|
665
|
+
local fxchain = r.fxchain
|
|
666
|
+
if not fxchain then
|
|
667
|
+
local _, src_chunk = api.GetTrackStateChunk(track_of(r.source), "", false)
|
|
668
|
+
fxchain = M.extract_fxchain(src_chunk or "")
|
|
669
|
+
if not fxchain then error("source track has no FX chain", 0) end
|
|
670
|
+
end
|
|
671
|
+
fxchain = M.regenerate_fxids(fxchain, function() return api.genGuid("") end)
|
|
672
|
+
local tr = track_of(r.track)
|
|
673
|
+
local _, target_chunk = api.GetTrackStateChunk(tr, "", false)
|
|
674
|
+
if not api.SetTrackStateChunk(tr, M.splice_fxchain(target_chunk, fxchain), false) then
|
|
675
|
+
error("SetTrackStateChunk failed", 0)
|
|
676
|
+
end
|
|
677
|
+
return { fxCount = api.TrackFX_GetCount(tr) }
|
|
678
|
+
|
|
679
|
+
elseif t == "select_tracks" then
|
|
680
|
+
local tracks = {}
|
|
681
|
+
for k, res in ipairs(r.tracks) do tracks[k] = track_of(res) end
|
|
682
|
+
return { selected = ctx.actions.select_tracks(api, tracks, op.mode) }
|
|
683
|
+
|
|
684
|
+
elseif t == "run_action" then
|
|
685
|
+
local a = r.action
|
|
686
|
+
local toggle = ctx.actions.exec(api, a.id, a.section)
|
|
687
|
+
return { id = a.id, name = a.name, toggle = toggle }
|
|
688
|
+
|
|
689
|
+
elseif ctx.arrange and ctx.arrange.PLAN_OPS[t] then
|
|
690
|
+
return ctx.arrange.apply_op(api, op)
|
|
691
|
+
end
|
|
692
|
+
end
|
|
693
|
+
|
|
694
|
+
local function remember(ctx, key, result)
|
|
695
|
+
ctx.applied = ctx.applied or {}
|
|
696
|
+
ctx.applied_order = ctx.applied_order or {}
|
|
697
|
+
ctx.applied[key] = result
|
|
698
|
+
table.insert(ctx.applied_order, key)
|
|
699
|
+
if #ctx.applied_order > MAX_REMEMBERED_KEYS then
|
|
700
|
+
ctx.applied[table.remove(ctx.applied_order, 1)] = nil
|
|
701
|
+
end
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
local function describe(resolved)
|
|
705
|
+
local out = {}
|
|
706
|
+
for i, r in ipairs(resolved) do
|
|
707
|
+
out[i] = { index = r.index, op = r.op.op }
|
|
708
|
+
if r.action then out[i].action = { id = r.action.id, name = r.action.name } end
|
|
709
|
+
end
|
|
710
|
+
return out
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
--- Run a plan. `ctx` persists across calls (idempotency memory) and supplies
|
|
714
|
+
--- `snapshot_save(name)` for the optional pre-apply snapshot.
|
|
715
|
+
--- Returns data, or nil + error string (the bridge handler contract).
|
|
716
|
+
function M.run_plan(api, params, ctx)
|
|
717
|
+
ctx = ctx or {}
|
|
718
|
+
local ops = params.ops
|
|
719
|
+
if type(ops) == "string" then
|
|
720
|
+
local val, err
|
|
721
|
+
if api.CF_Json_Parse then
|
|
722
|
+
local ok
|
|
723
|
+
ok, val = api.CF_Json_Parse(ops)
|
|
724
|
+
if not ok then val = nil end
|
|
725
|
+
else
|
|
726
|
+
val, err = M.json_decode(ops)
|
|
727
|
+
end
|
|
728
|
+
if type(val) ~= "table" then
|
|
729
|
+
return nil, "ops is not a valid JSON array" .. (err and (" (" .. err .. ")") or "") .. " — nothing was changed"
|
|
730
|
+
end
|
|
731
|
+
ops = val
|
|
732
|
+
end
|
|
733
|
+
if type(ops) ~= "table" or #ops == 0 then return nil, "ops array required" end
|
|
734
|
+
|
|
735
|
+
local key = params.idempotencyKey
|
|
736
|
+
if key and key ~= "" and ctx.applied and ctx.applied[key] then
|
|
737
|
+
return { alreadyApplied = true, idempotencyKey = key, result = ctx.applied[key] }
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
local resolved, conflicts, warnings = M.validate(api, ops, ctx)
|
|
741
|
+
|
|
742
|
+
if params.dryRun then
|
|
743
|
+
return {
|
|
744
|
+
dryRun = true, valid = #conflicts == 0, ops = describe(resolved),
|
|
745
|
+
conflicts = conflicts, warnings = warnings,
|
|
746
|
+
}
|
|
747
|
+
end
|
|
748
|
+
if #conflicts > 0 then
|
|
749
|
+
local msgs = {}
|
|
750
|
+
for _, c in ipairs(conflicts) do
|
|
751
|
+
msgs[#msgs + 1] = "op " .. c.op .. " (" .. tostring(c.type) .. "): " .. c.error
|
|
752
|
+
end
|
|
753
|
+
return nil, "Plan rejected, nothing was changed — " .. table.concat(msgs, "; ")
|
|
754
|
+
end
|
|
755
|
+
|
|
756
|
+
if params.snapshotBefore and params.snapshotBefore ~= "" then
|
|
757
|
+
if not ctx.snapshot_save then return nil, "snapshotBefore is not supported by this bridge" end
|
|
758
|
+
local _, err = ctx.snapshot_save(params.snapshotBefore)
|
|
759
|
+
if err then return nil, "Pre-apply snapshot failed, nothing was changed — " .. err end
|
|
760
|
+
end
|
|
761
|
+
|
|
762
|
+
local label = params.label or ("MCP: run_plan (" .. #ops .. " ops)")
|
|
763
|
+
local bound = {}
|
|
764
|
+
local results = {}
|
|
765
|
+
local failed_at, failure
|
|
766
|
+
|
|
767
|
+
if api.PreventUIRefresh then api.PreventUIRefresh(1) end
|
|
768
|
+
api.Undo_BeginBlock()
|
|
769
|
+
for i, r in ipairs(resolved) do
|
|
770
|
+
local ok, res = pcall(apply_op, api, r, bound, ctx)
|
|
771
|
+
if not ok then
|
|
772
|
+
failed_at, failure = i - 1, tostring(res)
|
|
773
|
+
break
|
|
774
|
+
end
|
|
775
|
+
results[i] = { index = i - 1, op = r.op.op, result = res }
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
if failed_at then
|
|
779
|
+
api.Undo_EndBlock(REVERT_LABEL, -1)
|
|
780
|
+
-- Only undo our own block: if the failing op wrote nothing, no undo
|
|
781
|
+
-- point exists and the top of the stack belongs to the user.
|
|
782
|
+
if api.Undo_CanUndo2(0) == REVERT_LABEL then api.Undo_DoUndo2(0) end
|
|
783
|
+
if api.PreventUIRefresh then api.PreventUIRefresh(-1) end
|
|
784
|
+
return nil, "op " .. failed_at .. " (" .. tostring(ops[failed_at + 1].op) .. ") failed: "
|
|
785
|
+
.. failure .. " — plan reverted, nothing was changed"
|
|
786
|
+
end
|
|
787
|
+
|
|
788
|
+
api.Undo_EndBlock(label, -1)
|
|
789
|
+
if api.PreventUIRefresh then api.PreventUIRefresh(-1) end
|
|
790
|
+
if api.TrackList_AdjustWindows then api.TrackList_AdjustWindows(false) end
|
|
791
|
+
|
|
792
|
+
local data = { success = true, applied = #results, results = results }
|
|
793
|
+
if key and key ~= "" then
|
|
794
|
+
data.idempotencyKey = key
|
|
795
|
+
remember(ctx, key, data)
|
|
796
|
+
end
|
|
797
|
+
return data
|
|
798
|
+
end
|
|
799
|
+
|
|
800
|
+
M.resolve_track = resolve_track
|
|
801
|
+
return M
|