davinci-resolve-mcp 2.69.0 → 2.69.3
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 +155 -0
- package/README.md +1 -1
- package/install.py +8 -3
- package/package.json +1 -1
- package/resolve-advanced/package.json +3 -3
- package/scripts/install_resolve_bridge.py +75 -5
- package/src/granular/common.py +1 -1
- package/src/server.py +1 -1
- package/src/utils/bridge_differential.py +68 -2
- package/src/utils/platform.py +10 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,161 @@
|
|
|
2
2
|
|
|
3
3
|
Release history for the DaVinci Resolve MCP Server. The latest release is summarized in the root README; older entries live here to keep the README focused.
|
|
4
4
|
|
|
5
|
+
## What's New in v2.69.3
|
|
6
|
+
|
|
7
|
+
Ships the v2.69.2 bundle, which never reached npm. Same fixes, plus the one that
|
|
8
|
+
stopped it publishing.
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **The offline guard failed the publish workflow instead of guarding it.**
|
|
13
|
+
`tests/__init__.py` installs the guard before any test module loads, and the
|
|
14
|
+
guard imports `src.server` — which imports `anyio`. The publish workflow
|
|
15
|
+
installs only pyflakes, by design: the static drift guards are pure AST
|
|
16
|
+
readers that deliberately run without the runtime stack. So every module
|
|
17
|
+
argument raised at import, six of them turned into six `_FailedTest` errors,
|
|
18
|
+
and the npm publish failed with what looked like six broken guards rather than
|
|
19
|
+
one missing package.
|
|
20
|
+
|
|
21
|
+
This shipped in v2.69.1's tail (the guard moved under `unittest` after that
|
|
22
|
+
tag was cut), so v2.69.2 was the first release to run it and the first to fail.
|
|
23
|
+
|
|
24
|
+
A missing third-party package now skips the swap: if `src.server` cannot be
|
|
25
|
+
imported there is no live-Resolve entry point to neuter, so the guard is
|
|
26
|
+
vacuously satisfied. The skip is narrow on purpose — a `ModuleNotFoundError`
|
|
27
|
+
naming something under `src/`, or any non-import error, still propagates.
|
|
28
|
+
Swallowing those would leave the static guards silently vacuous, which is a
|
|
29
|
+
worse failure than the one being fixed since it fails open.
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
|
|
33
|
+
- `tests/test_offline_guard.py` — five tests, including the two that matter:
|
|
34
|
+
a broken import inside `src/` must still raise, and a non-import error must
|
|
35
|
+
never be swallowed. A skip that fails open would be indistinguishable from a
|
|
36
|
+
passing suite.
|
|
37
|
+
|
|
38
|
+
### Validation
|
|
39
|
+
|
|
40
|
+
- Offline suite 2247 → 2252. Reproduced the CI failure locally against an
|
|
41
|
+
interpreter without `anyio` (six errors before, clean after), rather than
|
|
42
|
+
inferring it from the workflow log.
|
|
43
|
+
|
|
44
|
+
## What's New in v2.69.2
|
|
45
|
+
|
|
46
|
+
Community bug-fix bundle: #100, #101 and #102, plus the installer half of #104.
|
|
47
|
+
The headline is that a fresh clone had stopped working entirely.
|
|
48
|
+
|
|
49
|
+
### Fixed
|
|
50
|
+
|
|
51
|
+
- **A fresh install produced a server that could not start** (issue #103, PR
|
|
52
|
+
#101 by @Mastaish). `install.py` installed `mcp[cli]` with no upper bound, and
|
|
53
|
+
the MCP Python SDK published 2.0.0 — which restructured the package and
|
|
54
|
+
dropped `mcp.server.fastmcp`, the module `src/server.py` imports. Every clone
|
|
55
|
+
after that release died at import, surfacing to the user as nothing more
|
|
56
|
+
informative than "Server disconnected".
|
|
57
|
+
|
|
58
|
+
The contributed fix caps the SDK in `requirements.txt`, which `install.py`
|
|
59
|
+
installs second. That works, but the unpinned first call still downloads 2.x
|
|
60
|
+
and its `httpx2` tree before downgrading it, and the fix evaporates silently
|
|
61
|
+
if the install order ever changes — so the cap is now on the `pip install`
|
|
62
|
+
call as well. `McpSdkPinTest` guards both sites, reading the actual string
|
|
63
|
+
literals rather than matching text: `install.py` discusses `mcp[cli]` in prose
|
|
64
|
+
too, and a first cut of that guard was satisfied by a comment. It is
|
|
65
|
+
conditional on `server.py` still importing `mcp.server.fastmcp`, so it retires
|
|
66
|
+
itself when the server is ported to the 2.x layout instead of blocking it.
|
|
67
|
+
|
|
68
|
+
- **The Advanced (Node) suite was not running at all on Node 20+** (PR #102 by
|
|
69
|
+
@double2tea). `node --test` resolves a bare directory argument as a module
|
|
70
|
+
entry point on Node 20 and later, so the suite exited with `MODULE_NOT_FOUND`
|
|
71
|
+
at 4 failures and 0 passes — a shape that reads more like a broken checkout
|
|
72
|
+
than 731 skipped tests. Explicit test-file globs restore the full run on 18,
|
|
73
|
+
20 and 22 alike.
|
|
74
|
+
|
|
75
|
+
- **`RESOLVE_SCRIPT_API` / `RESOLVE_SCRIPT_LIB` were silently overwritten** (PR
|
|
76
|
+
#100 by @abbc400). `get_resolve_paths()` returned only platform defaults, and
|
|
77
|
+
`src/server.py` writes those straight back into `os.environ` — so a client
|
|
78
|
+
that had correctly pointed at a Resolve installed outside `/Applications` had
|
|
79
|
+
its setting discarded at import. Every other signal looked healthy (Resolve
|
|
80
|
+
running, external scripting Local, `fuscript` listening) while `scriptapp()`
|
|
81
|
+
returned `None`. The override now wins, but only when the path exists, so a
|
|
82
|
+
stale variable cannot shadow a working default install.
|
|
83
|
+
|
|
84
|
+
- **The bridge installer wrote to a tree Resolve did not read** (issue #104,
|
|
85
|
+
reported by @RananjayRaj). Which script tree the free App Store build scans is
|
|
86
|
+
not decidable from outside Resolve: this installer was measured on a machine
|
|
87
|
+
where the documented Blackmagic Design tree listed, and #104 reports the exact
|
|
88
|
+
opposite on the same 21.0.3.7 build — the documented tree listed nothing, not
|
|
89
|
+
even the Lua canary (which rules out the framework-Python explanation and
|
|
90
|
+
points at the folder), while the Fusion standalone tree listed everything with
|
|
91
|
+
no restart.
|
|
92
|
+
|
|
93
|
+
Rather than pick a winner from two contradictory measurements, both container
|
|
94
|
+
trees now receive the files, documented path first. This stays inside the
|
|
95
|
+
sandbox: outside a container that path really is Fusion's own tree and is
|
|
96
|
+
still not targeted.
|
|
97
|
+
|
|
98
|
+
- **`--probe-only` reported success on a machine with no Resolve installed**
|
|
99
|
+
(also #104). A container outlives the app that created it, and the container's
|
|
100
|
+
existence is precisely what makes the installer target it — so a stale
|
|
101
|
+
container from an uninstalled Resolve produced a clean success listing files
|
|
102
|
+
that had genuinely been written and would never be read. The installer now
|
|
103
|
+
warns when no app bundle can be found, honoring `RESOLVE_APP` as
|
|
104
|
+
`scripts/doctor.py` already does. It warns rather than refuses: not every
|
|
105
|
+
legitimate install location can be enumerated, and refusing wrongly would
|
|
106
|
+
block a working install.
|
|
107
|
+
|
|
108
|
+
### Validation
|
|
109
|
+
|
|
110
|
+
- Offline suite 2237 → 2247 (six new installer-target tests, one pin guard).
|
|
111
|
+
Advanced Node suite 731 tests / 701 pass on Node 18.
|
|
112
|
+
- The new guards were verified to fail against the pre-fix code, not merely to
|
|
113
|
+
pass against the fix.
|
|
114
|
+
- No DaVinci Resolve scripting behavior changed; the path-resolution change is
|
|
115
|
+
env-var-gated with defaults unchanged. Live Resolve validation not required.
|
|
116
|
+
|
|
117
|
+
## What's New in v2.69.1
|
|
118
|
+
|
|
119
|
+
Bug fix. The Studio bridge differential no longer reports the Deliver page its
|
|
120
|
+
own render probes navigated to as a transport difference.
|
|
121
|
+
|
|
122
|
+
### Fixed
|
|
123
|
+
|
|
124
|
+
- **A clean bridge produced a red result on any run that did not start on
|
|
125
|
+
Deliver.** Run live on Studio 19.1.3.7, the differential reported
|
|
126
|
+
`resolve.GetCurrentPage` as a `value_mismatch` — bridge `edit`, native
|
|
127
|
+
`deliver` — and it reproduced on command: start on Edit, one difference; start
|
|
128
|
+
on Deliver, none. The transport was never involved. The harness's own render
|
|
129
|
+
probes navigate Resolve to the Deliver page, and the bridge pass runs before
|
|
130
|
+
the native pass, so the native pass read a page the bridge pass had moved. Read
|
|
131
|
+
at the same instant the two transports always agreed.
|
|
132
|
+
|
|
133
|
+
Adding the method to `VOLATILE_METHODS` would have turned red green and thrown
|
|
134
|
+
away the signal — a bridge that genuinely reports the wrong page is exactly
|
|
135
|
+
what this harness exists to catch. Instead each pass now records the page on
|
|
136
|
+
the way in and on the way out, and the value is compared **only when both
|
|
137
|
+
passes prove it held still**. If both were stable and the values still
|
|
138
|
+
disagree, that is the transport, and it is reported as before. A page that
|
|
139
|
+
cannot be read counts as unknown rather than stable, so an unreadable page
|
|
140
|
+
disables the comparison instead of quietly restoring the false positive.
|
|
141
|
+
|
|
142
|
+
`page_compared_by_value` and both passes' before/after values now ride in the
|
|
143
|
+
report either way. "The page was not compared" is a fact about the run, and
|
|
144
|
+
burying it would recreate the silence this fixes.
|
|
145
|
+
|
|
146
|
+
### Added
|
|
147
|
+
|
|
148
|
+
- `tests/test_bridge_differential.py` — the module's first tests, 13 of them,
|
|
149
|
+
including the one that matters: a stable page that genuinely disagrees must
|
|
150
|
+
still be reported, or the fix is a mute button rather than a fix. The absence
|
|
151
|
+
of any coverage here is why the false positive shipped.
|
|
152
|
+
|
|
153
|
+
### Validation
|
|
154
|
+
|
|
155
|
+
- Static checks and the full unit suite: 2243 → 2256 tests, pyflakes clean.
|
|
156
|
+
- Live Resolve validation on Studio 19.1.3.7 after the fix: 142 probes, 0
|
|
157
|
+
differences, 110/112 read methods, started from the Edit page that previously
|
|
158
|
+
failed.
|
|
159
|
+
|
|
5
160
|
## What's New in v2.69.0
|
|
6
161
|
|
|
7
162
|
The free edition is documented as reachable, silence ripple stops clipping
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# DaVinci Resolve MCP Server
|
|
2
2
|
|
|
3
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
4
4
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
5
5
|
[](docs/reference/api-coverage.md)
|
|
6
6
|
[-blue.svg)](#server-modes)
|
package/install.py
CHANGED
|
@@ -36,7 +36,7 @@ from src.utils.update_check import (
|
|
|
36
36
|
|
|
37
37
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
38
38
|
|
|
39
|
-
VERSION = "2.69.
|
|
39
|
+
VERSION = "2.69.3"
|
|
40
40
|
# Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
|
|
41
41
|
# Resolve's scripting bridge loads into newer interpreters on recent builds
|
|
42
42
|
# (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
|
|
@@ -796,9 +796,14 @@ def install_dependencies(venv_path, project_dir):
|
|
|
796
796
|
|
|
797
797
|
print(f" Installing dependencies...")
|
|
798
798
|
|
|
799
|
-
# Install MCP SDK
|
|
799
|
+
# Install MCP SDK. The upper bound is load-bearing: SDK 2.0.0 restructured
|
|
800
|
+
# the package and dropped `mcp.server.fastmcp`, which src/server.py imports.
|
|
801
|
+
# requirements.txt carries the same constraint, but it is installed second —
|
|
802
|
+
# pinning here too means we never download 2.x (and its httpx2 tree) only to
|
|
803
|
+
# downgrade it, and the fix does not depend on install ordering. Lift both
|
|
804
|
+
# together when server.py is ported to the 2.x layout.
|
|
800
805
|
subprocess.run(
|
|
801
|
-
[str(pip), "install", "-q", "mcp[cli]"],
|
|
806
|
+
[str(pip), "install", "-q", "mcp[cli]>=1.29,<2"],
|
|
802
807
|
check=True, capture_output=True
|
|
803
808
|
)
|
|
804
809
|
|
package/package.json
CHANGED
|
@@ -38,9 +38,9 @@
|
|
|
38
38
|
"_comment": "audio (split/trim/convert) + conform frame ops need system ffmpeg/ffprobe on PATH (NOT bundled — FFmpeg binaries are GPL; this package stays MIT). Install ffmpeg yourself: brew install ffmpeg."
|
|
39
39
|
},
|
|
40
40
|
"scripts": {
|
|
41
|
-
"test": "node --test vendor/drp-format/__tests__
|
|
42
|
-
"test:libs": "node --test vendor/drp-format/__tests__
|
|
43
|
-
"test:server": "node --test test
|
|
41
|
+
"test": "node --test vendor/drp-format/__tests__/*.test.js vendor/drt-format/__tests__/*.test.js vendor/drx-codec/__tests__/*.test.js test/*.test.mjs",
|
|
42
|
+
"test:libs": "node --test vendor/drp-format/__tests__/*.test.js vendor/drt-format/__tests__/*.test.js vendor/drx-codec/__tests__/*.test.js",
|
|
43
|
+
"test:server": "node --test test/*.test.mjs"
|
|
44
44
|
},
|
|
45
45
|
"engines": {
|
|
46
46
|
"node": ">=18.0.0"
|
|
@@ -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.
|
|
57
|
-
#:
|
|
58
|
-
#:
|
|
59
|
-
#:
|
|
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).")
|
package/src/granular/common.py
CHANGED
|
@@ -85,7 +85,7 @@ if not logging.getLogger().handlers:
|
|
|
85
85
|
handlers=[logging.StreamHandler()],
|
|
86
86
|
)
|
|
87
87
|
|
|
88
|
-
VERSION = "2.69.
|
|
88
|
+
VERSION = "2.69.3"
|
|
89
89
|
logger = logging.getLogger("davinci-resolve-mcp")
|
|
90
90
|
logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
|
|
91
91
|
logger.info(f"Detected platform: {get_platform()}")
|
package/src/server.py
CHANGED
|
@@ -61,6 +61,28 @@ VOLATILE_METHODS = frozenset({
|
|
|
61
61
|
"IsPlayheadOnVideoFrame",
|
|
62
62
|
})
|
|
63
63
|
|
|
64
|
+
#: App-global state that **the harness's own probes move**, which is a different
|
|
65
|
+
#: problem from `VOLATILE_METHODS` drifting on its own.
|
|
66
|
+
#:
|
|
67
|
+
#: Measured on Studio 19.1.3.7: the render probes navigate Resolve to the Deliver
|
|
68
|
+
#: page, and the bridge pass runs before the native pass — so the native pass
|
|
69
|
+
#: reads a page the bridge pass changed. That produced a `value_mismatch` on
|
|
70
|
+
#: `GetCurrentPage` (bridge `edit`, native `deliver`) on every run that did not
|
|
71
|
+
#: happen to start on Deliver, and none on the runs that did. Read at the same
|
|
72
|
+
#: instant the two transports always agreed.
|
|
73
|
+
#:
|
|
74
|
+
#: Excusing it unconditionally would throw away a real signal — a bridge that
|
|
75
|
+
#: genuinely reported the wrong page is exactly the bug this harness is for. So
|
|
76
|
+
#: each pass records the page on the way in and on the way out, and the value is
|
|
77
|
+
#: only excused when a pass proves it did not hold still. If both passes were
|
|
78
|
+
#: stable and the values still disagree, that is the transport and it is reported.
|
|
79
|
+
ORDER_SENSITIVE_METHODS = frozenset({"GetCurrentPage"})
|
|
80
|
+
|
|
81
|
+
#: Key `run_probes` adds for `compare`'s own use rather than as a probe result.
|
|
82
|
+
#: Underscore-prefixed so the diff skips it instead of reading it as a method
|
|
83
|
+
#: that one transport has and the other does not.
|
|
84
|
+
PASS_META_KEY = "_pass_meta"
|
|
85
|
+
|
|
64
86
|
#: Never invoked by the harness, whatever the enumeration says. These either
|
|
65
87
|
#: change the project, touch the filesystem, or block on UI. The write paths are
|
|
66
88
|
#: exercised by named scenarios instead, where the setup and teardown are
|
|
@@ -326,6 +348,19 @@ def probe_one(target: Any, method: str) -> Dict[str, Any]:
|
|
|
326
348
|
return {"outcome": "returned", "value": normalise(value), "type": describe(value)}
|
|
327
349
|
|
|
328
350
|
|
|
351
|
+
def _current_page(resolve: Any) -> Optional[str]:
|
|
352
|
+
"""The page, or None if it cannot be read.
|
|
353
|
+
|
|
354
|
+
Never raises: this is bookkeeping around the probes, and a failure here must
|
|
355
|
+
not take down a pass that is otherwise fine.
|
|
356
|
+
"""
|
|
357
|
+
try:
|
|
358
|
+
page = resolve.GetCurrentPage()
|
|
359
|
+
except Exception: # noqa: BLE001 - an unreadable page is simply unknown
|
|
360
|
+
return None
|
|
361
|
+
return page if isinstance(page, str) else None
|
|
362
|
+
|
|
363
|
+
|
|
329
364
|
def run_probes(resolve: Any, methods: Sequence[str]) -> Dict[str, Dict[str, Any]]:
|
|
330
365
|
"""Every enumerated safe method, against every object that has it.
|
|
331
366
|
|
|
@@ -333,6 +368,7 @@ def run_probes(resolve: Any, methods: Sequence[str]) -> Dict[str, Dict[str, Any]
|
|
|
333
368
|
not merely that they did.
|
|
334
369
|
"""
|
|
335
370
|
results: Dict[str, Dict[str, Any]] = {}
|
|
371
|
+
page_before = _current_page(resolve)
|
|
336
372
|
for label, chain in OBJECT_GRAPH:
|
|
337
373
|
target = walk(resolve, chain)
|
|
338
374
|
if target is None:
|
|
@@ -342,13 +378,28 @@ def run_probes(resolve: Any, methods: Sequence[str]) -> Dict[str, Dict[str, Any]
|
|
|
342
378
|
if getattr(target, method, None) is None:
|
|
343
379
|
continue # not on this object; absence is reported by the diff
|
|
344
380
|
results[f"{label}.{method}"] = probe_one(target, method)
|
|
381
|
+
page_after = _current_page(resolve)
|
|
382
|
+
results[PASS_META_KEY] = {
|
|
383
|
+
"page_before": page_before,
|
|
384
|
+
"page_after": page_after,
|
|
385
|
+
# None on either side means we could not tell, which must not read as
|
|
386
|
+
# "stable" — an unknown has to disable the value comparison, not enable it.
|
|
387
|
+
"page_stable": page_before is not None and page_before == page_after,
|
|
388
|
+
}
|
|
345
389
|
return results
|
|
346
390
|
|
|
347
391
|
|
|
348
392
|
def compare(native: Dict[str, Dict[str, Any]], bridged: Dict[str, Dict[str, Any]]) -> Dict[str, Any]:
|
|
349
393
|
"""Diff two probe runs. Empty `differences` is the pass condition."""
|
|
350
394
|
differences: List[Dict[str, Any]] = []
|
|
395
|
+
# A pass that moved the page cannot have its page compared by value against
|
|
396
|
+
# another pass taken at a different moment. Both must have held still.
|
|
397
|
+
native_meta = native.get(PASS_META_KEY) or {}
|
|
398
|
+
bridge_meta = bridged.get(PASS_META_KEY) or {}
|
|
399
|
+
page_held_still = bool(native_meta.get("page_stable")) and bool(bridge_meta.get("page_stable"))
|
|
351
400
|
for key in sorted(set(native) | set(bridged)):
|
|
401
|
+
if key.startswith("_"):
|
|
402
|
+
continue # harness bookkeeping, not a probe result
|
|
352
403
|
left, right = native.get(key), bridged.get(key)
|
|
353
404
|
if left is None or right is None:
|
|
354
405
|
differences.append({
|
|
@@ -364,17 +415,32 @@ def compare(native: Dict[str, Dict[str, Any]], bridged: Dict[str, Dict[str, Any]
|
|
|
364
415
|
differences.append({"key": key, "kind": "volatile_type_mismatch",
|
|
365
416
|
"native": left.get("type"), "bridge": right.get("type")})
|
|
366
417
|
continue
|
|
418
|
+
if method in ORDER_SENSITIVE_METHODS and not page_held_still:
|
|
419
|
+
# The harness moved it mid-run, so the two readings are of different
|
|
420
|
+
# moments. Still hold the transport to returning the same kind of
|
|
421
|
+
# thing, and say why the value was not compared.
|
|
422
|
+
if left.get("type") != right.get("type") or left["outcome"] != right["outcome"]:
|
|
423
|
+
differences.append({"key": key, "kind": "order_sensitive_type_mismatch",
|
|
424
|
+
"native": left.get("type"), "bridge": right.get("type")})
|
|
425
|
+
continue
|
|
367
426
|
if left != right:
|
|
368
427
|
differences.append({"key": key, "kind": "value_mismatch",
|
|
369
428
|
"native": left, "bridge": right})
|
|
370
|
-
|
|
429
|
+
probe_keys = {k for k in set(native) | set(bridged) if not k.startswith("_")}
|
|
430
|
+
agreed = len({k for k in set(native) & set(bridged) if not k.startswith("_")}) - len(
|
|
371
431
|
[d for d in differences if d["kind"] != "only_one_transport_reached_it"]
|
|
372
432
|
)
|
|
373
433
|
return {
|
|
374
|
-
"compared": len(
|
|
434
|
+
"compared": len(probe_keys),
|
|
375
435
|
"agreed": agreed,
|
|
376
436
|
"differences": differences,
|
|
377
437
|
"identical": not differences,
|
|
438
|
+
# Reported either way. "The page was not compared" is a fact about the
|
|
439
|
+
# run that a reader needs, and burying it would recreate the silence
|
|
440
|
+
# this fix exists to end.
|
|
441
|
+
"page_compared_by_value": page_held_still,
|
|
442
|
+
"page_native": {k: native_meta.get(k) for k in ("page_before", "page_after")},
|
|
443
|
+
"page_bridge": {k: bridge_meta.get(k) for k in ("page_before", "page_after")},
|
|
378
444
|
}
|
|
379
445
|
|
|
380
446
|
|
package/src/utils/platform.py
CHANGED
|
@@ -55,6 +55,16 @@ def get_resolve_paths():
|
|
|
55
55
|
lib_path = "/Applications/DaVinci Resolve/DaVinci Resolve.app/Contents/Libraries/Fusion/fusionscript.so"
|
|
56
56
|
modules_path = os.path.join(api_path, "Modules")
|
|
57
57
|
|
|
58
|
+
# An explicit install location wins over the platform default: Resolve is
|
|
59
|
+
# not always under /Applications (external volume, custom install dir).
|
|
60
|
+
env_api = os.environ.get("RESOLVE_SCRIPT_API")
|
|
61
|
+
if env_api and os.path.isdir(env_api):
|
|
62
|
+
api_path = env_api
|
|
63
|
+
modules_path = os.path.join(api_path, "Modules")
|
|
64
|
+
env_lib = os.environ.get("RESOLVE_SCRIPT_LIB")
|
|
65
|
+
if env_lib and os.path.isfile(env_lib):
|
|
66
|
+
lib_path = env_lib
|
|
67
|
+
|
|
58
68
|
return {
|
|
59
69
|
"api_path": api_path,
|
|
60
70
|
"lib_path": lib_path,
|