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,727 @@
|
|
|
1
|
+
"""Shared bootstrap and helpers for the granular Resolve MCP server."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import platform
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
import time
|
|
10
|
+
from typing import Any, Dict, List, Optional, Union
|
|
11
|
+
|
|
12
|
+
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
13
|
+
SRC_DIR = os.path.dirname(CURRENT_DIR)
|
|
14
|
+
PROJECT_DIR = os.path.dirname(SRC_DIR)
|
|
15
|
+
|
|
16
|
+
for path in (SRC_DIR, PROJECT_DIR):
|
|
17
|
+
if path not in sys.path:
|
|
18
|
+
sys.path.insert(0, path)
|
|
19
|
+
|
|
20
|
+
from mcp.server.fastmcp import FastMCP
|
|
21
|
+
from mcp.types import ToolAnnotations
|
|
22
|
+
|
|
23
|
+
from src.utils.app_control import (
|
|
24
|
+
get_app_state,
|
|
25
|
+
open_preferences,
|
|
26
|
+
open_project_settings,
|
|
27
|
+
quit_resolve_app,
|
|
28
|
+
restart_resolve_app,
|
|
29
|
+
)
|
|
30
|
+
from src.utils.cdl import normalize_cdl_payload
|
|
31
|
+
from src.utils.cloud_operations import (
|
|
32
|
+
create_cloud_project,
|
|
33
|
+
import_cloud_project,
|
|
34
|
+
load_cloud_project,
|
|
35
|
+
restore_cloud_project,
|
|
36
|
+
)
|
|
37
|
+
from src.utils.layout_presets import (
|
|
38
|
+
delete_layout_preset,
|
|
39
|
+
export_layout_preset,
|
|
40
|
+
import_layout_preset,
|
|
41
|
+
list_layout_presets,
|
|
42
|
+
load_layout_preset,
|
|
43
|
+
save_layout_preset,
|
|
44
|
+
)
|
|
45
|
+
from src.utils.object_inspection import inspect_object, print_object_help
|
|
46
|
+
from src.utils.platform import get_platform, get_resolve_paths
|
|
47
|
+
from src.utils.project_properties import (
|
|
48
|
+
get_all_project_properties,
|
|
49
|
+
get_color_settings,
|
|
50
|
+
get_project_info,
|
|
51
|
+
get_project_metadata,
|
|
52
|
+
get_project_property,
|
|
53
|
+
get_superscale_settings,
|
|
54
|
+
get_timeline_format_settings,
|
|
55
|
+
set_color_science_mode,
|
|
56
|
+
set_color_space,
|
|
57
|
+
set_project_property,
|
|
58
|
+
set_superscale_settings,
|
|
59
|
+
set_timeline_format,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
paths = get_resolve_paths()
|
|
63
|
+
RESOLVE_API_PATH = os.environ.get("RESOLVE_SCRIPT_API") or paths["api_path"]
|
|
64
|
+
RESOLVE_LIB_PATH = os.environ.get("RESOLVE_SCRIPT_LIB") or paths["lib_path"]
|
|
65
|
+
RESOLVE_MODULES_PATH = (
|
|
66
|
+
os.path.join(RESOLVE_API_PATH, "Modules") if RESOLVE_API_PATH else paths["modules_path"]
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
if RESOLVE_API_PATH:
|
|
70
|
+
os.environ["RESOLVE_SCRIPT_API"] = RESOLVE_API_PATH
|
|
71
|
+
if RESOLVE_LIB_PATH:
|
|
72
|
+
os.environ["RESOLVE_SCRIPT_LIB"] = RESOLVE_LIB_PATH
|
|
73
|
+
if RESOLVE_MODULES_PATH and RESOLVE_MODULES_PATH not in sys.path:
|
|
74
|
+
sys.path.append(RESOLVE_MODULES_PATH)
|
|
75
|
+
|
|
76
|
+
if not logging.getLogger().handlers:
|
|
77
|
+
logging.basicConfig(
|
|
78
|
+
level=logging.INFO,
|
|
79
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
80
|
+
handlers=[logging.StreamHandler()],
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
VERSION = "2.23.0"
|
|
84
|
+
logger = logging.getLogger("davinci-resolve-mcp")
|
|
85
|
+
logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
|
|
86
|
+
logger.info(f"Detected platform: {get_platform()}")
|
|
87
|
+
logger.info(f"Using Resolve API path: {RESOLVE_API_PATH}")
|
|
88
|
+
logger.info(f"Using Resolve library path: {RESOLVE_LIB_PATH}")
|
|
89
|
+
|
|
90
|
+
mcp = FastMCP("DaVinciResolveMCP")
|
|
91
|
+
|
|
92
|
+
READ_ONLY_TOOL = ToolAnnotations(
|
|
93
|
+
readOnlyHint=True,
|
|
94
|
+
destructiveHint=False,
|
|
95
|
+
idempotentHint=True,
|
|
96
|
+
openWorldHint=False,
|
|
97
|
+
)
|
|
98
|
+
WRITE_TOOL = ToolAnnotations(
|
|
99
|
+
readOnlyHint=False,
|
|
100
|
+
destructiveHint=False,
|
|
101
|
+
idempotentHint=False,
|
|
102
|
+
openWorldHint=False,
|
|
103
|
+
)
|
|
104
|
+
IDEMPOTENT_WRITE_TOOL = ToolAnnotations(
|
|
105
|
+
readOnlyHint=False,
|
|
106
|
+
destructiveHint=False,
|
|
107
|
+
idempotentHint=True,
|
|
108
|
+
openWorldHint=False,
|
|
109
|
+
)
|
|
110
|
+
DESTRUCTIVE_TOOL = ToolAnnotations(
|
|
111
|
+
readOnlyHint=False,
|
|
112
|
+
destructiveHint=True,
|
|
113
|
+
idempotentHint=False,
|
|
114
|
+
openWorldHint=False,
|
|
115
|
+
)
|
|
116
|
+
EXTERNAL_READ_TOOL = ToolAnnotations(
|
|
117
|
+
readOnlyHint=True,
|
|
118
|
+
destructiveHint=False,
|
|
119
|
+
idempotentHint=True,
|
|
120
|
+
openWorldHint=True,
|
|
121
|
+
)
|
|
122
|
+
EXTERNAL_WRITE_TOOL = ToolAnnotations(
|
|
123
|
+
readOnlyHint=False,
|
|
124
|
+
destructiveHint=False,
|
|
125
|
+
idempotentHint=False,
|
|
126
|
+
openWorldHint=True,
|
|
127
|
+
)
|
|
128
|
+
EXTERNAL_DESTRUCTIVE_TOOL = ToolAnnotations(
|
|
129
|
+
readOnlyHint=False,
|
|
130
|
+
destructiveHint=True,
|
|
131
|
+
idempotentHint=False,
|
|
132
|
+
openWorldHint=True,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _annotations_for_tool_name(tool_name: str) -> ToolAnnotations:
|
|
137
|
+
"""Infer conservative MCP client-safety hints for legacy granular tools."""
|
|
138
|
+
name = (tool_name or "").lower()
|
|
139
|
+
read_prefixes = (
|
|
140
|
+
"get_",
|
|
141
|
+
"list_",
|
|
142
|
+
"inspect_",
|
|
143
|
+
"probe_",
|
|
144
|
+
"validate_",
|
|
145
|
+
"compare_",
|
|
146
|
+
"detect_",
|
|
147
|
+
"summarize_",
|
|
148
|
+
"review_",
|
|
149
|
+
"is_",
|
|
150
|
+
"has_",
|
|
151
|
+
)
|
|
152
|
+
destructive_prefixes = (
|
|
153
|
+
"delete_",
|
|
154
|
+
"remove_",
|
|
155
|
+
"clear_",
|
|
156
|
+
"reset_",
|
|
157
|
+
"replace_",
|
|
158
|
+
"unlink_",
|
|
159
|
+
"quit",
|
|
160
|
+
"restart",
|
|
161
|
+
"close_",
|
|
162
|
+
"stop_",
|
|
163
|
+
"overwrite_",
|
|
164
|
+
"lift_",
|
|
165
|
+
"set_",
|
|
166
|
+
"load_",
|
|
167
|
+
"switch_",
|
|
168
|
+
)
|
|
169
|
+
write_prefixes = (
|
|
170
|
+
"add_",
|
|
171
|
+
"append_",
|
|
172
|
+
"apply_",
|
|
173
|
+
"assign_",
|
|
174
|
+
"copy_",
|
|
175
|
+
"create_",
|
|
176
|
+
"duplicate_",
|
|
177
|
+
"export_",
|
|
178
|
+
"import_",
|
|
179
|
+
"insert_",
|
|
180
|
+
"link_",
|
|
181
|
+
"move_",
|
|
182
|
+
"open_",
|
|
183
|
+
"render_",
|
|
184
|
+
"rename_",
|
|
185
|
+
"save_",
|
|
186
|
+
"start_",
|
|
187
|
+
"sync_",
|
|
188
|
+
"transcribe_",
|
|
189
|
+
)
|
|
190
|
+
if name.startswith(read_prefixes):
|
|
191
|
+
return READ_ONLY_TOOL
|
|
192
|
+
if name.startswith(destructive_prefixes):
|
|
193
|
+
return DESTRUCTIVE_TOOL
|
|
194
|
+
if name.startswith(write_prefixes):
|
|
195
|
+
return WRITE_TOOL
|
|
196
|
+
return WRITE_TOOL
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
_original_mcp_tool = mcp.tool
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _tool_with_default_annotations(
|
|
203
|
+
name=None,
|
|
204
|
+
title=None,
|
|
205
|
+
description=None,
|
|
206
|
+
annotations=None,
|
|
207
|
+
icons=None,
|
|
208
|
+
meta=None,
|
|
209
|
+
structured_output=None,
|
|
210
|
+
):
|
|
211
|
+
"""Default unannotated granular tools to explicit MCP safety hints."""
|
|
212
|
+
|
|
213
|
+
def decorator(func):
|
|
214
|
+
tool_name = name or getattr(func, "__name__", "")
|
|
215
|
+
return _original_mcp_tool(
|
|
216
|
+
name=name,
|
|
217
|
+
title=title,
|
|
218
|
+
description=description,
|
|
219
|
+
annotations=annotations or _annotations_for_tool_name(tool_name),
|
|
220
|
+
icons=icons,
|
|
221
|
+
meta=meta,
|
|
222
|
+
structured_output=structured_output,
|
|
223
|
+
)(func)
|
|
224
|
+
|
|
225
|
+
return decorator
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
mcp.tool = _tool_with_default_annotations
|
|
229
|
+
|
|
230
|
+
resolve = None
|
|
231
|
+
dvr_script = None
|
|
232
|
+
|
|
233
|
+
try:
|
|
234
|
+
import DaVinciResolveScript as dvr_script # type: ignore
|
|
235
|
+
|
|
236
|
+
resolve = dvr_script.scriptapp("Resolve")
|
|
237
|
+
if resolve:
|
|
238
|
+
logger.info(
|
|
239
|
+
f"Connected to DaVinci Resolve: {resolve.GetProductName()} {resolve.GetVersionString()}"
|
|
240
|
+
)
|
|
241
|
+
else:
|
|
242
|
+
logger.error("Failed to get Resolve object. Is DaVinci Resolve running?")
|
|
243
|
+
except ImportError as exc:
|
|
244
|
+
logger.error(f"Failed to import DaVinciResolveScript: {exc}")
|
|
245
|
+
logger.error("Check that DaVinci Resolve is installed and running.")
|
|
246
|
+
logger.error(f"RESOLVE_SCRIPT_API: {RESOLVE_API_PATH}")
|
|
247
|
+
logger.error(f"RESOLVE_SCRIPT_LIB: {RESOLVE_LIB_PATH}")
|
|
248
|
+
logger.error(f"RESOLVE_MODULES_PATH: {RESOLVE_MODULES_PATH}")
|
|
249
|
+
logger.error(f"sys.path: {sys.path}")
|
|
250
|
+
resolve = None
|
|
251
|
+
except Exception as exc:
|
|
252
|
+
logger.error(f"Unexpected error initializing Resolve: {exc}")
|
|
253
|
+
resolve = None
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _normalize_cdl(cdl):
|
|
257
|
+
"""Normalize CDL payloads to the string format Resolve's SetCDL expects."""
|
|
258
|
+
return normalize_cdl_payload(cdl)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class ResolveProxy:
|
|
262
|
+
"""Late-bound proxy for modules that pass the shared Resolve object around."""
|
|
263
|
+
|
|
264
|
+
def _target(self):
|
|
265
|
+
return get_resolve()
|
|
266
|
+
|
|
267
|
+
def __bool__(self):
|
|
268
|
+
return self._target() is not None
|
|
269
|
+
|
|
270
|
+
def __getattr__(self, name):
|
|
271
|
+
target = self._target()
|
|
272
|
+
if target is None:
|
|
273
|
+
raise AttributeError("DaVinci Resolve is not connected")
|
|
274
|
+
return getattr(target, name)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _resolve_safe_dir(path):
|
|
278
|
+
"""Redirect sandbox/temp paths that Resolve can't access to ~/Desktop/resolve-stills.
|
|
279
|
+
|
|
280
|
+
Covers macOS (/var/folders, /private/var), Linux (/tmp, /var/tmp),
|
|
281
|
+
and Windows (AppData\\Local\\Temp) sandbox temp directories.
|
|
282
|
+
"""
|
|
283
|
+
system_temp = tempfile.gettempdir()
|
|
284
|
+
_is_sandbox = False
|
|
285
|
+
if platform.system() == "Darwin":
|
|
286
|
+
_is_sandbox = path.startswith("/var/") or path.startswith("/private/var/")
|
|
287
|
+
elif platform.system() == "Linux":
|
|
288
|
+
_is_sandbox = path.startswith("/tmp") or path.startswith("/var/tmp")
|
|
289
|
+
elif platform.system() == "Windows":
|
|
290
|
+
try:
|
|
291
|
+
_is_sandbox = os.path.commonpath([os.path.abspath(path), os.path.abspath(system_temp)]) == os.path.abspath(system_temp)
|
|
292
|
+
except ValueError:
|
|
293
|
+
_is_sandbox = False
|
|
294
|
+
if _is_sandbox:
|
|
295
|
+
return os.path.join(os.path.expanduser("~"), "Documents", "resolve-stills")
|
|
296
|
+
return path
|
|
297
|
+
|
|
298
|
+
def _is_resolve_handle_live(candidate) -> bool:
|
|
299
|
+
"""Return True when a cached Resolve handle still answers root API calls."""
|
|
300
|
+
try:
|
|
301
|
+
get_version = getattr(candidate, "GetVersion", None)
|
|
302
|
+
if not callable(get_version):
|
|
303
|
+
return False
|
|
304
|
+
return bool(get_version())
|
|
305
|
+
except Exception as exc:
|
|
306
|
+
logger.warning(f"Cached Resolve handle is stale: {exc}")
|
|
307
|
+
return False
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _try_connect():
|
|
311
|
+
"""Attempt to connect to Resolve once. Returns resolve object or None."""
|
|
312
|
+
global resolve
|
|
313
|
+
try:
|
|
314
|
+
candidate = dvr_script.scriptapp("Resolve")
|
|
315
|
+
if candidate and _is_resolve_handle_live(candidate):
|
|
316
|
+
resolve = candidate
|
|
317
|
+
logger.info(f"Connected: {resolve.GetProductName()} {resolve.GetVersionString()}")
|
|
318
|
+
else:
|
|
319
|
+
resolve = None
|
|
320
|
+
return resolve
|
|
321
|
+
except Exception as e:
|
|
322
|
+
logger.error(f"Connection error: {e}")
|
|
323
|
+
resolve = None
|
|
324
|
+
return None
|
|
325
|
+
|
|
326
|
+
def _launch_resolve():
|
|
327
|
+
"""Launch DaVinci Resolve and wait for it to become available."""
|
|
328
|
+
sys_name = platform.system().lower()
|
|
329
|
+
if sys_name == "darwin":
|
|
330
|
+
app_path = "/Applications/DaVinci Resolve/DaVinci Resolve.app"
|
|
331
|
+
if not os.path.exists(app_path):
|
|
332
|
+
return False
|
|
333
|
+
subprocess.Popen(["open", app_path])
|
|
334
|
+
elif sys_name == "windows":
|
|
335
|
+
app_path = r"C:\Program Files\Blackmagic Design\DaVinci Resolve\Resolve.exe"
|
|
336
|
+
if not os.path.exists(app_path):
|
|
337
|
+
return False
|
|
338
|
+
subprocess.Popen([app_path])
|
|
339
|
+
elif sys_name == "linux":
|
|
340
|
+
app_path = "/opt/resolve/bin/resolve"
|
|
341
|
+
if not os.path.exists(app_path):
|
|
342
|
+
return False
|
|
343
|
+
subprocess.Popen([app_path])
|
|
344
|
+
else:
|
|
345
|
+
return False
|
|
346
|
+
logger.info("Launched DaVinci Resolve, waiting for it to respond...")
|
|
347
|
+
for i in range(30):
|
|
348
|
+
time.sleep(2)
|
|
349
|
+
if _try_connect():
|
|
350
|
+
logger.info(f"Resolve responded after {(i+1)*2}s")
|
|
351
|
+
return True
|
|
352
|
+
logger.warning("Resolve did not respond within 60s after launch")
|
|
353
|
+
return False
|
|
354
|
+
|
|
355
|
+
def get_resolve():
|
|
356
|
+
"""Lazy connection to Resolve — connects on first tool call, auto-launches if needed."""
|
|
357
|
+
global resolve
|
|
358
|
+
if resolve is not None and _is_resolve_handle_live(resolve):
|
|
359
|
+
return resolve
|
|
360
|
+
resolve = None
|
|
361
|
+
if _try_connect():
|
|
362
|
+
return resolve
|
|
363
|
+
logger.info("Resolve not running, attempting to launch automatically...")
|
|
364
|
+
_launch_resolve()
|
|
365
|
+
return resolve
|
|
366
|
+
|
|
367
|
+
def get_project_manager():
|
|
368
|
+
"""Get ProjectManager with lazy connection and null guard."""
|
|
369
|
+
r = get_resolve()
|
|
370
|
+
if not r:
|
|
371
|
+
return None
|
|
372
|
+
pm = r.GetProjectManager()
|
|
373
|
+
return pm
|
|
374
|
+
|
|
375
|
+
def get_current_project():
|
|
376
|
+
"""Get current project with lazy connection and null guards."""
|
|
377
|
+
pm = get_project_manager()
|
|
378
|
+
if not pm:
|
|
379
|
+
return None, None
|
|
380
|
+
proj = pm.GetCurrentProject()
|
|
381
|
+
return pm, proj
|
|
382
|
+
|
|
383
|
+
def get_all_media_pool_clips(media_pool):
|
|
384
|
+
"""Get all clips from media pool recursively including subfolders."""
|
|
385
|
+
clips = []
|
|
386
|
+
root_folder = media_pool.GetRootFolder()
|
|
387
|
+
|
|
388
|
+
def process_folder(folder):
|
|
389
|
+
folder_clips = folder.GetClipList()
|
|
390
|
+
if folder_clips:
|
|
391
|
+
clips.extend(folder_clips)
|
|
392
|
+
|
|
393
|
+
sub_folders = folder.GetSubFolderList()
|
|
394
|
+
for sub_folder in sub_folders:
|
|
395
|
+
process_folder(sub_folder)
|
|
396
|
+
|
|
397
|
+
process_folder(root_folder)
|
|
398
|
+
return clips
|
|
399
|
+
|
|
400
|
+
def get_all_media_pool_folders(media_pool):
|
|
401
|
+
"""Get all folders from media pool recursively."""
|
|
402
|
+
folders = []
|
|
403
|
+
root_folder = media_pool.GetRootFolder()
|
|
404
|
+
|
|
405
|
+
def process_folder(folder):
|
|
406
|
+
folders.append(folder)
|
|
407
|
+
|
|
408
|
+
sub_folders = folder.GetSubFolderList()
|
|
409
|
+
for sub_folder in sub_folders:
|
|
410
|
+
process_folder(sub_folder)
|
|
411
|
+
|
|
412
|
+
process_folder(root_folder)
|
|
413
|
+
return folders
|
|
414
|
+
|
|
415
|
+
def _get_mp():
|
|
416
|
+
resolve = get_resolve()
|
|
417
|
+
if resolve is None:
|
|
418
|
+
return None, None, {"error": "Not connected to DaVinci Resolve"}
|
|
419
|
+
project = resolve.GetProjectManager().GetCurrentProject()
|
|
420
|
+
if not project:
|
|
421
|
+
return None, None, {"error": "No project currently open"}
|
|
422
|
+
mp = project.GetMediaPool()
|
|
423
|
+
if not mp:
|
|
424
|
+
return project, None, {"error": "Failed to get MediaPool"}
|
|
425
|
+
return project, mp, None
|
|
426
|
+
|
|
427
|
+
def _find_clip_by_id(folder, target_id):
|
|
428
|
+
for clip in (folder.GetClipList() or []):
|
|
429
|
+
if clip.GetUniqueId() == target_id:
|
|
430
|
+
return clip
|
|
431
|
+
for sub in (folder.GetSubFolderList() or []):
|
|
432
|
+
found = _find_clip_by_id(sub, target_id)
|
|
433
|
+
if found:
|
|
434
|
+
return found
|
|
435
|
+
return None
|
|
436
|
+
|
|
437
|
+
_SUBTITLE_LANGUAGE_SUFFIXES = {
|
|
438
|
+
"auto": "AUTO",
|
|
439
|
+
"danish": "DANISH",
|
|
440
|
+
"dutch": "DUTCH",
|
|
441
|
+
"english": "ENGLISH",
|
|
442
|
+
"french": "FRENCH",
|
|
443
|
+
"german": "GERMAN",
|
|
444
|
+
"italian": "ITALIAN",
|
|
445
|
+
"japanese": "JAPANESE",
|
|
446
|
+
"korean": "KOREAN",
|
|
447
|
+
"mandarin_simplified": "MANDARIN_SIMPLIFIED",
|
|
448
|
+
"mandarin-simplified": "MANDARIN_SIMPLIFIED",
|
|
449
|
+
"mandarin_traditional": "MANDARIN_TRADITIONAL",
|
|
450
|
+
"mandarin-traditional": "MANDARIN_TRADITIONAL",
|
|
451
|
+
"norwegian": "NORWEGIAN",
|
|
452
|
+
"portuguese": "PORTUGUESE",
|
|
453
|
+
"russian": "RUSSIAN",
|
|
454
|
+
"spanish": "SPANISH",
|
|
455
|
+
"swedish": "SWEDISH",
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
_SUBTITLE_PRESET_SUFFIXES = {
|
|
459
|
+
"default": "SUBTITLE_DEFAULT",
|
|
460
|
+
"subtitle_default": "SUBTITLE_DEFAULT",
|
|
461
|
+
"subtitle-default": "SUBTITLE_DEFAULT",
|
|
462
|
+
"teletext": "TELETEXT",
|
|
463
|
+
"netflix": "NETFLIX",
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
_SUBTITLE_LINE_BREAK_SUFFIXES = {
|
|
467
|
+
"single": "LINE_SINGLE",
|
|
468
|
+
"double": "LINE_DOUBLE",
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _build_subtitle_settings(resolve_obj, language=None, preset=None,
|
|
473
|
+
chars_per_line=None, line_break=None, gap=None):
|
|
474
|
+
"""Build the autoCaptionSettings dict for Timeline.CreateSubtitlesFromAudio.
|
|
475
|
+
|
|
476
|
+
Maps user-friendly strings to resolve.AUTO_CAPTION_* constants per docs
|
|
477
|
+
lines 720-761. Returns (settings_dict, None) or (None, error_dict).
|
|
478
|
+
"""
|
|
479
|
+
settings = {}
|
|
480
|
+
if language is not None:
|
|
481
|
+
suffix = _SUBTITLE_LANGUAGE_SUFFIXES.get(str(language).strip().lower())
|
|
482
|
+
if not suffix:
|
|
483
|
+
valid = sorted(set(_SUBTITLE_LANGUAGE_SUFFIXES.keys()))
|
|
484
|
+
return None, {"error": f"Unknown language '{language}'. Valid: {valid}"}
|
|
485
|
+
settings[resolve_obj.SUBTITLE_LANGUAGE] = getattr(resolve_obj, f"AUTO_CAPTION_{suffix}")
|
|
486
|
+
if preset is not None:
|
|
487
|
+
suffix = _SUBTITLE_PRESET_SUFFIXES.get(str(preset).strip().lower())
|
|
488
|
+
if not suffix:
|
|
489
|
+
valid = sorted(set(_SUBTITLE_PRESET_SUFFIXES.keys()))
|
|
490
|
+
return None, {"error": f"Unknown preset '{preset}'. Valid: {valid}"}
|
|
491
|
+
settings[resolve_obj.SUBTITLE_CAPTION_PRESET] = getattr(resolve_obj, f"AUTO_CAPTION_{suffix}")
|
|
492
|
+
if chars_per_line is not None:
|
|
493
|
+
if not isinstance(chars_per_line, int) or not (1 <= chars_per_line <= 60):
|
|
494
|
+
return None, {"error": "chars_per_line must be an integer between 1 and 60"}
|
|
495
|
+
settings[resolve_obj.SUBTITLE_CHARS_PER_LINE] = chars_per_line
|
|
496
|
+
if line_break is not None:
|
|
497
|
+
suffix = _SUBTITLE_LINE_BREAK_SUFFIXES.get(str(line_break).strip().lower())
|
|
498
|
+
if not suffix:
|
|
499
|
+
valid = sorted(set(_SUBTITLE_LINE_BREAK_SUFFIXES.keys()))
|
|
500
|
+
return None, {"error": f"Unknown line_break '{line_break}'. Valid: {valid}"}
|
|
501
|
+
settings[resolve_obj.SUBTITLE_LINE_BREAK] = getattr(resolve_obj, f"AUTO_CAPTION_{suffix}")
|
|
502
|
+
if gap is not None:
|
|
503
|
+
if not isinstance(gap, int) or not (0 <= gap <= 10):
|
|
504
|
+
return None, {"error": "gap must be an integer between 0 and 10"}
|
|
505
|
+
settings[resolve_obj.SUBTITLE_GAP] = gap
|
|
506
|
+
return settings, None
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _frame_int(value):
|
|
510
|
+
if value is None:
|
|
511
|
+
return None
|
|
512
|
+
try:
|
|
513
|
+
return int(round(float(value)))
|
|
514
|
+
except (TypeError, ValueError):
|
|
515
|
+
return None
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _timeline_start_frame(timeline):
|
|
519
|
+
if not timeline:
|
|
520
|
+
return None
|
|
521
|
+
try:
|
|
522
|
+
return _frame_int(timeline.GetStartFrame())
|
|
523
|
+
except Exception:
|
|
524
|
+
return None
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _normalize_record_frame(ci, index, timeline_start_frame=None):
|
|
528
|
+
rf = _frame_int(ci.get("recordFrame", ci.get("record_frame")))
|
|
529
|
+
if rf is None:
|
|
530
|
+
return None, {"error": f"clip_infos[{index}] record_frame/recordFrame must be numeric"}
|
|
531
|
+
|
|
532
|
+
mode_raw = ci.get("recordFrameMode", ci.get("record_frame_mode", "relative"))
|
|
533
|
+
mode = str(mode_raw or "relative").strip().lower()
|
|
534
|
+
mode_aliases = {
|
|
535
|
+
"relative": "relative",
|
|
536
|
+
"timeline_relative": "relative",
|
|
537
|
+
"offset": "relative",
|
|
538
|
+
"absolute": "absolute",
|
|
539
|
+
"timeline_absolute": "absolute",
|
|
540
|
+
"auto": "auto",
|
|
541
|
+
}
|
|
542
|
+
mode = mode_aliases.get(mode)
|
|
543
|
+
if not mode:
|
|
544
|
+
return None, {
|
|
545
|
+
"error": f"clip_infos[{index}] record_frame_mode must be 'relative', 'absolute', or 'auto'"
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
start = _frame_int(timeline_start_frame)
|
|
549
|
+
if start in (None, 0) or mode == "absolute":
|
|
550
|
+
return rf, None
|
|
551
|
+
if mode == "auto":
|
|
552
|
+
return (start + rf) if rf < start else rf, None
|
|
553
|
+
return start + rf, None
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _build_append_clip_info_dict(root, ci, index, timeline_start_frame=None):
|
|
557
|
+
"""Build one MediaPool.AppendToTimeline clipInfo map (6 keys per docs line 221).
|
|
558
|
+
|
|
559
|
+
Required: clip_id (or media_pool_item_id), start_frame, end_frame,
|
|
560
|
+
record_frame, track_index. Optional: media_type (1=video only, 2=audio only).
|
|
561
|
+
"""
|
|
562
|
+
if not isinstance(ci, dict):
|
|
563
|
+
return None, {"error": f"clip_infos[{index}] must be an object"}
|
|
564
|
+
cid = ci.get("clip_id") or ci.get("media_pool_item_id")
|
|
565
|
+
if not cid:
|
|
566
|
+
return None, {"error": f"clip_infos[{index}] requires clip_id or media_pool_item_id"}
|
|
567
|
+
mp_item = _find_clip_by_id(root, cid)
|
|
568
|
+
if not mp_item:
|
|
569
|
+
return None, {"error": f"clip_infos[{index}]: media pool clip not found: {cid}"}
|
|
570
|
+
sf = ci.get("startFrame", ci.get("start_frame"))
|
|
571
|
+
ef = ci.get("endFrame", ci.get("end_frame"))
|
|
572
|
+
if sf is None or ef is None:
|
|
573
|
+
return None, {"error": f"clip_infos[{index}] requires start_frame/startFrame and end_frame/endFrame"}
|
|
574
|
+
rf = ci.get("recordFrame", ci.get("record_frame"))
|
|
575
|
+
if rf is None:
|
|
576
|
+
return None, {"error": f"clip_infos[{index}] requires record_frame/recordFrame"}
|
|
577
|
+
rf, rf_err = _normalize_record_frame(ci, index, timeline_start_frame)
|
|
578
|
+
if rf_err:
|
|
579
|
+
return None, rf_err
|
|
580
|
+
ti = ci.get("trackIndex", ci.get("track_index"))
|
|
581
|
+
if ti is None:
|
|
582
|
+
return None, {"error": f"clip_infos[{index}] requires track_index/trackIndex"}
|
|
583
|
+
out: Dict[str, Any] = {
|
|
584
|
+
"mediaPoolItem": mp_item,
|
|
585
|
+
"startFrame": sf,
|
|
586
|
+
"endFrame": ef,
|
|
587
|
+
"recordFrame": rf,
|
|
588
|
+
"trackIndex": ti,
|
|
589
|
+
}
|
|
590
|
+
mt = ci.get("mediaType", ci.get("media_type"))
|
|
591
|
+
if mt is not None:
|
|
592
|
+
out["mediaType"] = mt
|
|
593
|
+
return out, None
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
_AUDIO_SYNC_MODE_SUFFIXES = {
|
|
597
|
+
"waveform": "WAVEFORM",
|
|
598
|
+
"timecode": "TIMECODE",
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
_AUDIO_SYNC_CHANNEL_SPECIAL = {
|
|
602
|
+
"automatic": "CHANNEL_AUTOMATIC",
|
|
603
|
+
"auto": "CHANNEL_AUTOMATIC",
|
|
604
|
+
"mix": "CHANNEL_MIX",
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _build_audio_sync_settings(resolve_obj, sync_mode=None, channel_number=None,
|
|
609
|
+
retain_embedded_audio=None, retain_video_metadata=None):
|
|
610
|
+
"""Build the {audioSyncSettings} dict for MediaPool.AutoSyncAudio per docs lines 600-614.
|
|
611
|
+
|
|
612
|
+
Returns (settings_dict, None) on success or (None, error_dict) on validation failure.
|
|
613
|
+
"""
|
|
614
|
+
settings: Dict[Any, Any] = {}
|
|
615
|
+
if sync_mode is not None:
|
|
616
|
+
suffix = _AUDIO_SYNC_MODE_SUFFIXES.get(str(sync_mode).strip().lower())
|
|
617
|
+
if not suffix:
|
|
618
|
+
valid = sorted(_AUDIO_SYNC_MODE_SUFFIXES.keys())
|
|
619
|
+
return None, {"error": f"Unknown sync_mode '{sync_mode}'. Valid: {valid}"}
|
|
620
|
+
settings[resolve_obj.AUDIO_SYNC_MODE] = getattr(resolve_obj, f"AUDIO_SYNC_{suffix}")
|
|
621
|
+
if channel_number is not None:
|
|
622
|
+
if isinstance(channel_number, str):
|
|
623
|
+
special = _AUDIO_SYNC_CHANNEL_SPECIAL.get(channel_number.strip().lower())
|
|
624
|
+
if not special:
|
|
625
|
+
return None, {"error": f"Unknown channel_number '{channel_number}'. Use an int >= 1 or 'automatic'/'mix'."}
|
|
626
|
+
settings[resolve_obj.AUDIO_SYNC_CHANNEL_NUMBER] = getattr(resolve_obj, f"AUDIO_SYNC_{special}")
|
|
627
|
+
elif isinstance(channel_number, int):
|
|
628
|
+
settings[resolve_obj.AUDIO_SYNC_CHANNEL_NUMBER] = channel_number
|
|
629
|
+
else:
|
|
630
|
+
return None, {"error": f"channel_number must be int >= 1 or 'automatic'/'mix', got {type(channel_number).__name__}"}
|
|
631
|
+
if retain_embedded_audio is not None:
|
|
632
|
+
settings[resolve_obj.AUDIO_SYNC_RETAIN_EMBEDDED_AUDIO] = bool(retain_embedded_audio)
|
|
633
|
+
if retain_video_metadata is not None:
|
|
634
|
+
settings[resolve_obj.AUDIO_SYNC_RETAIN_VIDEO_METADATA] = bool(retain_video_metadata)
|
|
635
|
+
return settings, None
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _build_create_clip_info_dict(root, ci, index, timeline_start_frame=None):
|
|
639
|
+
"""Build one MediaPool.CreateTimelineFromClips clipInfo map.
|
|
640
|
+
|
|
641
|
+
See docs/reference/resolve_scripting_api.txt line 224: 4 keys — mediaPoolItem,
|
|
642
|
+
startFrame, endFrame, recordFrame.
|
|
643
|
+
"""
|
|
644
|
+
if not isinstance(ci, dict):
|
|
645
|
+
return None, {"error": f"clip_infos[{index}] must be an object"}
|
|
646
|
+
cid = ci.get("clip_id") or ci.get("media_pool_item_id")
|
|
647
|
+
if not cid:
|
|
648
|
+
return None, {"error": f"clip_infos[{index}] requires clip_id or media_pool_item_id"}
|
|
649
|
+
mp_item = _find_clip_by_id(root, cid)
|
|
650
|
+
if not mp_item:
|
|
651
|
+
return None, {"error": f"clip_infos[{index}]: media pool clip not found: {cid}"}
|
|
652
|
+
sf = ci.get("startFrame", ci.get("start_frame"))
|
|
653
|
+
ef = ci.get("endFrame", ci.get("end_frame"))
|
|
654
|
+
if sf is None or ef is None:
|
|
655
|
+
return None, {"error": f"clip_infos[{index}] requires start_frame/startFrame and end_frame/endFrame"}
|
|
656
|
+
rf = ci.get("recordFrame", ci.get("record_frame"))
|
|
657
|
+
if rf is None:
|
|
658
|
+
return None, {"error": f"clip_infos[{index}] requires record_frame/recordFrame"}
|
|
659
|
+
rf, rf_err = _normalize_record_frame(ci, index, timeline_start_frame)
|
|
660
|
+
if rf_err:
|
|
661
|
+
return None, rf_err
|
|
662
|
+
return {
|
|
663
|
+
"mediaPoolItem": mp_item,
|
|
664
|
+
"startFrame": sf,
|
|
665
|
+
"endFrame": ef,
|
|
666
|
+
"recordFrame": rf,
|
|
667
|
+
}, None
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _find_clips_by_ids(folder, ids_set):
|
|
671
|
+
found = []
|
|
672
|
+
for clip in (folder.GetClipList() or []):
|
|
673
|
+
if clip.GetUniqueId() in ids_set:
|
|
674
|
+
found.append(clip)
|
|
675
|
+
for sub in (folder.GetSubFolderList() or []):
|
|
676
|
+
found.extend(_find_clips_by_ids(sub, ids_set))
|
|
677
|
+
return found
|
|
678
|
+
|
|
679
|
+
def _navigate_to_folder(mp, folder_path):
|
|
680
|
+
root = mp.GetRootFolder()
|
|
681
|
+
if not folder_path or folder_path in ("Master", "/", ""):
|
|
682
|
+
return root
|
|
683
|
+
parts = folder_path.strip("/").split("/")
|
|
684
|
+
if parts[0] == "Master":
|
|
685
|
+
parts = parts[1:]
|
|
686
|
+
current = root
|
|
687
|
+
for part in parts:
|
|
688
|
+
found = False
|
|
689
|
+
for sub in (current.GetSubFolderList() or []):
|
|
690
|
+
if sub.GetName() == part:
|
|
691
|
+
current = sub
|
|
692
|
+
found = True
|
|
693
|
+
break
|
|
694
|
+
if not found:
|
|
695
|
+
return None
|
|
696
|
+
return current
|
|
697
|
+
|
|
698
|
+
def _get_timeline():
|
|
699
|
+
resolve = get_resolve()
|
|
700
|
+
if resolve is None:
|
|
701
|
+
return None, None, {"error": "Not connected to DaVinci Resolve"}
|
|
702
|
+
project = resolve.GetProjectManager().GetCurrentProject()
|
|
703
|
+
if not project:
|
|
704
|
+
return None, None, {"error": "No project currently open"}
|
|
705
|
+
tl = project.GetCurrentTimeline()
|
|
706
|
+
if not tl:
|
|
707
|
+
return project, None, {"error": "No current timeline"}
|
|
708
|
+
return project, tl, None
|
|
709
|
+
|
|
710
|
+
def _get_timeline_item(track_type="video", track_index=1, item_index=0):
|
|
711
|
+
_, tl, err = _get_timeline()
|
|
712
|
+
if err:
|
|
713
|
+
return None, err
|
|
714
|
+
items = tl.GetItemListInTrack(track_type, track_index)
|
|
715
|
+
if not items or item_index >= len(items):
|
|
716
|
+
return None, {"error": f"No item at index {item_index} on {track_type} track {track_index}"}
|
|
717
|
+
return items[item_index], None
|
|
718
|
+
|
|
719
|
+
def _has_method(obj, method_name):
|
|
720
|
+
return callable(getattr(obj, method_name, None))
|
|
721
|
+
|
|
722
|
+
def _requires_method(obj, method_name, min_version):
|
|
723
|
+
if _has_method(obj, method_name):
|
|
724
|
+
return None
|
|
725
|
+
return {"error": f"{method_name} requires DaVinci Resolve {min_version}+"}
|
|
726
|
+
|
|
727
|
+
__all__ = [name for name in globals() if not name.startswith("__")]
|