davinci-resolve-mcp 2.98.1 → 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 +124 -0
- package/README.md +1 -1
- package/README.zh-CN.md +2 -2
- package/docs/install.md +8 -0
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/analysis_dashboard.py +11 -4
- package/src/granular/common.py +1 -1
- package/src/server.py +148 -24
- package/src/utils/app_control.py +2 -0
- package/src/utils/image_qc.py +2 -1
- package/src/utils/media_analysis.py +12 -0
- package/src/utils/resolve_bridge.py +2 -1
- package/src/utils/resolve_runtime.py +12 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,130 @@
|
|
|
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
|
+
|
|
64
|
+
## What's New in v2.98.2
|
|
65
|
+
|
|
66
|
+
**A tool installed next to the server was invisible to it.** Reported in
|
|
67
|
+
[#153](https://github.com/samuelgursky/davinci-resolve-mcp/issues/153) by
|
|
68
|
+
@7daysdedicated as an encoding fault in the whisper probe. The probe turned out
|
|
69
|
+
not to be the cause, and the encoding fault turned out to be real somewhere
|
|
70
|
+
else, so both are fixed here.
|
|
71
|
+
|
|
72
|
+
### Fixed
|
|
73
|
+
|
|
74
|
+
- **The server's own virtualenv was not searched for command-line tools.**
|
|
75
|
+
`pip install openai-whisper` writes a `whisper` executable into
|
|
76
|
+
`venv/Scripts` on Windows and `venv/bin` elsewhere, and that directory is on
|
|
77
|
+
PATH only while the environment is *activated* — which nothing does, since the
|
|
78
|
+
client launches `venv/python server.py` directly. So `shutil.which("whisper")`
|
|
79
|
+
returned None and `capabilities` reported `whisper_cli.available: false` for a
|
|
80
|
+
tool sitting beside the interpreter looking for it, with nothing in the
|
|
81
|
+
response to say why. The interpreter's script directory now leads the PATH
|
|
82
|
+
augmentation that already covered Homebrew and `/usr/local`. Not
|
|
83
|
+
Windows-specific: the same gap existed on macOS and Linux.
|
|
84
|
+
|
|
85
|
+
- **Text-mode reads of a child process decoded with the locale codec.** Nineteen
|
|
86
|
+
`subprocess.run(..., text=True)` calls across the server, panel, and utils had
|
|
87
|
+
no `encoding=`, so Python used the platform locale — cp1252 on a default
|
|
88
|
+
Windows install, a codec with no mapping for most of what a media tool prints.
|
|
89
|
+
A clip name in Japanese from the advanced-server bridge, a localized WMIC
|
|
90
|
+
banner, or a user script's output was then a `UnicodeDecodeError` raised
|
|
91
|
+
inside a call whose job was to answer a yes/no question. All nineteen now read
|
|
92
|
+
UTF-8 with `errors="replace"`, so the answer can be wrong in the last
|
|
93
|
+
character but never an exception. The WMIC read matters most: it feeds the
|
|
94
|
+
second-instance guard fixed in v2.97.6, and it must fail to "cannot tell".
|
|
95
|
+
|
|
96
|
+
- **Child Python processes are handed `PYTHONIOENCODING=utf-8`.** A script run
|
|
97
|
+
through `resolve_control` writes into a pipe, where Python picks the locale
|
|
98
|
+
codepage rather than the console's, so printing a non-Latin-1 character killed
|
|
99
|
+
the script with `UnicodeEncodeError` — and the failure read as the script's
|
|
100
|
+
fault rather than the pipe's. The transcription path already did this; the
|
|
101
|
+
script-runner did not.
|
|
102
|
+
|
|
103
|
+
### Added
|
|
104
|
+
|
|
105
|
+
- **`tests.test_child_process_text_encoding`** — a static walk over `src/` that
|
|
106
|
+
fails on any text-mode child read without an explicit `encoding=`. Static
|
|
107
|
+
because the exception needs a non-UTF-8 locale to reproduce and no machine in
|
|
108
|
+
this suite has one: the missing argument is visible in the source, the
|
|
109
|
+
`UnicodeDecodeError` is only visible in Tokyo. The walk carries a test that it
|
|
110
|
+
can still see an offender, since a guard that quietly matches nothing passes
|
|
111
|
+
forever. Also covers the venv script directory being on PATH, first, and
|
|
112
|
+
idempotently.
|
|
113
|
+
|
|
114
|
+
### Note on the report
|
|
115
|
+
|
|
116
|
+
The diagnosis in #153 named `whisper --help` as the probe. That string is
|
|
117
|
+
display text in the install-guidance table and is never executed — detection is
|
|
118
|
+
`shutil.which("whisper")` — and the transcription path already decoded UTF-8 and
|
|
119
|
+
already set `PYTHONIOENCODING`. The cause was the PATH gap the report mentioned
|
|
120
|
+
last, in passing, as an aside about `pip`. Worth stating plainly, because the
|
|
121
|
+
reporter's `whisper.cmd` workaround set the encoding *and* put a `whisper` on
|
|
122
|
+
PATH, and only the second half was doing the work.
|
|
123
|
+
|
|
124
|
+
### Changed
|
|
125
|
+
|
|
126
|
+
- `docs/install.md` says which environment optional packages have to be
|
|
127
|
+
installed into, which is the part that was undocumented.
|
|
128
|
+
|
|
5
129
|
## What's New in v2.98.1
|
|
6
130
|
|
|
7
131
|
**The Bash guard could be walked past with a newline.** Reported and fixed 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.3 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
|
|
16
16
|
|
|
17
17
|
一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
|
|
18
18
|
|
package/docs/install.md
CHANGED
|
@@ -35,6 +35,14 @@ rather than guessing** if it is absent:
|
|
|
35
35
|
`python scripts/doctor.py` reports which are present. `media_analysis`
|
|
36
36
|
`capabilities` covers the analysis stack in more detail.
|
|
37
37
|
|
|
38
|
+
**Install them into the environment the server runs from.** A managed install
|
|
39
|
+
uses its own virtualenv, and `pip install` from an unrelated shell puts the
|
|
40
|
+
package somewhere that interpreter never looks. Use the venv's own pip —
|
|
41
|
+
`<install>/venv/bin/pip` (`venv\Scripts\pip.exe` on Windows) — or activate it
|
|
42
|
+
first. Command-line tools that come with a package (`openai-whisper` installs a
|
|
43
|
+
`whisper` executable) are found in that venv's script directory automatically
|
|
44
|
+
since v2.98.2; before that they had to be on PATH by hand.
|
|
45
|
+
|
|
38
46
|
Model weights carry their own licences, separate from the code that loads them.
|
|
39
47
|
|
|
40
48
|
> **Python 3.13 / 3.14:** these are **allowed** — setup will use them and warn.
|
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.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
|
@@ -14569,7 +14569,8 @@ def _launch_claude_code_terminal() -> Dict[str, Any]:
|
|
|
14569
14569
|
try:
|
|
14570
14570
|
check = subprocess.run(
|
|
14571
14571
|
["osascript", "-e", 'application "iTerm" is running'],
|
|
14572
|
-
capture_output=True, text=True,
|
|
14572
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
14573
|
+
timeout=8,
|
|
14573
14574
|
)
|
|
14574
14575
|
iterm_running = (check.stdout or "").strip().lower() == "true"
|
|
14575
14576
|
except Exception:
|
|
@@ -14592,7 +14593,8 @@ def _launch_claude_code_terminal() -> Dict[str, Any]:
|
|
|
14592
14593
|
try:
|
|
14593
14594
|
proc = subprocess.run(
|
|
14594
14595
|
["osascript", "-e", script],
|
|
14595
|
-
capture_output=True, text=True,
|
|
14596
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
14597
|
+
timeout=15,
|
|
14596
14598
|
)
|
|
14597
14599
|
if proc.returncode != 0:
|
|
14598
14600
|
return {"success": False, "error": (proc.stderr or "").strip() or "osascript failed"}
|
|
@@ -14623,7 +14625,8 @@ def _native_directory_picker(initial: Optional[str] = None) -> Dict[str, Any]:
|
|
|
14623
14625
|
import subprocess
|
|
14624
14626
|
proc = subprocess.run(
|
|
14625
14627
|
["osascript", "-e", script],
|
|
14626
|
-
capture_output=True, text=True,
|
|
14628
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
14629
|
+
timeout=120,
|
|
14627
14630
|
)
|
|
14628
14631
|
if proc.returncode != 0:
|
|
14629
14632
|
stderr = (proc.stderr or "").strip()
|
|
@@ -15194,7 +15197,11 @@ def _run_advanced_bridge(surface: str, op: str, args: Optional[Dict[str, Any]] =
|
|
|
15194
15197
|
# stdin=DEVNULL: never let a child race-read a protocol/stdin stream (api_truth).
|
|
15195
15198
|
proc = subprocess.run(
|
|
15196
15199
|
[node, bridge, str(surface), str(op), json.dumps(args or {})],
|
|
15197
|
-
|
|
15200
|
+
# The bridge answers in JSON that carries clip and project names;
|
|
15201
|
+
# decoding those with the locale codepage is how a non-ASCII name
|
|
15202
|
+
# turns a working panel call into a UnicodeDecodeError (#153).
|
|
15203
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
15204
|
+
timeout=timeout,
|
|
15198
15205
|
stdin=subprocess.DEVNULL, cwd=_advanced_root(),
|
|
15199
15206
|
)
|
|
15200
15207
|
except subprocess.TimeoutExpired:
|
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.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.
|
|
14
|
+
VERSION = "2.98.3"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -1724,8 +1724,8 @@ def _activate_resolve_window() -> Dict[str, Any]:
|
|
|
1724
1724
|
import subprocess
|
|
1725
1725
|
proc = subprocess.run(
|
|
1726
1726
|
["osascript", "-e", 'tell application "DaVinci Resolve" to activate'],
|
|
1727
|
-
capture_output=True, text=True,
|
|
1728
|
-
stdin=subprocess.DEVNULL,
|
|
1727
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
1728
|
+
timeout=5, stdin=subprocess.DEVNULL,
|
|
1729
1729
|
)
|
|
1730
1730
|
return {
|
|
1731
1731
|
"activated": proc.returncode == 0,
|
|
@@ -1738,8 +1738,8 @@ def _activate_resolve_window() -> Dict[str, Any]:
|
|
|
1738
1738
|
["powershell", "-NoProfile", "-Command",
|
|
1739
1739
|
"$s = New-Object -ComObject WScript.Shell; "
|
|
1740
1740
|
"$null = $s.AppActivate('DaVinci Resolve')"],
|
|
1741
|
-
capture_output=True, text=True,
|
|
1742
|
-
stdin=subprocess.DEVNULL,
|
|
1741
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
1742
|
+
timeout=5, stdin=subprocess.DEVNULL,
|
|
1743
1743
|
)
|
|
1744
1744
|
return {
|
|
1745
1745
|
"activated": proc.returncode == 0,
|
|
@@ -1751,8 +1751,8 @@ def _activate_resolve_window() -> Dict[str, Any]:
|
|
|
1751
1751
|
if shutil.which("wmctrl"):
|
|
1752
1752
|
proc = subprocess.run(
|
|
1753
1753
|
["wmctrl", "-a", "DaVinci Resolve"],
|
|
1754
|
-
capture_output=True, text=True,
|
|
1755
|
-
stdin=subprocess.DEVNULL,
|
|
1754
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
1755
|
+
timeout=5, stdin=subprocess.DEVNULL,
|
|
1756
1756
|
)
|
|
1757
1757
|
return {
|
|
1758
1758
|
"activated": proc.returncode == 0,
|
|
@@ -1762,8 +1762,8 @@ def _activate_resolve_window() -> Dict[str, Any]:
|
|
|
1762
1762
|
if shutil.which("xdotool"):
|
|
1763
1763
|
proc = subprocess.run(
|
|
1764
1764
|
["xdotool", "search", "--name", "DaVinci Resolve", "windowactivate"],
|
|
1765
|
-
capture_output=True, text=True,
|
|
1766
|
-
stdin=subprocess.DEVNULL,
|
|
1765
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
1766
|
+
timeout=5, stdin=subprocess.DEVNULL,
|
|
1767
1767
|
)
|
|
1768
1768
|
return {
|
|
1769
1769
|
"activated": proc.returncode == 0,
|
|
@@ -1794,8 +1794,8 @@ def _send_resolve_keystroke_go_to_mark_in() -> Dict[str, Any]:
|
|
|
1794
1794
|
)
|
|
1795
1795
|
proc = subprocess.run(
|
|
1796
1796
|
["osascript", "-e", script],
|
|
1797
|
-
capture_output=True, text=True,
|
|
1798
|
-
stdin=subprocess.DEVNULL,
|
|
1797
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
1798
|
+
timeout=5, stdin=subprocess.DEVNULL,
|
|
1799
1799
|
)
|
|
1800
1800
|
return {
|
|
1801
1801
|
"sent": proc.returncode == 0,
|
|
@@ -1810,8 +1810,8 @@ def _send_resolve_keystroke_go_to_mark_in() -> Dict[str, Any]:
|
|
|
1810
1810
|
"Add-Type -AssemblyName System.Windows.Forms; "
|
|
1811
1811
|
"Start-Sleep -Milliseconds 150; "
|
|
1812
1812
|
"[System.Windows.Forms.SendKeys]::SendWait('+i')"],
|
|
1813
|
-
capture_output=True, text=True,
|
|
1814
|
-
stdin=subprocess.DEVNULL,
|
|
1813
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
1814
|
+
timeout=5, stdin=subprocess.DEVNULL,
|
|
1815
1815
|
)
|
|
1816
1816
|
return {
|
|
1817
1817
|
"sent": proc.returncode == 0,
|
|
@@ -1824,8 +1824,8 @@ def _send_resolve_keystroke_go_to_mark_in() -> Dict[str, Any]:
|
|
|
1824
1824
|
if shutil.which("xdotool"):
|
|
1825
1825
|
proc = subprocess.run(
|
|
1826
1826
|
["xdotool", "search", "--name", "DaVinci Resolve", "key", "--window", "%@", "shift+i"],
|
|
1827
|
-
capture_output=True, text=True,
|
|
1828
|
-
stdin=subprocess.DEVNULL,
|
|
1827
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
1828
|
+
timeout=5, stdin=subprocess.DEVNULL,
|
|
1829
1829
|
)
|
|
1830
1830
|
return {"sent": proc.returncode == 0, "platform": "linux", "tool": "xdotool", "shortcut": "Shift+I"}
|
|
1831
1831
|
return {"sent": False, "platform": sys.platform, "note": "no key-send tool found"}
|
|
@@ -15286,7 +15286,8 @@ def _port_owner_pid(host: str, port: int) -> Optional[int]:
|
|
|
15286
15286
|
try:
|
|
15287
15287
|
result = subprocess.run(
|
|
15288
15288
|
["lsof", "-nP", "-iTCP:" + str(port), "-sTCP:LISTEN", "-t"],
|
|
15289
|
-
capture_output=True, timeout=3, text=True,
|
|
15289
|
+
capture_output=True, timeout=3, text=True, encoding="utf-8",
|
|
15290
|
+
errors="replace", check=False,
|
|
15290
15291
|
stdin=subprocess.DEVNULL,
|
|
15291
15292
|
)
|
|
15292
15293
|
except (OSError, subprocess.TimeoutExpired):
|
|
@@ -16561,6 +16562,8 @@ def _make_spec_hook_runner(timeout: float = 120.0):
|
|
|
16561
16562
|
stdin=subprocess.DEVNULL,
|
|
16562
16563
|
capture_output=True,
|
|
16563
16564
|
text=True,
|
|
16565
|
+
encoding="utf-8",
|
|
16566
|
+
errors="replace",
|
|
16564
16567
|
)
|
|
16565
16568
|
return proc.returncode == 0
|
|
16566
16569
|
except Exception as exc:
|
|
@@ -26579,6 +26582,121 @@ def _fusion_set_text_plus(comp, p: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
26579
26582
|
comp.Unlock()
|
|
26580
26583
|
|
|
26581
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
|
+
|
|
26582
26700
|
def _fusion_get_text_plus(comp, p: Dict[str, Any]) -> Dict[str, Any]:
|
|
26583
26701
|
"""Read the text of a Fusion Text+ tool / title template. (issue #73)"""
|
|
26584
26702
|
tool, err = _fusion_find_text_tool(comp, p)
|
|
@@ -26624,7 +26742,9 @@ def fusion_comp(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
26624
26742
|
get_attrs(tool_name) -> {attrs}
|
|
26625
26743
|
add_keyframe(tool_name, input_name, time, value) -> {success}
|
|
26626
26744
|
get_keyframes(tool_name, input_name) -> {keyframes}
|
|
26627
|
-
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.
|
|
26628
26748
|
get_comp_info() -> {name, tool_count, attrs}
|
|
26629
26749
|
get_position(tool_name) -> {tool_name, x, y} — read a node's FlowView position
|
|
26630
26750
|
set_position(tool_name, x, y) -> {success, x, y, readback} — move a node
|
|
@@ -26920,11 +27040,7 @@ def fusion_comp(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
26920
27040
|
return _err(f"Tool '{p['tool_name']}' not found")
|
|
26921
27041
|
comp.Lock()
|
|
26922
27042
|
try:
|
|
26923
|
-
|
|
26924
|
-
if not inp:
|
|
26925
|
-
return _err(f"Input '{p['input_name']}' not found on tool '{p['tool_name']}'")
|
|
26926
|
-
inp.RemoveKeyFrame(p["time"])
|
|
26927
|
-
return _ok()
|
|
27043
|
+
return _fusion_delete_keyframe(tool, p)
|
|
26928
27044
|
finally:
|
|
26929
27045
|
comp.Unlock()
|
|
26930
27046
|
|
|
@@ -27141,7 +27257,8 @@ def _validate_lua_syntax(source: str) -> Dict[str, Any]:
|
|
|
27141
27257
|
f.write(source)
|
|
27142
27258
|
tmp = f.name
|
|
27143
27259
|
try:
|
|
27144
|
-
result = subprocess.run([luac, "-p", tmp], capture_output=True, text=True,
|
|
27260
|
+
result = subprocess.run([luac, "-p", tmp], capture_output=True, text=True,
|
|
27261
|
+
encoding="utf-8", errors="replace", timeout=10,
|
|
27145
27262
|
stdin=subprocess.DEVNULL)
|
|
27146
27263
|
if result.returncode == 0:
|
|
27147
27264
|
return {"valid": True, "errors": None, "checker": luac}
|
|
@@ -27694,6 +27811,12 @@ def _python_env_for_resolve() -> Dict[str, str]:
|
|
|
27694
27811
|
env = os.environ.copy()
|
|
27695
27812
|
env["RESOLVE_SCRIPT_API"] = RESOLVE_API_PATH
|
|
27696
27813
|
env["RESOLVE_SCRIPT_LIB"] = RESOLVE_LIB_PATH
|
|
27814
|
+
# The child writes its stdout into a pipe, so Python picks the locale
|
|
27815
|
+
# codepage rather than the console's — cp1252 on a default Windows install.
|
|
27816
|
+
# A script that prints a non-Latin-1 character then dies with
|
|
27817
|
+
# UnicodeEncodeError instead of returning its output, and the failure is
|
|
27818
|
+
# attributed to the script rather than to the pipe it was handed (#153).
|
|
27819
|
+
env["PYTHONIOENCODING"] = "utf-8"
|
|
27697
27820
|
pp = env.get("PYTHONPATH", "")
|
|
27698
27821
|
if RESOLVE_MODULES_PATH not in pp:
|
|
27699
27822
|
env["PYTHONPATH"] = (RESOLVE_MODULES_PATH +
|
|
@@ -27741,7 +27864,8 @@ def _execute_python_script(path: str, args: List[str],
|
|
|
27741
27864
|
cmd = [sys.executable, "-c", _PY_SCRIPT_EXIT_GUARD, path] + [str(a) for a in args]
|
|
27742
27865
|
try:
|
|
27743
27866
|
result = safe_run(cmd, env=_python_env_for_resolve(),
|
|
27744
|
-
capture_output=True, text=True,
|
|
27867
|
+
capture_output=True, text=True, encoding="utf-8",
|
|
27868
|
+
errors="replace", timeout=timeout)
|
|
27745
27869
|
except subprocess.TimeoutExpired as e:
|
|
27746
27870
|
return _err(f"Script timed out after {timeout}s. "
|
|
27747
27871
|
f"Partial stdout: {(e.stdout or '')[:1000]}")
|
package/src/utils/app_control.py
CHANGED
package/src/utils/image_qc.py
CHANGED
|
@@ -189,7 +189,8 @@ def _probe_color_transfer(path: str) -> Optional[str]:
|
|
|
189
189
|
"-show_entries", "stream=color_transfer", "-of", "default=nw=1:nk=1", path,
|
|
190
190
|
]
|
|
191
191
|
try:
|
|
192
|
-
proc = subprocess.run(args, capture_output=True, text=True,
|
|
192
|
+
proc = subprocess.run(args, capture_output=True, text=True, encoding="utf-8",
|
|
193
|
+
errors="replace", timeout=30, check=False)
|
|
193
194
|
except (subprocess.TimeoutExpired, OSError):
|
|
194
195
|
return None
|
|
195
196
|
value = (proc.stdout or "").strip().lower()
|
|
@@ -335,8 +335,20 @@ def _ensure_path_includes_standard_tool_dirs() -> None:
|
|
|
335
335
|
/opt/homebrew/bin/ffprobe. Subprocess calls (subprocess.run(["ffprobe"...]))
|
|
336
336
|
then also fail to find the binary. Prepending the standard tool dirs here
|
|
337
337
|
fixes both detection and execution for every importer of this module.
|
|
338
|
+
|
|
339
|
+
The interpreter's own script directory is one of them, and it was missing.
|
|
340
|
+
A console script installed into the server's virtualenv — `pip install
|
|
341
|
+
openai-whisper` puts `whisper` in `venv/Scripts` on Windows, `venv/bin`
|
|
342
|
+
elsewhere — is only on PATH when that environment has been *activated*, and
|
|
343
|
+
nothing activates it: the client launches `venv/python server.py` directly.
|
|
344
|
+
So the tool was installed, working, and invisible, and `capabilities`
|
|
345
|
+
reported `whisper_cli.available: false` with no hint as to why (#153, where
|
|
346
|
+
the workaround that appeared to fix it was a shim placed on PATH by hand).
|
|
338
347
|
"""
|
|
339
348
|
candidates = [
|
|
349
|
+
# The venv this server is running from, first: a tool installed
|
|
350
|
+
# deliberately alongside it should win over an older copy elsewhere.
|
|
351
|
+
os.path.dirname(os.path.abspath(sys.executable)),
|
|
340
352
|
"/opt/homebrew/bin",
|
|
341
353
|
"/opt/homebrew/sbin",
|
|
342
354
|
"/usr/local/bin",
|
|
@@ -216,7 +216,8 @@ def _process_name(pid: int) -> str:
|
|
|
216
216
|
|
|
217
217
|
out = subprocess.run(
|
|
218
218
|
["ps", "-p", str(pid), "-o", "comm="],
|
|
219
|
-
capture_output=True, text=True,
|
|
219
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
220
|
+
timeout=5, check=False,
|
|
220
221
|
)
|
|
221
222
|
return (out.stdout or "").strip()
|
|
222
223
|
except Exception: # pragma: no cover - defensive
|
|
@@ -68,13 +68,23 @@ def _process_lines() -> Optional[List[str]]:
|
|
|
68
68
|
if platform.system().lower() == "windows":
|
|
69
69
|
# `tasklist` prints no command line, so the flag is invisible there.
|
|
70
70
|
# WMIC does print it and is what makes headless detection possible.
|
|
71
|
+
#
|
|
72
|
+
# Decoded explicitly: `text=True` alone decodes with the locale
|
|
73
|
+
# codepage, which raises UnicodeDecodeError on a byte cp1252 has no
|
|
74
|
+
# mapping for — and this read is the input to the second-instance
|
|
75
|
+
# guard, so it must fail to "cannot tell", never to an exception.
|
|
76
|
+
# ASCII is byte-identical under both codecs, so the matching this
|
|
77
|
+
# feeds is unchanged; what WMIC emits for a non-ASCII install path
|
|
78
|
+
# on a non-English Windows is not something we can verify here.
|
|
71
79
|
out = subprocess.run(
|
|
72
80
|
["wmic", "process", "where", "name='Resolve.exe'", "get", "CommandLine"],
|
|
73
|
-
capture_output=True, text=True,
|
|
81
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
82
|
+
timeout=10, check=False,
|
|
74
83
|
)
|
|
75
84
|
else:
|
|
76
85
|
out = subprocess.run(
|
|
77
|
-
["ps", "-Ao", "command="], capture_output=True, text=True,
|
|
86
|
+
["ps", "-Ao", "command="], capture_output=True, text=True,
|
|
87
|
+
encoding="utf-8", errors="replace", timeout=10, check=False,
|
|
78
88
|
)
|
|
79
89
|
if out.returncode != 0 and not out.stdout:
|
|
80
90
|
return None
|