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 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
- [![Version](https://img.shields.io/badge/version-2.69.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.69.3-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
4
4
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
5
5
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
6
6
  [![Tools](https://img.shields.io/badge/MCP%20Tools-34%20(341%20full)-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.0"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.69.0",
3
+ "version": "2.69.3",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -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__/ vendor/drt-format/__tests__/ vendor/drx-codec/__tests__/ test/",
42
- "test:libs": "node --test vendor/drp-format/__tests__/ vendor/drt-format/__tests__/ vendor/drx-codec/__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. 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).")
@@ -85,7 +85,7 @@ if not logging.getLogger().handlers:
85
85
  handlers=[logging.StreamHandler()],
86
86
  )
87
87
 
88
- VERSION = "2.69.0"
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
@@ -11,7 +11,7 @@ Usage:
11
11
  python src/server.py --full # Start the 341-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.69.0"
14
+ VERSION = "2.69.3"
15
15
 
16
16
  import base64
17
17
  import os
@@ -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
- agreed = len(set(native) & set(bridged)) - len(
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(set(native) | set(bridged)),
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
 
@@ -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,