davinci-resolve-mcp 2.69.1 → 2.70.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.
@@ -53,11 +53,26 @@ _SCRIPTS_SUFFIX = "Library/Application Support/Blackmagic Design/DaVinci Resolve
53
53
  #: `<container>/Data/Library/Application Support/Fusion/Scripts/Utility` appeared
54
54
  #: under Workspace ▸ Scripts, as did one in
55
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.
56
+ #: So Lite scans more locations than the README documents.
57
+ #:
58
+ #: **Second correction (issue #104), same version, opposite result:** on another
59
+ #: free 21.0.3.7 App Store install the documented tree listed *nothing* — not
60
+ #: even the Lua canary, which rules out the Python-detection explanation and
61
+ #: points at the folder — while the Fusion standalone tree listed all four files
62
+ #: with no restart. Two machines, same build, contradictory results.
63
+ #:
64
+ #: We cannot tell them apart from outside Resolve, so we stop trying to pick a
65
+ #: winner and install to BOTH container trees. The documented path stays first
66
+ #: (it is the one Blackmagic commits to), the standalone tree is the fallback
67
+ #: that demonstrably works where the documented one silently does not. The cost
68
+ #: is one extra App Management prompt; the cost of guessing wrong is a bridge
69
+ #: that never appears and gives the user nothing to diagnose.
60
70
  _SANDBOX_MARKER = "com.blackmagic-design."
71
+
72
+ #: Scanned by Lite in addition to `_SCRIPTS_SUFFIX`, inside the container only.
73
+ #: NOT used outside a sandbox: on a normal install this is genuinely Fusion's
74
+ #: own tree and has nothing to do with Resolve.
75
+ _SANDBOX_FALLBACK_SUFFIX = "Library/Application Support/Fusion/Scripts/Utility"
61
76
  #: Containers that are not Resolve (RAW Player, Speed Test, the IO XPC helper).
62
77
  _NON_RESOLVE_CONTAINERS = ("BlackmagicRaw", "IOXPC")
63
78
 
@@ -73,7 +88,9 @@ def script_targets() -> list[Path]:
73
88
 
74
89
  Sandboxed containers are included because the App Store build cannot see the
75
90
  normal per-user path — that isolation is exactly what lets the free edition
76
- coexist with a direct-download Studio install.
91
+ coexist with a direct-download Studio install. Each container contributes
92
+ TWO targets (see the module comment on issue #104): the documented tree
93
+ first, then the Fusion standalone tree that Lite also scans.
77
94
  """
78
95
  home = Path.home()
79
96
  candidates = [
@@ -87,6 +104,7 @@ def script_targets() -> list[Path]:
87
104
  for entry in sorted(containers.iterdir()):
88
105
  if _is_resolve_container(entry) and (entry / "Data").is_dir():
89
106
  sandboxed.append(entry / "Data" / _SCRIPTS_SUFFIX)
107
+ sandboxed.append(entry / "Data" / _SANDBOX_FALLBACK_SUFFIX)
90
108
 
91
109
  usable: list[Path] = []
92
110
  for path in candidates:
@@ -98,6 +116,51 @@ def script_targets() -> list[Path]:
98
116
  return list(dict.fromkeys(usable))
99
117
 
100
118
 
119
+ #: Where a Resolve app bundle actually lives. The App Store build installs flat
120
+ #: into /Applications; the direct download uses its own folder. RESOLVE_APP
121
+ #: overrides both, matching scripts/doctor.py.
122
+ _APP_BUNDLE_CANDIDATES = (
123
+ "/Applications/DaVinci Resolve/DaVinci Resolve.app",
124
+ "/Applications/DaVinci Resolve.app",
125
+ "/Applications/DaVinci Resolve Studio.app",
126
+ )
127
+
128
+
129
+ def installed_app_bundles() -> list[str]:
130
+ """Resolve app bundles present on this machine."""
131
+ override = os.environ.get("RESOLVE_APP")
132
+ candidates = (override,) + _APP_BUNDLE_CANDIDATES if override else _APP_BUNDLE_CANDIDATES
133
+ return [path for path in candidates if path and Path(path).is_dir()]
134
+
135
+
136
+ def stale_container_warning(targets: list[Path]) -> str | None:
137
+ """Are we about to install into a container left by an *uninstalled* Resolve?
138
+
139
+ A container outlives the app that created it — uninstalling Resolve leaves
140
+ the whole `~/Library/Containers/com.blackmagic-design.*` tree behind. Since
141
+ a container's existence is what makes us target it, the installer would
142
+ otherwise report a clean success on a machine with no Resolve at all, and
143
+ the user would go hunting through the Scripts menu of an app they do not
144
+ have (issue #104). Warn rather than refuse: we cannot enumerate every place
145
+ an app bundle might legitimately live, and being wrong in the refusing
146
+ direction blocks a working install.
147
+ """
148
+ if not any("Containers" in str(target) for target in targets):
149
+ return None
150
+ if installed_app_bundles():
151
+ return None
152
+ return (
153
+ "Installed into a sandbox container, but no DaVinci Resolve app bundle "
154
+ "was found on this machine. A container OUTLIVES the app that created "
155
+ "it, so this is what an uninstalled Resolve looks like — the files were "
156
+ "written, but nothing will ever read them. Checked: "
157
+ + ", ".join(_APP_BUNDLE_CANDIDATES)
158
+ + ". If Resolve is installed somewhere else, re-run with RESOLVE_APP "
159
+ "set to its .app bundle to silence this. If it is not installed, "
160
+ "install it first, then re-run."
161
+ )
162
+
163
+
101
164
  #: Resolve enumerates `.py` scripts in Workspace > Scripts only when it finds a
102
165
  #: **framework** Python. Homebrew/pyenv/conda interpreters are not detected, and
103
166
  #: the failure is completely silent: the script sits in the right folder with the
@@ -233,6 +296,8 @@ def install(*, probe_only: bool, port: int, rotate: bool) -> dict:
233
296
  launcher_path.write_text(launcher, encoding="utf-8")
234
297
  installed.append(str(launcher_path))
235
298
  result = {"installed": installed, "probe_only": probe_only, "python": python_preflight()}
299
+ stale = stale_container_warning(targets)
300
+ result["warnings"] = [stale] if stale else []
236
301
  if not probe_only:
237
302
  loaded = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
238
303
  result["config"] = {"path": str(CONFIG_PATH),
@@ -258,6 +323,11 @@ def main() -> int:
258
323
  print("WARNING: " + result["python"]["advice"])
259
324
  print("!" * 72)
260
325
  print()
326
+ for warning in result["warnings"]:
327
+ print("!" * 72)
328
+ print("WARNING: " + warning)
329
+ print("!" * 72)
330
+ print()
261
331
  print("Next:")
262
332
  print(" 1. Restart DaVinci Resolve so it re-scans the Scripts folders.")
263
333
  print(" 2. Open a saved project (the Scripts menu is empty in Project Manager).")
@@ -0,0 +1,345 @@
1
+ #!/usr/bin/env python3
2
+ """Exhaustively difference GUI against headless Resolve, surviving hangs.
3
+
4
+ python scripts/mode_matrix.py run --mode gui --out runs/gui.jsonl
5
+ python scripts/mode_matrix.py run --mode headless --out runs/headless.jsonl
6
+ python scripts/mode_matrix.py compare runs/gui.jsonl runs/headless.jsonl \
7
+ --out docs/reference/headless-capability-matrix.md
8
+
9
+ ## The architecture, and why it is not one script
10
+
11
+ `ProjectManager.SaveProject()` on the never-saved `Untitled Project` never
12
+ returns headless. Any harness that calls it in-process stops there, and its
13
+ report is whatever it wrote before it died. The first differential could not have
14
+ found that bug, and did not.
15
+
16
+ So this splits in two:
17
+
18
+ - a **worker** that runs probes in order and streams each result to JSONL,
19
+ flushed per line;
20
+ - a **supervisor** that runs the worker with a deadline. If the worker exits
21
+ early or overruns, the supervisor reads the JSONL, sees which probe has no
22
+ result, records it as `hang`, restarts Resolve, waits for it to be healthy,
23
+ and relaunches the worker to resume *after* the offender.
24
+
25
+ The consequence worth stating: a hang costs one probe and one Resolve restart,
26
+ not the run. That is what makes "exhaustive" possible at all.
27
+
28
+ ## Health, not liveness
29
+
30
+ Between batches the supervisor requires `GetCurrentDatabase()` to be non-None.
31
+ Resolve can accept scripting connections, answer product/version/page queries,
32
+ and still have no database attached — in that state every project call returns
33
+ False forever. A liveness check passes straight through it; only the database
34
+ reading catches it.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import argparse
40
+ import json
41
+ import os
42
+ import subprocess
43
+ import sys
44
+ import time
45
+ from datetime import datetime, timezone
46
+ from pathlib import Path
47
+ from typing import Any, Dict, List, Optional
48
+
49
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
50
+
51
+ from src.utils import mode_matrix as mm # noqa: E402
52
+ from src.utils import resolve_runtime as rr # noqa: E402
53
+ from src.utils.probe_catalogue import CATALOGUE, fingerprint # noqa: E402
54
+
55
+ WORKER = Path(__file__).resolve().parent / "mode_matrix_worker.py"
56
+
57
+
58
+ def scripting_env() -> Dict[str, str]:
59
+ env = dict(os.environ)
60
+ api = env.get(
61
+ "RESOLVE_SCRIPT_API",
62
+ "/Library/Application Support/Blackmagic Design/DaVinci Resolve/Developer/Scripting",
63
+ )
64
+ env["RESOLVE_SCRIPT_API"] = api
65
+ env.setdefault(
66
+ "RESOLVE_SCRIPT_LIB",
67
+ "/Applications/DaVinci Resolve/DaVinci Resolve.app/Contents/Libraries/Fusion/fusionscript.so",
68
+ )
69
+ modules = str(Path(api) / "Modules")
70
+ env["PYTHONPATH"] = f"{modules}:{env['PYTHONPATH']}" if env.get("PYTHONPATH") else modules
71
+ env.pop("DAVINCI_RESOLVE_BRIDGE", None)
72
+ return env
73
+
74
+
75
+ # ── Resolve supervision ──────────────────────────────────────────────────────
76
+
77
+
78
+ def health(timeout: float = 420.0) -> Optional[Dict[str, Any]]:
79
+ """Wait for a Resolve whose ProjectManager has a database attached."""
80
+ code = (
81
+ "import DaVinciResolveScript as d, json, time, sys\n"
82
+ "for _ in range(int(sys.argv[1])):\n"
83
+ " r = d.scriptapp('Resolve')\n"
84
+ " if r is not None:\n"
85
+ " try:\n"
86
+ " pm = r.GetProjectManager()\n"
87
+ " db = pm.GetCurrentDatabase() if pm else None\n"
88
+ " if db:\n"
89
+ " print(json.dumps(db)); sys.exit(0)\n"
90
+ " except Exception: pass\n"
91
+ " time.sleep(1)\n"
92
+ "sys.exit(3)\n"
93
+ )
94
+ try:
95
+ out = subprocess.run([sys.executable, "-c", code, str(int(timeout))],
96
+ env=scripting_env(), capture_output=True, text=True,
97
+ timeout=timeout + 60)
98
+ except subprocess.TimeoutExpired:
99
+ print(" health: timed out waiting for a database", file=sys.stderr)
100
+ return None
101
+ if out.returncode != 0:
102
+ # Say WHY. "no healthy Resolve" with no reason sent this investigation
103
+ # looking at Resolve twice when the probe subprocess had simply failed to
104
+ # start — an unhelpful error of exactly the kind this study keeps finding
105
+ # in the API it is measuring.
106
+ detail = (out.stderr or out.stdout or "").strip().splitlines()
107
+ print(f" health: probe exited {out.returncode}"
108
+ + (f": {detail[-1][:200]}" if detail else " with no output"), file=sys.stderr)
109
+ return None
110
+ try:
111
+ return json.loads(out.stdout.strip().splitlines()[-1])
112
+ except Exception:
113
+ print(f" health: unparseable output {out.stdout.strip()[:200]!r}", file=sys.stderr)
114
+ return None
115
+
116
+
117
+ def stop_resolve(force_after: float = 30.0) -> str:
118
+ """Quit, escalating to a kill. Returns how it went."""
119
+ code = (
120
+ "import DaVinciResolveScript as d, signal\n"
121
+ "signal.signal(signal.SIGALRM, lambda *a: (_ for _ in ()).throw(SystemExit(7)))\n"
122
+ "signal.alarm(20)\n"
123
+ "r = d.scriptapp('Resolve')\n"
124
+ "r and r.Quit()\n"
125
+ )
126
+ subprocess.run([sys.executable, "-c", code], env=scripting_env(),
127
+ capture_output=True, text=True, timeout=60, check=False)
128
+ deadline = time.monotonic() + force_after
129
+ while time.monotonic() < deadline:
130
+ if not (rr.resolve_processes() or []):
131
+ return "clean"
132
+ time.sleep(1)
133
+ subprocess.run(["pkill", "-9", "-f", "MacOS/Resolve"], check=False)
134
+ time.sleep(8)
135
+ return "forced"
136
+
137
+
138
+ def start_resolve(headless: bool) -> bool:
139
+ command = rr.launch_command(headless=headless)
140
+ if command is None:
141
+ return False
142
+ subprocess.Popen(command, stdin=subprocess.DEVNULL,
143
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
144
+ return True
145
+
146
+
147
+ def restart(headless: bool) -> Optional[Dict[str, Any]]:
148
+ how = stop_resolve()
149
+ print(f" resolve stopped ({how}); restarting {'headless' if headless else 'gui'}", flush=True)
150
+ if not start_resolve(headless):
151
+ return None
152
+ # Generous: a boot after an unclean shutdown was measured at 2m05s to a
153
+ # usable script server, against under 3s clean. Giving up early here would
154
+ # abort the run for a Resolve that was merely slow.
155
+ return health(420.0)
156
+
157
+
158
+ # ── the run ──────────────────────────────────────────────────────────────────
159
+
160
+
161
+ def completed_probes(path: Path) -> Dict[str, Dict[str, Any]]:
162
+ results: Dict[str, Dict[str, Any]] = {}
163
+ if not path.exists():
164
+ return results
165
+ for line in path.read_text(errors="replace").splitlines():
166
+ line = line.strip()
167
+ if not line:
168
+ continue
169
+ try:
170
+ record = json.loads(line)
171
+ except json.JSONDecodeError:
172
+ continue # a torn final line from a killed worker
173
+ # `_start` breadcrumbs are phase markers, not results; `last_phase`
174
+ # reads them. Keyed apart so a marker can never be mistaken for an
175
+ # outcome.
176
+ if "probe" in record:
177
+ results[record["probe"]] = record
178
+ return results
179
+
180
+
181
+ def last_phase(path: Path) -> Optional[Dict[str, Any]]:
182
+ """The most recent phase breadcrumb, naming what was in flight."""
183
+ if not path.exists():
184
+ return None
185
+ marker = None
186
+ for line in path.read_text(errors="replace").splitlines():
187
+ line = line.strip()
188
+ if not line:
189
+ continue
190
+ try:
191
+ record = json.loads(line)
192
+ except json.JSONDecodeError:
193
+ continue
194
+ if "_start" in record:
195
+ marker = record
196
+ return marker
197
+
198
+
199
+ def append(path: Path, record: Dict[str, Any]) -> None:
200
+ with path.open("a") as handle:
201
+ handle.write(json.dumps(record, default=str) + "\n")
202
+
203
+
204
+ def run(mode: str, out: Path, only: Optional[List[str]], batch_timeout: float) -> int:
205
+ headless = mode == "headless"
206
+ observed = rr.runtime_mode()
207
+ if observed["running"] and bool(observed["headless"]) != headless:
208
+ print(f"REFUSE: a Resolve is running in the other mode: {observed['command_lines']}",
209
+ file=sys.stderr)
210
+ return 1
211
+ if not observed["running"]:
212
+ start_resolve(headless)
213
+ db = health()
214
+ if db is None:
215
+ print("REFUSE: no healthy Resolve (no database attached).", file=sys.stderr)
216
+ return 2
217
+
218
+ # Blocking probes are opt-in by name. One of them stops the whole
219
+ # application in both modes, costing a force-kill and a slow restart, so a
220
+ # sweep that included them by default could never run to completion
221
+ # unattended — which is the entire point of a sweep.
222
+ if only:
223
+ planned = [p.name for p in CATALOGUE if p.name in only]
224
+ else:
225
+ planned = [p.name for p in CATALOGUE if not p.blocking]
226
+ skipped_blocking = [p.name for p in CATALOGUE if p.blocking and p.name not in planned]
227
+ out.parent.mkdir(parents=True, exist_ok=True)
228
+ print(f"mode={mode} db={json.dumps(db)} probes={len(planned)} out={out}", flush=True)
229
+ if skipped_blocking:
230
+ # Never silently: an excluded probe that vanishes from the report reads
231
+ # as one that passed.
232
+ print(f" excluded (blocking, run explicitly with --only): {', '.join(skipped_blocking)}",
233
+ flush=True)
234
+
235
+ hangs = 0
236
+ while True:
237
+ done = completed_probes(out)
238
+ remaining = [n for n in planned if n not in done]
239
+ if not remaining:
240
+ break
241
+ print(f" worker: {len(remaining)} probe(s) remaining, starting at {remaining[0]}", flush=True)
242
+ proc = subprocess.Popen(
243
+ [sys.executable, "-u", str(WORKER), "--out", str(out), "--probes", *remaining],
244
+ env=scripting_env(), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
245
+ )
246
+ try:
247
+ proc.communicate(timeout=batch_timeout)
248
+ except subprocess.TimeoutExpired:
249
+ proc.kill()
250
+ proc.communicate()
251
+
252
+ after = completed_probes(out)
253
+ newly = [n for n in remaining if n in after]
254
+ still = [n for n in remaining if n not in after]
255
+ if not still:
256
+ break
257
+ # The first unrecorded probe is the one that was in flight when the
258
+ # worker stopped. Nothing else can have taken it down: results are
259
+ # flushed per line, in order.
260
+ culprit = still[0]
261
+ probe = next(p for p in CATALOGUE if p.name == culprit)
262
+ marker = last_phase(out) or {}
263
+ phase = marker.get("phase") if marker.get("_start") == culprit else "unknown"
264
+ hangs += 1
265
+ print(f" !! {culprit} did not return in phase={phase} "
266
+ f"(worker completed {len(newly)} probe(s) first)", flush=True)
267
+ append(out, {
268
+ "probe": culprit, "category": probe.category, "outcome": "hang",
269
+ "value": None, "seconds": None, "mode": mode, "phase": phase,
270
+ "catalogue": fingerprint(),
271
+ # A hang during `state` setup is NOT evidence about the probe's own
272
+ # call; it is evidence about reaching the state. Recorded as such so
273
+ # the comparison does not report a bug in the wrong place.
274
+ "detail": ("hung while setting up its state, not in the probe call itself"
275
+ if phase == "state" else
276
+ "worker died or overran with this probe's call in flight"),
277
+ })
278
+ if restart(headless) is None:
279
+ print("REFUSE: Resolve did not come back healthy after a hang.", file=sys.stderr)
280
+ return 3
281
+
282
+ results = completed_probes(out)
283
+ counts: Dict[str, int] = {}
284
+ for record in results.values():
285
+ counts[record.get("outcome", "?")] = counts.get(record.get("outcome", "?"), 0) + 1
286
+ print(json.dumps({"mode": mode, "probes": len(results), "outcomes": counts,
287
+ "hangs_recovered": hangs}, indent=2))
288
+ return 0
289
+
290
+
291
+ def compare(gui_paths: List[Path], headless_paths: List[Path], out: Optional[Path]) -> int:
292
+ gui_stores, headless_stores = [], []
293
+ for label, paths, sink in (("gui", gui_paths, gui_stores),
294
+ ("headless", headless_paths, headless_stores)):
295
+ for path in paths:
296
+ store = completed_probes(path)
297
+ modes = {r.get("mode") for r in store.values()}
298
+ # A mislabeled file inverts every verdict silently; the recorded mode
299
+ # is the check, not the filename.
300
+ if modes and modes != {label}:
301
+ print(f"REFUSE: {path} was labelled {label} but contains {modes}", file=sys.stderr)
302
+ return 1
303
+ stamps = {r.get("catalogue") for r in store.values() if r.get("catalogue")}
304
+ if stamps and stamps != {fingerprint()}:
305
+ print(f"REFUSE: {path} was recorded with catalogue {stamps}, "
306
+ f"current is {fingerprint()}. Re-run it; comparing across "
307
+ f"catalogue versions reports harness changes as findings.",
308
+ file=sys.stderr)
309
+ return 1
310
+ sink.append(store)
311
+ matrix = mm.build_repeated_matrix(gui_stores, headless_stores)
312
+ matrix["generated_at"] = datetime.now(timezone.utc).isoformat(timespec="seconds")
313
+ text = mm.render_markdown(matrix)
314
+ if out:
315
+ out.parent.mkdir(parents=True, exist_ok=True)
316
+ out.write_text(text, encoding="utf-8")
317
+ out.with_suffix(".json").write_text(json.dumps(matrix, indent=2, default=str), encoding="utf-8")
318
+ print(text)
319
+ return 0
320
+
321
+
322
+ def main() -> int:
323
+ parser = argparse.ArgumentParser(description=__doc__,
324
+ formatter_class=argparse.RawDescriptionHelpFormatter)
325
+ sub = parser.add_subparsers(dest="command", required=True)
326
+ r = sub.add_parser("run")
327
+ r.add_argument("--mode", choices=("gui", "headless"), required=True)
328
+ r.add_argument("--out", type=Path, required=True)
329
+ r.add_argument("--only", nargs="*")
330
+ r.add_argument("--batch-timeout", type=float, default=900.0)
331
+ c = sub.add_parser("compare")
332
+ # Lists, not single files: one run per mode reports flakes as findings, which
333
+ # was measured happening. Repeats are how a difference is distinguished from
334
+ # noise.
335
+ c.add_argument("--gui", type=Path, nargs="+", required=True)
336
+ c.add_argument("--headless", type=Path, nargs="+", required=True)
337
+ c.add_argument("--out", type=Path)
338
+ args = parser.parse_args()
339
+ if args.command == "run":
340
+ return run(args.mode, args.out, args.only, args.batch_timeout)
341
+ return compare(args.gui, args.headless, args.out)
342
+
343
+
344
+ if __name__ == "__main__":
345
+ raise SystemExit(main())