davinci-resolve-mcp 2.98.2 → 2.98.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,65 @@
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.3
6
+
7
+ **`fusion_comp` could never delete a Fusion keyframe.** Reported in
8
+ [#155](https://github.com/samuelgursky/davinci-resolve-mcp/issues/155) by
9
+ @Andrei-59, with the root cause already identified: the handler called a method
10
+ that does not exist. The diagnosis was correct, and the suggested replacement is
11
+ confirmed here against a live build.
12
+
13
+ ### Fixed
14
+
15
+ - **`delete_keyframe` called `RemoveKeyFrame()` on a Fusion Input.** No such
16
+ method exists there. Keyframes do not live on the Input — they live on the
17
+ spline modifier connected to it, which is what `add_keyframe` attaches via
18
+ `AddModifier(input_name, "BezierSpline")`. The action now reaches that spline
19
+ through `inp.GetConnectedOutput().GetTool()` and calls `DeleteKeyFrames(time)`
20
+ on it. Introduced with the tool in v2.1.0 and broken for every input, every
21
+ frame, and every tool since; there is no version of the server in which it
22
+ worked.
23
+
24
+ - **The failure surfaced as `'NoneType' object is not callable`.** The
25
+ fusionscript bridge resolves an unknown attribute to `None` instead of raising
26
+ `AttributeError`, so the bad lookup succeeded silently and only died at the
27
+ callsite — an error naming neither the method nor the object. Every branch of
28
+ the action now returns the normal error envelope: `FUSION_INPUT_NOT_ANIMATED`
29
+ when the input has no modifier, `FUSION_KEYFRAME_NOT_FOUND` when nothing is
30
+ keyed at that frame (the frame list is included in `state`),
31
+ `FUSION_DELETE_KEYFRAMES_UNSUPPORTED` when the modifier has no removal method,
32
+ and `INVALID_FRAME` for a non-numeric `time`. The existing `_has_method` guard
33
+ — which exists precisely for this silent-`None` class — is applied before the
34
+ call rather than after it.
35
+
36
+ - **Success is verified by readback, not by the return value.** Live testing
37
+ showed `DeleteKeyFrames()` returns `None` whether or not it removed anything,
38
+ so trusting the return would have reported failure on every successful
39
+ delete — and trusting the absence of an exception would have reported success
40
+ on every silent no-op. The handler re-reads the keyframe list and returns
41
+ `FUSION_DELETE_KEYFRAME_NOOP` if the frame is still there. On success it
42
+ returns `{success, time, remaining_keyframes}`.
43
+
44
+ ### Testing
45
+
46
+ - `tests/test_fusion_comp_targeting.py` gains eleven `delete_keyframe` cases,
47
+ including a regression test that models the bridge's silent-`None` attribute
48
+ lookup and asserts the handler never reaches for `RemoveKeyFrame` on the
49
+ Input. The action previously had no test coverage at all.
50
+ - `tests/live_fusion_delete_keyframe_validation.py` is a new self-contained live
51
+ harness: it creates a scratch project, inserts a Fusion composition clip (no
52
+ media needed), and reports which keyframe methods the Input and the spline
53
+ actually expose before asserting the delete.
54
+
55
+ ### Verified live
56
+
57
+ Validated on **DaVinci Resolve Studio 19.1.3.7** (macOS). `RemoveKeyFrame`
58
+ confirmed absent on the Input and resolving to `None`; `DeleteKeyFrames`
59
+ confirmed present on the `BezierSpline` and confirmed to remove the key. The
60
+ reporter's exact reproduction — `add_keyframe` then `delete_keyframe` on the
61
+ same tool/input/frame — was run end-to-end through the patched handler and
62
+ succeeds. Not re-verified on Studio 21.0.4.5, the reporter's build.
63
+
5
64
  ## What's New in v2.98.2
6
65
 
7
66
  **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.3-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.3-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.3 版 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.3"
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
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.3",
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.3"
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.3"
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