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 +128 -0
- package/README.md +1 -1
- package/README.zh-CN.md +2 -2
- package/docs/SKILL.md +11 -0
- package/docs/reference/api-limitations.md +10 -2
- package/install.py +88 -8
- package/package.json +1 -1
- package/src/granular/common.py +1 -1
- package/src/server.py +154 -134
- package/src/utils/api_truth.py +48 -1
- package/src/utils/platform.py +101 -0
- package/src/utils/resolve_runtime.py +58 -2
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
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
8
|
[-blue.svg)](#server-modes)
|
package/README.zh-CN.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.md) | 简体中文
|
|
4
4
|
|
|
5
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
8
|
[-blue.svg)](#服务器模式)
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
[](https://www.python.org/downloads/)
|
|
13
13
|
[](https://opensource.org/licenses/MIT)
|
|
14
14
|
|
|
15
|
-
> 本翻译对应 v2.98.
|
|
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,
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2208
|
-
|
|
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
package/src/granular/common.py
CHANGED
|
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
|
|
|
87
87
|
handlers=[logging.StreamHandler()],
|
|
88
88
|
)
|
|
89
89
|
|
|
90
|
-
VERSION = "2.98.
|
|
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.
|
|
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
|
-
|
|
25951
|
-
|
|
25952
|
-
|
|
25953
|
-
|
|
25954
|
-
|
|
25955
|
-
|
|
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
|
-
|
|
26079
|
-
|
|
26080
|
-
|
|
26081
|
-
|
|
26082
|
-
|
|
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
|
-
|
|
26260
|
-
|
|
26261
|
-
|
|
26262
|
-
|
|
26263
|
-
|
|
26264
|
-
|
|
26265
|
-
|
|
26266
|
-
|
|
26267
|
-
|
|
26268
|
-
|
|
26269
|
-
|
|
26270
|
-
|
|
26271
|
-
|
|
26272
|
-
|
|
26273
|
-
|
|
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
|
-
|
|
26453
|
-
|
|
26454
|
-
|
|
26455
|
-
|
|
26456
|
-
|
|
26457
|
-
|
|
26458
|
-
|
|
26459
|
-
|
|
26460
|
-
|
|
26461
|
-
|
|
26462
|
-
|
|
26463
|
-
|
|
26464
|
-
|
|
26465
|
-
|
|
26466
|
-
|
|
26467
|
-
|
|
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(
|
|
26519
|
+
rec["readback"] = _ser(tool.GetInput(fusion_id))
|
|
26470
26520
|
except Exception as exc:
|
|
26471
26521
|
rec["readback_error"] = str(exc)
|
|
26472
|
-
|
|
26473
|
-
|
|
26474
|
-
|
|
26475
|
-
|
|
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
|
-
|
|
26500
|
-
|
|
26501
|
-
|
|
26502
|
-
|
|
26503
|
-
|
|
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
|
-
|
|
26507
|
-
|
|
26508
|
-
|
|
26509
|
-
|
|
26510
|
-
|
|
26511
|
-
|
|
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":
|
|
26514
|
-
"
|
|
26548
|
+
"success": ok,
|
|
26549
|
+
"target": str(connect_to),
|
|
26550
|
+
"input_name": input_name,
|
|
26515
26551
|
}
|
|
26516
|
-
|
|
26517
|
-
|
|
26518
|
-
|
|
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.
|
|
26602
|
+
out["readback"] = _ser(tool.GetInput(input_id))
|
|
26568
26603
|
except Exception as exc:
|
|
26569
|
-
|
|
26570
|
-
|
|
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
|
-
|
|
26961
|
-
|
|
26962
|
-
|
|
26963
|
-
|
|
26964
|
-
|
|
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"])
|
package/src/utils/api_truth.py
CHANGED
|
@@ -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 "
|
package/src/utils/platform.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|