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.
- package/AGENTS.md +85 -0
- package/CHANGELOG.md +802 -0
- package/CLAUDE.md +15 -0
- package/LICENSE +21 -0
- package/README.md +159 -0
- package/SECURITY.md +53 -0
- package/bin/davinci-resolve-mcp.mjs +376 -0
- package/docs/README.md +56 -0
- package/docs/SKILL.md +1145 -0
- package/docs/authoring/fuse-dctl-authoring.md +242 -0
- package/docs/authoring/script-plugin-authoring.md +195 -0
- package/docs/contributing.md +82 -0
- package/docs/guides/color-decision-guide.md +387 -0
- package/docs/guides/editorial-decision-guide.md +136 -0
- package/docs/guides/media-analysis-guide.md +615 -0
- package/docs/guides/multicam-setup-guide.md +138 -0
- package/docs/install.md +198 -0
- package/docs/integrations/workflow-integrations.md +120 -0
- package/docs/kernels/README.md +28 -0
- package/docs/kernels/audio-fairlight-kernel.md +86 -0
- package/docs/kernels/color-grade-kernel.md +103 -0
- package/docs/kernels/extension-authoring-kernel.md +101 -0
- package/docs/kernels/fusion-composition-kernel.md +91 -0
- package/docs/kernels/media-pool-ingest-kernel.md +147 -0
- package/docs/kernels/project-lifecycle-kernel.md +120 -0
- package/docs/kernels/render-deliver-kernel.md +92 -0
- package/docs/kernels/review-annotation-kernel.md +110 -0
- package/docs/kernels/timeline-conform-interchange-kernel.md +99 -0
- package/docs/kernels/timeline-edit-kernel.md +189 -0
- package/docs/notes/codec-plugin-notes.md +136 -0
- package/docs/notes/dctl-notes.md +234 -0
- package/docs/notes/fusion-template-notes.md +136 -0
- package/docs/notes/lut-notes.md +136 -0
- package/docs/notes/openfx-notes.md +120 -0
- package/docs/process/release-process.md +152 -0
- package/docs/reference/api-coverage.md +488 -0
- package/docs/reference/resolve_scripting_api.txt +1012 -0
- package/examples/README.md +53 -0
- package/examples/markers/README.md +81 -0
- package/examples/media/README.md +94 -0
- package/examples/timeline/README.md +98 -0
- package/install.py +1196 -0
- package/package.json +52 -0
- package/scripts/audit_api_parity.py +275 -0
- package/scripts/live_media_analysis_polish_probe.py +65 -0
- package/src/__init__.py +3 -0
- package/src/analysis_dashboard.py +4936 -0
- package/src/control_panel.py +13 -0
- package/src/granular/__init__.py +17 -0
- package/src/granular/common.py +727 -0
- package/src/granular/folder.py +287 -0
- package/src/granular/gallery.py +306 -0
- package/src/granular/graph.py +309 -0
- package/src/granular/media_pool.py +679 -0
- package/src/granular/media_pool_item.py +852 -0
- package/src/granular/media_storage.py +179 -0
- package/src/granular/project.py +1594 -0
- package/src/granular/resolve_control.py +521 -0
- package/src/granular/timeline.py +1074 -0
- package/src/granular/timeline_item.py +2251 -0
- package/src/resolve_mcp_server.py +43 -0
- package/src/server.py +15691 -0
- package/src/utils/__init__.py +3 -0
- package/src/utils/app_control.py +319 -0
- package/src/utils/audio_fairlight_live_probe.py +263 -0
- package/src/utils/cdl.py +20 -0
- package/src/utils/cloud_operations.py +192 -0
- package/src/utils/color_grade_live_probe.py +444 -0
- package/src/utils/dctl_templates.py +368 -0
- package/src/utils/extension_authoring_live_probe.py +292 -0
- package/src/utils/fuse_templates.py +1968 -0
- package/src/utils/fusion_composition_live_probe.py +284 -0
- package/src/utils/layout_presets.py +333 -0
- package/src/utils/mcp_stdio.py +32 -0
- package/src/utils/media_analysis.py +3618 -0
- package/src/utils/media_analysis_jobs.py +796 -0
- package/src/utils/media_pool_ingest_live_probe.py +592 -0
- package/src/utils/multicam.py +393 -0
- package/src/utils/object_inspection.py +287 -0
- package/src/utils/platform.py +157 -0
- package/src/utils/project_lifecycle_live_probe.py +376 -0
- package/src/utils/project_properties.py +601 -0
- package/src/utils/render_deliver_live_probe.py +384 -0
- package/src/utils/resolve_connection.py +77 -0
- package/src/utils/review_annotation_live_probe.py +352 -0
- package/src/utils/script_templates.py +1193 -0
- package/src/utils/sync_detection.py +887 -0
- package/src/utils/timeline_conform_live_probe.py +280 -0
- package/src/utils/timeline_kernel_live_probe.py +1091 -0
- package/src/utils/timeline_kernel_probe.py +185 -0
- package/src/utils/timeline_title_text.py +87 -0
- 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
|
+
}
|