davinci-resolve-mcp 2.68.1 → 2.68.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +72 -0
- package/README.md +1 -1
- package/install.py +1 -1
- package/package.json +1 -1
- package/scripts/bridge_differential.py +22 -4
- package/scripts/install_resolve_bridge.py +13 -3
- package/src/granular/common.py +1 -1
- package/src/server.py +118 -16
- package/src/utils/bridge_differential.py +27 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,78 @@
|
|
|
2
2
|
|
|
3
3
|
Release history for the DaVinci Resolve MCP Server. The latest release is summarized in the root README; older entries live here to keep the README focused.
|
|
4
4
|
|
|
5
|
+
## What's New in v2.68.2
|
|
6
|
+
|
|
7
|
+
Bug fixes. The server no longer opens a second DaVinci Resolve, and a failed
|
|
8
|
+
connection now tells the caller what is actually wrong.
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **The server launched Resolve when one was already running — usually the wrong
|
|
13
|
+
one.** The free edition refuses external scripting by design, so on a machine
|
|
14
|
+
where only it is running `scriptapp("Resolve")` always returns None.
|
|
15
|
+
`get_resolve()` read that as *"Resolve is not running"* and ran `open` on the
|
|
16
|
+
application; with both editions installed that started **Studio**, a second and
|
|
17
|
+
different application, on every tool call.
|
|
18
|
+
|
|
19
|
+
`resolve_is_running()` now answers the question that was being assumed. It
|
|
20
|
+
returns None when it cannot tell, deliberately — "cannot tell" must never decay
|
|
21
|
+
into "nothing is running", because that is the answer that leads to launching
|
|
22
|
+
something. A genuinely absent Resolve is still launched, as documented, and the
|
|
23
|
+
macOS candidate list now contains **both** editions rather than only the
|
|
24
|
+
installer path.
|
|
25
|
+
- **The error a caller received described something that had not happened.**
|
|
26
|
+
Eleven sites asserted that Resolve was not running, that starting it had been
|
|
27
|
+
tried and failed, and that the reader should check their Studio install. After
|
|
28
|
+
the fix above none of that was true, and the accurate guidance was only reaching
|
|
29
|
+
the log. `_not_connected_error()` derives the message from the situation and
|
|
30
|
+
names the applicable remedy — external scripting on Studio, or the in-app bridge
|
|
31
|
+
on the free edition, including the framework-Python prerequisite. Three codes:
|
|
32
|
+
`SCRIPTING_UNAVAILABLE`, `BRIDGE_UNAVAILABLE`, `RESOLVE_NOT_RUNNING`.
|
|
33
|
+
- **Those two are marked `retryable: false`.** The `not_connected` category
|
|
34
|
+
defaults to retryable because auto-launch may succeed next time; neither of
|
|
35
|
+
these can — one needs a preference changed, the other a script started inside
|
|
36
|
+
Resolve — and reporting them as retryable sends an agent into a loop it cannot
|
|
37
|
+
win.
|
|
38
|
+
- **The offline test suite opened Resolve, and connected to whatever was running.**
|
|
39
|
+
A stub-based audio test reached for `AUDIO_SYNC_*`, which are attributes on the
|
|
40
|
+
*live* object, so it called `get_resolve()`, found nothing, and started Resolve;
|
|
41
|
+
when Resolve *was* open it connected instead, making the test's result depend on
|
|
42
|
+
the state of the machine. `tests/conftest.py` closes both suite-wide and names
|
|
43
|
+
any launch attempt in the terminal summary.
|
|
44
|
+
- **`src/server.py` reported the wrong tool count to every agent.** Its module
|
|
45
|
+
docstring said "34 compound tools" while the agent-facing workflow prompt and
|
|
46
|
+
the startup log line said 32. The drift guard only required the correct number
|
|
47
|
+
to appear *somewhere* in the file, so the wrong one shipped. It now rejects any
|
|
48
|
+
other count in front of that phrase, and counts decorators from the parsed
|
|
49
|
+
syntax tree rather than by matching text — a docstring mentioning
|
|
50
|
+
`@mcp.tool()` had been counted as a 35th tool.
|
|
51
|
+
- **The bridge harness graded the wrong clip and reported a known trap as a
|
|
52
|
+
finding.** It took the first video item, which is often a generator or title
|
|
53
|
+
that has no MediaPoolItem and answers None to every node query, so the grading
|
|
54
|
+
surface read as unreachable; it now takes the first gradeable item. And it
|
|
55
|
+
passed the codec *description* `"H.264"` to `SetCurrentRenderFormatAndCodec`,
|
|
56
|
+
which only accepts the id `"H264"`, making that observation permanently false
|
|
57
|
+
for a documented reason.
|
|
58
|
+
|
|
59
|
+
### Changed
|
|
60
|
+
|
|
61
|
+
- `scripts/install_resolve_bridge.py` no longer claims Resolve ignores Fusion's
|
|
62
|
+
standalone script tree. Measured on free 21.0.3.7: markers left in two
|
|
63
|
+
undocumented container paths both appeared under Workspace ▸ Scripts, so Lite
|
|
64
|
+
scans more locations than the README documents. Install behaviour is unchanged —
|
|
65
|
+
the documented paths work, and each extra target is another chance for the macOS
|
|
66
|
+
App Management prompt to stall the copy — but the stated reason is now the true
|
|
67
|
+
one.
|
|
68
|
+
|
|
69
|
+
### Validated
|
|
70
|
+
|
|
71
|
+
Free edition alone (21.0.3.7, App Store), Studio never launched at any point,
|
|
72
|
+
confirmed by process check after every stage: 165/165 read-shaped MCP actions
|
|
73
|
+
clean with Blackmagic's module blocked; 109/112 API read methods exercised; render
|
|
74
|
+
to a non-empty file with `set_format` true; AAF/DRT/EDL/FCPXML all written;
|
|
75
|
+
SetLUT clears and reads back. Suite 1913 passing.
|
|
76
|
+
|
|
5
77
|
## What's New in v2.68.1
|
|
6
78
|
|
|
7
79
|
Bug fix. A missing tool argument now returns the structured error envelope the
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# DaVinci Resolve MCP Server
|
|
2
2
|
|
|
3
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
4
4
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
5
5
|
[](docs/reference/api-coverage.md)
|
|
6
6
|
[-blue.svg)](#server-modes)
|
package/install.py
CHANGED
|
@@ -36,7 +36,7 @@ from src.utils.update_check import (
|
|
|
36
36
|
|
|
37
37
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
38
38
|
|
|
39
|
-
VERSION = "2.68.
|
|
39
|
+
VERSION = "2.68.2"
|
|
40
40
|
# Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
|
|
41
41
|
# Resolve's scripting bridge loads into newer interpreters on recent builds
|
|
42
42
|
# (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
|
package/package.json
CHANGED
|
@@ -75,11 +75,21 @@ def scenario_render(resolve: Any, workspace: Path) -> Dict[str, Any]:
|
|
|
75
75
|
observations: Dict[str, Any] = {}
|
|
76
76
|
formats = project.GetRenderFormats() or {}
|
|
77
77
|
observations["format_count"] = len(formats)
|
|
78
|
-
|
|
78
|
+
codecs = project.GetRenderCodecs("mov") or {}
|
|
79
|
+
observations["codec_count_for_mov"] = len(codecs)
|
|
79
80
|
|
|
80
81
|
output = workspace / "bridge_render_probe"
|
|
81
82
|
output.mkdir(parents=True, exist_ok=True)
|
|
82
|
-
|
|
83
|
+
# `GetRenderCodecs` returns {description: id} and only the **id** is accepted.
|
|
84
|
+
# Passing the description shown in the Deliver page returns False — verified
|
|
85
|
+
# again here on free 21.0.3.7: 'H.264' -> False, 'H264' -> True. Feeding it the
|
|
86
|
+
# description made this observation permanently False for a documented reason,
|
|
87
|
+
# which is a harness reporting a known trap as if it were a finding.
|
|
88
|
+
codec_id = codecs.get("H.264") or next(iter(codecs.values()), None)
|
|
89
|
+
observations["codec_id"] = codec_id
|
|
90
|
+
observations["set_format"] = (
|
|
91
|
+
project.SetCurrentRenderFormatAndCodec("mov", codec_id) if codec_id else False
|
|
92
|
+
)
|
|
83
93
|
observations["set_settings"] = project.SetRenderSettings({
|
|
84
94
|
"TargetDir": str(output),
|
|
85
95
|
"CustomName": "bridge_render_probe",
|
|
@@ -147,8 +157,16 @@ def scenario_set_lut(resolve: Any, _workspace: Path) -> Dict[str, Any]:
|
|
|
147
157
|
items = timeline.GetItemListInTrack("video", 1) or []
|
|
148
158
|
if not items:
|
|
149
159
|
return {"skipped": "no clip on video track 1"}
|
|
150
|
-
|
|
151
|
-
|
|
160
|
+
# Not items[0]: a generator or title has no MediaPoolItem and answers None to
|
|
161
|
+
# every node question, so grading it silently produces a row of nulls that
|
|
162
|
+
# reads like a bridge failure. Measured on a real timeline — SMPTE Color Bar
|
|
163
|
+
# returns None for GetNumNodes and GetNodeGraph, the clip beside it returns 1
|
|
164
|
+
# and a live graph.
|
|
165
|
+
item = next((i for i in items if i.GetMediaPoolItem()), None)
|
|
166
|
+
if item is None:
|
|
167
|
+
return {"skipped": "video track 1 holds only generators/titles, which cannot be graded",
|
|
168
|
+
"items_seen": len(items)}
|
|
169
|
+
observations: Dict[str, Any] = {"clip": item.GetName(), "node_count": item.GetNumNodes()}
|
|
152
170
|
original = item.GetLUT(1)
|
|
153
171
|
observations["lut_before"] = original
|
|
154
172
|
# An empty path is the documented way to clear a node's LUT, so it is a
|
|
@@ -37,9 +37,9 @@ _SCRIPTS_SUFFIX = "Library/Application Support/Blackmagic Design/DaVinci Resolve
|
|
|
37
37
|
#: Two traps, both hit on a real install:
|
|
38
38
|
#:
|
|
39
39
|
#: 1. `<container>/Data/Library/Application Support/Fusion/Scripts/Utility`
|
|
40
|
-
#: exists and is pre-scaffolded with Color/Comp/Deliver/Edit/Tool/Utility
|
|
41
|
-
#:
|
|
42
|
-
#:
|
|
40
|
+
#: exists and is pre-scaffolded with Color/Comp/Deliver/Edit/Tool/Utility, but
|
|
41
|
+
#: it is **Fusion's standalone tree**, not Resolve's. Keying on "which
|
|
42
|
+
#: directory already exists" picks it over the one the README documents.
|
|
43
43
|
#: 2. Resolve does not create its own `Fusion/Scripts` tree until a script is
|
|
44
44
|
#: installed, so requiring the target to pre-exist skips the free edition
|
|
45
45
|
#: entirely — the one build the bridge exists for.
|
|
@@ -47,6 +47,16 @@ _SCRIPTS_SUFFIX = "Library/Application Support/Blackmagic Design/DaVinci Resolve
|
|
|
47
47
|
#: So: a container that exists means the edition is installed, and the tree is
|
|
48
48
|
#: *created*. Blackmagic's README is the authority on the suffix, not the
|
|
49
49
|
#: filesystem.
|
|
50
|
+
#:
|
|
51
|
+
#: **Correction, measured on free 21.0.3.7:** an earlier version of trap 1 said
|
|
52
|
+
#: Resolve "does not scan" the Fusion standalone tree. It does — a marker left in
|
|
53
|
+
#: `<container>/Data/Library/Application Support/Fusion/Scripts/Utility` appeared
|
|
54
|
+
#: under Workspace ▸ Scripts, as did one in
|
|
55
|
+
#: `<container>/Data/Documents/Blackmagic Design/DaVinci Resolve/Fusion/Scripts/Utility`.
|
|
56
|
+
#: So Lite scans more locations than the README documents. That changes nothing
|
|
57
|
+
#: about *where to install* — the documented paths work, and each extra target is
|
|
58
|
+
#: another chance for the App Management prompt to stall the copy — but the reason
|
|
59
|
+
#: to prefer them is that they are documented, not that the others are dead.
|
|
50
60
|
_SANDBOX_MARKER = "com.blackmagic-design."
|
|
51
61
|
#: Containers that are not Resolve (RAW Player, Speed Test, the IO XPC helper).
|
|
52
62
|
_NON_RESOLVE_CONTAINERS = ("BlackmagicRaw", "IOXPC")
|
package/src/granular/common.py
CHANGED
|
@@ -85,7 +85,7 @@ if not logging.getLogger().handlers:
|
|
|
85
85
|
handlers=[logging.StreamHandler()],
|
|
86
86
|
)
|
|
87
87
|
|
|
88
|
-
VERSION = "2.68.
|
|
88
|
+
VERSION = "2.68.2"
|
|
89
89
|
logger = logging.getLogger("davinci-resolve-mcp")
|
|
90
90
|
logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
|
|
91
91
|
logger.info(f"Detected platform: {get_platform()}")
|
package/src/server.py
CHANGED
|
@@ -11,7 +11,7 @@ Usage:
|
|
|
11
11
|
python src/server.py --full # Start the 341-tool granular server instead
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
|
-
VERSION = "2.68.
|
|
14
|
+
VERSION = "2.68.2"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -296,7 +296,7 @@ def davinci_resolve_workflow() -> str:
|
|
|
296
296
|
return """Use this DaVinci Resolve MCP server as a guarded post-production control surface.
|
|
297
297
|
|
|
298
298
|
Core pattern:
|
|
299
|
-
- Prefer the
|
|
299
|
+
- Prefer the 34 compound tools and their action names over raw scripting.
|
|
300
300
|
- Start by probing state: resolve_control.get_version/get_page, project_manager.get_current, timeline.get_current, and media_pool.probe_media_pool.
|
|
301
301
|
- Before mutating timelines, media pools, render settings, grades, projects, databases, or extensions, prefer the matching probe, capabilities, boundary_report, safe_*, or dry_run action when one exists.
|
|
302
302
|
- Preserve source media integrity. Never transcode, proxy, rewrite, move, rename, or create derivatives of source media unless the user explicitly asks. Analysis output belongs in sidecars or analysis directories.
|
|
@@ -899,6 +899,37 @@ def _launch_resolve():
|
|
|
899
899
|
logger.warning("Resolve did not respond within 60s after launch")
|
|
900
900
|
return False
|
|
901
901
|
|
|
902
|
+
#: Process names that mean a DaVinci Resolve application is already running.
|
|
903
|
+
#: Deliberately not the full path: the App Store build lives at
|
|
904
|
+
#: `/Applications/DaVinci Resolve.app` and the installer build inside
|
|
905
|
+
#: `/Applications/DaVinci Resolve/`, and either one counts.
|
|
906
|
+
_RESOLVE_PROCESS_PATTERNS = (
|
|
907
|
+
"DaVinci Resolve.app/Contents/MacOS/Resolve", # macOS, both editions
|
|
908
|
+
"Resolve.exe", # Windows
|
|
909
|
+
"/opt/resolve/bin/resolve", # Linux
|
|
910
|
+
)
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def resolve_is_running() -> Optional[bool]:
|
|
914
|
+
"""Is a Resolve application already up? None when it cannot be determined.
|
|
915
|
+
|
|
916
|
+
Best-effort and dependency-free. `None` rather than `False` on failure, so an
|
|
917
|
+
unanswerable question never silently becomes "nothing is running" — which is
|
|
918
|
+
the answer that leads to launching something.
|
|
919
|
+
"""
|
|
920
|
+
try:
|
|
921
|
+
if platform.system().lower() == "windows":
|
|
922
|
+
out = subprocess.run(["tasklist"], capture_output=True, text=True, timeout=10, check=False)
|
|
923
|
+
else:
|
|
924
|
+
out = subprocess.run(["ps", "-Ao", "command="], capture_output=True, text=True, timeout=10, check=False)
|
|
925
|
+
if out.returncode != 0 and not out.stdout:
|
|
926
|
+
return None
|
|
927
|
+
listing = out.stdout or ""
|
|
928
|
+
except Exception: # pragma: no cover - defensive; an unknown answer is None
|
|
929
|
+
return None
|
|
930
|
+
return any(pattern in listing for pattern in _RESOLVE_PROCESS_PATTERNS)
|
|
931
|
+
|
|
932
|
+
|
|
902
933
|
def get_resolve():
|
|
903
934
|
"""Lazy connection to Resolve — connects on first tool call, auto-launches if needed."""
|
|
904
935
|
global resolve
|
|
@@ -909,12 +940,84 @@ def get_resolve():
|
|
|
909
940
|
# Try to connect to an already-running Resolve.
|
|
910
941
|
if _try_connect():
|
|
911
942
|
return resolve
|
|
912
|
-
#
|
|
943
|
+
# Failing to connect is NOT the same as nothing running, and conflating
|
|
944
|
+
# them opened an application nobody asked for. The free edition refuses
|
|
945
|
+
# external scripting by design, so on a machine where only it is running
|
|
946
|
+
# `_try_connect` always fails — and this then launched *Studio*, a second,
|
|
947
|
+
# different application, on every tool call. Reported three times before
|
|
948
|
+
# it was traced.
|
|
949
|
+
already_running = resolve_is_running()
|
|
950
|
+
if already_running:
|
|
951
|
+
logger.error(
|
|
952
|
+
"DaVinci Resolve is already running but is not answering the scripting API, "
|
|
953
|
+
"so it will NOT be launched again. Either enable Preferences > General > "
|
|
954
|
+
"'External scripting using' = Local (Studio only), or, on the free edition, "
|
|
955
|
+
"use the in-app bridge: install it, run Workspace > Scripts > resolve_bridge, "
|
|
956
|
+
"and set DAVINCI_RESOLVE_BRIDGE=1."
|
|
957
|
+
)
|
|
958
|
+
return None
|
|
959
|
+
if already_running is None:
|
|
960
|
+
logger.warning("Could not determine whether Resolve is running; not launching it.")
|
|
961
|
+
return None
|
|
913
962
|
logger.info("Resolve not running, attempting to launch automatically...")
|
|
914
963
|
_launch_resolve()
|
|
915
964
|
return resolve
|
|
916
965
|
|
|
917
966
|
|
|
967
|
+
def _not_connected_error():
|
|
968
|
+
"""The caller-facing "no Resolve" error, describing what is actually the case.
|
|
969
|
+
|
|
970
|
+
Eleven call sites used to assert that Resolve was not running, that starting it
|
|
971
|
+
had been tried and failed, and that the reader should check their Studio
|
|
972
|
+
install. Once `get_resolve` stopped launching a second application, all three
|
|
973
|
+
claims were wrong: nothing was launched, Resolve *is* running, and a
|
|
974
|
+
free-edition user was being sent to check an install they may not have. The
|
|
975
|
+
accurate guidance was only reaching the log.
|
|
976
|
+
|
|
977
|
+
So the message is derived from the situation instead of asserted, and it names
|
|
978
|
+
the fix that applies: enable external scripting on Studio, or use the in-app
|
|
979
|
+
bridge on the free edition.
|
|
980
|
+
"""
|
|
981
|
+
running = resolve_is_running()
|
|
982
|
+
bridge_on = _bridge_requested()
|
|
983
|
+
if bridge_on:
|
|
984
|
+
return _err(
|
|
985
|
+
"The in-app bridge is enabled but not answering.",
|
|
986
|
+
code="BRIDGE_UNAVAILABLE", category="not_connected",
|
|
987
|
+
# `not_connected` defaults to retryable because auto-launch may
|
|
988
|
+
# succeed next time. Nothing here will: the listener only appears once
|
|
989
|
+
# someone runs the script inside Resolve, so an agent retrying on a
|
|
990
|
+
# loop it cannot win is strictly worse than being told to stop.
|
|
991
|
+
retryable=False,
|
|
992
|
+
reason="DAVINCI_RESOLVE_BRIDGE is set, so no other transport is tried.",
|
|
993
|
+
remediation="In Resolve, run Workspace > Scripts > resolve_bridge. If it is not in "
|
|
994
|
+
"that menu, run `python scripts/install_resolve_bridge.py` and restart "
|
|
995
|
+
"Resolve; a framework Python from python.org is required for Resolve to "
|
|
996
|
+
"list .py scripts at all.",
|
|
997
|
+
state={"resolve_running": running, "bridge_enabled": True},
|
|
998
|
+
)
|
|
999
|
+
if running:
|
|
1000
|
+
return _err(
|
|
1001
|
+
"DaVinci Resolve is running but is not answering the scripting API.",
|
|
1002
|
+
code="SCRIPTING_UNAVAILABLE", category="not_connected",
|
|
1003
|
+
# Not retryable: a preference has to change, or the bridge has to be
|
|
1004
|
+
# started. Retrying the same call cannot make either happen.
|
|
1005
|
+
retryable=False,
|
|
1006
|
+
reason="External scripting is a Studio feature; the free edition refuses it "
|
|
1007
|
+
"regardless of the preference. Resolve was NOT launched again.",
|
|
1008
|
+
remediation="On Studio: Preferences > General > 'External scripting using' = Local. "
|
|
1009
|
+
"On the free edition: install the in-app bridge, run "
|
|
1010
|
+
"Workspace > Scripts > resolve_bridge, and set DAVINCI_RESOLVE_BRIDGE=1.",
|
|
1011
|
+
state={"resolve_running": True, "bridge_enabled": False},
|
|
1012
|
+
)
|
|
1013
|
+
return _err(
|
|
1014
|
+
"DaVinci Resolve is not running and could not be started.",
|
|
1015
|
+
code="RESOLVE_NOT_RUNNING", category="not_connected",
|
|
1016
|
+
remediation="Start DaVinci Resolve and open a project, then retry.",
|
|
1017
|
+
state={"resolve_running": bool(running), "bridge_enabled": False},
|
|
1018
|
+
)
|
|
1019
|
+
|
|
1020
|
+
|
|
918
1021
|
def _destructive_versioning_provider() -> Optional[Tuple[Any, Any, str, Optional[str]]]:
|
|
919
1022
|
"""Provider used by the C6 version-on-mutate hook.
|
|
920
1023
|
|
|
@@ -13065,11 +13168,11 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
13065
13168
|
r = get_resolve() # auto-launches if not running
|
|
13066
13169
|
if r is not None:
|
|
13067
13170
|
return _ok(message="DaVinci Resolve is running and connected.")
|
|
13068
|
-
return
|
|
13171
|
+
return _not_connected_error()
|
|
13069
13172
|
|
|
13070
13173
|
r = get_resolve() # auto-launches if not running
|
|
13071
13174
|
if r is None:
|
|
13072
|
-
return
|
|
13175
|
+
return _not_connected_error()
|
|
13073
13176
|
|
|
13074
13177
|
if action == "get_version":
|
|
13075
13178
|
update_env = _setup_update_env()
|
|
@@ -13992,7 +14095,7 @@ def layout_presets(action: str, params: Optional[Dict[str, Any]] = None) -> Dict
|
|
|
13992
14095
|
p = _params(params)
|
|
13993
14096
|
r = get_resolve()
|
|
13994
14097
|
if r is None:
|
|
13995
|
-
return
|
|
14098
|
+
return _not_connected_error()
|
|
13996
14099
|
|
|
13997
14100
|
if action == "save":
|
|
13998
14101
|
if not p.get("name"):
|
|
@@ -14037,7 +14140,7 @@ def render_presets(action: str, params: Optional[Dict[str, Any]] = None) -> Dict
|
|
|
14037
14140
|
p = _params(params)
|
|
14038
14141
|
r = get_resolve()
|
|
14039
14142
|
if r is None:
|
|
14040
|
-
return
|
|
14143
|
+
return _not_connected_error()
|
|
14041
14144
|
|
|
14042
14145
|
if action == "import_render":
|
|
14043
14146
|
return {"success": bool(r.ImportRenderPreset(p["path"]))}
|
|
@@ -14980,7 +15083,7 @@ def project_manager(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
14980
15083
|
p = _params(params)
|
|
14981
15084
|
r = get_resolve()
|
|
14982
15085
|
if r is None:
|
|
14983
|
-
return
|
|
15086
|
+
return _not_connected_error()
|
|
14984
15087
|
pm = r.GetProjectManager()
|
|
14985
15088
|
|
|
14986
15089
|
if action == "lint":
|
|
@@ -15100,7 +15203,7 @@ def project_manager_folders(action: str, params: Optional[Dict[str, Any]] = None
|
|
|
15100
15203
|
p = _params(params)
|
|
15101
15204
|
r = get_resolve()
|
|
15102
15205
|
if r is None:
|
|
15103
|
-
return
|
|
15206
|
+
return _not_connected_error()
|
|
15104
15207
|
pm = r.GetProjectManager()
|
|
15105
15208
|
|
|
15106
15209
|
if action == "list":
|
|
@@ -15142,7 +15245,7 @@ def project_manager_cloud(action: str, params: Optional[Dict[str, Any]] = None)
|
|
|
15142
15245
|
p = _params(params)
|
|
15143
15246
|
r = get_resolve()
|
|
15144
15247
|
if r is None:
|
|
15145
|
-
return
|
|
15248
|
+
return _not_connected_error()
|
|
15146
15249
|
pm = r.GetProjectManager()
|
|
15147
15250
|
|
|
15148
15251
|
# cloudSettings is enum-keyed (CLOUD_SETTING_*/CLOUD_SYNC_*); resolve string
|
|
@@ -15180,7 +15283,7 @@ def project_manager_database(action: str, params: Optional[Dict[str, Any]] = Non
|
|
|
15180
15283
|
p = _params(params)
|
|
15181
15284
|
r = get_resolve()
|
|
15182
15285
|
if r is None:
|
|
15183
|
-
return
|
|
15286
|
+
return _not_connected_error()
|
|
15184
15287
|
pm = r.GetProjectManager()
|
|
15185
15288
|
|
|
15186
15289
|
if action == "get_current":
|
|
@@ -16190,7 +16293,7 @@ def media_storage(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[
|
|
|
16190
16293
|
p = _params(params)
|
|
16191
16294
|
r = get_resolve()
|
|
16192
16295
|
if r is None:
|
|
16193
|
-
return
|
|
16296
|
+
return _not_connected_error()
|
|
16194
16297
|
ms = r.GetMediaStorage()
|
|
16195
16298
|
|
|
16196
16299
|
if action == "get_volumes":
|
|
@@ -24936,8 +25039,7 @@ def _execute_python_script(path: str, args: List[str],
|
|
|
24936
25039
|
def _execute_lua_script(path: str) -> Dict[str, Any]:
|
|
24937
25040
|
r = get_resolve()
|
|
24938
25041
|
if r is None:
|
|
24939
|
-
return
|
|
24940
|
-
"auto-launch failed.")
|
|
25042
|
+
return _not_connected_error()
|
|
24941
25043
|
fusion = r.Fusion()
|
|
24942
25044
|
if fusion is None:
|
|
24943
25045
|
return _err("handle.Fusion() returned None — cannot run Lua scripts.")
|
|
@@ -25002,7 +25104,7 @@ def _run_inline_lua(source: str) -> Dict[str, Any]:
|
|
|
25002
25104
|
"""
|
|
25003
25105
|
r = get_resolve()
|
|
25004
25106
|
if r is None:
|
|
25005
|
-
return
|
|
25107
|
+
return _not_connected_error()
|
|
25006
25108
|
fusion = r.Fusion()
|
|
25007
25109
|
if fusion is None:
|
|
25008
25110
|
return _err("handle.Fusion() returned None — cannot run inline Lua.")
|
|
@@ -26112,5 +26214,5 @@ if __name__ == "__main__":
|
|
|
26112
26214
|
logger.error(f"Unknown --transport {transport!r}; use stdio|sse|streamable-http")
|
|
26113
26215
|
sys.exit(2)
|
|
26114
26216
|
|
|
26115
|
-
logger.info(
|
|
26217
|
+
logger.info("Starting DaVinci Resolve MCP Server (34 compound tools)")
|
|
26116
26218
|
run_fastmcp_stdio(mcp)
|
|
@@ -182,12 +182,22 @@ def probe_surface(roots: Sequence[str] = SOURCE_ROOTS) -> Dict[str, Any]:
|
|
|
182
182
|
#: MediaPoolItem or Graph — which between them carry most of the read surface
|
|
183
183
|
#: worth qualifying (LUTs, node graphs, source timings, metadata).
|
|
184
184
|
_INDEX = "[]"
|
|
185
|
+
#: Pick the first element of a returned list for which `method()` is truthy.
|
|
186
|
+
#: Indexing blindly is not good enough: a timeline's first video item is often a
|
|
187
|
+
#: generator or a title, which has no MediaPoolItem and answers None to every
|
|
188
|
+
#: node-graph question. Measured on a real timeline — `GetNumNodes` and
|
|
189
|
+
#: `GetNodeGraph` both None on an SMPTE Color Bar, and 1 and a live graph on the
|
|
190
|
+
#: clip sitting next to it. Taking index 0 therefore reported the whole grading
|
|
191
|
+
#: surface as unreachable, which is the silent coverage gap this module exists to
|
|
192
|
+
#: refuse.
|
|
193
|
+
_FIRST_WITH = "[first-with]"
|
|
185
194
|
|
|
186
195
|
_PM = ("GetProjectManager", ())
|
|
187
196
|
_PROJECT = (_PM, ("GetCurrentProject", ()))
|
|
188
197
|
_POOL = _PROJECT + (("GetMediaPool", ()),)
|
|
189
198
|
_TIMELINE = _PROJECT + (("GetCurrentTimeline", ()),)
|
|
190
|
-
_TIMELINE_ITEM = _TIMELINE + (("GetItemListInTrack", ("video", 1)),
|
|
199
|
+
_TIMELINE_ITEM = _TIMELINE + (("GetItemListInTrack", ("video", 1)),
|
|
200
|
+
(_FIRST_WITH, ("GetMediaPoolItem",)))
|
|
191
201
|
_GALLERY = _PROJECT + (("GetGallery", ()),)
|
|
192
202
|
|
|
193
203
|
OBJECT_GRAPH: Tuple[Tuple[str, Tuple[Any, ...]], ...] = (
|
|
@@ -229,6 +239,22 @@ def walk(resolve: Any, chain: Iterable[Tuple[str, Tuple[Any, ...]]]) -> Any:
|
|
|
229
239
|
return None
|
|
230
240
|
current = current[index]
|
|
231
241
|
continue
|
|
242
|
+
if method == _FIRST_WITH:
|
|
243
|
+
if not isinstance(current, (list, tuple)):
|
|
244
|
+
return None
|
|
245
|
+
probe = args[0]
|
|
246
|
+
chosen = None
|
|
247
|
+
for candidate in current:
|
|
248
|
+
try:
|
|
249
|
+
if getattr(candidate, probe, lambda: None)():
|
|
250
|
+
chosen = candidate
|
|
251
|
+
break
|
|
252
|
+
except Exception: # noqa: BLE001 - an unusable candidate, not a verdict
|
|
253
|
+
continue
|
|
254
|
+
if chosen is None:
|
|
255
|
+
return None
|
|
256
|
+
current = chosen
|
|
257
|
+
continue
|
|
232
258
|
function = getattr(current, method, None)
|
|
233
259
|
if function is None:
|
|
234
260
|
return None
|