davinci-resolve-mcp 2.33.6 → 2.33.8
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 +26 -0
- package/README.md +1 -1
- package/docs/SKILL.md +1 -1
- package/docs/reference/readwrite-symmetry.md +25 -0
- package/install.py +1 -1
- package/package.json +1 -1
- package/scripts/audit_readwrite_symmetry.py +80 -0
- package/scripts/measure_bridge_cost.py +65 -0
- package/src/granular/common.py +1 -1
- package/src/server.py +10 -2
- package/src/utils/bridge_metrics.py +76 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,32 @@
|
|
|
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.33.8
|
|
6
|
+
|
|
7
|
+
Bridge-call performance instrumentation.
|
|
8
|
+
|
|
9
|
+
- **Added** `src/utils/bridge_metrics.py` — a counting proxy that wraps a Resolve
|
|
10
|
+
handle and tallies attribute accesses and method calls (each a COM/socket
|
|
11
|
+
round-trip), so the real bridge cost of an operation is measured rather than
|
|
12
|
+
guessed.
|
|
13
|
+
- **Added** `scripts/measure_bridge_cost.py` — runs a representative media-pool
|
|
14
|
+
traversal through the proxy and reports round-trips per clip. A minimal
|
|
15
|
+
name+type walk measured ~6.7 round-trips per clip, confirming round-trips scale
|
|
16
|
+
linearly with traversal size. A property cache remains gated on profiling
|
|
17
|
+
*repeated*-read patterns in real workflows (don't cache blind).
|
|
18
|
+
|
|
19
|
+
## What's New in v2.33.7
|
|
20
|
+
|
|
21
|
+
Read/write symmetry audit and a gap it surfaced.
|
|
22
|
+
|
|
23
|
+
- **Added** `scripts/audit_readwrite_symmetry.py` + generated
|
|
24
|
+
`docs/reference/readwrite-symmetry.md` — scans every tool's action surface and
|
|
25
|
+
reports `set_`/`add_`/`create_` writes that lack a read counterpart, so
|
|
26
|
+
write-without-read gaps surface before users hit them. A repeatable
|
|
27
|
+
feature-discovery method.
|
|
28
|
+
- **Added** `fusion_comp(action="get_frame_range")` — reads the comp's render
|
|
29
|
+
frame range, the read counterpart to `set_frame_range` (a gap the audit found).
|
|
30
|
+
|
|
5
31
|
## What's New in v2.33.6
|
|
6
32
|
|
|
7
33
|
Internal consolidation: a declarative parameter-contract validator and centralized
|
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/docs/SKILL.md
CHANGED
|
@@ -997,7 +997,7 @@ Key actions:
|
|
|
997
997
|
temp `.setting` file), optionally renaming and repositioning it
|
|
998
998
|
- `auto_arrange(tool_names?, direction?, spacing?, x?, y?)` — lay tools out in a row
|
|
999
999
|
(`direction="horizontal"`, default) or column (`"vertical"`)
|
|
1000
|
-
- `get_comp_info`, `set_frame_range(start, end)`, `render`
|
|
1000
|
+
- `get_comp_info`, `set_frame_range(start, end)`, `get_frame_range`, `render`
|
|
1001
1001
|
- `start_undo(name?)` / `end_undo(keep?)`
|
|
1002
1002
|
- `bulk_set_inputs(ops)` — batch set inputs across multiple timeline item comps in
|
|
1003
1003
|
one call; each op requires timeline scope plus `tool_name`, `input_name`, `value`
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<!-- Generated by scripts/audit_readwrite_symmetry.py — do not edit by hand. -->
|
|
2
|
+
|
|
3
|
+
# Read/Write Symmetry Audit
|
|
4
|
+
|
|
5
|
+
- write-style actions scanned: **106**
|
|
6
|
+
- with a matching read: **57**
|
|
7
|
+
- `set_` actions with no `get_`/`list_` (real readback gaps): **11**
|
|
8
|
+
|
|
9
|
+
## High-signal gaps — `set_` with no read counterpart
|
|
10
|
+
|
|
11
|
+
- `set_cache`
|
|
12
|
+
- `set_cdl`
|
|
13
|
+
- `set_clips_linked`
|
|
14
|
+
- `set_high_priority`
|
|
15
|
+
- `set_keyframe_interpolation`
|
|
16
|
+
- `set_mcp_update_policy`
|
|
17
|
+
- `set_name`
|
|
18
|
+
- `set_node_enabled`
|
|
19
|
+
- `set_title_text`
|
|
20
|
+
- `set_track_enable`
|
|
21
|
+
- `set_track_lock`
|
|
22
|
+
|
|
23
|
+
## Low-signal (create/add/insert/import — usually expected): 37
|
|
24
|
+
|
|
25
|
+
`add_clip_mattes`, `add_comp`, `add_subfolder`, `add_sync_event_markers`, `add_timeline_mattes`, `add_track`, `add_version`, `apply_arri_cdl_lut`, `apply_fairlight_preset`, `apply_grade_from_drx`, `apply_look_to_items`, `apply_spec`, `create_compound_clip`, `create_fusion_clip`, `create_magic_mask`, `create_stereo_clip`, `create_subtitles`, `create_timeline`, `create_timeline_from_clips`, `create_variant_from_ranges`, `import_burnin`, `import_comp`, `import_folder`, `import_into_timeline`, `import_media`, `import_preset`, `import_project`, `import_render`, `import_timeline`, `import_to_pool`, `insert_audio`, `insert_fusion_composition`, `insert_fusion_generator`, `insert_fusion_title`, `insert_generator`, `insert_ofx_generator`, `insert_title`
|
package/install.py
CHANGED
|
@@ -35,7 +35,7 @@ from src.utils.update_check import (
|
|
|
35
35
|
|
|
36
36
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
37
37
|
|
|
38
|
-
VERSION = "2.33.
|
|
38
|
+
VERSION = "2.33.8"
|
|
39
39
|
# Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
|
|
40
40
|
# Resolve's scripting bridge loads into newer interpreters on recent builds
|
|
41
41
|
# (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
|
package/package.json
CHANGED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Audit read/write symmetry across the compound server's action surface.
|
|
3
|
+
|
|
4
|
+
For every mutating action (set_/add_/clear_/enable_/...) it checks whether a
|
|
5
|
+
matching read action (get_/list_/...) exists on the same tool, and reports the
|
|
6
|
+
asymmetries. The goal is to find write-without-read gaps before users have to —
|
|
7
|
+
the repeatable feature-discovery method behind R5.
|
|
8
|
+
|
|
9
|
+
Reads the `_unknown(action, [...])` lists in src/server.py, which enumerate every
|
|
10
|
+
action a tool accepts. Prints a markdown report.
|
|
11
|
+
"""
|
|
12
|
+
import ast
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
18
|
+
SERVER = os.path.join(ROOT, "src", "server.py")
|
|
19
|
+
|
|
20
|
+
READ_PREFIXES = ("get_", "list_", "probe_", "is_", "has_", "find_")
|
|
21
|
+
# `set_` is the high-signal class: a set with no get is a genuine readback gap.
|
|
22
|
+
# create_/add_/insert_/import_ are inherently writes that usually have no paired
|
|
23
|
+
# read of the same noun, so they're reported separately as low-signal.
|
|
24
|
+
HIGH_SIGNAL = ("set_",)
|
|
25
|
+
LOW_SIGNAL = ("add_", "create_", "insert_", "apply_", "import_")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _action_lists(src: str):
|
|
29
|
+
"""Yield lists of action-name string literals from each _unknown(action, [...])."""
|
|
30
|
+
for m in re.finditer(r"_unknown\(action,\s*\[(.*?)\]\)", src, re.DOTALL):
|
|
31
|
+
names = re.findall(r'"([a-z][a-z0-9_]*)"', m.group(1))
|
|
32
|
+
if names:
|
|
33
|
+
yield names
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _has_read(stem: str, aset: set) -> bool:
|
|
37
|
+
# Match get_<stem>, list_<stem>, and plural get_<stem>s (e.g. add_keyframe -> get_keyframes).
|
|
38
|
+
candidates = {rp + stem for rp in READ_PREFIXES}
|
|
39
|
+
candidates |= {rp + stem + "s" for rp in READ_PREFIXES}
|
|
40
|
+
candidates |= {rp + stem.rstrip("s") for rp in READ_PREFIXES}
|
|
41
|
+
return bool(candidates & aset)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def audit(src: str):
|
|
45
|
+
high, low, covered, total = set(), set(), 0, 0
|
|
46
|
+
for actions in _action_lists(src):
|
|
47
|
+
aset = set(actions)
|
|
48
|
+
for a in actions:
|
|
49
|
+
for wp in HIGH_SIGNAL + LOW_SIGNAL:
|
|
50
|
+
if a.startswith(wp):
|
|
51
|
+
total += 1
|
|
52
|
+
stem = a[len(wp):]
|
|
53
|
+
if _has_read(stem, aset):
|
|
54
|
+
covered += 1
|
|
55
|
+
elif wp in HIGH_SIGNAL:
|
|
56
|
+
high.add(a)
|
|
57
|
+
else:
|
|
58
|
+
low.add(a)
|
|
59
|
+
break
|
|
60
|
+
return total, covered, sorted(high), sorted(low)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def main():
|
|
64
|
+
src = open(SERVER, encoding="utf-8").read()
|
|
65
|
+
total, covered, high, low = audit(src)
|
|
66
|
+
print("# Read/Write Symmetry Audit\n")
|
|
67
|
+
print(f"- write-style actions scanned: **{total}**")
|
|
68
|
+
print(f"- with a matching read: **{covered}**")
|
|
69
|
+
print(f"- `set_` actions with no `get_`/`list_` (real readback gaps): **{len(high)}**\n")
|
|
70
|
+
if high:
|
|
71
|
+
print("## High-signal gaps — `set_` with no read counterpart\n")
|
|
72
|
+
for a in high:
|
|
73
|
+
print(f"- `{a}`")
|
|
74
|
+
print(f"\n## Low-signal (create/add/insert/import — usually expected): {len(low)}\n")
|
|
75
|
+
print(", ".join(f"`{a}`" for a in low))
|
|
76
|
+
return 0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
if __name__ == "__main__":
|
|
80
|
+
sys.exit(main())
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Measure DaVinci Resolve bridge round-trips for representative operations.
|
|
3
|
+
|
|
4
|
+
Wraps the live project in a counting proxy and runs a few common traversals,
|
|
5
|
+
reporting how many attribute accesses + method calls (each a bridge round-trip)
|
|
6
|
+
they cost. This is the measurement that gates whether a property cache is worth
|
|
7
|
+
building. Run against an OPEN project; it only reads.
|
|
8
|
+
|
|
9
|
+
PYTHONPATH=. venv/bin/python scripts/measure_bridge_cost.py
|
|
10
|
+
"""
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
import src.server as s
|
|
14
|
+
from src.utils.bridge_metrics import measure
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _walk_media_pool(project_proxy):
|
|
18
|
+
"""A typical media-pool traversal: every folder, every clip, name + a property."""
|
|
19
|
+
mp = project_proxy.GetMediaPool()
|
|
20
|
+
root = mp.GetRootFolder()
|
|
21
|
+
clips_seen = 0
|
|
22
|
+
|
|
23
|
+
def walk(folder):
|
|
24
|
+
nonlocal clips_seen
|
|
25
|
+
for clip in (folder.GetClipList() or []):
|
|
26
|
+
clip.GetName()
|
|
27
|
+
clip.GetClipProperty("Type")
|
|
28
|
+
clips_seen += 1
|
|
29
|
+
for sub in (folder.GetSubFolderList() or []):
|
|
30
|
+
walk(sub)
|
|
31
|
+
|
|
32
|
+
walk(root)
|
|
33
|
+
return clips_seen
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main():
|
|
37
|
+
r = s.get_resolve()
|
|
38
|
+
if not r:
|
|
39
|
+
print("Not connected to Resolve.")
|
|
40
|
+
return 1
|
|
41
|
+
proj = r.GetProjectManager().GetCurrentProject()
|
|
42
|
+
if not proj:
|
|
43
|
+
print("No project open.")
|
|
44
|
+
return 1
|
|
45
|
+
|
|
46
|
+
clips_holder = {}
|
|
47
|
+
|
|
48
|
+
def op(proj_proxy):
|
|
49
|
+
clips_holder["n"] = _walk_media_pool(proj_proxy)
|
|
50
|
+
|
|
51
|
+
counts = measure(op, proj)
|
|
52
|
+
n = clips_holder.get("n", 0)
|
|
53
|
+
total = counts["attr_access"] + counts["calls"]
|
|
54
|
+
print(f"project: {proj.GetName()!r}")
|
|
55
|
+
print(f"clips walked: {n}")
|
|
56
|
+
print(f"bridge attr-accesses: {counts['attr_access']}")
|
|
57
|
+
print(f"bridge method-calls: {counts['calls']}")
|
|
58
|
+
print(f"total round-trips: {total}")
|
|
59
|
+
if n:
|
|
60
|
+
print(f"round-trips per clip: {total / n:.1f}")
|
|
61
|
+
return 0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
if __name__ == "__main__":
|
|
65
|
+
sys.exit(main())
|
package/src/granular/common.py
CHANGED
|
@@ -80,7 +80,7 @@ if not logging.getLogger().handlers:
|
|
|
80
80
|
handlers=[logging.StreamHandler()],
|
|
81
81
|
)
|
|
82
82
|
|
|
83
|
-
VERSION = "2.33.
|
|
83
|
+
VERSION = "2.33.8"
|
|
84
84
|
logger = logging.getLogger("davinci-resolve-mcp")
|
|
85
85
|
logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
|
|
86
86
|
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.33.
|
|
14
|
+
VERSION = "2.33.8"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -18730,6 +18730,7 @@ def fusion_comp(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
18730
18730
|
auto_arrange(tool_names?, direction?, spacing?, x?, y?) -> {success, arranged, count}
|
|
18731
18731
|
Lay tools out in a row (direction="horizontal", default) or column ("vertical").
|
|
18732
18732
|
set_frame_range(start, end) -> {success}
|
|
18733
|
+
get_frame_range() -> {start, end} — read the comp's render frame range
|
|
18733
18734
|
render() -> {success}
|
|
18734
18735
|
start_undo(name?) -> {success}
|
|
18735
18736
|
end_undo(keep?) -> {success}
|
|
@@ -19021,6 +19022,13 @@ def fusion_comp(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
19021
19022
|
comp.SetAttrs({"COMPN_RenderStartTime": p["start"], "COMPN_RenderEndTime": p["end"]})
|
|
19022
19023
|
return _ok()
|
|
19023
19024
|
|
|
19025
|
+
elif action == "get_frame_range":
|
|
19026
|
+
attrs = comp.GetAttrs() or {}
|
|
19027
|
+
return {
|
|
19028
|
+
"start": attrs.get("COMPN_RenderStartTime"),
|
|
19029
|
+
"end": attrs.get("COMPN_RenderEndTime"),
|
|
19030
|
+
}
|
|
19031
|
+
|
|
19024
19032
|
elif action == "render":
|
|
19025
19033
|
comp.Render()
|
|
19026
19034
|
return _ok()
|
|
@@ -19159,7 +19167,7 @@ def fusion_comp(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
19159
19167
|
"connect","disconnect","get_inputs","get_outputs",
|
|
19160
19168
|
"set_input","get_input","set_attrs","get_attrs",
|
|
19161
19169
|
"add_keyframe","get_keyframes","delete_keyframe",
|
|
19162
|
-
"get_comp_info","set_frame_range","render",
|
|
19170
|
+
"get_comp_info","set_frame_range","get_frame_range","render",
|
|
19163
19171
|
"start_undo","end_undo",
|
|
19164
19172
|
"get_position","set_position","copy_tool","auto_arrange",
|
|
19165
19173
|
"bulk_set_inputs",
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Instrumentation for counting DaVinci Resolve bridge round-trips.
|
|
2
|
+
|
|
3
|
+
Every attribute access and method call on a Resolve object is a COM/socket
|
|
4
|
+
round-trip — the dominant cost of most operations. Before optimizing (or adding
|
|
5
|
+
a cache), you have to *measure* where the round-trips concentrate. This module
|
|
6
|
+
provides an opt-in counting proxy that wraps a Resolve handle and tallies
|
|
7
|
+
attribute accesses and method calls, so a hot path's real bridge cost is
|
|
8
|
+
visible instead of guessed.
|
|
9
|
+
|
|
10
|
+
This is the measurement half of the bridge-perf work. A property cache is only
|
|
11
|
+
worth building once measurement shows a clear, repeated-read hot spot — don't
|
|
12
|
+
cache blind.
|
|
13
|
+
|
|
14
|
+
Example:
|
|
15
|
+
|
|
16
|
+
counts = {}
|
|
17
|
+
rp = CountingProxy(resolve, counts)
|
|
18
|
+
rp.GetProjectManager().GetCurrentProject().GetName()
|
|
19
|
+
print(counts) # {'attr_access': N, 'calls': M}
|
|
20
|
+
"""
|
|
21
|
+
from typing import Any, Dict, Optional
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CountingProxy:
|
|
25
|
+
"""Wrap a Resolve object, counting attribute accesses and method calls.
|
|
26
|
+
|
|
27
|
+
Method return values that are themselves objects are wrapped too, so a whole
|
|
28
|
+
call chain through the bridge is counted. Primitives are returned as-is.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
__slots__ = ("_target", "_counter")
|
|
32
|
+
|
|
33
|
+
def __init__(self, target: Any, counter: Dict[str, int]):
|
|
34
|
+
object.__setattr__(self, "_target", target)
|
|
35
|
+
object.__setattr__(self, "_counter", counter)
|
|
36
|
+
counter.setdefault("attr_access", 0)
|
|
37
|
+
counter.setdefault("calls", 0)
|
|
38
|
+
|
|
39
|
+
def __getattr__(self, name: str) -> Any:
|
|
40
|
+
counter = object.__getattribute__(self, "_counter")
|
|
41
|
+
target = object.__getattribute__(self, "_target")
|
|
42
|
+
counter["attr_access"] += 1
|
|
43
|
+
attr = getattr(target, name)
|
|
44
|
+
if callable(attr):
|
|
45
|
+
def wrapped(*args: Any, **kwargs: Any) -> Any:
|
|
46
|
+
counter["calls"] += 1
|
|
47
|
+
result = attr(*args, **kwargs)
|
|
48
|
+
return _maybe_wrap(result, counter)
|
|
49
|
+
return wrapped
|
|
50
|
+
return _maybe_wrap(attr, counter)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_PRIMITIVE = (str, int, float, bool, bytes, type(None))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _maybe_wrap(value: Any, counter: Dict[str, int]) -> Any:
|
|
57
|
+
if isinstance(value, _PRIMITIVE):
|
|
58
|
+
return value
|
|
59
|
+
if isinstance(value, (list, tuple)):
|
|
60
|
+
# Wrap object elements so traversals (e.g. clip lists) are counted.
|
|
61
|
+
return type(value)(_maybe_wrap(v, counter) for v in value)
|
|
62
|
+
if isinstance(value, dict):
|
|
63
|
+
return {k: _maybe_wrap(v, counter) for k, v in value.items()}
|
|
64
|
+
# Likely a Resolve API object — wrap so its further use is counted.
|
|
65
|
+
return CountingProxy(value, counter)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def measure(fn, target: Any) -> Dict[str, int]:
|
|
69
|
+
"""Run ``fn(proxy)`` against a counting proxy of ``target``; return the counts.
|
|
70
|
+
|
|
71
|
+
``fn`` receives the proxy and should exercise the operation under study.
|
|
72
|
+
"""
|
|
73
|
+
counter: Dict[str, int] = {}
|
|
74
|
+
proxy = CountingProxy(target, counter)
|
|
75
|
+
fn(proxy)
|
|
76
|
+
return counter
|