davinci-resolve-mcp 2.69.3 → 2.70.1
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 +171 -0
- package/README.md +16 -10
- package/docs/SKILL.md +89 -6
- package/docs/guides/headless-edit-loop.md +238 -0
- package/docs/reference/api-limitations.md +65 -4
- package/docs/reference/headless-capability-matrix.md +56 -0
- package/docs/reference/headless-cli.md +751 -0
- package/docs/reference/pixel-equality.md +11 -0
- package/docs/reference/roundtrip-fidelity.md +17 -0
- package/docs/reference/roundtrip-rich-fidelity.md +18 -0
- package/install.py +1 -1
- package/package.json +1 -1
- package/scripts/bisect_headless_hang.py +125 -0
- package/scripts/contact_sheet.py +209 -0
- package/scripts/doctor.py +87 -17
- package/scripts/headless_differential.py +578 -0
- package/scripts/install_resolve_bridge.py +174 -56
- package/scripts/mode_matrix.py +345 -0
- package/scripts/mode_matrix_worker.py +232 -0
- package/scripts/pixel_equality.py +402 -0
- package/scripts/relink_moved_media.py +267 -0
- package/scripts/render_stress.py +542 -0
- package/scripts/resolve_headless.py +225 -0
- package/scripts/roundtrip_matrix.py +434 -0
- package/scripts/roundtrip_rich.py +431 -0
- package/src/granular/common.py +1 -1
- package/src/server.py +92 -67
- package/src/utils/api_truth.py +302 -4
- package/src/utils/headless_differential.py +363 -0
- package/src/utils/mode_matrix.py +338 -0
- package/src/utils/probe_catalogue.py +796 -0
- package/src/utils/project_cleanup.py +61 -9
- package/src/utils/resolve_runtime.py +233 -0
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
python scripts/install_resolve_bridge.py --probe-only # settle the host model first
|
|
4
4
|
python scripts/install_resolve_bridge.py # probe + bridge launcher
|
|
5
5
|
|
|
6
|
-
Deploys to every applicable macOS/Linux location, including the sandboxed
|
|
7
|
-
Store container used by the free edition — which is why the free edition can
|
|
8
|
-
installed alongside a direct-download Studio without either disturbing the other.
|
|
6
|
+
Deploys to every applicable macOS/Windows/Linux location, including the sandboxed
|
|
7
|
+
App Store container used by the free edition — which is why the free edition can
|
|
8
|
+
be installed alongside a direct-download Studio without either disturbing the other.
|
|
9
9
|
|
|
10
10
|
Nothing is started here. Resolve must be restarted after installing so it
|
|
11
11
|
re-scans the Scripts folders, and the script is then run from
|
|
@@ -76,6 +76,56 @@ _SANDBOX_FALLBACK_SUFFIX = "Library/Application Support/Fusion/Scripts/Utility"
|
|
|
76
76
|
#: Containers that are not Resolve (RAW Player, Speed Test, the IO XPC helper).
|
|
77
77
|
_NON_RESOLVE_CONTAINERS = ("BlackmagicRaw", "IOXPC")
|
|
78
78
|
|
|
79
|
+
#: Windows Scripts/Utility trees, per Blackmagic's own README
|
|
80
|
+
#: (docs/reference/resolve_scripting_api.txt, "Specific user"/"All users").
|
|
81
|
+
#: Two traps, both live in those two lines:
|
|
82
|
+
#:
|
|
83
|
+
#: 1. The per-user tree carries a `Support` segment that the all-users tree does
|
|
84
|
+
#: NOT. They are not one layout under two roots, and deriving one from the
|
|
85
|
+
#: other lands in a folder Resolve never scans.
|
|
86
|
+
#: 2. The README writes the per-user root as `%APPDATA%\Roaming\...`, which is
|
|
87
|
+
#: wrong — `%APPDATA%` already IS `...\AppData\Roaming`, so following it
|
|
88
|
+
#: literally yields `AppData\Roaming\Roaming\...`. The paths below drop the
|
|
89
|
+
#: doubled segment; both were confirmed present and writable on the Windows 11
|
|
90
|
+
#: machine in issue #106.
|
|
91
|
+
_WINDOWS_USER_SCRIPTS_SUFFIX = (
|
|
92
|
+
"Blackmagic Design/DaVinci Resolve/Support/Fusion/Scripts/Utility"
|
|
93
|
+
)
|
|
94
|
+
_WINDOWS_ALL_USERS_SCRIPTS_SUFFIX = "Blackmagic Design/DaVinci Resolve/Fusion/Scripts/Utility"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
#: The product folder both Windows trees hang off. Its existence is the "Resolve
|
|
98
|
+
#: is installed" signal — see `_windows_script_candidates`.
|
|
99
|
+
_WINDOWS_PRODUCT_ROOT = "Blackmagic Design/DaVinci Resolve"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _windows_script_candidates() -> list[Path]:
|
|
103
|
+
"""Scripts/Utility trees Resolve scans on Windows (issue #106).
|
|
104
|
+
|
|
105
|
+
Gated on the *product* folder, not the Scripts/Utility tree, for the same
|
|
106
|
+
reason the sandbox branch is: Resolve does not create its `Fusion/Scripts`
|
|
107
|
+
tree until a script is installed, so requiring the target to pre-exist skips
|
|
108
|
+
a fresh free-edition install — the one build the bridge exists for. The
|
|
109
|
+
product folder is created on first launch, so it answers "is Resolve
|
|
110
|
+
installed" without demanding a folder only we will ever create.
|
|
111
|
+
|
|
112
|
+
Per-user first: it is the one a non-elevated account can actually write,
|
|
113
|
+
while `%PROGRAMDATA%` typically needs an elevated prompt. Ordering it first
|
|
114
|
+
means the target that will succeed is also the one reported first.
|
|
115
|
+
"""
|
|
116
|
+
candidates: list[Path] = []
|
|
117
|
+
for env_var, suffix in (
|
|
118
|
+
("APPDATA", _WINDOWS_USER_SCRIPTS_SUFFIX),
|
|
119
|
+
("PROGRAMDATA", _WINDOWS_ALL_USERS_SCRIPTS_SUFFIX),
|
|
120
|
+
):
|
|
121
|
+
root = os.environ.get(env_var)
|
|
122
|
+
if not root:
|
|
123
|
+
continue
|
|
124
|
+
target = Path(root) / suffix
|
|
125
|
+
if target.is_dir() or (Path(root) / _WINDOWS_PRODUCT_ROOT).is_dir():
|
|
126
|
+
candidates.append(target)
|
|
127
|
+
return candidates
|
|
128
|
+
|
|
79
129
|
|
|
80
130
|
def _is_resolve_container(path: Path) -> bool:
|
|
81
131
|
if not path.name.startswith(_SANDBOX_MARKER):
|
|
@@ -91,28 +141,40 @@ def script_targets() -> list[Path]:
|
|
|
91
141
|
coexist with a direct-download Studio install. Each container contributes
|
|
92
142
|
TWO targets (see the module comment on issue #104): the documented tree
|
|
93
143
|
first, then the Fusion standalone tree that Lite also scans.
|
|
144
|
+
|
|
145
|
+
Windows has no sandbox and no `~/Library`, so it gets its own candidate list
|
|
146
|
+
(issue #106) — without one this returned empty on every Windows machine and
|
|
147
|
+
the installer exited with "Is Resolve installed?" on a working install.
|
|
148
|
+
Those candidates arrive pre-vetted, like the sandboxed ones: they are gated
|
|
149
|
+
on Resolve's product folder rather than on the Scripts tree, which Resolve
|
|
150
|
+
does not create until a script is installed.
|
|
94
151
|
"""
|
|
95
152
|
home = Path.home()
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
153
|
+
# Pre-vetted targets, whose tree gets created rather than being required.
|
|
154
|
+
vetted: list[Path] = []
|
|
155
|
+
candidates: list[Path] = []
|
|
156
|
+
if sys.platform == "win32":
|
|
157
|
+
vetted.extend(_windows_script_candidates())
|
|
158
|
+
else:
|
|
159
|
+
candidates = [
|
|
160
|
+
home / _SCRIPTS_SUFFIX,
|
|
161
|
+
Path("/") / _SCRIPTS_SUFFIX,
|
|
162
|
+
home / ".local/share/DaVinciResolve/Fusion/Scripts/Utility",
|
|
163
|
+
]
|
|
164
|
+
containers = home / "Library/Containers"
|
|
165
|
+
if containers.is_dir():
|
|
166
|
+
for entry in sorted(containers.iterdir()):
|
|
167
|
+
if _is_resolve_container(entry) and (entry / "Data").is_dir():
|
|
168
|
+
# The container's existence is the signal; the tree gets created.
|
|
169
|
+
vetted.append(entry / "Data" / _SCRIPTS_SUFFIX)
|
|
170
|
+
vetted.append(entry / "Data" / _SANDBOX_FALLBACK_SUFFIX)
|
|
108
171
|
|
|
109
172
|
usable: list[Path] = []
|
|
110
173
|
for path in candidates:
|
|
111
174
|
# Non-sandboxed: the install created the tree, so require it.
|
|
112
175
|
if path.is_dir() or (path.parent.is_dir() and os.access(path.parent, os.W_OK)):
|
|
113
176
|
usable.append(path)
|
|
114
|
-
|
|
115
|
-
usable.extend(sandboxed)
|
|
177
|
+
usable.extend(vetted)
|
|
116
178
|
return list(dict.fromkeys(usable))
|
|
117
179
|
|
|
118
180
|
|
|
@@ -197,7 +259,23 @@ def framework_pythons() -> list[str]:
|
|
|
197
259
|
|
|
198
260
|
|
|
199
261
|
def python_preflight() -> dict:
|
|
200
|
-
"""Will Resolve list the Python probe we are about to install?
|
|
262
|
+
"""Will Resolve list the Python probe we are about to install?
|
|
263
|
+
|
|
264
|
+
The framework-Python trap is **macOS-only**: `/Library/Frameworks/...` does
|
|
265
|
+
not exist on Windows or Linux, where Resolve finds Python by other means (the
|
|
266
|
+
registry, or the system interpreter). Running the macOS check there always
|
|
267
|
+
found nothing and emitted the macOS remediation — telling the Windows 11 user
|
|
268
|
+
in issue #106, who had a working python.org 3.12, to go install the Python
|
|
269
|
+
they already had. Report "no known reason it will not list" off macOS rather
|
|
270
|
+
than a false alarm; the Lua canary still ships either way, so a genuine
|
|
271
|
+
enumeration problem remains diagnosable.
|
|
272
|
+
"""
|
|
273
|
+
if sys.platform != "darwin":
|
|
274
|
+
return {
|
|
275
|
+
"framework_pythons": [],
|
|
276
|
+
"resolve_will_list_python_scripts": True,
|
|
277
|
+
"advice": None,
|
|
278
|
+
}
|
|
201
279
|
frameworks = framework_pythons()
|
|
202
280
|
return {
|
|
203
281
|
"framework_pythons": frameworks,
|
|
@@ -256,48 +334,41 @@ def install(*, probe_only: bool, port: int, rotate: bool) -> dict:
|
|
|
256
334
|
"No writable DaVinci Resolve Scripts/Utility folder found. Is Resolve installed?"
|
|
257
335
|
)
|
|
258
336
|
installed: list[str] = []
|
|
337
|
+
# A target can be real, scanned by Resolve, and still unwritable — the
|
|
338
|
+
# Windows all-users tree under %PROGRAMDATA% needs elevation, and Windows
|
|
339
|
+
# `os.access(W_OK)` reports the read-only flag rather than the ACL, so the
|
|
340
|
+
# filter above cannot screen it out. One un-writable target must not abort an
|
|
341
|
+
# install that already succeeded into the per-user tree, so failures are
|
|
342
|
+
# collected and only a clean sweep is fatal.
|
|
343
|
+
skipped: list[tuple[str, str]] = []
|
|
259
344
|
for target in targets:
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
installed.append(str(runtime / module))
|
|
275
|
-
# A sandboxed (App Store) build cannot read ~/.config at all — inside
|
|
276
|
-
# the container `~` IS the container, so an absolute real-home path is
|
|
277
|
-
# unreachable by construction (measured: "Operation not permitted").
|
|
278
|
-
# The runtime dir is already inside the container, so the config goes
|
|
279
|
-
# beside the modules. Same token, so the out-of-sandbox client still
|
|
280
|
-
# authenticates against ~/.config.
|
|
281
|
-
runtime_config = runtime / "bridge.json"
|
|
282
|
-
shutil.copy2(CONFIG_PATH, runtime_config)
|
|
283
|
-
os.chmod(runtime_config, 0o600)
|
|
284
|
-
installed.append(str(runtime_config))
|
|
285
|
-
# The launcher IS the menu entry. Without it the modules sit in the
|
|
286
|
-
# runtime dir with nothing able to start them.
|
|
287
|
-
launcher_source = (REPO / "scripts/resolve_bridge_launcher.py").read_text(encoding="utf-8")
|
|
288
|
-
launcher = launcher_source.replace(
|
|
289
|
-
"@@RUNTIME_ROOT_B64@@",
|
|
290
|
-
base64.urlsafe_b64encode(str(runtime).encode("utf-8")).decode("ascii"),
|
|
291
|
-
).replace(
|
|
292
|
-
"@@CONFIG_PATH_B64@@",
|
|
293
|
-
base64.urlsafe_b64encode(str(CONFIG_PATH).encode("utf-8")).decode("ascii"),
|
|
294
|
-
)
|
|
295
|
-
launcher_path = target / "resolve_bridge.py"
|
|
296
|
-
launcher_path.write_text(launcher, encoding="utf-8")
|
|
297
|
-
installed.append(str(launcher_path))
|
|
345
|
+
try:
|
|
346
|
+
target.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
347
|
+
_install_to(target, probe_only=probe_only, installed=installed)
|
|
348
|
+
except OSError as exc:
|
|
349
|
+
skipped.append((str(target), str(exc)))
|
|
350
|
+
if not installed:
|
|
351
|
+
raise SystemExit(
|
|
352
|
+
"Found DaVinci Resolve Scripts/Utility folders but could not write to "
|
|
353
|
+
"any of them:\n"
|
|
354
|
+
+ "\n".join(f" {path}: {reason}" for path, reason in skipped)
|
|
355
|
+
+ "\nRe-run from an account that can write these folders (on Windows, "
|
|
356
|
+
"the %PROGRAMDATA% tree needs an elevated prompt; the per-user "
|
|
357
|
+
"%APPDATA% tree does not)."
|
|
358
|
+
)
|
|
298
359
|
result = {"installed": installed, "probe_only": probe_only, "python": python_preflight()}
|
|
299
360
|
stale = stale_container_warning(targets)
|
|
300
361
|
result["warnings"] = [stale] if stale else []
|
|
362
|
+
if skipped:
|
|
363
|
+
result["warnings"].append(
|
|
364
|
+
"Skipped "
|
|
365
|
+
+ str(len(skipped))
|
|
366
|
+
+ " unwritable Scripts/Utility folder(s): "
|
|
367
|
+
+ "; ".join(f"{path} ({reason})" for path, reason in skipped)
|
|
368
|
+
+ ". This is harmless as long as the bridge appears under "
|
|
369
|
+
"Workspace > Scripts."
|
|
370
|
+
)
|
|
371
|
+
result["skipped"] = [path for path, _ in skipped]
|
|
301
372
|
if not probe_only:
|
|
302
373
|
loaded = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
|
303
374
|
result["config"] = {"path": str(CONFIG_PATH),
|
|
@@ -305,6 +376,53 @@ def install(*, probe_only: bool, port: int, rotate: bool) -> dict:
|
|
|
305
376
|
return result
|
|
306
377
|
|
|
307
378
|
|
|
379
|
+
def _install_to(target: Path, *, probe_only: bool, installed: list[str]) -> None:
|
|
380
|
+
"""Place the probes, canary and (unless probe-only) the bridge into `target`.
|
|
381
|
+
|
|
382
|
+
Raises OSError if the target turns out to be unwritable; `install()` treats
|
|
383
|
+
that as a skip rather than a failure so one un-writable tree cannot abort an
|
|
384
|
+
install that succeeded elsewhere.
|
|
385
|
+
"""
|
|
386
|
+
for probe in ("resolve_bridge_probe.py", "resolve_capability_probe.py"):
|
|
387
|
+
shutil.copy2(REPO / "scripts" / probe, target / probe)
|
|
388
|
+
installed.append(str(target / probe))
|
|
389
|
+
# Lua always enumerates; Python only with a framework install. The canary
|
|
390
|
+
# makes "Python not detected" distinguishable from "wrong folder".
|
|
391
|
+
canary = target / "resolve_bridge_canary.lua"
|
|
392
|
+
canary.write_text(_LUA_CANARY, encoding="utf-8")
|
|
393
|
+
installed.append(str(canary))
|
|
394
|
+
if probe_only:
|
|
395
|
+
return
|
|
396
|
+
runtime = target.parents[1] / ".davinci_mcp_runtime"
|
|
397
|
+
runtime.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
398
|
+
for module in ("resolve_bridge.py", "resolve_bridge_ops.py"):
|
|
399
|
+
shutil.copy2(REPO / "src/utils" / module, runtime / module)
|
|
400
|
+
installed.append(str(runtime / module))
|
|
401
|
+
# A sandboxed (App Store) build cannot read ~/.config at all — inside
|
|
402
|
+
# the container `~` IS the container, so an absolute real-home path is
|
|
403
|
+
# unreachable by construction (measured: "Operation not permitted").
|
|
404
|
+
# The runtime dir is already inside the container, so the config goes
|
|
405
|
+
# beside the modules. Same token, so the out-of-sandbox client still
|
|
406
|
+
# authenticates against ~/.config.
|
|
407
|
+
runtime_config = runtime / "bridge.json"
|
|
408
|
+
shutil.copy2(CONFIG_PATH, runtime_config)
|
|
409
|
+
os.chmod(runtime_config, 0o600)
|
|
410
|
+
installed.append(str(runtime_config))
|
|
411
|
+
# The launcher IS the menu entry. Without it the modules sit in the
|
|
412
|
+
# runtime dir with nothing able to start them.
|
|
413
|
+
launcher_source = (REPO / "scripts/resolve_bridge_launcher.py").read_text(encoding="utf-8")
|
|
414
|
+
launcher = launcher_source.replace(
|
|
415
|
+
"@@RUNTIME_ROOT_B64@@",
|
|
416
|
+
base64.urlsafe_b64encode(str(runtime).encode("utf-8")).decode("ascii"),
|
|
417
|
+
).replace(
|
|
418
|
+
"@@CONFIG_PATH_B64@@",
|
|
419
|
+
base64.urlsafe_b64encode(str(CONFIG_PATH).encode("utf-8")).decode("ascii"),
|
|
420
|
+
)
|
|
421
|
+
launcher_path = target / "resolve_bridge.py"
|
|
422
|
+
launcher_path.write_text(launcher, encoding="utf-8")
|
|
423
|
+
installed.append(str(launcher_path))
|
|
424
|
+
|
|
425
|
+
|
|
308
426
|
def main() -> int:
|
|
309
427
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
310
428
|
parser.add_argument("--probe-only", action="store_true",
|
|
@@ -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())
|