davinci-resolve-mcp 2.23.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 (92) hide show
  1. package/AGENTS.md +85 -0
  2. package/CHANGELOG.md +802 -0
  3. package/CLAUDE.md +15 -0
  4. package/LICENSE +21 -0
  5. package/README.md +159 -0
  6. package/SECURITY.md +53 -0
  7. package/bin/davinci-resolve-mcp.mjs +376 -0
  8. package/docs/README.md +56 -0
  9. package/docs/SKILL.md +1145 -0
  10. package/docs/authoring/fuse-dctl-authoring.md +242 -0
  11. package/docs/authoring/script-plugin-authoring.md +195 -0
  12. package/docs/contributing.md +82 -0
  13. package/docs/guides/color-decision-guide.md +387 -0
  14. package/docs/guides/editorial-decision-guide.md +136 -0
  15. package/docs/guides/media-analysis-guide.md +615 -0
  16. package/docs/guides/multicam-setup-guide.md +138 -0
  17. package/docs/install.md +198 -0
  18. package/docs/integrations/workflow-integrations.md +120 -0
  19. package/docs/kernels/README.md +28 -0
  20. package/docs/kernels/audio-fairlight-kernel.md +86 -0
  21. package/docs/kernels/color-grade-kernel.md +103 -0
  22. package/docs/kernels/extension-authoring-kernel.md +101 -0
  23. package/docs/kernels/fusion-composition-kernel.md +91 -0
  24. package/docs/kernels/media-pool-ingest-kernel.md +147 -0
  25. package/docs/kernels/project-lifecycle-kernel.md +120 -0
  26. package/docs/kernels/render-deliver-kernel.md +92 -0
  27. package/docs/kernels/review-annotation-kernel.md +110 -0
  28. package/docs/kernels/timeline-conform-interchange-kernel.md +99 -0
  29. package/docs/kernels/timeline-edit-kernel.md +189 -0
  30. package/docs/notes/codec-plugin-notes.md +136 -0
  31. package/docs/notes/dctl-notes.md +234 -0
  32. package/docs/notes/fusion-template-notes.md +136 -0
  33. package/docs/notes/lut-notes.md +136 -0
  34. package/docs/notes/openfx-notes.md +120 -0
  35. package/docs/process/release-process.md +152 -0
  36. package/docs/reference/api-coverage.md +488 -0
  37. package/docs/reference/resolve_scripting_api.txt +1012 -0
  38. package/examples/README.md +53 -0
  39. package/examples/markers/README.md +81 -0
  40. package/examples/media/README.md +94 -0
  41. package/examples/timeline/README.md +98 -0
  42. package/install.py +1196 -0
  43. package/package.json +52 -0
  44. package/scripts/audit_api_parity.py +275 -0
  45. package/scripts/live_media_analysis_polish_probe.py +65 -0
  46. package/src/__init__.py +3 -0
  47. package/src/analysis_dashboard.py +4936 -0
  48. package/src/control_panel.py +13 -0
  49. package/src/granular/__init__.py +17 -0
  50. package/src/granular/common.py +727 -0
  51. package/src/granular/folder.py +287 -0
  52. package/src/granular/gallery.py +306 -0
  53. package/src/granular/graph.py +309 -0
  54. package/src/granular/media_pool.py +679 -0
  55. package/src/granular/media_pool_item.py +852 -0
  56. package/src/granular/media_storage.py +179 -0
  57. package/src/granular/project.py +1594 -0
  58. package/src/granular/resolve_control.py +521 -0
  59. package/src/granular/timeline.py +1074 -0
  60. package/src/granular/timeline_item.py +2251 -0
  61. package/src/resolve_mcp_server.py +43 -0
  62. package/src/server.py +15691 -0
  63. package/src/utils/__init__.py +3 -0
  64. package/src/utils/app_control.py +319 -0
  65. package/src/utils/audio_fairlight_live_probe.py +263 -0
  66. package/src/utils/cdl.py +20 -0
  67. package/src/utils/cloud_operations.py +192 -0
  68. package/src/utils/color_grade_live_probe.py +444 -0
  69. package/src/utils/dctl_templates.py +368 -0
  70. package/src/utils/extension_authoring_live_probe.py +292 -0
  71. package/src/utils/fuse_templates.py +1968 -0
  72. package/src/utils/fusion_composition_live_probe.py +284 -0
  73. package/src/utils/layout_presets.py +333 -0
  74. package/src/utils/mcp_stdio.py +32 -0
  75. package/src/utils/media_analysis.py +3618 -0
  76. package/src/utils/media_analysis_jobs.py +796 -0
  77. package/src/utils/media_pool_ingest_live_probe.py +592 -0
  78. package/src/utils/multicam.py +393 -0
  79. package/src/utils/object_inspection.py +287 -0
  80. package/src/utils/platform.py +157 -0
  81. package/src/utils/project_lifecycle_live_probe.py +376 -0
  82. package/src/utils/project_properties.py +601 -0
  83. package/src/utils/render_deliver_live_probe.py +384 -0
  84. package/src/utils/resolve_connection.py +77 -0
  85. package/src/utils/review_annotation_live_probe.py +352 -0
  86. package/src/utils/script_templates.py +1193 -0
  87. package/src/utils/sync_detection.py +887 -0
  88. package/src/utils/timeline_conform_live_probe.py +280 -0
  89. package/src/utils/timeline_kernel_live_probe.py +1091 -0
  90. package/src/utils/timeline_kernel_probe.py +185 -0
  91. package/src/utils/timeline_title_text.py +87 -0
  92. package/src/utils/update_check.py +610 -0
@@ -0,0 +1,1193 @@
1
+ #!/usr/bin/env python3
2
+ """Boilerplate generators for Resolve-page Lua/Python scripts.
3
+
4
+ These generate scripts that get installed into Resolve's
5
+ Fusion/Scripts/<category>/ directories so they appear in the
6
+ Workspace → Scripts menu. Two template kinds:
7
+
8
+ 'scaffold' — minimal boilerplate with Resolve handle setup, ready
9
+ for the LLM/user to fill in custom logic.
10
+ 'media_rules' — the rules-and-variables DSL: declarative VARIABLES +
11
+ RULES tables interpreted by an embedded engine that
12
+ handles sources, extract patterns, transforms,
13
+ targets, actions, conditions, dry-run, external CSV/
14
+ JSON data, and per-rule metadata.
15
+
16
+ See docs/authoring/script-plugin-authoring.md for the full DSL spec.
17
+ """
18
+
19
+ from typing import Any, Dict, Optional
20
+
21
+
22
+ def header(name: str, kind: str, language: str) -> str:
23
+ """MCP marker comment placed at the top of generated scripts."""
24
+ if language == "py":
25
+ comment = "#"
26
+ else:
27
+ comment = "--"
28
+ return (
29
+ f"{comment} @mcp-script name={name} kind={kind} language={language}\n"
30
+ f"{comment} Generated by davinci-resolve-mcp. Edit freely; the\n"
31
+ f"{comment} marker above only affects how the MCP `script_plugin\n"
32
+ f"{comment} list` action reports this file.\n\n"
33
+ )
34
+
35
+
36
+ VALID_LANGUAGES = ("lua", "py")
37
+ VALID_CATEGORIES = ("Edit", "Color", "Deliver", "Comp",
38
+ "Tool", "Utility", "Views")
39
+
40
+
41
+ # ─── scaffold template ────────────────────────────────────────────────────────
42
+
43
+ def scaffold(name: str, options: Optional[Dict[str, Any]] = None) -> str:
44
+ """Minimal stub: connects to Resolve, gets project/media-pool/timeline
45
+ handles, defines an empty main() function for the user to fill in.
46
+
47
+ options:
48
+ language: 'lua' (default) | 'py'
49
+ """
50
+ options = options or {}
51
+ language = options.get("language", "lua")
52
+ if language not in VALID_LANGUAGES:
53
+ raise ValueError(f"Invalid language '{language}'. Valid: {list(VALID_LANGUAGES)}")
54
+
55
+ if language == "lua":
56
+ return header(name, "scaffold", "lua") + f'''-- {name}: starting scaffold for a Resolve-page Lua script.
57
+
58
+ local resolve = Resolve()
59
+ if not resolve then
60
+ print("[{name}] No Resolve handle.")
61
+ return
62
+ end
63
+
64
+ local pm = resolve:GetProjectManager()
65
+ local project = pm:GetCurrentProject()
66
+ if not project then
67
+ print("[{name}] No project open.")
68
+ return
69
+ end
70
+
71
+ local mp = project:GetMediaPool()
72
+ local timeline = project:GetCurrentTimeline()
73
+
74
+ -- ════════════════════════════════════════════════════════════════════
75
+ -- Implement your logic here.
76
+ -- ════════════════════════════════════════════════════════════════════
77
+ local function main()
78
+ print("[{name}] Hello from Resolve " .. resolve:GetVersionString())
79
+ print("[{name}] Project: " .. project:GetName())
80
+ end
81
+
82
+ main()
83
+ '''
84
+ # Python
85
+ return header(name, "scaffold", "py") + f'''"""{name}: starting scaffold for a Resolve-page Python script."""
86
+
87
+ import sys
88
+
89
+ try:
90
+ import DaVinciResolveScript as dvr_script
91
+ except ImportError:
92
+ sys.exit("[{name}] DaVinciResolveScript not available.")
93
+
94
+ resolve = dvr_script.scriptapp("Resolve")
95
+ if not resolve:
96
+ sys.exit("[{name}] No Resolve handle.")
97
+
98
+ pm = resolve.GetProjectManager()
99
+ project = pm.GetCurrentProject()
100
+ if not project:
101
+ sys.exit("[{name}] No project open.")
102
+
103
+ mp = project.GetMediaPool()
104
+ timeline = project.GetCurrentTimeline()
105
+
106
+
107
+ # ════════════════════════════════════════════════════════════════════
108
+ # Implement your logic here.
109
+ # ════════════════════════════════════════════════════════════════════
110
+ def main():
111
+ print(f"[{name}] Hello from Resolve {{resolve.GetVersionString()}}")
112
+ print(f"[{name}] Project: {{project.GetName()}}")
113
+
114
+
115
+ main()
116
+ '''
117
+
118
+
119
+ # ─── media_rules template ────────────────────────────────────────────────────
120
+
121
+ def _example_rules_lua() -> str:
122
+ """Default RULES table illustrating the most common patterns."""
123
+ return '''local RULES = {
124
+ -- Rule 1: Pull ISO date YYYY-MM-DD from anywhere in the file path
125
+ -- and set it as the clip's Shoot Date metadata.
126
+ {
127
+ name = "Shoot Date from file path",
128
+ target = "media_pool_clips",
129
+ extract = {
130
+ { source = "file_path", pattern = "DATE_PATTERN",
131
+ into = {"yr", "mo", "dy"} },
132
+ },
133
+ apply = {
134
+ { type = "set_metadata", field = "Shoot Date",
135
+ value = "{yr}-{mo}-{dy}" },
136
+ },
137
+ },
138
+
139
+ -- Rule 2: Reel Name from filename prefix (e.g. "A001_C002.mov" → "A001").
140
+ {
141
+ name = "Reel Name from filename prefix",
142
+ target = "media_pool_clips",
143
+ extract = {
144
+ { source = "filename", pattern = "REEL_PATTERN",
145
+ into = {"reel"} },
146
+ },
147
+ apply = {
148
+ { type = "set_clip_property", field = "Reel Name",
149
+ value = "{reel}" },
150
+ },
151
+ },
152
+
153
+ -- Rule 3: Scene from filename, organize into Scenes/Scene N bin.
154
+ {
155
+ name = "Scene from filename + auto-bin",
156
+ target = "media_pool_clips",
157
+ extract = {
158
+ { source = "filename", pattern = "SCENE_PATTERN",
159
+ into = {"scene"} },
160
+ },
161
+ apply = {
162
+ { type = "set_metadata", field = "Scene",
163
+ value = "Scene {scene | pad(2, '0')}" },
164
+ { type = "move_to_bin",
165
+ path = "Scenes/Scene {scene | pad(2, '0')}" },
166
+ },
167
+ },
168
+ }
169
+ '''
170
+
171
+
172
+ def _example_rules_py() -> str:
173
+ return '''RULES = [
174
+ # Rule 1: Pull ISO date YYYY-MM-DD from anywhere in the file path
175
+ # and set it as the clip's Shoot Date metadata.
176
+ {
177
+ "name": "Shoot Date from file path",
178
+ "target": "media_pool_clips",
179
+ "extract": [
180
+ {"source": "file_path", "pattern": "DATE_PATTERN",
181
+ "into": ["yr", "mo", "dy"]},
182
+ ],
183
+ "apply": [
184
+ {"type": "set_metadata", "field": "Shoot Date",
185
+ "value": "{yr}-{mo}-{dy}"},
186
+ ],
187
+ },
188
+
189
+ # Rule 2: Reel Name from filename prefix.
190
+ {
191
+ "name": "Reel Name from filename prefix",
192
+ "target": "media_pool_clips",
193
+ "extract": [
194
+ {"source": "filename", "pattern": "REEL_PATTERN",
195
+ "into": ["reel"]},
196
+ ],
197
+ "apply": [
198
+ {"type": "set_clip_property", "field": "Reel Name",
199
+ "value": "{reel}"},
200
+ ],
201
+ },
202
+
203
+ # Rule 3: Scene from filename, organize into Scenes/Scene N bin.
204
+ {
205
+ "name": "Scene from filename + auto-bin",
206
+ "target": "media_pool_clips",
207
+ "extract": [
208
+ {"source": "filename", "pattern": "SCENE_PATTERN",
209
+ "into": ["scene"]},
210
+ ],
211
+ "apply": [
212
+ {"type": "set_metadata", "field": "Scene",
213
+ "value": "Scene {scene | pad(2, '0')}"},
214
+ {"type": "move_to_bin",
215
+ "path": "Scenes/Scene {scene | pad(2, '0')}"},
216
+ ],
217
+ },
218
+ ]
219
+ '''
220
+
221
+
222
+ def media_rules(name: str, options: Optional[Dict[str, Any]] = None) -> str:
223
+ """Rules-and-variables DSL script.
224
+
225
+ options:
226
+ language: 'lua' (default) | 'py'
227
+ rules: list of rule dicts to embed (default: 3-rule example
228
+ covering shoot-date / reel / scene workflows)
229
+ variables: dict of named values referenced by rules (default: regex
230
+ patterns for date, reel, scene)
231
+ external_data: optional config for CSV/JSON lookup
232
+ dry_run: default DRY_RUN value (default false)
233
+ """
234
+ options = options or {}
235
+ language = options.get("language", "lua")
236
+ if language not in VALID_LANGUAGES:
237
+ raise ValueError(f"Invalid language '{language}'. "
238
+ f"Valid: {list(VALID_LANGUAGES)}")
239
+ dry_run = bool(options.get("dry_run", False))
240
+
241
+ if language == "lua":
242
+ return _media_rules_lua(name, options, dry_run)
243
+ return _media_rules_py(name, options, dry_run)
244
+
245
+
246
+ def _media_rules_lua(name: str, options: Dict[str, Any], dry_run: bool) -> str:
247
+ rules_block = options.get("rules_lua") or _example_rules_lua()
248
+ return header(name, "media_rules", "lua") + f'''-- {name}: rules + variables DSL for declarative Resolve operations.
249
+
250
+ -- ════════════════════════════════════════════════════════════════════
251
+ -- VARIABLES — patterns, lookup tables, lookup keys. Edit freely.
252
+ -- ════════════════════════════════════════════════════════════════════
253
+ local VARIABLES = {{
254
+ DATE_PATTERN = "(%d%d%d%d)-(%d%d)-(%d%d)",
255
+ REEL_PATTERN = "^([A-Z]%d%d%d)[_%-]",
256
+ SCENE_PATTERN = "[Ss]cene[_%-]?(%d+)",
257
+ -- Lookup tables can be referenced by the `lookup` transform pipe.
258
+ CAMERA_TABLE = {{ A = "A-Cam", B = "B-Cam", C = "C-Cam" }},
259
+ }}
260
+
261
+ -- ════════════════════════════════════════════════════════════════════
262
+ -- ENGINE GLOBALS — runtime behavior switches.
263
+ -- ════════════════════════════════════════════════════════════════════
264
+ local DRY_RUN = {str(dry_run).lower()} -- true = report only, no mutations
265
+ local LOG_LEVEL = "normal" -- "quiet" | "normal" | "verbose"
266
+ local LIMIT_TO_FIRST_N = nil -- e.g. 5 to test on only first 5 items
267
+ local BACKUP_BEFORE_RUN = false -- true = export project before mutating
268
+
269
+ -- Optional external data (CSV/JSON); see docs/authoring/script-plugin-authoring.md
270
+ local EXTERNAL_DATA = nil
271
+ -- EXAMPLE:
272
+ -- local EXTERNAL_DATA = {{
273
+ -- csv = "/Volumes/Production/dit_log.csv",
274
+ -- match_on = {{ source = "filename", column = "Filename",
275
+ -- strategy = "exact" }}, -- "exact" | "regex" | "fuzzy"
276
+ -- }}
277
+
278
+ -- ════════════════════════════════════════════════════════════════════
279
+ -- RULES — declarative operations. Compose freely.
280
+ -- ════════════════════════════════════════════════════════════════════
281
+ {rules_block}
282
+ -- ════════════════════════════════════════════════════════════════════
283
+ -- ENGINE — interprets the above. Edit only if extending the DSL.
284
+ -- ════════════════════════════════════════════════════════════════════
285
+ {LUA_ENGINE}
286
+
287
+ run_engine(VARIABLES, RULES, EXTERNAL_DATA, {{
288
+ dry_run = DRY_RUN,
289
+ log_level = LOG_LEVEL,
290
+ limit = LIMIT_TO_FIRST_N,
291
+ backup = BACKUP_BEFORE_RUN,
292
+ }})
293
+ '''
294
+
295
+
296
+ def _media_rules_py(name: str, options: Dict[str, Any], dry_run: bool) -> str:
297
+ rules_block = options.get("rules_py") or _example_rules_py()
298
+ return header(name, "media_rules", "py") + f'''"""{name}: rules + variables DSL for declarative Resolve operations."""
299
+
300
+ import os
301
+ import re
302
+ import sys
303
+ import json
304
+ import csv
305
+ from datetime import datetime
306
+
307
+ try:
308
+ import DaVinciResolveScript as dvr_script
309
+ except ImportError:
310
+ sys.exit("[{name}] DaVinciResolveScript not available.")
311
+
312
+
313
+ # ════════════════════════════════════════════════════════════════════
314
+ # VARIABLES — patterns, lookup tables, lookup keys. Edit freely.
315
+ # ════════════════════════════════════════════════════════════════════
316
+ VARIABLES = {{
317
+ "DATE_PATTERN": r"(\\d{{4}})-(\\d{{2}})-(\\d{{2}})",
318
+ "REEL_PATTERN": r"^([A-Z]\\d{{3}})[_\\-]",
319
+ "SCENE_PATTERN": r"[Ss]cene[_\\-]?(\\d+)",
320
+ # Lookup tables can be referenced by the `lookup` transform pipe.
321
+ "CAMERA_TABLE": {{"A": "A-Cam", "B": "B-Cam", "C": "C-Cam"}},
322
+ }}
323
+
324
+
325
+ # ════════════════════════════════════════════════════════════════════
326
+ # ENGINE GLOBALS — runtime behavior switches.
327
+ # ════════════════════════════════════════════════════════════════════
328
+ DRY_RUN = {dry_run} # True = report only, no mutations
329
+ LOG_LEVEL = "normal" # "quiet" | "normal" | "verbose"
330
+ LIMIT_TO_FIRST_N = None # e.g. 5 to test on first 5 only
331
+ BACKUP_BEFORE_RUN = False # True = export project before mutating
332
+
333
+ # Optional external data (CSV/JSON); see docs/authoring/script-plugin-authoring.md
334
+ EXTERNAL_DATA = None
335
+ # EXAMPLE:
336
+ # EXTERNAL_DATA = {{
337
+ # "csv": "/Volumes/Production/dit_log.csv",
338
+ # "match_on": {{"source": "filename", "column": "Filename",
339
+ # "strategy": "exact"}}, # "exact" | "regex" | "fuzzy"
340
+ # }}
341
+
342
+
343
+ # ════════════════════════════════════════════════════════════════════
344
+ # RULES — declarative operations. Compose freely.
345
+ # ════════════════════════════════════════════════════════════════════
346
+ {rules_block}
347
+
348
+ # ════════════════════════════════════════════════════════════════════
349
+ # ENGINE — interprets the above. Edit only if extending the DSL.
350
+ # ════════════════════════════════════════════════════════════════════
351
+ {PY_ENGINE}
352
+
353
+
354
+ run_engine(VARIABLES, RULES, EXTERNAL_DATA, {{
355
+ "dry_run": DRY_RUN,
356
+ "log_level": LOG_LEVEL,
357
+ "limit": LIMIT_TO_FIRST_N,
358
+ "backup": BACKUP_BEFORE_RUN,
359
+ }})
360
+ '''
361
+
362
+
363
+ # ═══════════════════════════════════════════════════════════════════════════════
364
+ # LUA ENGINE
365
+ # ═══════════════════════════════════════════════════════════════════════════════
366
+
367
+ LUA_ENGINE = r"""
368
+ local function log(level, msg)
369
+ if LOG_LEVEL == "quiet" then return end
370
+ if LOG_LEVEL == "normal" and level == "debug" then return end
371
+ print("[mcp-script] " .. msg)
372
+ end
373
+
374
+ -- ─── External data loaders ──────────────────────────────────────────────────
375
+
376
+ local function load_csv(path)
377
+ local f = io.open(path, "r")
378
+ if not f then return nil, "could not open " .. path end
379
+ local rows, headers = {}, nil
380
+ for line in f:lines() do
381
+ local fields = {}
382
+ for v in (line .. ","):gmatch("([^,]*),") do
383
+ table.insert(fields, v:gsub('^"(.-)"$', "%1"))
384
+ end
385
+ if not headers then
386
+ headers = fields
387
+ else
388
+ local row = {}
389
+ for i, h in ipairs(headers) do row[h] = fields[i] end
390
+ table.insert(rows, row)
391
+ end
392
+ end
393
+ f:close()
394
+ return rows
395
+ end
396
+
397
+ local function load_json(path)
398
+ local f = io.open(path, "r")
399
+ if not f then return nil end
400
+ local content = f:read("*a")
401
+ f:close()
402
+ -- Resolve's Lua includes bmd which has json support
403
+ if bmd and bmd.readstring then
404
+ local ok, parsed = pcall(bmd.readstring, content)
405
+ if ok then return parsed end
406
+ end
407
+ return nil
408
+ end
409
+
410
+ -- ─── Transform pipes ────────────────────────────────────────────────────────
411
+
412
+ local function levenshtein(a, b)
413
+ if a == b then return 0 end
414
+ if #a == 0 then return #b end
415
+ if #b == 0 then return #a end
416
+ local prev = {}
417
+ for j = 0, #b do prev[j] = j end
418
+ for i = 1, #a do
419
+ local cur = {[0] = i}
420
+ for j = 1, #b do
421
+ local cost = (a:sub(i,i) == b:sub(j,j)) and 0 or 1
422
+ cur[j] = math.min(prev[j] + 1, cur[j-1] + 1, prev[j-1] + cost)
423
+ end
424
+ prev = cur
425
+ end
426
+ return prev[#b]
427
+ end
428
+
429
+ local function apply_pipe(value, pipe, vars)
430
+ local op, arg = pipe:match("^(%w+)%s*%(?(.*)%)?$")
431
+ op = op or pipe
432
+ if op == "upper" then return tostring(value):upper()
433
+ elseif op == "lower" then return tostring(value):lower()
434
+ elseif op == "title" then
435
+ return tostring(value):gsub("(%a)(%w*)", function(a, b) return a:upper() .. b:lower() end)
436
+ elseif op == "slug" then
437
+ return tostring(value):lower():gsub("%s+", "_"):gsub("[^%w_%-]", "")
438
+ elseif op == "pad" then
439
+ local n, ch = arg:match("(%d+)%s*,?%s*['\"]?(.?)['\"]?")
440
+ n = tonumber(n) or 2; ch = (ch ~= "" and ch) or "0"
441
+ local s = tostring(value)
442
+ while #s < n do s = ch .. s end
443
+ return s
444
+ elseif op == "lookup" then
445
+ local tbl_name = arg:gsub("['\"]", ""):gsub("%s", "")
446
+ local tbl = vars[tbl_name]
447
+ if type(tbl) == "table" and tbl[value] ~= nil then return tbl[value] end
448
+ return value
449
+ elseif op == "add" or op == "sub" or op == "mul" or op == "div" then
450
+ local n = tonumber(value) or 0
451
+ local k = tonumber(arg) or 0
452
+ if op == "add" then return tostring(n + k)
453
+ elseif op == "sub" then return tostring(n - k)
454
+ elseif op == "mul" then return tostring(n * k)
455
+ elseif op == "div" then return tostring(k ~= 0 and (n / k) or 0) end
456
+ elseif op == "date" then
457
+ -- arg is a strftime format; assumes value is "Y-m-d" or similar
458
+ return tostring(value) -- pass-through; Lua os.date is awkward to chain
459
+ end
460
+ return value
461
+ end
462
+
463
+ local function substitute(template, captures, vars)
464
+ if type(template) ~= "string" then return template end
465
+ return (template:gsub("{([^}]+)}", function(expr)
466
+ local parts, value = {}, nil
467
+ for part in expr:gmatch("[^|]+") do
468
+ table.insert(parts, (part:gsub("^%s+", ""):gsub("%s+$", "")))
469
+ end
470
+ local var_name = parts[1]
471
+ -- Special prefix "external_data:..."
472
+ if var_name:match("^external_data:") then
473
+ local col = var_name:sub(15)
474
+ value = captures.__external_row and captures.__external_row[col] or ""
475
+ else
476
+ value = captures[var_name]
477
+ end
478
+ if value == nil then return "{" .. expr .. "}" end
479
+ for i = 2, #parts do
480
+ value = apply_pipe(value, parts[i], vars)
481
+ end
482
+ return tostring(value)
483
+ end))
484
+ end
485
+
486
+ -- ─── Source resolvers ───────────────────────────────────────────────────────
487
+
488
+ local function basename(p)
489
+ return p and (p:match("([^/\\]+)$") or p) or ""
490
+ end
491
+
492
+ local function dirname(p)
493
+ return p and (p:match("(.+)[/\\][^/\\]+$") or "") or ""
494
+ end
495
+
496
+ local function resolve_source(spec, item, project, mp, captures)
497
+ if spec == "file_path" then return item:GetClipProperty("File Path") or ""
498
+ elseif spec == "filename" then return basename(item:GetClipProperty("File Path") or "")
499
+ elseif spec == "dirname" then return basename(dirname(item:GetClipProperty("File Path") or ""))
500
+ elseif spec == "parent_dir" then return basename(dirname(dirname(item:GetClipProperty("File Path") or "")))
501
+ elseif spec == "grandparent_dir" then return basename(dirname(dirname(dirname(item:GetClipProperty("File Path") or ""))))
502
+ elseif spec == "file_extension" then
503
+ local fp = item:GetClipProperty("File Path") or ""
504
+ return fp:match("%.([^.]+)$") or ""
505
+ elseif spec == "bin_name" then
506
+ return captures.__bin_name or ""
507
+ elseif spec == "media_pool_path" then
508
+ return captures.__bin_path or ""
509
+ elseif spec == "static_value" then
510
+ return captures.__static or ""
511
+ elseif spec:match("^clip_property:") then
512
+ return item:GetClipProperty(spec:sub(15)) or ""
513
+ elseif spec:match("^metadata:") then
514
+ return item:GetMetadata(spec:sub(10)) or ""
515
+ elseif spec:match("^embedded_metadata:") then
516
+ return item:GetMetadata(spec:sub(19)) or ""
517
+ elseif spec:match("^camera_metadata:") then
518
+ return item:GetMetadata(spec:sub(17)) or ""
519
+ elseif spec:match("^previous_capture:") then
520
+ return captures[spec:sub(18)] or ""
521
+ elseif spec == "clip_duration" then return item:GetClipProperty("Duration") or ""
522
+ elseif spec == "clip_resolution" then return item:GetClipProperty("Resolution") or ""
523
+ elseif spec == "frame_rate" then return item:GetClipProperty("FPS") or ""
524
+ elseif spec == "codec" then return item:GetClipProperty("Video Codec") or ""
525
+ elseif spec == "audio_channels" then return item:GetClipProperty("Audio Ch") or ""
526
+ elseif spec == "audio_format" then return item:GetClipProperty("Audio Codec") or ""
527
+ elseif spec == "start_tc" then return item:GetClipProperty("Start TC") or ""
528
+ elseif spec == "end_tc" then return item:GetClipProperty("End TC") or ""
529
+ elseif spec == "creation_time" or spec == "modification_time" then
530
+ return item:GetClipProperty("Date Created") or "" -- closest available
531
+ end
532
+ return ""
533
+ end
534
+
535
+ -- ─── Target resolvers ───────────────────────────────────────────────────────
536
+
537
+ local function walk_bins(folder, prefix, out)
538
+ out = out or {}
539
+ prefix = prefix or ""
540
+ for _, clip in ipairs(folder:GetClipList() or {}) do
541
+ table.insert(out, {item = clip, bin_path = prefix, bin_name = folder:GetName()})
542
+ end
543
+ for _, sub in ipairs(folder:GetSubFolderList() or {}) do
544
+ local p = (prefix ~= "" and (prefix .. "/") or "") .. sub:GetName()
545
+ walk_bins(sub, p, out)
546
+ end
547
+ return out
548
+ end
549
+
550
+ local function resolve_target(spec, project, mp)
551
+ local tl = project:GetCurrentTimeline()
552
+ if spec == "media_pool_clips" then
553
+ return walk_bins(mp:GetRootFolder(), "")
554
+ elseif spec == "current_bin_clips" then
555
+ local cur = mp:GetCurrentFolder()
556
+ local out = {}
557
+ for _, c in ipairs(cur:GetClipList() or {}) do
558
+ table.insert(out, {item = c, bin_path = "", bin_name = cur:GetName()})
559
+ end
560
+ return out
561
+ elseif spec:match("^bin_path:") then
562
+ local path = spec:sub(10)
563
+ local folder = mp:GetRootFolder()
564
+ for seg in path:gmatch("[^/]+") do
565
+ local found = nil
566
+ for _, sub in ipairs(folder:GetSubFolderList() or {}) do
567
+ if sub:GetName() == seg then found = sub; break end
568
+ end
569
+ if not found then return {} end
570
+ folder = found
571
+ end
572
+ local out = {}
573
+ for _, c in ipairs(folder:GetClipList() or {}) do
574
+ table.insert(out, {item = c, bin_path = path, bin_name = folder:GetName()})
575
+ end
576
+ return out
577
+ elseif spec == "selected_clips" then
578
+ local sel = mp:GetSelectedClips() or {}
579
+ local out = {}
580
+ for _, c in ipairs(sel) do table.insert(out, {item = c, bin_path = "", bin_name = ""}) end
581
+ return out
582
+ elseif spec == "timeline_items" or spec == "selected_timeline_items" then
583
+ if not tl then return {} end
584
+ local out = {}
585
+ for trk = 1, tl:GetTrackCount("video") do
586
+ for _, ti in ipairs(tl:GetItemListInTrack("video", trk) or {}) do
587
+ table.insert(out, {item = ti, bin_path = "", bin_name = "", track = trk})
588
+ end
589
+ end
590
+ return out
591
+ elseif spec:match("^timeline_items_in_track:") then
592
+ if not tl then return {} end
593
+ local n = tonumber(spec:sub(25)) or 1
594
+ local out = {}
595
+ for _, ti in ipairs(tl:GetItemListInTrack("video", n) or {}) do
596
+ table.insert(out, {item = ti, bin_path = "", bin_name = "", track = n})
597
+ end
598
+ return out
599
+ end
600
+ return {}
601
+ end
602
+
603
+ -- ─── Action handlers ────────────────────────────────────────────────────────
604
+
605
+ local function get_or_create_bin(mp, path)
606
+ local folder = mp:GetRootFolder()
607
+ if path == "" or path == nil then return folder end
608
+ for seg in path:gmatch("[^/]+") do
609
+ local found = nil
610
+ for _, sub in ipairs(folder:GetSubFolderList() or {}) do
611
+ if sub:GetName() == seg then found = sub; break end
612
+ end
613
+ if not found then
614
+ found = mp:AddSubFolder(folder, seg)
615
+ end
616
+ if not found then return nil end
617
+ folder = found
618
+ end
619
+ return folder
620
+ end
621
+
622
+ local function run_action(action, item, captures, vars, project, mp, dry)
623
+ local typ = action.type
624
+ if typ == "set_metadata" then
625
+ local v = substitute(action.value, captures, vars)
626
+ if not dry then item:SetMetadata(action.field, v) end
627
+ return string.format("set_metadata %s = %q", action.field, v)
628
+ elseif typ == "set_clip_property" then
629
+ local v = substitute(action.value, captures, vars)
630
+ if not dry then item:SetClipProperty(action.field, v) end
631
+ return string.format("set_clip_property %s = %q", action.field, v)
632
+ elseif typ == "rename_clip" then
633
+ local v = substitute(action.value, captures, vars)
634
+ if not dry then item:SetClipProperty("Clip Name", v) end
635
+ return string.format("rename → %q", v)
636
+ elseif typ == "move_to_bin" then
637
+ local p = substitute(action.path, captures, vars)
638
+ if not dry then
639
+ local target = get_or_create_bin(mp, p)
640
+ if target then mp:MoveClips({item}, target) end
641
+ end
642
+ return string.format("move_to_bin %q", p)
643
+ elseif typ == "set_clip_color" then
644
+ local v = substitute(action.color or action.value, captures, vars)
645
+ if not dry then item:SetClipColor(v) end
646
+ return string.format("set_clip_color %s", v)
647
+ elseif typ == "flag_clip" then
648
+ local v = substitute(action.color or action.value, captures, vars)
649
+ if not dry then item:AddFlag(v) end
650
+ return string.format("flag_clip %s", v)
651
+ elseif typ == "add_keyword" then
652
+ local v = substitute(action.keyword or action.value, captures, vars)
653
+ if not dry then item:AddKeyword(v) end
654
+ return string.format("add_keyword %q", v)
655
+ elseif typ == "add_marker" then
656
+ local frame = tonumber(substitute(tostring(action.frame or 0), captures, vars)) or 0
657
+ local color = substitute(action.color or "Blue", captures, vars)
658
+ local note = substitute(action.note or "", captures, vars)
659
+ local name = substitute(action.name or note, captures, vars)
660
+ if not dry then item:AddMarker(frame, color, name, note, action.duration or 1) end
661
+ return string.format("marker @%d %s %q", frame, color, note)
662
+ elseif typ == "apply_lut" then
663
+ local v = substitute(action.lut_path or action.value, captures, vars)
664
+ if not dry then item:SetClipProperty("Input LUT", v) end
665
+ return string.format("apply_lut %q", v)
666
+ elseif typ == "set_in_out" then
667
+ if not dry then
668
+ if action.in_point then item:SetClipProperty("In Point", tostring(action.in_point)) end
669
+ if action.out_point then item:SetClipProperty("Out Point", tostring(action.out_point)) end
670
+ end
671
+ return string.format("set_in_out in=%s out=%s", tostring(action.in_point), tostring(action.out_point))
672
+ elseif typ == "tag_for_review" then
673
+ local v = substitute(action.value or "needs_review", captures, vars)
674
+ if not dry then item:SetMetadata(action.field or "Description", v) end
675
+ return string.format("tag_for_review %q", v)
676
+ elseif typ == "notify" or typ == "print" then
677
+ local msg = substitute(action.message or action.value or "", captures, vars)
678
+ log("normal", msg)
679
+ return "notify"
680
+ end
681
+ return "(unknown action: " .. tostring(typ) .. ")"
682
+ end
683
+
684
+ -- ─── External data matching ────────────────────────────────────────────────
685
+
686
+ local function find_external_row(rows, match_cfg, item, captures, vars)
687
+ if not rows or #rows == 0 then return nil end
688
+ local key_value = resolve_source(match_cfg.source, item, nil, nil, captures)
689
+ if not key_value or key_value == "" then return nil end
690
+ local strategy = match_cfg.strategy or "exact"
691
+ local column = match_cfg.column
692
+ if strategy == "exact" then
693
+ for _, row in ipairs(rows) do
694
+ if row[column] == key_value then return row end
695
+ end
696
+ elseif strategy == "regex" then
697
+ for _, row in ipairs(rows) do
698
+ if row[column] and key_value:match(row[column]) then return row end
699
+ end
700
+ elseif strategy == "fuzzy" then
701
+ local best, best_dist = nil, math.huge
702
+ for _, row in ipairs(rows) do
703
+ if row[column] then
704
+ local d = levenshtein(key_value:lower(), tostring(row[column]):lower())
705
+ if d < best_dist then best, best_dist = row, d end
706
+ end
707
+ end
708
+ return best -- lowest-distance row even if not exact
709
+ end
710
+ return nil
711
+ end
712
+
713
+ -- ─── Main loop ─────────────────────────────────────────────────────────────
714
+
715
+ function run_engine(vars, rules, external_cfg, opts)
716
+ local resolve = Resolve()
717
+ if not resolve then log("normal", "No Resolve handle.") return end
718
+ local pm = resolve:GetProjectManager()
719
+ local project = pm:GetCurrentProject()
720
+ if not project then log("normal", "No project open.") return end
721
+ local mp = project:GetMediaPool()
722
+
723
+ if opts.backup and not opts.dry_run then
724
+ local bak = "/tmp/" .. project:GetName() .. "_mcp_backup.drp"
725
+ pm:ExportProject(project:GetName(), bak)
726
+ log("normal", "Backup written to " .. bak)
727
+ end
728
+
729
+ -- Load external data once
730
+ local external_rows = nil
731
+ if external_cfg then
732
+ if external_cfg.csv then external_rows = load_csv(external_cfg.csv) end
733
+ if external_cfg.json then external_rows = load_json(external_cfg.json) end
734
+ if external_rows then
735
+ log("normal", "Loaded " .. #external_rows .. " external data rows")
736
+ end
737
+ end
738
+
739
+ local matched_set = {}
740
+ local total_actions, dry = 0, opts.dry_run
741
+
742
+ for ri, rule in ipairs(rules) do
743
+ if rule.enabled == false then
744
+ log("verbose", "skipping disabled rule: " .. (rule.name or ri))
745
+ else
746
+ local items = resolve_target(rule.target, project, mp)
747
+ local count = 0
748
+ for ii, entry in ipairs(items) do
749
+ if opts.limit and ii > opts.limit then break end
750
+ local item = entry.item
751
+ if rule.target == "unmatched_clips" and matched_set[item] then
752
+ -- skip
753
+ else
754
+ local captures = {
755
+ __bin_path = entry.bin_path,
756
+ __bin_name = entry.bin_name,
757
+ __static = "",
758
+ }
759
+ -- External data row
760
+ if external_rows and external_cfg.match_on then
761
+ captures.__external_row = find_external_row(
762
+ external_rows, external_cfg.match_on,
763
+ item, captures, vars)
764
+ end
765
+ -- Extract patterns
766
+ local matched = true
767
+ for _, ex in ipairs(rule.extract or {}) do
768
+ local src_value = resolve_source(ex.source, item, project, mp, captures)
769
+ local pat = vars[ex.pattern] or ex.pattern
770
+ local results = {string.match(src_value, pat)}
771
+ if #results == 0 then
772
+ matched = false; break
773
+ end
774
+ for i, name in ipairs(ex.into or {}) do
775
+ captures[name] = results[i]
776
+ end
777
+ end
778
+ if matched and rule.condition then
779
+ local ok, res = pcall(rule.condition, captures, vars)
780
+ if not ok or not res then matched = false end
781
+ end
782
+ if matched or #(rule.extract or {}) == 0 then
783
+ for _, action in ipairs(rule.apply or {}) do
784
+ local desc = run_action(action, item, captures, vars,
785
+ project, mp, dry)
786
+ log("verbose", string.format(" rule[%s] %s", rule.name or ri, desc))
787
+ total_actions = total_actions + 1
788
+ end
789
+ matched_set[item] = true
790
+ count = count + 1
791
+ if rule.stop_on_match then break end
792
+ end
793
+ end
794
+ end
795
+ log("normal", string.format("rule[%s] matched %d items", rule.name or ri, count))
796
+ end
797
+ end
798
+
799
+ log("normal", string.format("Done. %s%d action(s).",
800
+ dry and "[DRY-RUN] would have run " or "Ran ", total_actions))
801
+ end
802
+ """
803
+
804
+
805
+ # ═══════════════════════════════════════════════════════════════════════════════
806
+ # PYTHON ENGINE
807
+ # ═══════════════════════════════════════════════════════════════════════════════
808
+
809
+ PY_ENGINE = r'''
810
+ def _log(level, msg, opts):
811
+ if opts.get("log_level") == "quiet":
812
+ return
813
+ if opts.get("log_level", "normal") == "normal" and level == "debug":
814
+ return
815
+ print(f"[mcp-script] {msg}")
816
+
817
+
818
+ def _load_csv(path):
819
+ with open(path, newline="", encoding="utf-8") as f:
820
+ return list(csv.DictReader(f))
821
+
822
+
823
+ def _load_json(path):
824
+ with open(path, encoding="utf-8") as f:
825
+ data = json.load(f)
826
+ if isinstance(data, dict):
827
+ return [{"_key": k, **(v if isinstance(v, dict) else {"value": v})}
828
+ for k, v in data.items()]
829
+ return data
830
+
831
+
832
+ def _levenshtein(a, b):
833
+ if a == b: return 0
834
+ if not a: return len(b)
835
+ if not b: return len(a)
836
+ prev = list(range(len(b) + 1))
837
+ for i, ca in enumerate(a, start=1):
838
+ cur = [i] + [0] * len(b)
839
+ for j, cb in enumerate(b, start=1):
840
+ cost = 0 if ca == cb else 1
841
+ cur[j] = min(prev[j] + 1, cur[j-1] + 1, prev[j-1] + cost)
842
+ prev = cur
843
+ return prev[-1]
844
+
845
+
846
+ def _apply_pipe(value, pipe, vars):
847
+ pipe = pipe.strip()
848
+ m = re.match(r"^(\w+)\s*\(?(.*?)\)?$", pipe)
849
+ op = m.group(1) if m else pipe
850
+ arg = m.group(2) if m else ""
851
+ sval = str(value)
852
+ if op == "upper": return sval.upper()
853
+ if op == "lower": return sval.lower()
854
+ if op == "title": return sval.title()
855
+ if op == "slug": return re.sub(r"[^\w\-]", "", sval.lower().replace(" ", "_"))
856
+ if op == "pad":
857
+ m2 = re.match(r"\s*(\d+)\s*,?\s*['\"]?(.?)['\"]?", arg)
858
+ n = int(m2.group(1)) if m2 else 2
859
+ ch = m2.group(2) if m2 and m2.group(2) else "0"
860
+ return sval.rjust(n, ch)
861
+ if op == "lookup":
862
+ tbl_name = arg.strip().strip("'\"")
863
+ tbl = vars.get(tbl_name)
864
+ if isinstance(tbl, dict) and value in tbl:
865
+ return tbl[value]
866
+ return value
867
+ if op in ("add", "sub", "mul", "div"):
868
+ try:
869
+ n = float(value); k = float(arg)
870
+ except (ValueError, TypeError):
871
+ return value
872
+ result = {"add": n + k, "sub": n - k, "mul": n * k,
873
+ "div": n / k if k else 0}[op]
874
+ return str(int(result) if result.is_integer() else result)
875
+ if op == "date":
876
+ try:
877
+ d = datetime.strptime(sval, "%Y-%m-%d")
878
+ return d.strftime(arg.strip("'\""))
879
+ except ValueError:
880
+ return value
881
+ return value
882
+
883
+
884
+ def _substitute(template, captures, vars):
885
+ if not isinstance(template, str):
886
+ return template
887
+ def repl(m):
888
+ expr = m.group(1)
889
+ parts = [p.strip() for p in expr.split("|")]
890
+ var_name = parts[0]
891
+ if var_name.startswith("external_data:"):
892
+ col = var_name[14:]
893
+ row = captures.get("__external_row") or {}
894
+ value = row.get(col, "")
895
+ else:
896
+ value = captures.get(var_name)
897
+ if value is None:
898
+ return "{" + expr + "}"
899
+ for pipe in parts[1:]:
900
+ value = _apply_pipe(value, pipe, vars)
901
+ return str(value)
902
+ return re.sub(r"\{([^}]+)\}", repl, template)
903
+
904
+
905
+ def _basename(p):
906
+ return os.path.basename(p) if p else ""
907
+
908
+
909
+ def _resolve_source(spec, item, captures):
910
+ if spec == "static_value":
911
+ return captures.get("__static", "")
912
+ if spec == "bin_name":
913
+ return captures.get("__bin_name", "")
914
+ if spec == "media_pool_path":
915
+ return captures.get("__bin_path", "")
916
+ if spec.startswith("clip_property:"):
917
+ return item.GetClipProperty(spec[14:]) or ""
918
+ if spec.startswith("metadata:") or spec.startswith("embedded_metadata:") or spec.startswith("camera_metadata:"):
919
+ key = spec.split(":", 1)[1]
920
+ return item.GetMetadata(key) or ""
921
+ if spec.startswith("previous_capture:"):
922
+ return captures.get(spec[17:], "")
923
+
924
+ fp = item.GetClipProperty("File Path") or ""
925
+ if spec == "file_path": return fp
926
+ if spec == "filename": return os.path.basename(fp)
927
+ if spec == "dirname": return os.path.basename(os.path.dirname(fp))
928
+ if spec == "parent_dir": return os.path.basename(os.path.dirname(os.path.dirname(fp)))
929
+ if spec == "grandparent_dir":
930
+ return os.path.basename(os.path.dirname(os.path.dirname(os.path.dirname(fp))))
931
+ if spec == "file_extension":
932
+ return os.path.splitext(fp)[1].lstrip(".")
933
+
934
+ prop_map = {
935
+ "clip_duration": "Duration",
936
+ "clip_resolution": "Resolution",
937
+ "frame_rate": "FPS",
938
+ "codec": "Video Codec",
939
+ "audio_channels": "Audio Ch",
940
+ "audio_format": "Audio Codec",
941
+ "start_tc": "Start TC",
942
+ "end_tc": "End TC",
943
+ "creation_time": "Date Created",
944
+ "modification_time": "Date Modified",
945
+ }
946
+ if spec in prop_map:
947
+ return item.GetClipProperty(prop_map[spec]) or ""
948
+ return ""
949
+
950
+
951
+ def _walk_bins(folder, prefix=""):
952
+ out = []
953
+ for clip in (folder.GetClipList() or []):
954
+ out.append({"item": clip, "bin_path": prefix, "bin_name": folder.GetName()})
955
+ for sub in (folder.GetSubFolderList() or []):
956
+ p = (prefix + "/" if prefix else "") + sub.GetName()
957
+ out.extend(_walk_bins(sub, p))
958
+ return out
959
+
960
+
961
+ def _resolve_target(spec, project, mp):
962
+ tl = project.GetCurrentTimeline()
963
+ if spec == "media_pool_clips":
964
+ return _walk_bins(mp.GetRootFolder())
965
+ if spec == "current_bin_clips":
966
+ cur = mp.GetCurrentFolder()
967
+ return [{"item": c, "bin_path": "", "bin_name": cur.GetName()}
968
+ for c in (cur.GetClipList() or [])]
969
+ if spec.startswith("bin_path:"):
970
+ path = spec[9:]
971
+ folder = mp.GetRootFolder()
972
+ for seg in path.split("/"):
973
+ found = None
974
+ for sub in (folder.GetSubFolderList() or []):
975
+ if sub.GetName() == seg:
976
+ found = sub; break
977
+ if not found: return []
978
+ folder = found
979
+ return [{"item": c, "bin_path": path, "bin_name": folder.GetName()}
980
+ for c in (folder.GetClipList() or [])]
981
+ if spec == "selected_clips":
982
+ return [{"item": c, "bin_path": "", "bin_name": ""}
983
+ for c in (mp.GetSelectedClips() or [])]
984
+ if spec in ("timeline_items", "selected_timeline_items"):
985
+ if not tl: return []
986
+ out = []
987
+ for trk in range(1, (tl.GetTrackCount("video") or 0) + 1):
988
+ for ti in (tl.GetItemListInTrack("video", trk) or []):
989
+ out.append({"item": ti, "bin_path": "", "bin_name": "", "track": trk})
990
+ return out
991
+ if spec.startswith("timeline_items_in_track:"):
992
+ if not tl: return []
993
+ n = int(spec[24:]) if spec[24:].isdigit() else 1
994
+ return [{"item": ti, "bin_path": "", "bin_name": "", "track": n}
995
+ for ti in (tl.GetItemListInTrack("video", n) or [])]
996
+ return []
997
+
998
+
999
+ def _get_or_create_bin(mp, path):
1000
+ folder = mp.GetRootFolder()
1001
+ if not path:
1002
+ return folder
1003
+ for seg in path.split("/"):
1004
+ found = None
1005
+ for sub in (folder.GetSubFolderList() or []):
1006
+ if sub.GetName() == seg:
1007
+ found = sub; break
1008
+ if not found:
1009
+ found = mp.AddSubFolder(folder, seg)
1010
+ if not found:
1011
+ return None
1012
+ folder = found
1013
+ return folder
1014
+
1015
+
1016
+ def _run_action(action, item, captures, vars, project, mp, dry):
1017
+ typ = action.get("type")
1018
+ if typ == "set_metadata":
1019
+ v = _substitute(action["value"], captures, vars)
1020
+ if not dry: item.SetMetadata(action["field"], v)
1021
+ return f'set_metadata {action["field"]} = {v!r}'
1022
+ if typ == "set_clip_property":
1023
+ v = _substitute(action["value"], captures, vars)
1024
+ if not dry: item.SetClipProperty(action["field"], v)
1025
+ return f'set_clip_property {action["field"]} = {v!r}'
1026
+ if typ == "rename_clip":
1027
+ v = _substitute(action["value"], captures, vars)
1028
+ if not dry: item.SetClipProperty("Clip Name", v)
1029
+ return f"rename → {v!r}"
1030
+ if typ == "move_to_bin":
1031
+ p = _substitute(action["path"], captures, vars)
1032
+ if not dry:
1033
+ target = _get_or_create_bin(mp, p)
1034
+ if target: mp.MoveClips([item], target)
1035
+ return f"move_to_bin {p!r}"
1036
+ if typ == "set_clip_color":
1037
+ v = _substitute(action.get("color") or action["value"], captures, vars)
1038
+ if not dry: item.SetClipColor(v)
1039
+ return f"set_clip_color {v}"
1040
+ if typ == "flag_clip":
1041
+ v = _substitute(action.get("color") or action["value"], captures, vars)
1042
+ if not dry: item.AddFlag(v)
1043
+ return f"flag_clip {v}"
1044
+ if typ == "add_keyword":
1045
+ v = _substitute(action.get("keyword") or action["value"], captures, vars)
1046
+ if not dry: item.AddKeyword(v)
1047
+ return f"add_keyword {v!r}"
1048
+ if typ == "add_marker":
1049
+ frame = int(_substitute(str(action.get("frame", 0)), captures, vars) or 0)
1050
+ color = _substitute(action.get("color", "Blue"), captures, vars)
1051
+ note = _substitute(action.get("note", ""), captures, vars)
1052
+ name = _substitute(action.get("name", note), captures, vars)
1053
+ if not dry:
1054
+ item.AddMarker(frame, color, name, note, action.get("duration", 1))
1055
+ return f"marker @{frame} {color} {note!r}"
1056
+ if typ == "apply_lut":
1057
+ v = _substitute(action.get("lut_path") or action["value"], captures, vars)
1058
+ if not dry: item.SetClipProperty("Input LUT", v)
1059
+ return f"apply_lut {v!r}"
1060
+ if typ == "set_in_out":
1061
+ if not dry:
1062
+ if "in_point" in action:
1063
+ item.SetClipProperty("In Point", str(action["in_point"]))
1064
+ if "out_point" in action:
1065
+ item.SetClipProperty("Out Point", str(action["out_point"]))
1066
+ return f"set_in_out in={action.get('in_point')} out={action.get('out_point')}"
1067
+ if typ == "tag_for_review":
1068
+ v = _substitute(action.get("value", "needs_review"), captures, vars)
1069
+ if not dry: item.SetMetadata(action.get("field", "Description"), v)
1070
+ return f"tag_for_review {v!r}"
1071
+ if typ in ("notify", "print"):
1072
+ msg = _substitute(action.get("message") or action.get("value", ""), captures, vars)
1073
+ print(f"[mcp-script] {msg}")
1074
+ return "notify"
1075
+ return f"(unknown action: {typ})"
1076
+
1077
+
1078
+ def _find_external_row(rows, match_cfg, item, captures):
1079
+ if not rows: return None
1080
+ key = _resolve_source(match_cfg["source"], item, captures)
1081
+ if not key: return None
1082
+ strategy = match_cfg.get("strategy", "exact")
1083
+ col = match_cfg["column"]
1084
+ if strategy == "exact":
1085
+ for row in rows:
1086
+ if row.get(col) == key: return row
1087
+ elif strategy == "regex":
1088
+ for row in rows:
1089
+ pat = row.get(col)
1090
+ if pat and re.search(pat, key): return row
1091
+ elif strategy == "fuzzy":
1092
+ best, best_d = None, float("inf")
1093
+ for row in rows:
1094
+ v = row.get(col)
1095
+ if v:
1096
+ d = _levenshtein(key.lower(), str(v).lower())
1097
+ if d < best_d:
1098
+ best, best_d = row, d
1099
+ return best
1100
+ return None
1101
+
1102
+
1103
+ def run_engine(vars, rules, external_cfg, opts):
1104
+ resolve = dvr_script.scriptapp("Resolve")
1105
+ if not resolve:
1106
+ _log("normal", "No Resolve handle.", opts); return
1107
+ pm = resolve.GetProjectManager()
1108
+ project = pm.GetCurrentProject()
1109
+ if not project:
1110
+ _log("normal", "No project open.", opts); return
1111
+ mp = project.GetMediaPool()
1112
+
1113
+ if opts.get("backup") and not opts.get("dry_run"):
1114
+ bak = f"/tmp/{project.GetName()}_mcp_backup.drp"
1115
+ pm.ExportProject(project.GetName(), bak)
1116
+ _log("normal", f"Backup written to {bak}", opts)
1117
+
1118
+ external_rows = None
1119
+ if external_cfg:
1120
+ if external_cfg.get("csv"):
1121
+ external_rows = _load_csv(external_cfg["csv"])
1122
+ elif external_cfg.get("json"):
1123
+ external_rows = _load_json(external_cfg["json"])
1124
+ if external_rows:
1125
+ _log("normal", f"Loaded {len(external_rows)} external rows", opts)
1126
+
1127
+ matched_set = set()
1128
+ total = 0
1129
+ dry = opts.get("dry_run", False)
1130
+
1131
+ for ri, rule in enumerate(rules):
1132
+ if rule.get("enabled") is False:
1133
+ continue
1134
+ items = _resolve_target(rule["target"], project, mp)
1135
+ count = 0
1136
+ for ii, entry in enumerate(items):
1137
+ if opts.get("limit") and ii >= opts["limit"]:
1138
+ break
1139
+ item = entry["item"]
1140
+ if rule["target"] == "unmatched_clips" and id(item) in matched_set:
1141
+ continue
1142
+ captures = {
1143
+ "__bin_path": entry.get("bin_path", ""),
1144
+ "__bin_name": entry.get("bin_name", ""),
1145
+ "__static": "",
1146
+ }
1147
+ if external_rows and external_cfg.get("match_on"):
1148
+ captures["__external_row"] = _find_external_row(
1149
+ external_rows, external_cfg["match_on"], item, captures)
1150
+
1151
+ matched = True
1152
+ for ex in (rule.get("extract") or []):
1153
+ src_value = _resolve_source(ex["source"], item, captures)
1154
+ pat = vars.get(ex["pattern"], ex["pattern"])
1155
+ m = re.search(pat, src_value)
1156
+ if not m:
1157
+ matched = False; break
1158
+ for i, name in enumerate(ex.get("into", []), start=1):
1159
+ try:
1160
+ captures[name] = m.group(i)
1161
+ except IndexError:
1162
+ captures[name] = ""
1163
+
1164
+ if matched and rule.get("condition"):
1165
+ try:
1166
+ if not rule["condition"](captures, vars):
1167
+ matched = False
1168
+ except Exception:
1169
+ matched = False
1170
+
1171
+ if matched or not (rule.get("extract")):
1172
+ for action in (rule.get("apply") or []):
1173
+ desc = _run_action(action, item, captures, vars,
1174
+ project, mp, dry)
1175
+ _log("verbose", f' rule[{rule.get("name", ri)}] {desc}', opts)
1176
+ total += 1
1177
+ matched_set.add(id(item))
1178
+ count += 1
1179
+ if rule.get("stop_on_match"):
1180
+ break
1181
+ _log("normal",
1182
+ f'rule[{rule.get("name", ri)}] matched {count} items', opts)
1183
+
1184
+ _log("normal",
1185
+ ("[DRY-RUN] would have run " if dry else "Ran ")
1186
+ + f"{total} action(s).", opts)
1187
+ '''
1188
+
1189
+
1190
+ TEMPLATES = {
1191
+ "scaffold": scaffold,
1192
+ "media_rules": media_rules,
1193
+ }