davinci-resolve-mcp 2.98.3 → 2.98.5

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,134 @@
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.98.5
6
+
7
+ **Every Fusion parameter this server wrote was ignored at render.** A value
8
+ write (`SetInput` / `SetExpression`) wrapped in `Comp.Lock()`/`Unlock()` is
9
+ stored in the graph and reads back correctly — `GetInput` returns it, and so
10
+ did this server's own `get_input` — while the delivered render ignores it
11
+ completely. Found on 2026-08-21 while re-running a Fusion isolation on Studio
12
+ 19.1.3.7 to settle a conflicting measurement reported in
13
+ [#156](https://github.com/samuelgursky/davinci-resolve-mcp/pull/156).
14
+
15
+ Measured on Studio 19.1.3.7 with `MediaIn -> Blur(XBlurSize 20) -> MediaOut` on
16
+ a media-backed clip, rendering the same 48 frames to H.264 each time:
17
+
18
+ | value written via | render vs no-comp baseline |
19
+ | --- | --- |
20
+ | `fusion_comp set_input` (write inside `Comp.Lock()`) | PSNR **inf** — bit-identical, ignored |
21
+ | the same write, lock removed | PSNR **24.38 dB**, 2.0 MB → 727 KB |
22
+ | raw `tool.XBlurSize = 20.0` | PSNR **24.38 dB** |
23
+ | raw `tool.SetInput("XBlurSize", 20)` | PSNR **24.38 dB** |
24
+
25
+ The variable was isolated against the comp handle (`AddFusionComp`,
26
+ `GetFusionCompByIndex` and `GetFusionCompByName` all render), the node name, and
27
+ the write form. Only the lock around the write decides it. **Structural** edits
28
+ are unaffected — `AddTool` and `ConnectInput` inside a lock render normally — so
29
+ this is not "Lock is unsafe"; the lock suppresses the parameter-change
30
+ invalidation that a value write depends on.
31
+
32
+ ### Fixed
33
+
34
+ - **Six value-write sites no longer hold a comp lock across the write:**
35
+ `fusion_comp set_input`, `fusion_comp safe_set_inputs`, `bulk_set_inputs`,
36
+ `bulk_set_expressions`, the Text+ writer behind `set_text`, and
37
+ `add_mask` — where the lock spanned `AddTool` *and* every input write, so a
38
+ mask was created at default size and position and every parameter the caller
39
+ passed did nothing. Structural work keeps its lock; in `add_mask` the lock now
40
+ closes after the node is created and renamed.
41
+
42
+ ### Why this went unnoticed
43
+
44
+ Every readback the API offers agreed with the value that was written. This is
45
+ the failure mode the repo's own guidance describes — prove a Fusion or grade
46
+ claim with a rendered frame, never with readback — except the cause was ours,
47
+ not Resolve's. It also explains an unknown share of past "the comp was ignored"
48
+ reports, which look identical from the API side.
49
+
50
+ ### Tests
51
+
52
+ - `tests/live_fusion_value_write_validation.py` — renders a baseline, writes a
53
+ blur size through the compound tool, renders again, and asserts PSNR actually
54
+ moved. Disposable project, synthetic media, restores the previous project.
55
+ - `tests/test_fusion_value_write_lock.py` — AST guard failing any value write
56
+ that sits inside a `Comp.Lock()`/`Unlock()` region, with a self-check that the
57
+ guard can still see a known-bad shape.
58
+
59
+ Both were mutation-checked against the pre-fix code: reintroducing the lock in
60
+ `set_input` makes the live harness report `PSNR inf -> IGNORED at render` and
61
+ fails the offline guard.
62
+
63
+ - `api_truth`: new `Composition.Lock` entry; the `AddFusionComp` entry records
64
+ that its 2026-08-02 rooted-comp result **reproduced** on 19.1.3.7 (PSNR 24.38 dB).
65
+
66
+ ## What's New in v2.98.4
67
+
68
+ **Setup reported success over an install that could never work.** Reported and
69
+ fixed in [#154](https://github.com/samuelgursky/davinci-resolve-mcp/pull/154) by
70
+ @DadManBlues, from a DaVinci Resolve Studio 21.0.4 install on `F:\Blackmagic
71
+ Design\DaVinci Resolve` (Windows 11). The chain: `RESOLVE_PATHS["Windows"]["lib"]`
72
+ held a single hardcoded `C:\Program Files\...` candidate, so `find_resolve_paths()`
73
+ returned `lib_path=None`; `build_server_env()` wrote that out as
74
+ `"RESOLVE_SCRIPT_LIB": ""`, which reads as configured in the config file but is
75
+ falsy to the loader, so it fell back to the same missing path; the connection
76
+ check then failed with `DLL load failed` in the middle of the output, and the
77
+ installer's last line said `Setup complete!`. Every tool afterwards failed with
78
+ `SCRIPTING_UNAVAILABLE`, whose remediation pointed at the Resolve edition and the
79
+ External-scripting preference — both already correct.
80
+
81
+ ### Fixed
82
+
83
+ - **Resolve is now found outside the default install location.**
84
+ `resolve_runtime.running_resolve_lib()` derives the scripting library from the
85
+ running Resolve's own image path, which needs no guessing on any platform, and
86
+ `platform.discover_scripting_lib()` covers cold installs: `%PROGRAMFILES%` /
87
+ `%PROGRAMW6432%` / `%PROGRAMFILES(X86)%` plus the existing drive letters on
88
+ Windows (two fixed paths each, no directory walk), both bundle locations on
89
+ macOS — the App Store build installs to `/Applications/DaVinci Resolve.app`
90
+ rather than `/Applications/DaVinci Resolve/DaVinci Resolve.app`, the same class
91
+ of miss — and the `/opt/resolve` layouts on Linux. Discovery runs only when the
92
+ platform default is absent and no usable env override exists, and the default
93
+ is kept when discovery finds nothing, so the error message still names the
94
+ location people expect.
95
+ - **Empty environment values are omitted rather than written.**
96
+ `build_server_env()` no longer emits `"RESOLVE_SCRIPT_LIB": ""`.
97
+ - **A failed verification is no longer reported as success.** The `Library: Not
98
+ found (optional — API path is sufficient)` line was wrong and is corrected; a
99
+ DLL-load failure is diagnosed explicitly, naming the current
100
+ `RESOLVE_SCRIPT_LIB`, before the Python 3.13+ ABI theory; and setup ends in
101
+ `Setup incomplete — the scripting API did not load.`, still listing any configs
102
+ it wrote and marking them non-functional.
103
+
104
+ ### Fixed in follow-up review
105
+
106
+ - **The no-clients branch still printed `Environment ready!`** over a failed
107
+ verification, and **`main()` returned `None` either way**, so
108
+ `npx davinci-resolve-mcp setup` in a script or CI saw exit status 0 over a dead
109
+ install — the same lie as `Setup complete!`, one block further down. The
110
+ summary line and the exit status now agree.
111
+ - **Two bare `except Exception` fallbacks narrowed to `ImportError`.** A defect
112
+ raised inside `running_resolve_lib()` or `discover_scripting_lib()` would have
113
+ been laundered into "nothing found" and the caller would have gone on to report
114
+ the platform default — this repo's recurring silent-fallback bug class.
115
+ - **`_windows_lib_candidates` docstring corrected.** It claimed only fixed drives
116
+ are probed (there is no `GetDriveTypeW` check, so a connected network drive is
117
+ probed too) and that it runs on every connection attempt (`get_resolve_paths()`
118
+ is import-time, so the real cost is one `ps`/`wmic` spawn at server startup, and
119
+ only when the default is already missing).
120
+
121
+ ### Tests
122
+
123
+ `tests/test_scripting_lib_discovery.py` (21 cases): library derivation on Windows
124
+ and macOS layouts, WMIC-quoted command lines, the three `None` paths, the
125
+ per-platform candidate lists, override-beats-discovery precedence, the surviving
126
+ platform default, the omitted empty key, both reporting behaviours, and the exit
127
+ status in all three of its states. The `GetResolvePathsDiscoveryTests` cases force
128
+ the platform default absent — without that they check nothing on a machine where
129
+ Resolve *is* at the default path, and on macOS they fail outright; neither the
130
+ Linux CI box nor the Windows machine that prompted the fix shows it, because on
131
+ both the default is already missing for real.
132
+
5
133
  ## What's New in v2.98.3
6
134
 
7
135
  **`fusion_comp` could never delete a Fusion keyframe.** Reported in
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  English | [简体中文](README.zh-CN.md)
4
4
 
5
- [![Version](https://img.shields.io/badge/version-2.98.3-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.98.5-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-35%20(353%20full)-blue.svg)](#server-modes)
package/README.zh-CN.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [English](README.md) | 简体中文
4
4
 
5
- [![Version](https://img.shields.io/badge/version-2.98.3-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.98.5-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-35%20(353%20full)-blue.svg)](#服务器模式)
@@ -12,7 +12,7 @@
12
12
  [![Python](https://img.shields.io/badge/python-3.10+-green.svg)](https://www.python.org/downloads/)
13
13
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT)
14
14
 
15
- > 本翻译对应 v2.98.3 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v2.98.5 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
package/docs/SKILL.md CHANGED
@@ -1690,6 +1690,17 @@ Target a comp either from a timeline item (pass `clip_id`, `timeline_item_id`, o
1690
1690
  `timeline_item={track_type, track_index, item_index}`) or from the active Fusion
1691
1691
  page comp (omit timeline scope).
1692
1692
 
1693
+ READBACK IS NOT PROOF FOR FUSION PARAMETERS. Up to v2.98.4 every value write
1694
+ here ran inside a `Comp.Lock()`, and a value written under a comp lock is stored
1695
+ in the graph and returned by `get_input` while the RENDER ignores it entirely
1696
+ (Studio 19.1.3.7: PSNR inf vs the no-comp baseline — the delivered file was
1697
+ bit-identical to no comp at all). Fixed in v2.98.5, and guarded by
1698
+ `tests/test_fusion_value_write_lock.py` plus the rendered-frame harness
1699
+ `tests/live_fusion_value_write_validation.py`. The lesson outlives the bug: a
1700
+ Fusion parameter that reads back correctly has proven nothing about the output,
1701
+ so confirm any Fusion look with a rendered frame (`gallery_stills
1702
+ grab_and_export` or a frame from a delivered render), never with `get_input`.
1703
+
1693
1704
  Key actions:
1694
1705
  - `add_tool(tool_type, x?, y?, name?)` — common types: `Merge`, `Background`,
1695
1706
  `TextPlus`, `Transform`, `Blur`, `ColorCorrector`, `RectangleMask`,
@@ -12,7 +12,7 @@ that none exists).
12
12
 
13
13
  **Verified on:** DaVinci Resolve Studio 21.0.2
14
14
 
15
- **Totals:** 27 missing capabilities, 35 bugs / unreliable behaviors.
15
+ **Totals:** 27 missing capabilities, 36 bugs / unreliable behaviors.
16
16
 
17
17
  The authoritative source is the runtime-queryable `api_truth` ledger
18
18
  (`resolve_control api_truth "<query>"`); this document is generated from
@@ -339,10 +339,18 @@ values, or automation-hostile modal prompts.
339
339
  - **Workaround / current handling:** Author OTIO for Resolve by mirroring what Resolve itself exports, and give every event its media timecode origin. editorial.convert_to_interchange (target 'otio') does this and reports any event whose origin had to be assumed in `mediaOriginAssumed` — a non-empty list means the file will only import if that media really starts at 00:00:00:00. To debug a refusal, export any timeline with EXPORT_OTIO and diff your document against it; do NOT chase missing media or reach for sanitize_media, which cannot even parse a .otio (it is JSON, not XML).
340
340
  - **Tags:** timeline, import, interchange, otio, silent-failure, conform
341
341
 
342
+ ### Composition.Lock (suppresses render invalidation for value writes)
343
+
344
+ - **Object:** `Composition (Fusion, via TimelineItem comps)`
345
+ - **Signature:** `Lock() / Unlock()`
346
+ - **Behavior:** A value write performed between Comp.Lock() and Comp.Unlock() — SetInput(), or Input.SetExpression() — is stored in the graph and reads back correctly from GetInput(), but is NOT applied when the timeline is rendered. Measured live on Studio 19.1.3.7 (2026-08-21) with MediaIn -> Blur(XBlurSize 20) -> MediaOut on a media-backed clip: written under the lock the delivered H.264 render is bit-identical to the no-comp baseline (ffmpeg PSNR inf); the identical write with the lock removed renders at PSNR 24.38 dB and the file shrinks 2.0 MB -> 727 KB, as a blur should. The variable was isolated against the comp handle (AddFusionComp, GetFusionCompByIndex and GetFusionCompByName all render), the node name, and the write form (attribute assignment and SetInput both render unlocked). STRUCTURAL edits are unaffected: AddTool and ConnectInput inside a lock render normally, so the lock is not broadly unsafe — it suppresses the parameter-change invalidation that a value write depends on. Lock() is widely recommended for batching Fusion edits, which is how this reaches production code.
347
+ - **Workaround / current handling:** Never hold a comp lock across a value write. Lock only structural work (AddTool/ConnectInput) and set inputs outside it. Because every readback the API offers agrees with the value that was written, this failure is invisible without a render — prove Fusion parameter changes with a delivered frame or gallery_stills grab_and_export, never with GetInput.
348
+ - **Tags:** fusion, silent-failure, render, readback
349
+
342
350
  ### TimelineItem.AddFusionComp / LoadFusionCompByName
343
351
 
344
352
  - **Object:** `TimelineItem (media-backed clip)`
345
- - **Behavior:** A Fusion composition created on a media clip through the API is not applied at render WHEN MEDIAOUT HAS NO PATH FROM MEDIAIN. The original blanket form of this entry — 'never applied at render' — was too broad and was corrected on 2026-08-02: a comp wired MediaIn -> Blur -> MediaOut, created entirely through the API on an ordinary media clip, DOES render. PSNR between the plain and Fusion renders of the same timeline was 22.7 dB (identical would be infinite), the file shrank 22.5 MB -> 14.8 MB as a blur should, and the output was frame-for-frame identical in GUI and headless. A first attempt that wired ONLY MediaOut -> Blur, leaving the Blur with no source, made the render job come back 'Failed' with an 887-byte file — so an unrooted graph does not merely get bypassed, it can take the render down. What still stands is the original observation for the configuration it actually tested, which is retained below and has NOT been re-measured: AddFusionComp() returns the comp, AddTool/Connect/SetInput all succeed, and the whole graph reads back correctly (GetCompCount 1, MediaOut1.Input wired to the new tool, StyledText returning the value just set) — but the rendered output is byte-for-byte the untouched source media. Verified live on Studio 19.1.3.7 with the strongest form of the test: MediaOut1 fed ONLY by a Text+, with no path from MediaIn at all, still rendered the unmodified clip. LoadFusionCompByName on the sole comp does not activate it either. Contrast InsertFusionTitleIntoTimeline, whose comp DOES render — text set via SetInput('StyledText') appears in the output — so this is specific to comps attached to media-backed clips, not to Fusion through the API generally.
353
+ - **Behavior:** A Fusion composition created on a media clip through the API is not applied at render WHEN MEDIAOUT HAS NO PATH FROM MEDIAIN. The original blanket form of this entry — 'never applied at render' — was too broad and was corrected on 2026-08-02: a comp wired MediaIn -> Blur -> MediaOut, created entirely through the API on an ordinary media clip, DOES render. PSNR between the plain and Fusion renders of the same timeline was 22.7 dB (identical would be infinite), the file shrank 22.5 MB -> 14.8 MB as a blur should, and the output was frame-for-frame identical in GUI and headless. A first attempt that wired ONLY MediaOut -> Blur, leaving the Blur with no source, made the render job come back 'Failed' with an 887-byte file — so an unrooted graph does not merely get bypassed, it can take the render down. What still stands is the original observation for the configuration it actually tested, which is retained below and has NOT been re-measured: AddFusionComp() returns the comp, AddTool/Connect/SetInput all succeed, and the whole graph reads back correctly (GetCompCount 1, MediaOut1.Input wired to the new tool, StyledText returning the value just set) — but the rendered output is byte-for-byte the untouched source media. Verified live on Studio 19.1.3.7 with the strongest form of the test: MediaOut1 fed ONLY by a Text+, with no path from MediaIn at all, still rendered the unmodified clip. LoadFusionCompByName on the sole comp does not activate it either. Contrast InsertFusionTitleIntoTimeline, whose comp DOES render — text set via SetInput('StyledText') appears in the output — so this is specific to comps attached to media-backed clips, not to Fusion through the API generally. REPRODUCED 2026-08-21 on Studio 19.1.3.7: a rooted MediaIn -> Blur -> MediaOut comp built entirely through the API renders (PSNR 24.38 dB vs the no-comp baseline), so the 2026-08-02 correction stands. Note that an important share of 'the comp was ignored' readings are NOT this entry at all but the Composition.Lock bug above — a parameter written under a comp lock reads back correctly and never reaches the render, which looks identical from the API side.
346
354
  - **Workaround / current handling:** Wire the graph so MediaOut descends from MediaIn — that is the difference between a comp that renders and one that is silently bypassed, and it is what made this look like 'Fusion never renders from the API'. Never leave a tool unrooted: a MediaOut fed by a tool with no source failed the render job outright. For text or effects over picture, insert a Fusion title/generator as its own timeline clip and set its Text+ (fusion_comp set_text_plus), rather than attaching a comp to the media clip. Note the destination track cannot be chosen from the API (see the Track Selector entry), so overlaying onto an existing clip's track is not currently reachable end-to-end. Building the comp in the Fusion page UI works; only the API-created comp is ignored.
347
355
  - **Tags:** fusion, silent-failure, render
348
356
 
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "2.98.3"
40
+ VERSION = "2.98.5"
41
41
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
42
42
  # Resolve's scripting bridge loads into newer interpreters on recent builds
43
43
  # (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
@@ -284,6 +284,24 @@ def find_resolve_paths():
284
284
  lib_path = expanded
285
285
  break
286
286
 
287
+ if lib_path is None:
288
+ # The literal candidates above only cover a default install. An explicit
289
+ # override wins outright; failing that, ask where Resolve actually is.
290
+ # Skipping this is what wrote an empty RESOLVE_SCRIPT_LIB into working
291
+ # configs and left the server importing a DLL that was never there.
292
+ env_lib = os.environ.get("RESOLVE_SCRIPT_LIB")
293
+ if env_lib and os.path.isfile(env_lib):
294
+ lib_path = env_lib
295
+ else:
296
+ # Narrow except: an ImportError here means the helper is absent,
297
+ # which is a real answer. Anything else raised *inside* discovery is
298
+ # a defect and must not be laundered into "no library found".
299
+ try:
300
+ from src.utils.platform import discover_scripting_lib
301
+ except ImportError:
302
+ discover_scripting_lib = None
303
+ lib_path = discover_scripting_lib() if discover_scripting_lib else None
304
+
287
305
  return api_path, lib_path
288
306
 
289
307
 
@@ -520,7 +538,14 @@ def get_python_base_install(python_path):
520
538
 
521
539
 
522
540
  def build_server_env(python_path, api_path, lib_path, system=SYSTEM, python_home=None):
523
- """Build the env block used by all generated stdio MCP configs."""
541
+ """Build the env block used by all generated stdio MCP configs.
542
+
543
+ Keys whose value is empty are omitted rather than written as "". An empty
544
+ `RESOLVE_SCRIPT_LIB` is worse than an absent one: it reads as configured in
545
+ the config file, while `DaVinciResolveScript.py` treats it as unset and
546
+ silently reverts to its own hardcoded install path — so a machine with
547
+ Resolve elsewhere fails with a DLL-load error that names nothing useful.
548
+ """
524
549
  api_value = str(api_path or "")
525
550
  lib_value = str(lib_path or "")
526
551
  env = {
@@ -528,6 +553,7 @@ def build_server_env(python_path, api_path, lib_path, system=SYSTEM, python_home
528
553
  "RESOLVE_SCRIPT_LIB": lib_value,
529
554
  "PYTHONPATH": str(Path(api_value) / "Modules") if api_value else "",
530
555
  }
556
+ env = {key: value for key, value in env.items() if value}
531
557
 
532
558
  if system == "Windows":
533
559
  env["PYTHONHOME"] = str(python_home or get_python_base_install(python_path))
@@ -1921,7 +1947,13 @@ def main():
1921
1947
  if lib_path:
1922
1948
  print(f" Library: {green(lib_path)}")
1923
1949
  else:
1924
- print(f" Library: {yellow('Not found')} {dim('(optional API path is sufficient)')}")
1950
+ # Not optional, whatever this line used to claim: DaVinciResolveScript
1951
+ # is a thin wrapper that loads this binary, so without it every tool
1952
+ # fails at import. Saying "API path is sufficient" here sent people
1953
+ # looking at their Resolve edition and their preferences instead.
1954
+ print(f" Library: {red('Not found')} {dim('(required — the scripting API cannot load without it)')}")
1955
+ print(f" {dim('Set RESOLVE_SCRIPT_LIB to the fusionscript library inside your Resolve install,')}")
1956
+ print(f" {dim('or start Resolve and re-run setup so its location can be read from the process.')}")
1925
1957
 
1926
1958
  resolve_running = check_resolve_running()
1927
1959
  if resolve_running:
@@ -2122,6 +2154,7 @@ def main():
2122
2154
  if interactive:
2123
2155
  print_step(5, total_steps, "Verification")
2124
2156
 
2157
+ verification_failed = False
2125
2158
  if api_path:
2126
2159
  success, message = verify_resolve_connection(python_path, api_path, lib_path)
2127
2160
  try:
@@ -2171,13 +2204,28 @@ def main():
2171
2204
  else:
2172
2205
  print(f" Connected: {green(message)}")
2173
2206
  else:
2174
- print(f" Verify: {yellow(message)}")
2207
+ verification_failed = True
2208
+ print(f" Verify: {red(message)}")
2209
+ if "DLL load failed" in message or "cannot open shared object" in message:
2210
+ # This is the shape of a wrong or missing library path, and it
2211
+ # is the one failure the installer can diagnose precisely. Say
2212
+ # so before offering the interpreter theory below — a reader who
2213
+ # is told "try another Python" first will go and do that.
2214
+ print(
2215
+ f" The scripting library named by RESOLVE_SCRIPT_LIB did not load. "
2216
+ f"Current value: {lib_path or dim('(not set)')}"
2217
+ )
2218
+ print(
2219
+ " Point RESOLVE_SCRIPT_LIB at the fusionscript library inside your "
2220
+ "Resolve install, or start Resolve and re-run setup."
2221
+ )
2175
2222
  if py_abi_risk:
2176
2223
  print(
2177
2224
  f" On Python 3.13+ this may be an ABI mismatch with Resolve's "
2178
2225
  f"scripting library — try Python 3.10-3.12 if it persists."
2179
2226
  )
2180
2227
  else:
2228
+ verification_failed = True
2181
2229
  print(f" {yellow('Skipped')} — Resolve API path not detected")
2182
2230
 
2183
2231
  # ══════════════════════════════════════════════════════════════════════
@@ -2185,7 +2233,25 @@ def main():
2185
2233
  # ══════════════════════════════════════════════════════════════════════
2186
2234
 
2187
2235
  print(f"\n {'═' * 50}")
2188
- if configured or show_manual:
2236
+ if verification_failed and (configured or show_manual):
2237
+ # Writing the configs is not the job; a working connection is. Reporting
2238
+ # "Setup complete!" over a failed verification is how an install that
2239
+ # never worked gets handed to the user as finished — the error scrolls
2240
+ # past mid-output and the last line says success.
2241
+ print(f" {yellow(bold('Setup incomplete — the scripting API did not load.'))}")
2242
+ if configured:
2243
+ print(f" Configured: {', '.join(configured)} {dim('(written, but the server will fail to start)')}")
2244
+ print()
2245
+ print(f" {bold('Fix the verification error above, then re-run:')}")
2246
+ print(f" {cyan('python install.py')}")
2247
+ print()
2248
+ print(f" {dim(f'Server: {server_path}')}")
2249
+ print(f" {dim(f'Python: {python_path}')}")
2250
+ if api_path:
2251
+ print(f" {dim(f'API: {api_path}')}")
2252
+ if lib_path:
2253
+ print(f" {dim(f'Library: {lib_path}')}")
2254
+ elif configured or show_manual:
2189
2255
  print(f" {green(bold('Setup complete!'))}")
2190
2256
  if configured:
2191
2257
  print(f" Configured: {', '.join(configured)}")
@@ -2204,18 +2270,32 @@ def main():
2204
2270
  if api_path:
2205
2271
  print(f" {dim(f'API: {api_path}')}")
2206
2272
  elif not selected_ids:
2207
- print(f" {green(bold('Environment ready!'))}")
2208
- print(f" Run {cyan('python install.py --clients all')} to configure MCP clients later.")
2273
+ # Same rule as the configured branch above: a failed verification is
2274
+ # never "ready". Nothing was written here, so the remedy is the error
2275
+ # itself rather than a re-run to fix a config.
2276
+ if verification_failed:
2277
+ print(f" {yellow(bold('Environment incomplete — the scripting API did not load.'))}")
2278
+ print(f" {dim('No client configs were written.')}")
2279
+ print()
2280
+ print(f" {bold('Fix the verification error above, then re-run:')}")
2281
+ print(f" {cyan('python install.py')}")
2282
+ else:
2283
+ print(f" {green(bold('Environment ready!'))}")
2284
+ print(f" Run {cyan('python install.py --clients all')} to configure MCP clients later.")
2209
2285
  else:
2210
2286
  print(f" {yellow('No clients configured.')}")
2211
2287
  print(f" Run {cyan('python install.py')} again to retry.")
2212
2288
 
2213
2289
  print()
2290
+ # Exit status has to agree with the summary line above. `npx
2291
+ # davinci-resolve-mcp setup` is run from scripts and CI, where a zero over a
2292
+ # dead install is the same lie as "Setup complete!" was.
2293
+ return 1 if verification_failed else 0
2214
2294
 
2215
2295
 
2216
2296
  if __name__ == "__main__":
2217
2297
  try:
2218
- main()
2298
+ sys.exit(main() or 0)
2219
2299
  except KeyboardInterrupt:
2220
2300
  print(f"\n\n {dim('Interrupted.')}\n")
2221
2301
  sys.exit(1)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.98.3",
3
+ "version": "2.98.5",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "2.98.3"
90
+ VERSION = "2.98.5"
91
91
  logger = logging.getLogger("davinci-resolve-mcp")
92
92
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
93
93
  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 353-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.98.3"
14
+ VERSION = "2.98.5"
15
15
 
16
16
  import base64
17
17
  import os
@@ -25841,6 +25841,35 @@ def _fusion_group_settings_splice_inputs(p: Dict[str, Any]) -> Dict[str, Any]:
25841
25841
  }
25842
25842
 
25843
25843
 
25844
+ # Why no comp.Lock() around Fusion VALUE writes.
25845
+ #
25846
+ # Wrapping a value write (SetInput / SetExpression) in comp.Lock()/Unlock()
25847
+ # leaves the value fully readable — GetInput returns it, and so does this
25848
+ # server's own get_input — while the RENDER ignores it entirely. Measured live
25849
+ # on Studio 19.1.3.7 with a MediaIn -> Blur(XBlurSize 20) -> MediaOut comp on a
25850
+ # media-backed clip: identical graph, identical readback, delivered render
25851
+ # bit-identical to the no-comp baseline (ffmpeg PSNR inf). Removing the lock
25852
+ # from the write renders at PSNR 24.38 dB and the file shrinks 2.0 MB -> 727 KB,
25853
+ # as a blur should. The variable was isolated against the comp handle
25854
+ # (AddFusionComp / GetFusionCompByIndex / GetFusionCompByName all render), the
25855
+ # node name, and the write form (attribute assignment and SetInput both render
25856
+ # unlocked) — only the lock around the write decides it.
25857
+ #
25858
+ # STRUCTURAL edits are a different case and KEEP their lock: AddTool,
25859
+ # ConnectInput and friends invalidate the render through another path and were
25860
+ # verified to render while locked. So this is not "Lock is unsafe", it is
25861
+ # "Lock suppresses the parameter-change invalidation that a value write needs".
25862
+ #
25863
+ # This is why grade/Fusion claims are proven with a rendered frame and never
25864
+ # with comp readback: every readback the API offers agreed the value was set.
25865
+ _FUSION_VALUE_WRITE_NOTE = (
25866
+ "Fusion value writes (SetInput/SetExpression) must not be wrapped in "
25867
+ "comp.Lock()/Unlock(): the value reads back correctly but is ignored at "
25868
+ "render (Studio 19.1.3.7, PSNR inf vs baseline). Structural edits "
25869
+ "(AddTool/ConnectInput) are unaffected and keep their lock."
25870
+ )
25871
+
25872
+
25844
25873
  def _fusion_group_settings_load(comp, p: Dict[str, Any]) -> Dict[str, Any]:
25845
25874
  group_name = p.get("group_name")
25846
25875
  if not group_name:
@@ -25946,16 +25975,13 @@ def _fusion_comp_bulk_set_expressions(p: Dict[str, Any]) -> Dict[str, Any]:
25946
25975
  undo_started = True
25947
25976
  except Exception:
25948
25977
  undo_started = False
25949
- comp.Lock()
25950
- try:
25951
- time = op.get("time", 0)
25952
- inp = tool[op["input_name"]]
25953
- if not inp:
25954
- raise ValueError(f"Input {op['input_name']!r} not found on {op['tool_name']!r}")
25955
- inp.SetExpression(str(op["expression"]), time)
25956
- keep_undo = True
25957
- finally:
25958
- comp.Unlock()
25978
+ # No comp.Lock() around a value write — see _FUSION_VALUE_WRITE_NOTE.
25979
+ time = op.get("time", 0)
25980
+ inp = tool[op["input_name"]]
25981
+ if not inp:
25982
+ raise ValueError(f"Input {op['input_name']!r} not found on {op['tool_name']!r}")
25983
+ inp.SetExpression(str(op["expression"]), time)
25984
+ keep_undo = True
25959
25985
  except Exception as exc:
25960
25986
  error_message = str(exc)
25961
25987
  finally:
@@ -26074,15 +26100,12 @@ def _fusion_comp_bulk_set_inputs(p: Dict[str, Any]) -> Dict[str, Any]:
26074
26100
  undo_started = True
26075
26101
  except Exception:
26076
26102
  undo_started = False
26077
- comp.Lock()
26078
- try:
26079
- if "time" in op:
26080
- tool.SetInput(op["input_name"], op["value"], op["time"])
26081
- else:
26082
- tool.SetInput(op["input_name"], op["value"])
26083
- keep_undo = True
26084
- finally:
26085
- comp.Unlock()
26103
+ # No comp.Lock() around a value write — see _FUSION_VALUE_WRITE_NOTE.
26104
+ if "time" in op:
26105
+ tool.SetInput(op["input_name"], op["value"], op["time"])
26106
+ else:
26107
+ tool.SetInput(op["input_name"], op["value"])
26108
+ keep_undo = True
26086
26109
  except Exception as exc:
26087
26110
  error_message = str(exc)
26088
26111
  finally:
@@ -26255,25 +26278,22 @@ def _safe_set_fusion_inputs(comp, p: Dict[str, Any]):
26255
26278
  if p.get("dry_run"):
26256
26279
  return _ok(tool_name=tool_name, inputs=inputs, would_set=True)
26257
26280
  results = {}
26258
- comp.Lock()
26259
- try:
26260
- for input_name, value in inputs.items():
26261
- try:
26262
- if "time" in p:
26263
- tool.SetInput(input_name, value, p["time"])
26264
- else:
26265
- tool.SetInput(input_name, value)
26266
- row = {"success": True}
26267
- if p.get("readback", True):
26268
- try:
26269
- row["value"] = _ser(tool.GetInput(input_name, p["time"])) if "time" in p else _ser(tool.GetInput(input_name))
26270
- except Exception as exc:
26271
- row["readback_error"] = str(exc)
26272
- results[input_name] = row
26273
- except Exception as exc:
26274
- results[input_name] = {"success": False, "error": str(exc)}
26275
- finally:
26276
- comp.Unlock()
26281
+ # No comp.Lock() around value writes — see _FUSION_VALUE_WRITE_NOTE.
26282
+ for input_name, value in inputs.items():
26283
+ try:
26284
+ if "time" in p:
26285
+ tool.SetInput(input_name, value, p["time"])
26286
+ else:
26287
+ tool.SetInput(input_name, value)
26288
+ row = {"success": True}
26289
+ if p.get("readback", True):
26290
+ try:
26291
+ row["value"] = _ser(tool.GetInput(input_name, p["time"])) if "time" in p else _ser(tool.GetInput(input_name))
26292
+ except Exception as exc:
26293
+ row["readback_error"] = str(exc)
26294
+ results[input_name] = row
26295
+ except Exception as exc:
26296
+ results[input_name] = {"success": False, "error": str(exc)}
26277
26297
  return {"success": all(row.get("success") for row in results.values()), "tool_name": tool_name, "results": results}
26278
26298
 
26279
26299
 
@@ -26438,6 +26458,11 @@ def _fusion_add_mask(comp, p: Dict[str, Any]) -> Dict[str, Any]:
26438
26458
  y = p.get("y", -1)
26439
26459
  readback = bool(p.get("readback", True))
26440
26460
 
26461
+ # The lock covers only the STRUCTURAL half (AddTool + rename). The input
26462
+ # writes below must run outside it — see _FUSION_VALUE_WRITE_NOTE: a value
26463
+ # written under the lock reads back correctly and is ignored at render,
26464
+ # which on a mask means the shape exists at default size and position and
26465
+ # every parameter the caller passed silently does nothing.
26441
26466
  comp.Lock()
26442
26467
  try:
26443
26468
  tool = comp.AddTool(tool_type, x, y)
@@ -26449,83 +26474,84 @@ def _fusion_add_mask(comp, p: Dict[str, Any]) -> Dict[str, Any]:
26449
26474
  name = p.get("name")
26450
26475
  if name:
26451
26476
  tool.SetAttrs({"TOOLS_Name": str(name)})
26452
- attrs = tool.GetAttrs() or {}
26453
- tool_name = attrs.get("TOOLS_Name", "")
26454
-
26455
- results: List[Dict[str, Any]] = []
26456
-
26457
- # Center: accept center=[x,y]/{1:x,2:y}, or center_x / center_y.
26458
- center = p.get("center")
26459
- cx, cy = p.get("center_x"), p.get("center_y")
26460
- if center is None and (cx is not None or cy is not None):
26461
- center = [cx if cx is not None else 0.5, cy if cy is not None else 0.5]
26462
- if center is not None:
26463
- ok, err, applied = _fusion_set_point_input(tool, "Center", center)
26464
- rec = {"input": "Center", "value": center, "success": ok}
26465
- if not ok:
26466
- rec["error"] = err
26467
- elif readback:
26477
+ finally:
26478
+ comp.Unlock()
26479
+
26480
+ attrs = tool.GetAttrs() or {}
26481
+ tool_name = attrs.get("TOOLS_Name", "")
26482
+
26483
+ results: List[Dict[str, Any]] = []
26484
+
26485
+ # Center: accept center=[x,y]/{1:x,2:y}, or center_x / center_y.
26486
+ center = p.get("center")
26487
+ cx, cy = p.get("center_x"), p.get("center_y")
26488
+ if center is None and (cx is not None or cy is not None):
26489
+ center = [cx if cx is not None else 0.5, cy if cy is not None else 0.5]
26490
+ if center is not None:
26491
+ ok, err, applied = _fusion_set_point_input(tool, "Center", center)
26492
+ rec = {"input": "Center", "value": center, "success": ok}
26493
+ if not ok:
26494
+ rec["error"] = err
26495
+ elif readback:
26496
+ try:
26497
+ rec["readback"] = _ser(tool.GetInput("Center"))
26498
+ except Exception as exc:
26499
+ rec["readback_error"] = str(exc)
26500
+ results.append(rec)
26501
+
26502
+ # Scalar inputs (friendly aliases) + any raw passthrough inputs.
26503
+ to_set: List[tuple] = []
26504
+ for friendly, fusion_id in _MASK_INPUT_ALIASES.items():
26505
+ if friendly in p:
26506
+ to_set.append((fusion_id, p[friendly]))
26507
+ raw_inputs = p.get("inputs")
26508
+ if isinstance(raw_inputs, dict):
26509
+ for k, v in raw_inputs.items():
26510
+ to_set.append((str(k), v))
26511
+
26512
+ for fusion_id, value in to_set:
26513
+ rec = {"input": fusion_id, "value": value}
26514
+ try:
26515
+ tool.SetInput(fusion_id, value)
26516
+ rec["success"] = True
26517
+ if readback:
26468
26518
  try:
26469
- rec["readback"] = _ser(tool.GetInput("Center"))
26519
+ rec["readback"] = _ser(tool.GetInput(fusion_id))
26470
26520
  except Exception as exc:
26471
26521
  rec["readback_error"] = str(exc)
26472
- results.append(rec)
26473
-
26474
- # Scalar inputs (friendly aliases) + any raw passthrough inputs.
26475
- to_set: List[tuple] = []
26476
- for friendly, fusion_id in _MASK_INPUT_ALIASES.items():
26477
- if friendly in p:
26478
- to_set.append((fusion_id, p[friendly]))
26479
- raw_inputs = p.get("inputs")
26480
- if isinstance(raw_inputs, dict):
26481
- for k, v in raw_inputs.items():
26482
- to_set.append((str(k), v))
26483
-
26484
- for fusion_id, value in to_set:
26485
- rec = {"input": fusion_id, "value": value}
26486
- try:
26487
- tool.SetInput(fusion_id, value)
26488
- rec["success"] = True
26489
- if readback:
26490
- try:
26491
- rec["readback"] = _ser(tool.GetInput(fusion_id))
26492
- except Exception as exc:
26493
- rec["readback_error"] = str(exc)
26494
- except Exception as exc:
26495
- rec["success"] = False
26496
- rec["error"] = str(exc)
26497
- results.append(rec)
26522
+ except Exception as exc:
26523
+ rec["success"] = False
26524
+ rec["error"] = str(exc)
26525
+ results.append(rec)
26498
26526
 
26499
- out: Dict[str, Any] = {
26500
- "success": True,
26501
- "tool_name": tool_name,
26502
- "tool_type": attrs.get("TOOLS_RegID", tool_type),
26503
- "inputs_set": results,
26504
- }
26527
+ out: Dict[str, Any] = {
26528
+ "success": True,
26529
+ "tool_name": tool_name,
26530
+ "tool_type": attrs.get("TOOLS_RegID", tool_type),
26531
+ "inputs_set": results,
26532
+ }
26505
26533
 
26506
- # Optional wiring: connect this mask into a tool's mask input.
26507
- connect_to = p.get("connect_to")
26508
- if connect_to:
26509
- input_name = p.get("connect_input", "EffectMask")
26510
- target = comp.FindTool(str(connect_to))
26511
- if not target:
26534
+ # Optional wiring: connect this mask into a tool's mask input.
26535
+ connect_to = p.get("connect_to")
26536
+ if connect_to:
26537
+ input_name = p.get("connect_input", "EffectMask")
26538
+ target = comp.FindTool(str(connect_to))
26539
+ if not target:
26540
+ out["connection"] = {
26541
+ "success": False,
26542
+ "error": f"connect_to tool '{connect_to}' not found",
26543
+ }
26544
+ else:
26545
+ try:
26546
+ ok = bool(target.ConnectInput(input_name, tool))
26512
26547
  out["connection"] = {
26513
- "success": False,
26514
- "error": f"connect_to tool '{connect_to}' not found",
26548
+ "success": ok,
26549
+ "target": str(connect_to),
26550
+ "input_name": input_name,
26515
26551
  }
26516
- else:
26517
- try:
26518
- ok = bool(target.ConnectInput(input_name, tool))
26519
- out["connection"] = {
26520
- "success": ok,
26521
- "target": str(connect_to),
26522
- "input_name": input_name,
26523
- }
26524
- except Exception as exc:
26525
- out["connection"] = {"success": False, "error": str(exc)}
26526
- return out
26527
- finally:
26528
- comp.Unlock()
26552
+ except Exception as exc:
26553
+ out["connection"] = {"success": False, "error": str(exc)}
26554
+ return out
26529
26555
 
26530
26556
 
26531
26557
  def _fusion_find_text_tool(comp, p: Dict[str, Any]):
@@ -26561,25 +26587,22 @@ def _fusion_set_text_plus(comp, p: Dict[str, Any]) -> Dict[str, Any]:
26561
26587
  return err
26562
26588
  input_id = p.get("input_name", "StyledText")
26563
26589
  readback = bool(p.get("readback", True))
26564
- comp.Lock()
26590
+ # No comp.Lock() around a value write — see _FUSION_VALUE_WRITE_NOTE.
26565
26591
  try:
26592
+ tool.SetInput(input_id, text)
26593
+ except Exception as exc:
26594
+ return _err(f"SetInput({input_id!r}) failed: {exc}")
26595
+ out = {
26596
+ "success": True,
26597
+ "tool_name": (tool.GetAttrs() or {}).get("TOOLS_Name", ""),
26598
+ "input_name": input_id,
26599
+ }
26600
+ if readback:
26566
26601
  try:
26567
- tool.SetInput(input_id, text)
26602
+ out["readback"] = _ser(tool.GetInput(input_id))
26568
26603
  except Exception as exc:
26569
- return _err(f"SetInput({input_id!r}) failed: {exc}")
26570
- out = {
26571
- "success": True,
26572
- "tool_name": (tool.GetAttrs() or {}).get("TOOLS_Name", ""),
26573
- "input_name": input_id,
26574
- }
26575
- if readback:
26576
- try:
26577
- out["readback"] = _ser(tool.GetInput(input_id))
26578
- except Exception as exc:
26579
- out["readback_error"] = str(exc)
26580
- return out
26581
- finally:
26582
- comp.Unlock()
26604
+ out["readback_error"] = str(exc)
26605
+ return out
26583
26606
 
26584
26607
 
26585
26608
  def _fusion_keyframe_frames(inp) -> List[float]:
@@ -26956,15 +26979,12 @@ def fusion_comp(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
26956
26979
  tool = comp.FindTool(p["tool_name"])
26957
26980
  if not tool:
26958
26981
  return _err(f"Tool '{p['tool_name']}' not found")
26959
- comp.Lock()
26960
- try:
26961
- if "time" in p:
26962
- tool.SetInput(p["input_name"], p["value"], p["time"])
26963
- else:
26964
- tool.SetInput(p["input_name"], p["value"])
26965
- return _ok()
26966
- finally:
26967
- comp.Unlock()
26982
+ # No comp.Lock() around a value write — see _FUSION_VALUE_WRITE_NOTE.
26983
+ if "time" in p:
26984
+ tool.SetInput(p["input_name"], p["value"], p["time"])
26985
+ else:
26986
+ tool.SetInput(p["input_name"], p["value"])
26987
+ return _ok()
26968
26988
 
26969
26989
  elif action == "get_input":
26970
26990
  tool = comp.FindTool(p["tool_name"])
@@ -403,6 +403,44 @@ API_TRUTH: List[Dict[str, Any]] = [
403
403
  "tags": ["timeline", "import", "interchange", "otio", "silent-failure", "conform"],
404
404
  "submit": "bug",
405
405
  },
406
+ {
407
+ "symbol": "Composition.Lock (suppresses render invalidation for value writes)",
408
+ "object": "Composition (Fusion, via TimelineItem comps)",
409
+ "signature": "Lock() / Unlock()",
410
+ "reality": "A value write performed between Comp.Lock() and "
411
+ "Comp.Unlock() — SetInput(), or Input.SetExpression() — is "
412
+ "stored in the graph and reads back correctly from "
413
+ "GetInput(), but is NOT applied when the timeline is "
414
+ "rendered. Measured live on Studio 19.1.3.7 (2026-08-21) "
415
+ "with MediaIn -> Blur(XBlurSize 20) -> MediaOut on a "
416
+ "media-backed clip: written under the lock the delivered "
417
+ "H.264 render is bit-identical to the no-comp baseline "
418
+ "(ffmpeg PSNR inf); the identical write with the lock "
419
+ "removed renders at PSNR 24.38 dB and the file shrinks "
420
+ "2.0 MB -> 727 KB, as a blur should. The variable was "
421
+ "isolated against the comp handle (AddFusionComp, "
422
+ "GetFusionCompByIndex and GetFusionCompByName all render), "
423
+ "the node name, and the write form (attribute assignment "
424
+ "and SetInput both render unlocked). STRUCTURAL edits are "
425
+ "unaffected: AddTool and ConnectInput inside a lock render "
426
+ "normally, so the lock is not broadly unsafe — it "
427
+ "suppresses the parameter-change invalidation that a value "
428
+ "write depends on. Lock() is widely recommended for "
429
+ "batching Fusion edits, which is how this reaches "
430
+ "production code.",
431
+ "recommended": "Never hold a comp lock across a value write. Lock only "
432
+ "structural work (AddTool/ConnectInput) and set inputs "
433
+ "outside it. Because every readback the API offers "
434
+ "agrees with the value that was written, this failure "
435
+ "is invisible without a render — prove Fusion "
436
+ "parameter changes with a delivered frame or "
437
+ "gallery_stills grab_and_export, never with GetInput.",
438
+ "tags": ["fusion", "silent-failure", "render", "readback"],
439
+ "submit": "bug",
440
+ "mitigation": ["_FUSION_VALUE_WRITE_NOTE",
441
+ "tests/live_fusion_value_write_validation.py",
442
+ "tests/test_fusion_value_write_lock.py"],
443
+ },
406
444
  {
407
445
  "symbol": "TimelineItem.AddFusionComp / LoadFusionCompByName",
408
446
  "object": "TimelineItem (media-backed clip)",
@@ -434,7 +472,16 @@ API_TRUTH: List[Dict[str, Any]] = [
434
472
  "either. Contrast InsertFusionTitleIntoTimeline, whose comp "
435
473
  "DOES render — text set via SetInput('StyledText') appears in "
436
474
  "the output — so this is specific to comps attached to "
437
- "media-backed clips, not to Fusion through the API generally.",
475
+ "media-backed clips, not to Fusion through the API generally. "
476
+ "REPRODUCED 2026-08-21 on Studio 19.1.3.7: a rooted "
477
+ "MediaIn -> Blur -> MediaOut comp built entirely through "
478
+ "the API renders (PSNR 24.38 dB vs the no-comp baseline), "
479
+ "so the 2026-08-02 correction stands. Note that an "
480
+ "important share of 'the comp was ignored' readings are "
481
+ "NOT this entry at all but the Composition.Lock bug above "
482
+ "— a parameter written under a comp lock reads back "
483
+ "correctly and never reaches the render, which looks "
484
+ "identical from the API side.",
438
485
  "recommended": "Wire the graph so MediaOut descends from MediaIn — that "
439
486
  "is the difference between a comp that renders and one "
440
487
  "that is silently bypassed, and it is what made this look "
@@ -64,6 +64,12 @@ def get_resolve_paths():
64
64
  env_lib = os.environ.get("RESOLVE_SCRIPT_LIB")
65
65
  if env_lib and os.path.isfile(env_lib):
66
66
  lib_path = env_lib
67
+ elif not os.path.isfile(lib_path):
68
+ # No usable override and nothing at the default: look for the real
69
+ # install before handing back a path we already know is not there.
70
+ # The default is kept as the return value when discovery finds nothing,
71
+ # so the failure message still names the location people expect.
72
+ lib_path = discover_scripting_lib(platform_name) or lib_path
67
73
 
68
74
  return {
69
75
  "api_path": api_path,
@@ -71,6 +77,101 @@ def get_resolve_paths():
71
77
  "modules_path": modules_path
72
78
  }
73
79
 
80
+
81
+ def _windows_lib_candidates():
82
+ r"""Plausible `fusionscript.dll` locations on this machine.
83
+
84
+ `%PROGRAMFILES%` is not a constant — a 64-bit install can sit under
85
+ `%PROGRAMW6432%` — and Resolve is routinely moved to a second drive
86
+ because the application and its caches are large. Every drive letter that
87
+ responds to `isdir` is probed, two fixed paths each, no directory walk;
88
+ A: and B: are skipped so a machine with a floppy-mapped letter does not
89
+ stall. No drive-type check is made, so a mapped network drive that happens
90
+ to be connected is probed too — the cost is bounded (two `isfile` calls)
91
+ and the alternative is a `GetDriveTypeW` ctypes call for a case that only
92
+ arises once, on the path where the default was already missing.
93
+
94
+ This is reached from `get_resolve_paths()`, which runs at import time, so
95
+ the real cost is one `ps`/`wmic` spawn at server startup and only when the
96
+ platform default is absent.
97
+ """
98
+ relative = os.path.join("Blackmagic Design", "DaVinci Resolve", "fusionscript.dll")
99
+ candidates = []
100
+ for variable in ("PROGRAMFILES", "PROGRAMW6432", "PROGRAMFILES(X86)"):
101
+ base = os.environ.get(variable)
102
+ if base:
103
+ candidates.append(os.path.join(base, relative))
104
+ for letter in "CDEFGHIJKLMNOPQRSTUVWXYZ":
105
+ drive = f"{letter}:\\"
106
+ if not os.path.isdir(drive):
107
+ continue
108
+ candidates.append(os.path.join(drive, relative))
109
+ candidates.append(os.path.join(drive, "Program Files", relative))
110
+ return candidates
111
+
112
+
113
+ def _macos_lib_candidates():
114
+ """The App Store bundle as well as the installer one.
115
+
116
+ The default above names `/Applications/DaVinci Resolve/DaVinci Resolve.app`.
117
+ The App Store build installs to `/Applications/DaVinci Resolve.app` instead —
118
+ the same class of miss as a Windows install on another drive, and
119
+ `resolve_runtime.MACOS_RESOLVE_APPS` already records both.
120
+ """
121
+ inside_bundle = os.path.join("Contents", "Libraries", "Fusion", "fusionscript.so")
122
+ bundles = (
123
+ "/Applications/DaVinci Resolve/DaVinci Resolve.app",
124
+ "/Applications/DaVinci Resolve.app",
125
+ )
126
+ return [os.path.join(bundle, inside_bundle) for bundle in bundles]
127
+
128
+
129
+ def _linux_lib_candidates():
130
+ """The documented /opt layouts, both of which Blackmagic has shipped."""
131
+ return [
132
+ "/opt/resolve/libs/Fusion/fusionscript.so",
133
+ "/opt/resolve/libs/fusionscript.so",
134
+ "/opt/resolve/bin/fusionscript.so",
135
+ ]
136
+
137
+
138
+ def discover_scripting_lib(platform_name=None):
139
+ """Locate the scripting library of a Resolve installed outside the default.
140
+
141
+ Order: the running Resolve first — its executable path is the install
142
+ location, so it needs no guessing and covers every platform — then the
143
+ conventional roots for that platform, for when Resolve is not up at the
144
+ moment (installer runs, cold starts).
145
+
146
+ Returns None when nothing is found, which leaves the caller's default in
147
+ place rather than substituting a second guess.
148
+ """
149
+ if platform_name is None:
150
+ platform_name = get_platform()
151
+
152
+ # Relative import and a narrow except: this repo has had a run of
153
+ # silent-fallback bugs, and a bare `except Exception` here would swallow a
154
+ # real defect inside running_resolve_lib() as "Resolve is not running".
155
+ try:
156
+ from .resolve_runtime import running_resolve_lib
157
+ except ImportError:
158
+ running_resolve_lib = None
159
+ running = running_resolve_lib() if running_resolve_lib else None
160
+ if running and os.path.isfile(running):
161
+ return running
162
+
163
+ by_platform = {
164
+ 'windows': _windows_lib_candidates,
165
+ 'darwin': _macos_lib_candidates,
166
+ 'linux': _linux_lib_candidates,
167
+ }
168
+ builder = by_platform.get(platform_name)
169
+ candidates = builder() if builder else []
170
+ for candidate in candidates:
171
+ if os.path.isfile(candidate):
172
+ return candidate
173
+ return None
174
+
74
175
  def get_resolve_plugin_paths():
75
176
  """Get platform-specific paths for Resolve plugin install dirs.
76
177
 
@@ -122,11 +122,21 @@ def _is_resolve_command(line: str) -> bool:
122
122
  the executable is exactly what sits inside the first quoted span; anything
123
123
  after the closing quote is arguments, and the flag loop never sees it.
124
124
  """
125
+ return _matches_pattern(_executable_from_line(line))
126
+
127
+
128
+ def _executable_from_line(line: str) -> str:
129
+ """The executable path from a command line, with argument tokens removed.
130
+
131
+ Split out of `_is_resolve_command` so the install-location lookup below
132
+ agrees with the "is this Resolve" test about where the path ends. See that
133
+ function's docstring for why the quoting and flag-stripping rules are these.
134
+ """
125
135
  text = line.strip()
126
136
  if text.startswith('"'):
127
137
  close = text.find('"', 1)
128
138
  if close > 1:
129
- return _matches_pattern(text[1:close])
139
+ return text[1:close]
130
140
  while True:
131
141
  stripped = text.rstrip()
132
142
  cut = stripped.rfind(" -")
@@ -138,7 +148,7 @@ def _is_resolve_command(line: str) -> bool:
138
148
  if not candidate:
139
149
  break
140
150
  text = candidate
141
- return _matches_pattern(text)
151
+ return text
142
152
 
143
153
 
144
154
  def resolve_processes() -> Optional[List[str]]:
@@ -149,6 +159,52 @@ def resolve_processes() -> Optional[List[str]]:
149
159
  return [line for line in lines if _is_resolve_command(line)]
150
160
 
151
161
 
162
+ #: Where the scripting library sits relative to the Resolve executable. The
163
+ #: library ships *inside* the application, so the running executable's own path
164
+ #: is the only locator that is right by construction — every hardcoded install
165
+ #: root is a guess about where the user chose to put Resolve.
166
+ _LIB_RELATIVE_TO_EXECUTABLE = {
167
+ "windows": ("fusionscript.dll",),
168
+ "darwin": ("../Libraries/Fusion/fusionscript.so",),
169
+ "linux": (
170
+ "../libs/Fusion/fusionscript.so",
171
+ "../libs/fusionscript.so",
172
+ "fusionscript.so",
173
+ ),
174
+ }
175
+
176
+
177
+ def running_resolve_lib() -> Optional[str]:
178
+ """Scripting library of the *running* Resolve, or None.
179
+
180
+ Blackmagic's own `DaVinciResolveScript.py` falls back to one hardcoded
181
+ install path per platform, and this project's defaults mirror it. A Resolve
182
+ installed anywhere else — a second drive, an external volume, a custom
183
+ directory — is therefore invisible to both, and the failure is silent: the
184
+ module imports, the DLL behind it does not load, and the user is told the
185
+ edition or the preference is at fault.
186
+
187
+ The running process settles it without guessing. Returns None when nothing
188
+ is running, the process list is unavailable, or the derived path does not
189
+ exist; callers keep their existing defaults in that case.
190
+ """
191
+ processes = resolve_processes()
192
+ if not processes:
193
+ return None
194
+ suffixes = _LIB_RELATIVE_TO_EXECUTABLE.get(platform.system().lower(), ())
195
+ for line in processes:
196
+ executable_dir = os.path.dirname(_executable_from_line(line))
197
+ if not executable_dir:
198
+ continue
199
+ for suffix in suffixes:
200
+ candidate = os.path.normpath(
201
+ os.path.join(executable_dir, *suffix.split("/"))
202
+ )
203
+ if os.path.isfile(candidate):
204
+ return candidate
205
+ return None
206
+
207
+
152
208
  def runtime_mode() -> Dict[str, Any]:
153
209
  """`{running, headless, instances, command_lines, determinable}`.
154
210