davinci-resolve-mcp 2.98.2 → 2.98.4

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,132 @@
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.4
6
+
7
+ **Setup reported success over an install that could never work.** Reported and
8
+ fixed in [#154](https://github.com/samuelgursky/davinci-resolve-mcp/pull/154) by
9
+ @DadManBlues, from a DaVinci Resolve Studio 21.0.4 install on `F:\Blackmagic
10
+ Design\DaVinci Resolve` (Windows 11). The chain: `RESOLVE_PATHS["Windows"]["lib"]`
11
+ held a single hardcoded `C:\Program Files\...` candidate, so `find_resolve_paths()`
12
+ returned `lib_path=None`; `build_server_env()` wrote that out as
13
+ `"RESOLVE_SCRIPT_LIB": ""`, which reads as configured in the config file but is
14
+ falsy to the loader, so it fell back to the same missing path; the connection
15
+ check then failed with `DLL load failed` in the middle of the output, and the
16
+ installer's last line said `Setup complete!`. Every tool afterwards failed with
17
+ `SCRIPTING_UNAVAILABLE`, whose remediation pointed at the Resolve edition and the
18
+ External-scripting preference — both already correct.
19
+
20
+ ### Fixed
21
+
22
+ - **Resolve is now found outside the default install location.**
23
+ `resolve_runtime.running_resolve_lib()` derives the scripting library from the
24
+ running Resolve's own image path, which needs no guessing on any platform, and
25
+ `platform.discover_scripting_lib()` covers cold installs: `%PROGRAMFILES%` /
26
+ `%PROGRAMW6432%` / `%PROGRAMFILES(X86)%` plus the existing drive letters on
27
+ Windows (two fixed paths each, no directory walk), both bundle locations on
28
+ macOS — the App Store build installs to `/Applications/DaVinci Resolve.app`
29
+ rather than `/Applications/DaVinci Resolve/DaVinci Resolve.app`, the same class
30
+ of miss — and the `/opt/resolve` layouts on Linux. Discovery runs only when the
31
+ platform default is absent and no usable env override exists, and the default
32
+ is kept when discovery finds nothing, so the error message still names the
33
+ location people expect.
34
+ - **Empty environment values are omitted rather than written.**
35
+ `build_server_env()` no longer emits `"RESOLVE_SCRIPT_LIB": ""`.
36
+ - **A failed verification is no longer reported as success.** The `Library: Not
37
+ found (optional — API path is sufficient)` line was wrong and is corrected; a
38
+ DLL-load failure is diagnosed explicitly, naming the current
39
+ `RESOLVE_SCRIPT_LIB`, before the Python 3.13+ ABI theory; and setup ends in
40
+ `Setup incomplete — the scripting API did not load.`, still listing any configs
41
+ it wrote and marking them non-functional.
42
+
43
+ ### Fixed in follow-up review
44
+
45
+ - **The no-clients branch still printed `Environment ready!`** over a failed
46
+ verification, and **`main()` returned `None` either way**, so
47
+ `npx davinci-resolve-mcp setup` in a script or CI saw exit status 0 over a dead
48
+ install — the same lie as `Setup complete!`, one block further down. The
49
+ summary line and the exit status now agree.
50
+ - **Two bare `except Exception` fallbacks narrowed to `ImportError`.** A defect
51
+ raised inside `running_resolve_lib()` or `discover_scripting_lib()` would have
52
+ been laundered into "nothing found" and the caller would have gone on to report
53
+ the platform default — this repo's recurring silent-fallback bug class.
54
+ - **`_windows_lib_candidates` docstring corrected.** It claimed only fixed drives
55
+ are probed (there is no `GetDriveTypeW` check, so a connected network drive is
56
+ probed too) and that it runs on every connection attempt (`get_resolve_paths()`
57
+ is import-time, so the real cost is one `ps`/`wmic` spawn at server startup, and
58
+ only when the default is already missing).
59
+
60
+ ### Tests
61
+
62
+ `tests/test_scripting_lib_discovery.py` (21 cases): library derivation on Windows
63
+ and macOS layouts, WMIC-quoted command lines, the three `None` paths, the
64
+ per-platform candidate lists, override-beats-discovery precedence, the surviving
65
+ platform default, the omitted empty key, both reporting behaviours, and the exit
66
+ status in all three of its states. The `GetResolvePathsDiscoveryTests` cases force
67
+ the platform default absent — without that they check nothing on a machine where
68
+ Resolve *is* at the default path, and on macOS they fail outright; neither the
69
+ Linux CI box nor the Windows machine that prompted the fix shows it, because on
70
+ both the default is already missing for real.
71
+
72
+ ## What's New in v2.98.3
73
+
74
+ **`fusion_comp` could never delete a Fusion keyframe.** Reported in
75
+ [#155](https://github.com/samuelgursky/davinci-resolve-mcp/issues/155) by
76
+ @Andrei-59, with the root cause already identified: the handler called a method
77
+ that does not exist. The diagnosis was correct, and the suggested replacement is
78
+ confirmed here against a live build.
79
+
80
+ ### Fixed
81
+
82
+ - **`delete_keyframe` called `RemoveKeyFrame()` on a Fusion Input.** No such
83
+ method exists there. Keyframes do not live on the Input — they live on the
84
+ spline modifier connected to it, which is what `add_keyframe` attaches via
85
+ `AddModifier(input_name, "BezierSpline")`. The action now reaches that spline
86
+ through `inp.GetConnectedOutput().GetTool()` and calls `DeleteKeyFrames(time)`
87
+ on it. Introduced with the tool in v2.1.0 and broken for every input, every
88
+ frame, and every tool since; there is no version of the server in which it
89
+ worked.
90
+
91
+ - **The failure surfaced as `'NoneType' object is not callable`.** The
92
+ fusionscript bridge resolves an unknown attribute to `None` instead of raising
93
+ `AttributeError`, so the bad lookup succeeded silently and only died at the
94
+ callsite — an error naming neither the method nor the object. Every branch of
95
+ the action now returns the normal error envelope: `FUSION_INPUT_NOT_ANIMATED`
96
+ when the input has no modifier, `FUSION_KEYFRAME_NOT_FOUND` when nothing is
97
+ keyed at that frame (the frame list is included in `state`),
98
+ `FUSION_DELETE_KEYFRAMES_UNSUPPORTED` when the modifier has no removal method,
99
+ and `INVALID_FRAME` for a non-numeric `time`. The existing `_has_method` guard
100
+ — which exists precisely for this silent-`None` class — is applied before the
101
+ call rather than after it.
102
+
103
+ - **Success is verified by readback, not by the return value.** Live testing
104
+ showed `DeleteKeyFrames()` returns `None` whether or not it removed anything,
105
+ so trusting the return would have reported failure on every successful
106
+ delete — and trusting the absence of an exception would have reported success
107
+ on every silent no-op. The handler re-reads the keyframe list and returns
108
+ `FUSION_DELETE_KEYFRAME_NOOP` if the frame is still there. On success it
109
+ returns `{success, time, remaining_keyframes}`.
110
+
111
+ ### Testing
112
+
113
+ - `tests/test_fusion_comp_targeting.py` gains eleven `delete_keyframe` cases,
114
+ including a regression test that models the bridge's silent-`None` attribute
115
+ lookup and asserts the handler never reaches for `RemoveKeyFrame` on the
116
+ Input. The action previously had no test coverage at all.
117
+ - `tests/live_fusion_delete_keyframe_validation.py` is a new self-contained live
118
+ harness: it creates a scratch project, inserts a Fusion composition clip (no
119
+ media needed), and reports which keyframe methods the Input and the spline
120
+ actually expose before asserting the delete.
121
+
122
+ ### Verified live
123
+
124
+ Validated on **DaVinci Resolve Studio 19.1.3.7** (macOS). `RemoveKeyFrame`
125
+ confirmed absent on the Input and resolving to `None`; `DeleteKeyFrames`
126
+ confirmed present on the `BezierSpline` and confirmed to remove the key. The
127
+ reporter's exact reproduction — `add_keyframe` then `delete_keyframe` on the
128
+ same tool/input/frame — was run end-to-end through the patched handler and
129
+ succeeds. Not re-verified on Studio 21.0.4.5, the reporter's build.
130
+
5
131
  ## What's New in v2.98.2
6
132
 
7
133
  **A tool installed next to the server was invisible to it.** 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.2-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.98.4-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.2-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.98.4-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.2 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v2.98.4 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
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.2"
40
+ VERSION = "2.98.4"
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.2",
3
+ "version": "2.98.4",
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.2"
90
+ VERSION = "2.98.4"
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.2"
14
+ VERSION = "2.98.4"
15
15
 
16
16
  import base64
17
17
  import os
@@ -26582,6 +26582,121 @@ def _fusion_set_text_plus(comp, p: Dict[str, Any]) -> Dict[str, Any]:
26582
26582
  comp.Unlock()
26583
26583
 
26584
26584
 
26585
+ def _fusion_keyframe_frames(inp) -> List[float]:
26586
+ """Frame positions currently keyed on `inp`, as a sorted list.
26587
+
26588
+ Fusion's `GetKeyFrames()` returns {1-based index: frame_position}; the
26589
+ frames are the VALUES, not the keys. Frames come back as floats.
26590
+ """
26591
+ try:
26592
+ kfs = inp.GetKeyFrames()
26593
+ except Exception:
26594
+ return []
26595
+ if not kfs:
26596
+ return []
26597
+ return sorted(float(frame) for frame in kfs.values())
26598
+
26599
+
26600
+ def _fusion_input_spline(inp):
26601
+ """The modifier/spline tool driving `inp`, or None when it is not animated.
26602
+
26603
+ Keyframes do not live on the Input object -- they live on the spline
26604
+ connected to it, which is what `add_keyframe` attaches via AddModifier.
26605
+ """
26606
+ try:
26607
+ connected = inp.GetConnectedOutput()
26608
+ except Exception:
26609
+ return None
26610
+ if connected is None:
26611
+ return None
26612
+ if not _has_method(connected, "GetTool"):
26613
+ return None
26614
+ try:
26615
+ return connected.GetTool()
26616
+ except Exception:
26617
+ return None
26618
+
26619
+
26620
+ def _fusion_delete_keyframe(tool, p: Dict[str, Any]) -> Dict[str, Any]:
26621
+ """Remove one keyframe from an animated Fusion input. (issue #155)
26622
+
26623
+ The original implementation called `inp.RemoveKeyFrame(time)`. No such
26624
+ method exists on a Fusion Input, and the fusionscript bridge resolves an
26625
+ unknown attribute to None rather than raising AttributeError -- so the
26626
+ lookup succeeded silently and every call died at the callsite as an opaque
26627
+ `'NoneType' object is not callable`. The action had never worked.
26628
+
26629
+ Deletion happens on the spline, reached the same way `add_keyframe`
26630
+ created it, and every step that can be absent is checked before it is
26631
+ called. The result is verified by reading the keyframe list back, because
26632
+ a Fusion call returning without error is not proof it did anything.
26633
+ """
26634
+ tool_name = p["tool_name"]
26635
+ input_name = p["input_name"]
26636
+ inp = tool[input_name]
26637
+ if not inp:
26638
+ return _err(
26639
+ f"Input '{input_name}' not found on tool '{tool_name}'",
26640
+ code="FUSION_INPUT_NOT_FOUND", category="invalid_input",
26641
+ )
26642
+
26643
+ try:
26644
+ time = float(p["time"])
26645
+ except (TypeError, ValueError):
26646
+ return _err(
26647
+ f"time must be a frame number, got {p['time']!r}",
26648
+ code="INVALID_FRAME", category="invalid_input",
26649
+ )
26650
+
26651
+ spline = _fusion_input_spline(inp)
26652
+ if spline is None:
26653
+ return _err(
26654
+ f"Input '{input_name}' on tool '{tool_name}' is not animated, so it "
26655
+ "has no keyframe to delete",
26656
+ code="FUSION_INPUT_NOT_ANIMATED", category="precondition",
26657
+ remediation="Use add_keyframe first; it attaches the spline that holds keyframes.",
26658
+ state={"tool_name": tool_name, "input_name": input_name},
26659
+ )
26660
+
26661
+ if not _has_method(spline, "DeleteKeyFrames"):
26662
+ return _err(
26663
+ f"The modifier on '{tool_name}.{input_name}' has no DeleteKeyFrames method",
26664
+ code="FUSION_DELETE_KEYFRAMES_UNSUPPORTED", category="unsupported",
26665
+ reason="Only spline modifiers (e.g. BezierSpline) support keyframe removal.",
26666
+ state={"tool_name": tool_name, "input_name": input_name},
26667
+ )
26668
+
26669
+ before = _fusion_keyframe_frames(inp)
26670
+ if not any(abs(frame - time) < 1e-6 for frame in before):
26671
+ return _err(
26672
+ f"No keyframe at frame {time:g} on '{tool_name}.{input_name}'",
26673
+ code="FUSION_KEYFRAME_NOT_FOUND", category="precondition",
26674
+ state={"tool_name": tool_name, "input_name": input_name,
26675
+ "time": time, "keyframes": before},
26676
+ )
26677
+
26678
+ try:
26679
+ spline.DeleteKeyFrames(time)
26680
+ except Exception as exc:
26681
+ return _err(
26682
+ f"DeleteKeyFrames({time:g}) raised: {exc}",
26683
+ code="FUSION_DELETE_KEYFRAME_FAILED", category="resolve_api_failed",
26684
+ state={"tool_name": tool_name, "input_name": input_name, "time": time},
26685
+ )
26686
+
26687
+ after = _fusion_keyframe_frames(inp)
26688
+ if any(abs(frame - time) < 1e-6 for frame in after):
26689
+ return _err(
26690
+ f"DeleteKeyFrames({time:g}) returned without error but the keyframe "
26691
+ f"is still on '{tool_name}.{input_name}'",
26692
+ code="FUSION_DELETE_KEYFRAME_NOOP", category="resolve_api_failed",
26693
+ state={"tool_name": tool_name, "input_name": input_name,
26694
+ "time": time, "keyframes_before": before, "keyframes_after": after},
26695
+ )
26696
+
26697
+ return _ok(time=time, remaining_keyframes=after)
26698
+
26699
+
26585
26700
  def _fusion_get_text_plus(comp, p: Dict[str, Any]) -> Dict[str, Any]:
26586
26701
  """Read the text of a Fusion Text+ tool / title template. (issue #73)"""
26587
26702
  tool, err = _fusion_find_text_tool(comp, p)
@@ -26627,7 +26742,9 @@ def fusion_comp(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
26627
26742
  get_attrs(tool_name) -> {attrs}
26628
26743
  add_keyframe(tool_name, input_name, time, value) -> {success}
26629
26744
  get_keyframes(tool_name, input_name) -> {keyframes}
26630
- delete_keyframe(tool_name, input_name, time) -> {success}
26745
+ delete_keyframe(tool_name, input_name, time) -> {success, time, remaining_keyframes}
26746
+ Deletes on the spline attached to the input. Structured errors when the
26747
+ input is not animated or has no keyframe at that frame.
26631
26748
  get_comp_info() -> {name, tool_count, attrs}
26632
26749
  get_position(tool_name) -> {tool_name, x, y} — read a node's FlowView position
26633
26750
  set_position(tool_name, x, y) -> {success, x, y, readback} — move a node
@@ -26923,11 +27040,7 @@ def fusion_comp(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
26923
27040
  return _err(f"Tool '{p['tool_name']}' not found")
26924
27041
  comp.Lock()
26925
27042
  try:
26926
- inp = tool[p["input_name"]]
26927
- if not inp:
26928
- return _err(f"Input '{p['input_name']}' not found on tool '{p['tool_name']}'")
26929
- inp.RemoveKeyFrame(p["time"])
26930
- return _ok()
27043
+ return _fusion_delete_keyframe(tool, p)
26931
27044
  finally:
26932
27045
  comp.Unlock()
26933
27046
 
@@ -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