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.
@@ -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()