davinci-resolve-mcp 2.67.0 → 2.68.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/CHANGELOG.md +117 -0
- package/README.md +27 -2
- package/docs/SKILL.md +20 -0
- package/docs/kernels/render-deliver-kernel.md +14 -2
- package/install.py +1 -1
- package/package.json +1 -1
- package/scripts/bridge_differential.py +431 -0
- package/scripts/install_resolve_bridge.py +260 -0
- package/scripts/resolve_bridge_launcher.py +202 -0
- package/scripts/resolve_bridge_probe.py +162 -0
- package/scripts/resolve_capability_probe.py +270 -0
- package/src/granular/common.py +6 -1
- package/src/server.py +193 -6
- package/src/utils/bridge_differential.py +375 -0
- package/src/utils/captions.py +323 -0
- package/src/utils/colorimetry.py +196 -0
- package/src/utils/delivery_targets.py +225 -0
- package/src/utils/edit_engine.py +181 -7
- package/src/utils/image_qc.py +499 -0
- package/src/utils/page_lock.py +5 -1
- package/src/utils/resolve_bridge.py +557 -0
- package/src/utils/resolve_bridge_client.py +423 -0
- package/src/utils/resolve_bridge_ops.py +714 -0
- package/src/utils/resolve_connection.py +29 -1
- package/src/utils/silence_ripple.py +256 -8
- package/src/utils/strata_analyzers.py +7 -0
- package/src/utils/strata_faces.py +6 -0
- package/src/utils/transcript_edit.py +372 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Install the bridge (or just the host-model probe) into Resolve's Scripts folder.
|
|
2
|
+
|
|
3
|
+
python scripts/install_resolve_bridge.py --probe-only # settle the host model first
|
|
4
|
+
python scripts/install_resolve_bridge.py # probe + bridge launcher
|
|
5
|
+
|
|
6
|
+
Deploys to every applicable macOS/Linux location, including the sandboxed App
|
|
7
|
+
Store container used by the free edition — which is why the free edition can be
|
|
8
|
+
installed alongside a direct-download Studio without either disturbing the other.
|
|
9
|
+
|
|
10
|
+
Nothing is started here. Resolve must be restarted after installing so it
|
|
11
|
+
re-scans the Scripts folders, and the script is then run from
|
|
12
|
+
**Workspace ▸ Scripts**.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import base64
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import secrets
|
|
22
|
+
import shutil
|
|
23
|
+
import sys
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
REPO = Path(__file__).resolve().parent.parent
|
|
27
|
+
CONFIG_DIR = Path.home() / ".config/davinci-resolve-mcp"
|
|
28
|
+
CONFIG_PATH = CONFIG_DIR / "bridge.json"
|
|
29
|
+
DEFAULT_PORT = 49632
|
|
30
|
+
|
|
31
|
+
_SCRIPTS_SUFFIX = "Library/Application Support/Blackmagic Design/DaVinci Resolve/Fusion/Scripts/Utility"
|
|
32
|
+
|
|
33
|
+
#: A sandboxed (App Store) build sees its container as HOME, so Resolve's
|
|
34
|
+
#: documented per-user path virtualizes to `<container>/Data/` + the SAME suffix.
|
|
35
|
+
#: The vendor segment is kept.
|
|
36
|
+
#:
|
|
37
|
+
#: Two traps, both hit on a real install:
|
|
38
|
+
#:
|
|
39
|
+
#: 1. `<container>/Data/Library/Application Support/Fusion/Scripts/Utility`
|
|
40
|
+
#: exists and is pre-scaffolded with Color/Comp/Deliver/Edit/Tool/Utility —
|
|
41
|
+
#: but that is **Fusion's standalone tree**, not Resolve's, and Resolve does
|
|
42
|
+
#: not scan it. Keying on "which directory already exists" picks the decoy.
|
|
43
|
+
#: 2. Resolve does not create its own `Fusion/Scripts` tree until a script is
|
|
44
|
+
#: installed, so requiring the target to pre-exist skips the free edition
|
|
45
|
+
#: entirely — the one build the bridge exists for.
|
|
46
|
+
#:
|
|
47
|
+
#: So: a container that exists means the edition is installed, and the tree is
|
|
48
|
+
#: *created*. Blackmagic's README is the authority on the suffix, not the
|
|
49
|
+
#: filesystem.
|
|
50
|
+
_SANDBOX_MARKER = "com.blackmagic-design."
|
|
51
|
+
#: Containers that are not Resolve (RAW Player, Speed Test, the IO XPC helper).
|
|
52
|
+
_NON_RESOLVE_CONTAINERS = ("BlackmagicRaw", "IOXPC")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _is_resolve_container(path: Path) -> bool:
|
|
56
|
+
if not path.name.startswith(_SANDBOX_MARKER):
|
|
57
|
+
return False
|
|
58
|
+
return not any(marker in path.name for marker in _NON_RESOLVE_CONTAINERS)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def script_targets() -> list[Path]:
|
|
62
|
+
"""Every Scripts/Utility folder Resolve will scan on this machine.
|
|
63
|
+
|
|
64
|
+
Sandboxed containers are included because the App Store build cannot see the
|
|
65
|
+
normal per-user path — that isolation is exactly what lets the free edition
|
|
66
|
+
coexist with a direct-download Studio install.
|
|
67
|
+
"""
|
|
68
|
+
home = Path.home()
|
|
69
|
+
candidates = [
|
|
70
|
+
home / _SCRIPTS_SUFFIX,
|
|
71
|
+
Path("/") / _SCRIPTS_SUFFIX,
|
|
72
|
+
home / ".local/share/DaVinciResolve/Fusion/Scripts/Utility",
|
|
73
|
+
]
|
|
74
|
+
containers = home / "Library/Containers"
|
|
75
|
+
sandboxed: list[Path] = []
|
|
76
|
+
if containers.is_dir():
|
|
77
|
+
for entry in sorted(containers.iterdir()):
|
|
78
|
+
if _is_resolve_container(entry) and (entry / "Data").is_dir():
|
|
79
|
+
sandboxed.append(entry / "Data" / _SCRIPTS_SUFFIX)
|
|
80
|
+
|
|
81
|
+
usable: list[Path] = []
|
|
82
|
+
for path in candidates:
|
|
83
|
+
# Non-sandboxed: the install created the tree, so require it.
|
|
84
|
+
if path.is_dir() or (path.parent.is_dir() and os.access(path.parent, os.W_OK)):
|
|
85
|
+
usable.append(path)
|
|
86
|
+
# Sandboxed: the container's existence is the signal; the tree gets created.
|
|
87
|
+
usable.extend(sandboxed)
|
|
88
|
+
return list(dict.fromkeys(usable))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
#: Resolve enumerates `.py` scripts in Workspace > Scripts only when it finds a
|
|
92
|
+
#: **framework** Python. Homebrew/pyenv/conda interpreters are not detected, and
|
|
93
|
+
#: the failure is completely silent: the script sits in the right folder with the
|
|
94
|
+
#: right permissions and simply never appears. Lua is embedded, so `.lua` always
|
|
95
|
+
#: lists — which is why a Lua canary is installed alongside the probe.
|
|
96
|
+
_FRAMEWORK_PYTHON_ROOTS = (
|
|
97
|
+
Path("/Library/Frameworks/Python.framework/Versions"),
|
|
98
|
+
Path("/System/Library/Frameworks/Python.framework/Versions"),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
_LUA_CANARY = """-- Installed by davinci-resolve-mcp as an enumeration canary.
|
|
102
|
+
-- If THIS appears under Workspace > Scripts but resolve_bridge_probe does not,
|
|
103
|
+
-- Resolve is listing Lua and silently skipping Python: it cannot find a
|
|
104
|
+
-- framework Python install. Install one from python.org and restart Resolve.
|
|
105
|
+
print("Resolve is enumerating scripts. If the Python probe is missing, install a")
|
|
106
|
+
print("framework Python from python.org (Homebrew/pyenv are NOT detected).")
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def framework_pythons() -> list[str]:
|
|
111
|
+
"""Framework Python versions Resolve can actually see."""
|
|
112
|
+
found: list[str] = []
|
|
113
|
+
for root in _FRAMEWORK_PYTHON_ROOTS:
|
|
114
|
+
if not root.is_dir():
|
|
115
|
+
continue
|
|
116
|
+
for entry in sorted(root.iterdir()):
|
|
117
|
+
# `Versions/Current` is a symlink to a real version — counting it
|
|
118
|
+
# would report two installs where there is one.
|
|
119
|
+
if entry.is_symlink():
|
|
120
|
+
continue
|
|
121
|
+
if (entry / "bin" / "python3").exists() or (entry / "Python").exists():
|
|
122
|
+
found.append(f"{root}/{entry.name}")
|
|
123
|
+
return found
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def python_preflight() -> dict:
|
|
127
|
+
"""Will Resolve list the Python probe we are about to install?"""
|
|
128
|
+
frameworks = framework_pythons()
|
|
129
|
+
return {
|
|
130
|
+
"framework_pythons": frameworks,
|
|
131
|
+
"resolve_will_list_python_scripts": bool(frameworks),
|
|
132
|
+
"advice": None if frameworks else (
|
|
133
|
+
"No framework Python found. Resolve will silently ignore every .py "
|
|
134
|
+
"script in its Scripts folders — they will simply not appear in "
|
|
135
|
+
"Workspace > Scripts, with no error. Homebrew, pyenv and conda "
|
|
136
|
+
"interpreters are NOT detected. Install a framework build from "
|
|
137
|
+
"python.org (any recent 3.x), restart Resolve, and re-check. The "
|
|
138
|
+
"Lua canary installed alongside will list either way, so you can "
|
|
139
|
+
"tell 'Python not detected' apart from 'wrong folder'."
|
|
140
|
+
),
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def ensure_config(port: int, rotate: bool) -> dict:
|
|
145
|
+
existing: dict = {}
|
|
146
|
+
if CONFIG_PATH.exists():
|
|
147
|
+
try:
|
|
148
|
+
existing = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
|
149
|
+
except (OSError, ValueError):
|
|
150
|
+
existing = {}
|
|
151
|
+
token = existing.get("token")
|
|
152
|
+
if rotate or not isinstance(token, str) or len(token) < 43:
|
|
153
|
+
token = secrets.token_urlsafe(32)
|
|
154
|
+
# ResolveOperations requires roots; without them the bridge refuses to start.
|
|
155
|
+
# Conservative default: the user's home. Widen deliberately, not by accident.
|
|
156
|
+
existing_media = [r for r in (existing.get("allowed_media_roots") or []) if isinstance(r, str)]
|
|
157
|
+
existing_output = [r for r in (existing.get("allowed_output_roots") or []) if isinstance(r, str)]
|
|
158
|
+
config = {
|
|
159
|
+
"host": "127.0.0.1",
|
|
160
|
+
"port": port,
|
|
161
|
+
"token": token,
|
|
162
|
+
"auth_clock_skew_seconds": 60,
|
|
163
|
+
"allowed_media_roots": existing_media or [str(Path.home())],
|
|
164
|
+
"allowed_output_roots": existing_output or [str(Path.home() / "Movies")],
|
|
165
|
+
}
|
|
166
|
+
CONFIG_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
167
|
+
tmp = CONFIG_PATH.with_suffix(".tmp")
|
|
168
|
+
tmp.write_text(json.dumps(config, indent=2, sort_keys=True), encoding="utf-8")
|
|
169
|
+
os.chmod(tmp, 0o600)
|
|
170
|
+
tmp.replace(CONFIG_PATH)
|
|
171
|
+
os.chmod(CONFIG_PATH, 0o600)
|
|
172
|
+
return config
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def install(*, probe_only: bool, port: int, rotate: bool) -> dict:
|
|
176
|
+
# Write the config first: the launcher embeds its path, and a launcher
|
|
177
|
+
# pointing at a file that does not exist yet is a confusing first run.
|
|
178
|
+
if not probe_only:
|
|
179
|
+
ensure_config(port, rotate)
|
|
180
|
+
targets = script_targets()
|
|
181
|
+
if not targets:
|
|
182
|
+
raise SystemExit(
|
|
183
|
+
"No writable DaVinci Resolve Scripts/Utility folder found. Is Resolve installed?"
|
|
184
|
+
)
|
|
185
|
+
installed: list[str] = []
|
|
186
|
+
for target in targets:
|
|
187
|
+
target.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
188
|
+
for probe in ("resolve_bridge_probe.py", "resolve_capability_probe.py"):
|
|
189
|
+
shutil.copy2(REPO / "scripts" / probe, target / probe)
|
|
190
|
+
installed.append(str(target / probe))
|
|
191
|
+
# Lua always enumerates; Python only with a framework install. The canary
|
|
192
|
+
# makes "Python not detected" distinguishable from "wrong folder".
|
|
193
|
+
canary = target / "resolve_bridge_canary.lua"
|
|
194
|
+
canary.write_text(_LUA_CANARY, encoding="utf-8")
|
|
195
|
+
installed.append(str(canary))
|
|
196
|
+
if not probe_only:
|
|
197
|
+
runtime = target.parents[1] / ".davinci_mcp_runtime"
|
|
198
|
+
runtime.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
199
|
+
for module in ("resolve_bridge.py", "resolve_bridge_ops.py"):
|
|
200
|
+
shutil.copy2(REPO / "src/utils" / module, runtime / module)
|
|
201
|
+
installed.append(str(runtime / module))
|
|
202
|
+
# A sandboxed (App Store) build cannot read ~/.config at all — inside
|
|
203
|
+
# the container `~` IS the container, so an absolute real-home path is
|
|
204
|
+
# unreachable by construction (measured: "Operation not permitted").
|
|
205
|
+
# The runtime dir is already inside the container, so the config goes
|
|
206
|
+
# beside the modules. Same token, so the out-of-sandbox client still
|
|
207
|
+
# authenticates against ~/.config.
|
|
208
|
+
runtime_config = runtime / "bridge.json"
|
|
209
|
+
shutil.copy2(CONFIG_PATH, runtime_config)
|
|
210
|
+
os.chmod(runtime_config, 0o600)
|
|
211
|
+
installed.append(str(runtime_config))
|
|
212
|
+
# The launcher IS the menu entry. Without it the modules sit in the
|
|
213
|
+
# runtime dir with nothing able to start them.
|
|
214
|
+
launcher_source = (REPO / "scripts/resolve_bridge_launcher.py").read_text(encoding="utf-8")
|
|
215
|
+
launcher = launcher_source.replace(
|
|
216
|
+
"@@RUNTIME_ROOT_B64@@",
|
|
217
|
+
base64.urlsafe_b64encode(str(runtime).encode("utf-8")).decode("ascii"),
|
|
218
|
+
).replace(
|
|
219
|
+
"@@CONFIG_PATH_B64@@",
|
|
220
|
+
base64.urlsafe_b64encode(str(CONFIG_PATH).encode("utf-8")).decode("ascii"),
|
|
221
|
+
)
|
|
222
|
+
launcher_path = target / "resolve_bridge.py"
|
|
223
|
+
launcher_path.write_text(launcher, encoding="utf-8")
|
|
224
|
+
installed.append(str(launcher_path))
|
|
225
|
+
result = {"installed": installed, "probe_only": probe_only, "python": python_preflight()}
|
|
226
|
+
if not probe_only:
|
|
227
|
+
loaded = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
|
228
|
+
result["config"] = {"path": str(CONFIG_PATH),
|
|
229
|
+
**{k: v for k, v in loaded.items() if k != "token"}}
|
|
230
|
+
return result
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def main() -> int:
|
|
234
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
235
|
+
parser.add_argument("--probe-only", action="store_true",
|
|
236
|
+
help="install only the host-model probe (run this first)")
|
|
237
|
+
parser.add_argument("--port", type=int, default=DEFAULT_PORT)
|
|
238
|
+
parser.add_argument("--rotate-token", action="store_true")
|
|
239
|
+
args = parser.parse_args()
|
|
240
|
+
if not 1024 <= args.port <= 65535:
|
|
241
|
+
raise SystemExit("--port must be 1024..65535")
|
|
242
|
+
|
|
243
|
+
result = install(probe_only=args.probe_only, port=args.port, rotate=args.rotate_token)
|
|
244
|
+
print(json.dumps(result, indent=2, sort_keys=True))
|
|
245
|
+
print()
|
|
246
|
+
if not result["python"]["resolve_will_list_python_scripts"]:
|
|
247
|
+
print("!" * 72)
|
|
248
|
+
print("WARNING: " + result["python"]["advice"])
|
|
249
|
+
print("!" * 72)
|
|
250
|
+
print()
|
|
251
|
+
print("Next:")
|
|
252
|
+
print(" 1. Restart DaVinci Resolve so it re-scans the Scripts folders.")
|
|
253
|
+
print(" 2. Open a saved project (the Scripts menu is empty in Project Manager).")
|
|
254
|
+
print(" 3. Workspace > Scripts > resolve_bridge_probe — run it TWICE.")
|
|
255
|
+
print(" 4. Read ~/.config/davinci-resolve-mcp/host-model-probe.json")
|
|
256
|
+
return 0
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
if __name__ == "__main__":
|
|
260
|
+
sys.exit(main())
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Workspace ▸ Scripts ▸ resolve_bridge — starts the in-app bridge.
|
|
2
|
+
|
|
3
|
+
Installed by ``python scripts/install_resolve_bridge.py``. Run it once per
|
|
4
|
+
Resolve session; it holds the loopback listener open until Resolve exits or a
|
|
5
|
+
client asks it to stop.
|
|
6
|
+
|
|
7
|
+
**This blocks, on purpose.** A Scripts-menu script is a child process of Resolve
|
|
8
|
+
(measured on Studio 19.1.3.7 and free 21.0.3.7), so a background thread dies the
|
|
9
|
+
instant this function returns. `serve()` detects the host model and blocks or
|
|
10
|
+
returns accordingly — see `src/utils/resolve_bridge.py`. While it is running this
|
|
11
|
+
script will appear "busy" in Resolve; that is the listener staying alive, not a
|
|
12
|
+
hang, and Resolve's UI is unaffected because this is a separate process.
|
|
13
|
+
|
|
14
|
+
The runtime directory is written in by the installer, because Resolve's Scripts
|
|
15
|
+
folder has no relation to the repository and nothing can be imported from it.
|
|
16
|
+
|
|
17
|
+
## The supervisor loop
|
|
18
|
+
|
|
19
|
+
Blocking has a cost: this script cannot be re-run while it is alive, so a stale
|
|
20
|
+
runtime used to mean quitting Resolve. `serve()` now reports *why* it returned,
|
|
21
|
+
and a `reload` sends us back around this loop with the runtime re-imported from
|
|
22
|
+
disk — new code live, no UI interaction.
|
|
23
|
+
|
|
24
|
+
Re-importing is done into a **throwaway module object** rather than with
|
|
25
|
+
`importlib.reload`, so a module that fails halfway cannot leave the running one
|
|
26
|
+
half-overwritten. The old modules keep serving and the failure is reported.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import base64
|
|
30
|
+
import importlib.util
|
|
31
|
+
import os
|
|
32
|
+
import sys
|
|
33
|
+
import traceback
|
|
34
|
+
|
|
35
|
+
RUNTIME_ROOT = base64.urlsafe_b64decode("@@RUNTIME_ROOT_B64@@".encode("ascii")).decode("utf-8")
|
|
36
|
+
_HOME_CONFIG = base64.urlsafe_b64decode("@@CONFIG_PATH_B64@@".encode("ascii")).decode("utf-8")
|
|
37
|
+
|
|
38
|
+
if RUNTIME_ROOT not in sys.path:
|
|
39
|
+
sys.path.insert(0, RUNTIME_ROOT)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def resolve_config_path():
|
|
43
|
+
"""Runtime-local config first — the only one a sandboxed build can read.
|
|
44
|
+
|
|
45
|
+
Inside the App Store sandbox, `~` is the container, so the absolute
|
|
46
|
+
real-home path baked in at install time raises "Operation not permitted".
|
|
47
|
+
The runtime directory sits inside the container next to the modules, so a
|
|
48
|
+
copy there is always reachable. Both copies carry the same token, so the
|
|
49
|
+
out-of-sandbox MCP client still authenticates from ~/.config.
|
|
50
|
+
"""
|
|
51
|
+
local = os.path.join(RUNTIME_ROOT, "bridge.json")
|
|
52
|
+
if os.path.isfile(local):
|
|
53
|
+
return local
|
|
54
|
+
return _HOME_CONFIG
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
CONFIG_PATH = resolve_config_path()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def acquire_resolve():
|
|
61
|
+
candidate = globals().get("resolve")
|
|
62
|
+
if candidate is not None:
|
|
63
|
+
return candidate
|
|
64
|
+
bmd = globals().get("bmd")
|
|
65
|
+
if bmd is not None:
|
|
66
|
+
try:
|
|
67
|
+
return bmd.scriptapp("Resolve")
|
|
68
|
+
except Exception:
|
|
69
|
+
return None
|
|
70
|
+
try:
|
|
71
|
+
import DaVinciResolveScript as script_module
|
|
72
|
+
return script_module.scriptapp("Resolve")
|
|
73
|
+
except Exception:
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def load_runtime():
|
|
78
|
+
"""Import the two runtime modules fresh, into new module objects.
|
|
79
|
+
|
|
80
|
+
Returns ``(resolve_bridge, resolve_bridge_ops)`` or raises. Nothing already
|
|
81
|
+
imported is mutated, so a failed reload leaves the running bridge intact.
|
|
82
|
+
"""
|
|
83
|
+
loaded = []
|
|
84
|
+
for name in ("resolve_bridge", "resolve_bridge_ops"):
|
|
85
|
+
path = os.path.join(RUNTIME_ROOT, name + ".py")
|
|
86
|
+
spec = importlib.util.spec_from_file_location(name, path)
|
|
87
|
+
if spec is None or spec.loader is None:
|
|
88
|
+
raise ImportError("cannot load %s from %s" % (name, path))
|
|
89
|
+
module = importlib.util.module_from_spec(spec)
|
|
90
|
+
spec.loader.exec_module(module)
|
|
91
|
+
# Registered only once both have executed, so a half-loaded pair is
|
|
92
|
+
# never visible to anything that imports by name.
|
|
93
|
+
loaded.append((name, module))
|
|
94
|
+
for name, module in loaded:
|
|
95
|
+
sys.modules[name] = module
|
|
96
|
+
return loaded[0][1], loaded[1][1]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def main():
|
|
100
|
+
print("=" * 70)
|
|
101
|
+
print("DaVinci Resolve MCP - in-app bridge")
|
|
102
|
+
print("=" * 70)
|
|
103
|
+
|
|
104
|
+
resolve_object = acquire_resolve()
|
|
105
|
+
if resolve_object is None:
|
|
106
|
+
print(" ERROR: no 'resolve' object. Run this from Workspace > Scripts.")
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
resolve_bridge, resolve_bridge_ops = load_runtime()
|
|
111
|
+
except Exception:
|
|
112
|
+
print(" ERROR: could not import the bridge runtime from:")
|
|
113
|
+
print(" %s" % RUNTIME_ROOT)
|
|
114
|
+
print(" Re-run: python scripts/install_resolve_bridge.py")
|
|
115
|
+
traceback.print_exc()
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
product = resolve_object.GetProductName()
|
|
120
|
+
version = resolve_object.GetVersionString()
|
|
121
|
+
except Exception:
|
|
122
|
+
product, version = "DaVinci Resolve", "?"
|
|
123
|
+
|
|
124
|
+
generation = 0
|
|
125
|
+
while True:
|
|
126
|
+
try:
|
|
127
|
+
config = resolve_bridge.load_config(CONFIG_PATH)
|
|
128
|
+
except Exception as exc:
|
|
129
|
+
print(" ERROR: bridge config unusable: %s" % exc)
|
|
130
|
+
print(" tried: %s" % CONFIG_PATH)
|
|
131
|
+
if CONFIG_PATH != _HOME_CONFIG:
|
|
132
|
+
print(" (runtime-local copy; the ~/.config one is unreachable from a sandbox)")
|
|
133
|
+
print(" Re-run: python scripts/install_resolve_bridge.py")
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
bridge_holder = {}
|
|
137
|
+
|
|
138
|
+
def lifecycle(mode, _ops=resolve_bridge_ops, _rb=resolve_bridge, _holder=bridge_holder):
|
|
139
|
+
"""Vet the request, then flag the serve loop. Never stops in place."""
|
|
140
|
+
if mode == "reload":
|
|
141
|
+
problems = _rb.validate_runtime_sources(RUNTIME_ROOT)
|
|
142
|
+
if problems:
|
|
143
|
+
# Refused by a bridge that is still serving, which is the
|
|
144
|
+
# whole point of checking before stopping.
|
|
145
|
+
raise _ops.OperationError(
|
|
146
|
+
"reload_rejected",
|
|
147
|
+
"the installed runtime will not compile: " + "; ".join(problems),
|
|
148
|
+
)
|
|
149
|
+
_holder["bridge"].request_stop(mode)
|
|
150
|
+
return {"generation": generation}
|
|
151
|
+
|
|
152
|
+
operations = resolve_bridge_ops.ResolveOperations(
|
|
153
|
+
resolve_object,
|
|
154
|
+
media_roots=list(config.get("allowed_media_roots") or [os.path.expanduser("~")]),
|
|
155
|
+
output_roots=list(config.get("allowed_output_roots") or [os.path.expanduser("~")]),
|
|
156
|
+
lifecycle=lifecycle,
|
|
157
|
+
)
|
|
158
|
+
bridge = resolve_bridge.Bridge(
|
|
159
|
+
resolve_object, config, resolve_bridge_ops.make_dispatch(operations)
|
|
160
|
+
)
|
|
161
|
+
bridge_holder["bridge"] = bridge
|
|
162
|
+
|
|
163
|
+
model = resolve_bridge._host_model()
|
|
164
|
+
print(" product : %s %s" % (product, version))
|
|
165
|
+
print(" listening : 127.0.0.1:%s" % config["port"])
|
|
166
|
+
print(" config : %s" % CONFIG_PATH)
|
|
167
|
+
print(" host model : %s (blocking=%s)" % (model["model"], model["blocking_required"]))
|
|
168
|
+
print(" operations : %d" % len(resolve_bridge_ops.ResolveOperations.OPERATIONS))
|
|
169
|
+
print(" generation : %d" % generation)
|
|
170
|
+
print()
|
|
171
|
+
print(" The MCP server connects with DAVINCI_RESOLVE_BRIDGE=1.")
|
|
172
|
+
if model["blocking_required"]:
|
|
173
|
+
print(" This script now HOLDS the listener open and will look busy until")
|
|
174
|
+
print(" Resolve exits. That is expected — closing it stops the bridge.")
|
|
175
|
+
print("=" * 70)
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
outcome = bridge.serve(host_model=model)
|
|
179
|
+
except KeyboardInterrupt:
|
|
180
|
+
bridge.stop()
|
|
181
|
+
print(" bridge stopped (interrupted).")
|
|
182
|
+
return
|
|
183
|
+
finally:
|
|
184
|
+
bridge.stop()
|
|
185
|
+
|
|
186
|
+
reason = (outcome or {}).get("stop_reason")
|
|
187
|
+
if reason != "reload":
|
|
188
|
+
print(" bridge stopped (%s)." % (reason or "returned"))
|
|
189
|
+
return
|
|
190
|
+
|
|
191
|
+
print(" reload requested — re-importing the runtime from disk...")
|
|
192
|
+
try:
|
|
193
|
+
resolve_bridge, resolve_bridge_ops = load_runtime()
|
|
194
|
+
except Exception:
|
|
195
|
+
# The old module objects are untouched, so serving again with them
|
|
196
|
+
# is a real fallback rather than a hope.
|
|
197
|
+
print(" RELOAD FAILED — continuing on the previously loaded runtime:")
|
|
198
|
+
traceback.print_exc()
|
|
199
|
+
generation += 1
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
main()
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Resolve Scripts-menu probe: does a menu script run in-process or as a child?
|
|
2
|
+
|
|
3
|
+
Run from **Workspace ▸ Scripts ▸ resolve_bridge_probe** (install with
|
|
4
|
+
``python scripts/install_resolve_bridge.py --probe-only``).
|
|
5
|
+
|
|
6
|
+
The answer decides how the bridge's listener must behave, and the two options
|
|
7
|
+
are fatal in opposite directions — a daemon thread dies instantly in a child
|
|
8
|
+
process, and a blocking loop wedges Resolve in-process. This settles it with
|
|
9
|
+
evidence instead of a guess.
|
|
10
|
+
|
|
11
|
+
Edition-independent: Workspace ▸ Scripts behaves the same on Studio and free, so
|
|
12
|
+
this can be run on whatever is already installed.
|
|
13
|
+
|
|
14
|
+
**Deliberately self-contained.** This file is copied into Resolve's Scripts
|
|
15
|
+
folder, where the repository is not on the path and nothing can be imported from
|
|
16
|
+
it. The ~20 lines of detection are duplicated here rather than imported, and
|
|
17
|
+
`tests/test_resolve_bridge.py` asserts the two copies agree. An import would
|
|
18
|
+
raise in Resolve's console and tell you nothing.
|
|
19
|
+
|
|
20
|
+
Writes findings to ``~/.config/davinci-resolve-mcp/host-model-probe.json`` and
|
|
21
|
+
prints them. Starts no listener and touches no project.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
import time
|
|
29
|
+
|
|
30
|
+
REPORT_PATH = os.path.expanduser("~/.config/davinci-resolve-mcp/host-model-probe.json")
|
|
31
|
+
|
|
32
|
+
# Kept in sync with src/utils/resolve_bridge.py — see the module docstring.
|
|
33
|
+
PARENT_MARKERS = ("resolve", "fuscript", "fusion")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def process_name(pid):
|
|
37
|
+
"""Best-effort process name; empty string when it cannot be read."""
|
|
38
|
+
try: # Linux
|
|
39
|
+
with open("/proc/%d/comm" % pid) as handle:
|
|
40
|
+
return handle.read().strip()
|
|
41
|
+
except (OSError, IOError):
|
|
42
|
+
pass
|
|
43
|
+
try: # macOS
|
|
44
|
+
out = subprocess.Popen(
|
|
45
|
+
["ps", "-p", str(pid), "-o", "comm="],
|
|
46
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
47
|
+
).communicate()[0]
|
|
48
|
+
if isinstance(out, bytes):
|
|
49
|
+
out = out.decode("utf-8", "replace")
|
|
50
|
+
return out.strip()
|
|
51
|
+
except Exception:
|
|
52
|
+
return ""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def host_model():
|
|
56
|
+
"""Ask about THIS process, not the parent.
|
|
57
|
+
|
|
58
|
+
The sandboxed App Store build blocks `ps` on another process, so the parent
|
|
59
|
+
name comes back empty even though the script IS a child of Resolve
|
|
60
|
+
(measured: pid 97667, parent 97258, name unreadable). Reading sys.executable
|
|
61
|
+
needs no permission, so it is the decision; the parent name is corroboration.
|
|
62
|
+
"""
|
|
63
|
+
parent_pid = os.getppid()
|
|
64
|
+
parent_name = process_name(parent_pid) or ""
|
|
65
|
+
executable = sys.executable or ""
|
|
66
|
+
self_is_host = bool(executable) and executable.rstrip("/").endswith("/Resolve")
|
|
67
|
+
return {
|
|
68
|
+
"model": "in_process" if self_is_host else "child_process",
|
|
69
|
+
"parent_pid": parent_pid,
|
|
70
|
+
"parent_name": parent_name,
|
|
71
|
+
"self_executable": executable,
|
|
72
|
+
"self_is_resolve": self_is_host,
|
|
73
|
+
"parent_corroborates": any(m in parent_name.lower() for m in PARENT_MARKERS),
|
|
74
|
+
"blocking_required": not self_is_host,
|
|
75
|
+
"pid": os.getpid(),
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def acquire_resolve():
|
|
80
|
+
"""The live object, if Resolve handed us one."""
|
|
81
|
+
candidate = globals().get("resolve")
|
|
82
|
+
if candidate is not None:
|
|
83
|
+
return candidate
|
|
84
|
+
bmd = globals().get("bmd")
|
|
85
|
+
if bmd is not None:
|
|
86
|
+
try:
|
|
87
|
+
return bmd.scriptapp("Resolve")
|
|
88
|
+
except Exception:
|
|
89
|
+
return None
|
|
90
|
+
try:
|
|
91
|
+
import DaVinciResolveScript as script_module
|
|
92
|
+
return script_module.scriptapp("Resolve")
|
|
93
|
+
except Exception:
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def main():
|
|
98
|
+
probe = host_model()
|
|
99
|
+
|
|
100
|
+
# Does the interpreter survive the script returning? A child process exits
|
|
101
|
+
# here; Resolve's own interpreter keeps this module alive. So a SECOND run
|
|
102
|
+
# sees the marker only in the in-process case — independent corroboration of
|
|
103
|
+
# the parent-name detection above.
|
|
104
|
+
module = sys.modules[__name__]
|
|
105
|
+
previous = getattr(module, "_PREVIOUS_RUN_AT", None)
|
|
106
|
+
probe["previous_run_in_this_interpreter"] = previous
|
|
107
|
+
probe["interpreter_persisted"] = previous is not None
|
|
108
|
+
module._PREVIOUS_RUN_AT = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
109
|
+
|
|
110
|
+
resolve_object = acquire_resolve()
|
|
111
|
+
probe["resolve_object_available"] = resolve_object is not None
|
|
112
|
+
if resolve_object is not None:
|
|
113
|
+
try:
|
|
114
|
+
product = resolve_object.GetProductName()
|
|
115
|
+
probe["product"] = product
|
|
116
|
+
probe["version"] = resolve_object.GetVersionString()
|
|
117
|
+
probe["edition"] = "studio" if "studio" in str(product).lower() else "free"
|
|
118
|
+
except Exception as exc:
|
|
119
|
+
probe["product_probe_error"] = type(exc).__name__
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
directory = os.path.dirname(REPORT_PATH)
|
|
123
|
+
if not os.path.isdir(directory):
|
|
124
|
+
os.makedirs(directory)
|
|
125
|
+
with open(REPORT_PATH, "w") as handle:
|
|
126
|
+
json.dump(probe, handle, indent=2, sort_keys=True)
|
|
127
|
+
written = REPORT_PATH
|
|
128
|
+
except (OSError, IOError) as exc:
|
|
129
|
+
# A sandboxed build may not reach ~/.config. The console output is the
|
|
130
|
+
# real result; the file is a convenience.
|
|
131
|
+
written = "could not write (%s) — read the console output above" % type(exc).__name__
|
|
132
|
+
|
|
133
|
+
print("=" * 68)
|
|
134
|
+
print("DaVinci Resolve MCP - Scripts-menu host-model probe")
|
|
135
|
+
print("=" * 68)
|
|
136
|
+
print(" host model : %s" % probe["model"])
|
|
137
|
+
print(" parent process : %s (pid %s)" % (probe["parent_name"] or "<unreadable — sandbox>", probe["parent_pid"]))
|
|
138
|
+
print(" this interpreter: %s" % (probe["self_executable"] or "<unknown>"))
|
|
139
|
+
print(" this pid : %s" % probe["pid"])
|
|
140
|
+
print(" blocking needed : %s" % probe["blocking_required"])
|
|
141
|
+
print(" resolve object : %s" % probe["resolve_object_available"])
|
|
142
|
+
if probe.get("product"):
|
|
143
|
+
print(" product : %s %s (%s)" % (probe["product"], probe.get("version", "?"), probe.get("edition")))
|
|
144
|
+
print(" interpreter kept: %s" % probe["interpreter_persisted"])
|
|
145
|
+
print()
|
|
146
|
+
if probe["blocking_required"]:
|
|
147
|
+
print(" -> CHILD PROCESS. The listener must BLOCK; a daemon thread would")
|
|
148
|
+
print(" die the moment this script returns.")
|
|
149
|
+
else:
|
|
150
|
+
print(" -> IN-PROCESS (or launched outside Resolve). The listener must")
|
|
151
|
+
print(" NOT block, or Resolve's script execution wedges.")
|
|
152
|
+
print()
|
|
153
|
+
print(" Run this a SECOND time. If 'interpreter kept' flips to True, the")
|
|
154
|
+
print(" script runs inside Resolve's own interpreter. If it stays False,")
|
|
155
|
+
print(" each run is a fresh child process.")
|
|
156
|
+
print()
|
|
157
|
+
print(" report: %s" % written)
|
|
158
|
+
print("=" * 68)
|
|
159
|
+
return probe
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
main()
|