davinci-resolve-mcp 4.8.2 → 4.8.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 +94 -0
- package/README.md +1 -1
- package/README.zh-CN.md +2 -2
- package/docs/SKILL.md +8 -2
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/granular/common.py +1 -1
- package/src/server.py +139 -18
- package/src/utils/api_truth.py +30 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,100 @@
|
|
|
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 v4.8.4 — a Fusion nest control is refused with the controls it folds named
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **`fusion_comp add_keyframe` on a nest control (`Softness1`, the Follower's
|
|
10
|
+
`TransformSize`, `Size1`, …) answered a generic `FUSION_ADD_MODIFIER_FAILED` with no
|
|
11
|
+
way forward.** ([#253](https://github.com/samuelgursky/davinci-resolve-mcp/issues/253), reported by @artpavelalex-ux as a follow-up to #250)
|
|
12
|
+
**Measured on Studio 19.1.3.7:** some entries `GetInputList()` returns are not
|
|
13
|
+
values at all. Inputs whose `INPID_InputControl` is `NestControl` (`INPB_Passive`
|
|
14
|
+
true) are the fold-down group headers the Fusion UI draws — `TextPlus Softness1`,
|
|
15
|
+
and on the text Follower `TransformSize` (display name "Size"), `Softness1` and
|
|
16
|
+
`Size1`. `Tool.AddModifier` returns False for them on every modifier type
|
|
17
|
+
(BezierSpline, Path, TextScramble all measured), so nothing could ever keyframe
|
|
18
|
+
them; this is not Follower-specific. The controls a header folds are the next
|
|
19
|
+
`INPI_LabelControl_NumInputs` entries in `GetInputList()` order —
|
|
20
|
+
`Softness1` → `SoftnessX1`, `SoftnessY1`, `SoftnessOnFillColorToo1`, `SoftnessGlow1`,
|
|
21
|
+
`SoftnessBlend1`; `TransformSize` → `LineSizeX/Y`, `WordSizeX/Y`, `CharacterSizeX/Y`;
|
|
22
|
+
`Size1` → `SizeX1`, `SizeY1` — and those take a spline normally.
|
|
23
|
+
- `add_keyframe` and `add_modifier` now detect a nest control before touching Fusion
|
|
24
|
+
and refuse it with **`FUSION_INPUT_IS_NEST_CONTROL`**, naming the folded controls
|
|
25
|
+
in the remediation and in `error.state.nest_members` (`_fusion_nest_members`).
|
|
26
|
+
- New `api_truth` entry `Tool.AddModifier (NestControl inputs)`, mapped on
|
|
27
|
+
`add_keyframe` and `add_modifier` results as a `known_limitation`.
|
|
28
|
+
- **Live-validated on landing through the real actions** on a disposable timeline:
|
|
29
|
+
the refusal named exactly those members on the Follower and on TextPlus;
|
|
30
|
+
`add_modifier` on `TransformSize` refused the same way; `SoftnessX1`,
|
|
31
|
+
`CharacterSizeX` and `Delay` keyframed and read back. Unit tests in
|
|
32
|
+
`tests/test_fusion_nest_control.py` against fakes whose input list is handed back
|
|
33
|
+
unsorted, so the member order is proven to come from the list order, not luck.
|
|
34
|
+
- The report itself arrived as an empty template with only its title; the
|
|
35
|
+
measurement was made from the title. Not measured: nests on tools other than
|
|
36
|
+
TextPlus and the Follower, and builds other than 19.1.3.7.
|
|
37
|
+
|
|
38
|
+
## What's New in v4.8.3 — nested folder ids resolve for delete and move
|
|
39
|
+
|
|
40
|
+
### Fixed
|
|
41
|
+
|
|
42
|
+
- **`media_pool.delete_folders` and `media_pool.move_folders` resolve
|
|
43
|
+
`folder_ids` anywhere in the Media Pool tree.** Both actions scanned only the
|
|
44
|
+
root folder's direct children, so an id belonging to any nested folder came
|
|
45
|
+
back `{"error":{"message":"No folders found"}}` — even though the caller was
|
|
46
|
+
holding the id `GetUniqueId()` had just handed them, and `mp.DeleteFolders()`
|
|
47
|
+
on the resolved object worked fine. Both now use the existing recursive
|
|
48
|
+
`_find_folder_by_id` walk through a shared `_folders_from_ids` resolver.
|
|
49
|
+
- **An unresolved id fails the call instead of being dropped** — the same
|
|
50
|
+
all-or-nothing rule 4.8.2 applied to the clip actions. Previously only an
|
|
51
|
+
all-empty result errored, so a mixed batch deleted or moved the subset that
|
|
52
|
+
happened to resolve and answered `{"success": true}`; `move_folders` did not
|
|
53
|
+
even stop at an empty result and answered `success: true` after
|
|
54
|
+
`MoveFolders([], target)`. Unresolved ids now return `FOLDER_NOT_FOUND` /
|
|
55
|
+
`invalid_input` naming both the unresolved and the resolved ids in `state`,
|
|
56
|
+
with nothing deleted or moved.
|
|
57
|
+
- An empty or non-list `folder_ids` returns `INVALID_FOLDER_IDS` instead of
|
|
58
|
+
iterating a bare string character by character; a missing one keeps the
|
|
59
|
+
surface-wide `MISSING_FOLDER_IDS`. The root (Master) folder — newly reachable
|
|
60
|
+
now that the search is recursive — is refused with
|
|
61
|
+
`ROOT_FOLDER_NOT_ELIGIBLE` before Resolve sees it. `move_folders`' "Target
|
|
62
|
+
folder not found" now carries `FOLDER_NOT_FOUND` / `invalid_input` too.
|
|
63
|
+
|
|
64
|
+
### Validation
|
|
65
|
+
|
|
66
|
+
- New `tests/test_media_pool_folder_ids.py` pins both actions against a
|
|
67
|
+
three-level-deep folder, the partial-batch case, and the boundary inputs.
|
|
68
|
+
Against the 4.8.2 code 10 of its 11 tests fail; all 11 pass after.
|
|
69
|
+
- Offline suite: 3,732 tests run, 84 skipped, 11 errors — the same environment
|
|
70
|
+
gaps as in 4.8.2 (`numpy` and `requests` absent from the venv, `node_modules`
|
|
71
|
+
not installed). None touch `media_pool`. Drift guards, api-parity,
|
|
72
|
+
api-limitations and read/write symmetry clean; tool counts unchanged.
|
|
73
|
+
- Live reproduction on Resolve Studio 21.1.0.14 (reporter): deleting
|
|
74
|
+
`Master/OUTDOORS/1_FOOTAGE/wetransfer_dscf1065-mov_2022-01-31_1207` by its
|
|
75
|
+
`GetUniqueId()` returned `No folders found`, while `mp.DeleteFolders()` with
|
|
76
|
+
the recursively resolved folder object succeeded.
|
|
77
|
+
- **Live-validated on Resolve Studio 21.1.0.14** with the new
|
|
78
|
+
`tests/live_nested_folder_ids_check.py`
|
|
79
|
+
(`venv/bin/python tests/live_nested_folder_ids_check.py`) against a real project
|
|
80
|
+
of 144 folders and 2,600 clips, probing a folder seven levels below Master.
|
|
81
|
+
`delete_folders` on its id reached `confirmation_required` naming exactly that
|
|
82
|
+
folder; `move_folders` resolved it and its current parent as the target;
|
|
83
|
+
partial and all-missing batches came back `FOLDER_NOT_FOUND` for both actions,
|
|
84
|
+
Master `ROOT_FOLDER_NOT_ELIGIBLE`, bare string and empty list
|
|
85
|
+
`INVALID_FOLDER_IDS`, no key `MISSING_FOLDER_IDS`. The check is strictly
|
|
86
|
+
non-destructive, with the same tripwire as the 4.8.2 clip check: the MediaPool
|
|
87
|
+
it hands the actions forwards only `GetRootFolder` / `GetCurrentFolder` and
|
|
88
|
+
intercepts every other method, so `DeleteFolders` / `MoveFolders` never reach
|
|
89
|
+
Resolve whatever the code does; token gating is forced on and no token is
|
|
90
|
+
passed; move probes aim at the folder's current parent; the destructive hook's
|
|
91
|
+
analysis-root writes are disabled and its logs go to a temp directory. Every
|
|
92
|
+
folder's parent and every clip's folder read back identical after the run.
|
|
93
|
+
- The same live check against the 4.8.2 code fails with 14 findings and
|
|
94
|
+
reproduces the reported bug: the depth-7 id came back `No folders found` from
|
|
95
|
+
`delete_folders`, and the tripwire intercepted five `MoveFolders` calls with an
|
|
96
|
+
empty folder list, each answered as a plain result rather than an error. The
|
|
97
|
+
pool was unchanged after that run too.
|
|
98
|
+
|
|
5
99
|
## What's New in v4.8.2 — a clip id that resolves to nothing fails the whole clip batch
|
|
6
100
|
|
|
7
101
|
### Fixed
|
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
|
-
> 本翻译对应 v4.8.
|
|
15
|
+
> 本翻译对应 v4.8.4 版 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
|
@@ -925,7 +925,10 @@ Key actions: `get_root_folder`, `get_current_folder`, `set_current_folder(path)`
|
|
|
925
925
|
`delete_clips`, `move_clips`, `relink` and `unlink` are all-or-nothing: if any id
|
|
926
926
|
in `clip_ids` matches no clip, the call fails with `CLIP_NOT_FOUND` (the
|
|
927
927
|
unresolved and resolved ids are in `error.state`) and no clip is changed. Drop the
|
|
928
|
-
stale ids and retry; do not read a partial batch as done.
|
|
928
|
+
stale ids and retry; do not read a partial batch as done. `delete_folders(folder_ids)`
|
|
929
|
+
and `move_folders(folder_ids, target_path)` work the same way with
|
|
930
|
+
`FOLDER_NOT_FOUND`; they resolve `folder_ids` at any depth (pass the ids
|
|
931
|
+
`folder get_subfolders` returns) and refuse the Master folder itself.
|
|
929
932
|
|
|
930
933
|
Media Pool / Ingest kernel actions (v2.8.0+) add safer agent-facing workflows:
|
|
931
934
|
`ingest_capabilities`, `probe_media_pool`, `probe_ingest_item`,
|
|
@@ -2044,7 +2047,10 @@ Key actions:
|
|
|
2044
2047
|
- `get_inputs(tool_name)` / `get_outputs(tool_name)`
|
|
2045
2048
|
- `set_attrs(tool_name, attrs)` / `get_attrs(tool_name)`
|
|
2046
2049
|
- `add_keyframe(tool_name, input_name, time, value, modifier?)` — attaches a
|
|
2047
|
-
BezierSpline (or `modifier`, e.g. `Path` for Point inputs) on first use
|
|
2050
|
+
BezierSpline (or `modifier`, e.g. `Path` for Point inputs) on first use. A nest
|
|
2051
|
+
control (a fold-down group header like `Softness1` or the Follower's
|
|
2052
|
+
`TransformSize`) is refused with `FUSION_INPUT_IS_NEST_CONTROL` naming the
|
|
2053
|
+
controls it folds (`SoftnessX1`/`SoftnessY1`, `CharacterSizeX`/`Y`, ...); keyframe those
|
|
2048
2054
|
- `add_modifier(tool_name, input_name, modifier)` → `{modifier_tool, modifier_type}`
|
|
2049
2055
|
— attach any modifier and get back the tool Fusion created, so a text modifier
|
|
2050
2056
|
(`Follower` on a TextPlus `StyledText`) can be driven with `set_input` /
|
package/install.py
CHANGED
|
@@ -37,7 +37,7 @@ from src.utils.update_check import (
|
|
|
37
37
|
|
|
38
38
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
39
39
|
|
|
40
|
-
VERSION = "4.8.
|
|
40
|
+
VERSION = "4.8.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
|
package/package.json
CHANGED
package/src/granular/common.py
CHANGED
|
@@ -93,7 +93,7 @@ if not logging.getLogger().handlers:
|
|
|
93
93
|
handlers=[logging.StreamHandler()],
|
|
94
94
|
)
|
|
95
95
|
|
|
96
|
-
VERSION = "4.8.
|
|
96
|
+
VERSION = "4.8.4"
|
|
97
97
|
logger = logging.getLogger("davinci-resolve-mcp")
|
|
98
98
|
logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
|
|
99
99
|
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 377-tool granular server instead
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
|
-
VERSION = "4.8.
|
|
14
|
+
VERSION = "4.8.4"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -2967,6 +2967,13 @@ def _find_clip_with_parent(folder, clip_id, _parent=None):
|
|
|
2967
2967
|
return found_clip, found_parent
|
|
2968
2968
|
return None, None
|
|
2969
2969
|
|
|
2970
|
+
|
|
2971
|
+
_FOLDER_ID_REMEDIATION = (
|
|
2972
|
+
"List folders with folder get_subfolders (walking down from "
|
|
2973
|
+
"path=\"Master\") and address by exact path or folder_id."
|
|
2974
|
+
)
|
|
2975
|
+
|
|
2976
|
+
|
|
2970
2977
|
def _find_folder_by_id(folder, folder_id):
|
|
2971
2978
|
if folder.GetUniqueId() == folder_id:
|
|
2972
2979
|
return folder
|
|
@@ -2977,6 +2984,57 @@ def _find_folder_by_id(folder, folder_id):
|
|
|
2977
2984
|
return None
|
|
2978
2985
|
|
|
2979
2986
|
|
|
2987
|
+
def _folders_from_ids(root, folder_ids, *, verb):
|
|
2988
|
+
"""Resolve Media Pool folder unique IDs to folder objects. Returns (folders, error).
|
|
2989
|
+
|
|
2990
|
+
Searches the WHOLE tree. The ids callers hold come from GetUniqueId() /
|
|
2991
|
+
folder.get_subfolders, which hand them out at every depth, so the shallow
|
|
2992
|
+
scan of root's direct children this replaced answered "No folders found" for
|
|
2993
|
+
any nested folder — verified live on Resolve Studio 21.1.0.14, where
|
|
2994
|
+
deleting Master/OUTDOORS/1_FOOTAGE/<clip bin> by its own id failed while
|
|
2995
|
+
mp.DeleteFolders() on the recursively resolved object worked.
|
|
2996
|
+
|
|
2997
|
+
Unresolved ids abort the call instead of being dropped. Skipping them let a
|
|
2998
|
+
partial match delete or move the subset it did find and answer plain
|
|
2999
|
+
{"success": true}, which is indistinguishable from every id having resolved.
|
|
3000
|
+
|
|
3001
|
+
The root (Master) folder is refused rather than passed to Resolve: it cannot
|
|
3002
|
+
be deleted or moved, and the shallow search could never return it.
|
|
3003
|
+
"""
|
|
3004
|
+
if not isinstance(folder_ids, list) or not folder_ids:
|
|
3005
|
+
return None, _err(
|
|
3006
|
+
"folder_ids must be a non-empty list of folder unique IDs",
|
|
3007
|
+
code="INVALID_FOLDER_IDS", category="invalid_input",
|
|
3008
|
+
remediation=_FOLDER_ID_REMEDIATION)
|
|
3009
|
+
root_id = root.GetUniqueId()
|
|
3010
|
+
folders, unresolved, named_root = [], [], False
|
|
3011
|
+
for fid in folder_ids:
|
|
3012
|
+
fid = str(fid)
|
|
3013
|
+
if fid == root_id:
|
|
3014
|
+
named_root = True
|
|
3015
|
+
continue
|
|
3016
|
+
found = _find_folder_by_id(root, fid)
|
|
3017
|
+
if found is None:
|
|
3018
|
+
unresolved.append(fid)
|
|
3019
|
+
else:
|
|
3020
|
+
folders.append(found)
|
|
3021
|
+
if named_root:
|
|
3022
|
+
return None, _err(
|
|
3023
|
+
f"The root (Master) folder cannot be {verb}",
|
|
3024
|
+
code="ROOT_FOLDER_NOT_ELIGIBLE", category="invalid_input",
|
|
3025
|
+
state={"root_folder_id": root_id})
|
|
3026
|
+
if unresolved:
|
|
3027
|
+
return None, _err(
|
|
3028
|
+
f"Folder(s) not found: {', '.join(unresolved)}",
|
|
3029
|
+
code="FOLDER_NOT_FOUND", category="invalid_input",
|
|
3030
|
+
reason=(f"{len(unresolved)} of {len(folder_ids)} folder_ids matched no folder "
|
|
3031
|
+
f"anywhere in the Media Pool; nothing was {verb}."),
|
|
3032
|
+
remediation=_FOLDER_ID_REMEDIATION,
|
|
3033
|
+
state={"unresolved_folder_ids": unresolved,
|
|
3034
|
+
"resolved_folder_ids": [f.GetUniqueId() for f in folders]})
|
|
3035
|
+
return folders, None
|
|
3036
|
+
|
|
3037
|
+
|
|
2980
3038
|
def _folder_from_params(mp, p, *path_keys, no_address="current"):
|
|
2981
3039
|
"""Resolve the folder an action was aimed at. Returns (folder, error).
|
|
2982
3040
|
|
|
@@ -2997,8 +3055,7 @@ def _folder_from_params(mp, p, *path_keys, no_address="current"):
|
|
|
2997
3055
|
`_navigate_folder(mp, "")`, which returns root). This fix must not also
|
|
2998
3056
|
change what omitting the address means.
|
|
2999
3057
|
"""
|
|
3000
|
-
remediation =
|
|
3001
|
-
"path=\"Master\") and address by exact path or folder_id.")
|
|
3058
|
+
remediation = _FOLDER_ID_REMEDIATION
|
|
3002
3059
|
path = _first_param(p, *path_keys)
|
|
3003
3060
|
if path:
|
|
3004
3061
|
f = _navigate_folder(mp, path)
|
|
@@ -21088,7 +21145,10 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
|
|
|
21088
21145
|
add_subfolder(name, parent_path?) -> {success, name, id}
|
|
21089
21146
|
delete_folders(folder_ids) -> {success}
|
|
21090
21147
|
DESTRUCTIVE. Deletes folders + every clip they contain.
|
|
21148
|
+
folder_ids resolve at ANY depth; an id matching nothing fails the whole
|
|
21149
|
+
call (FOLDER_NOT_FOUND) rather than deleting the ids that did resolve.
|
|
21091
21150
|
move_folders(folder_ids, target_path) -> {success}
|
|
21151
|
+
folder_ids resolve at ANY depth; unresolved ids fail the whole call.
|
|
21092
21152
|
refresh() -> {success}
|
|
21093
21153
|
create_timeline(name, if_exists?) -> {success, name, id}
|
|
21094
21154
|
— if_exists: version (default), reuse, or fail
|
|
@@ -21196,14 +21256,9 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
|
|
|
21196
21256
|
f = mp.AddSubFolder(parent, p["name"])
|
|
21197
21257
|
return _ok(name=f.GetName(), id=f.GetUniqueId()) if f else _err("Failed to create subfolder")
|
|
21198
21258
|
elif action == "delete_folders":
|
|
21199
|
-
folders = []
|
|
21200
|
-
|
|
21201
|
-
|
|
21202
|
-
for sub in (root.GetSubFolderList() or []):
|
|
21203
|
-
if sub.GetUniqueId() == fid:
|
|
21204
|
-
folders.append(sub)
|
|
21205
|
-
if not folders:
|
|
21206
|
-
return _err("No folders found")
|
|
21259
|
+
folders, folders_err = _folders_from_ids(root, p["folder_ids"], verb="deleted")
|
|
21260
|
+
if folders_err:
|
|
21261
|
+
return folders_err
|
|
21207
21262
|
if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required():
|
|
21208
21263
|
return _issue_confirm_token(
|
|
21209
21264
|
action="media_pool.delete_folders", params=p,
|
|
@@ -21219,12 +21274,12 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
|
|
|
21219
21274
|
elif action == "move_folders":
|
|
21220
21275
|
target = _navigate_folder(mp, p["target_path"])
|
|
21221
21276
|
if not target:
|
|
21222
|
-
return _err(f"Target folder not found: {p['target_path']}"
|
|
21223
|
-
|
|
21224
|
-
|
|
21225
|
-
|
|
21226
|
-
|
|
21227
|
-
|
|
21277
|
+
return _err(f"Target folder not found: {p['target_path']}",
|
|
21278
|
+
code="FOLDER_NOT_FOUND", category="invalid_input",
|
|
21279
|
+
remediation=_FOLDER_ID_REMEDIATION)
|
|
21280
|
+
folders, folders_err = _folders_from_ids(root, p["folder_ids"], verb="moved")
|
|
21281
|
+
if folders_err:
|
|
21282
|
+
return folders_err
|
|
21228
21283
|
return {"success": bool(mp.MoveFolders(folders, target))}
|
|
21229
21284
|
elif action == "refresh":
|
|
21230
21285
|
return {"success": bool(mp.RefreshFolders())}
|
|
@@ -30475,6 +30530,61 @@ def _fusion_modifier_id(name: Any) -> str:
|
|
|
30475
30530
|
return _FUSION_MODIFIER_IDS.get(text.lower(), text)
|
|
30476
30531
|
|
|
30477
30532
|
|
|
30533
|
+
def _fusion_nest_members(tool, input_name: str):
|
|
30534
|
+
"""(is_nest, member_ids) for an input that is a Fusion NestControl header.
|
|
30535
|
+
|
|
30536
|
+
Measured on Studio 19.1.3.7 (issue #253): inputs whose `INPID_InputControl`
|
|
30537
|
+
is "NestControl" (`INPB_Passive` true) are the fold-down group headers the
|
|
30538
|
+
Fusion UI draws — TextPlus `Softness1`, Follower `TransformSize`, `Softness1`,
|
|
30539
|
+
`Size1` — not animatable values. `Tool.AddModifier` returns False for them on
|
|
30540
|
+
every modifier type, and so did every attempt to keyframe them. The controls
|
|
30541
|
+
the header folds are the next `INPI_LabelControl_NumInputs` entries in
|
|
30542
|
+
`GetInputList()` order (Softness1 -> SoftnessX1, SoftnessY1,
|
|
30543
|
+
SoftnessOnFillColorToo1, SoftnessGlow1, SoftnessBlend1; TransformSize ->
|
|
30544
|
+
Line/Word/CharacterSize X and Y), and those take a spline normally.
|
|
30545
|
+
"""
|
|
30546
|
+
try:
|
|
30547
|
+
attrs = tool[input_name].GetAttrs() or {}
|
|
30548
|
+
except Exception:
|
|
30549
|
+
return False, []
|
|
30550
|
+
if attrs.get("INPID_InputControl") != "NestControl":
|
|
30551
|
+
return False, []
|
|
30552
|
+
try:
|
|
30553
|
+
count = int(attrs.get("INPI_LabelControl_NumInputs") or 0)
|
|
30554
|
+
except (TypeError, ValueError):
|
|
30555
|
+
count = 0
|
|
30556
|
+
members: List[str] = []
|
|
30557
|
+
try:
|
|
30558
|
+
input_list = tool.GetInputList() or {}
|
|
30559
|
+
keys = list(input_list.keys())
|
|
30560
|
+
try:
|
|
30561
|
+
keys.sort(key=float)
|
|
30562
|
+
except (TypeError, ValueError):
|
|
30563
|
+
pass
|
|
30564
|
+
ids = [((input_list[k].GetAttrs() or {}).get("INPS_ID") or "") for k in keys]
|
|
30565
|
+
if input_name in ids:
|
|
30566
|
+
start = ids.index(input_name) + 1
|
|
30567
|
+
members = [i for i in ids[start:start + count] if i]
|
|
30568
|
+
except Exception:
|
|
30569
|
+
members = []
|
|
30570
|
+
return True, members
|
|
30571
|
+
|
|
30572
|
+
|
|
30573
|
+
def _fusion_nest_control_error(tool_name: str, input_name: str, members: List[str], verb: str):
|
|
30574
|
+
listed = ", ".join(members) if members else "see get_inputs(tool_name)"
|
|
30575
|
+
return _err(
|
|
30576
|
+
f"'{input_name}' on '{tool_name}' is a nest control (a group header), "
|
|
30577
|
+
f"not an animatable input; it cannot be {verb}.",
|
|
30578
|
+
code="FUSION_INPUT_IS_NEST_CONTROL", category="invalid_input", retryable=False,
|
|
30579
|
+
reason="Fusion NestControl inputs are passive headers that fold a group of "
|
|
30580
|
+
"controls. Tool.AddModifier returns False for them on every modifier "
|
|
30581
|
+
"type (measured on Studio 19.1.3.7: TextPlus Softness1, Follower "
|
|
30582
|
+
"TransformSize / Softness1 / Size1).",
|
|
30583
|
+
remediation=f"Target one of the controls the nest folds instead: {listed}.",
|
|
30584
|
+
state={"nest_control": input_name, "nest_members": members},
|
|
30585
|
+
)
|
|
30586
|
+
|
|
30587
|
+
|
|
30478
30588
|
def _fusion_input_spline(inp):
|
|
30479
30589
|
"""The modifier/spline tool driving `inp`, or None when it is not animated.
|
|
30480
30590
|
|
|
@@ -30631,7 +30741,10 @@ def fusion_comp(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
30631
30741
|
add_keyframe(tool_name, input_name, time, value, modifier?) -> {success}
|
|
30632
30742
|
Attaches a BezierSpline (or `modifier`, e.g. 'Path' for Point inputs)
|
|
30633
30743
|
the first time an input is animated. Modifier names are mapped to
|
|
30634
|
-
their registry ID ('Follower' -> 'StyledTextFollower').
|
|
30744
|
+
their registry ID ('Follower' -> 'StyledTextFollower'). A nest control
|
|
30745
|
+
(a fold-down group header such as Softness1 or TransformSize) is refused
|
|
30746
|
+
with FUSION_INPUT_IS_NEST_CONTROL naming the controls it folds
|
|
30747
|
+
(SoftnessX1/SoftnessY1, ...): keyframe those.
|
|
30635
30748
|
add_modifier(tool_name, input_name, modifier) -> {success, modifier_tool, modifier_type}
|
|
30636
30749
|
Attach any modifier and return the tool Fusion created for it, so a
|
|
30637
30750
|
TEXT modifier (Follower on a TextPlus StyledText) can then be driven
|
|
@@ -30911,6 +31024,10 @@ def fusion_comp(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
30911
31024
|
except Exception:
|
|
30912
31025
|
_already_animated = False
|
|
30913
31026
|
if not _already_animated:
|
|
31027
|
+
_is_nest, _members = _fusion_nest_members(tool, p["input_name"])
|
|
31028
|
+
if _is_nest:
|
|
31029
|
+
return _fusion_nest_control_error(
|
|
31030
|
+
p["tool_name"], p["input_name"], _members, "keyframed")
|
|
30914
31031
|
# AddModifier reports through the Lua bridge, which resolves an
|
|
30915
31032
|
# unknown attribute to None rather than raising, so the return is
|
|
30916
31033
|
# not reliable evidence on its own. The readback below is: if the
|
|
@@ -30957,6 +31074,10 @@ def fusion_comp(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
30957
31074
|
if not inp:
|
|
30958
31075
|
return _err(f"Input '{input_name}' not found on tool '{p['tool_name']}'")
|
|
30959
31076
|
modifier_id = _fusion_modifier_id(requested)
|
|
31077
|
+
is_nest, members = _fusion_nest_members(tool, input_name)
|
|
31078
|
+
if is_nest:
|
|
31079
|
+
return _fusion_nest_control_error(p["tool_name"], input_name, members,
|
|
31080
|
+
"given a modifier")
|
|
30960
31081
|
try:
|
|
30961
31082
|
existing = inp.GetConnectedOutput()
|
|
30962
31083
|
except Exception:
|
package/src/utils/api_truth.py
CHANGED
|
@@ -3440,6 +3440,34 @@ API_TRUTH: List[Dict[str, Any]] = [
|
|
|
3440
3440
|
"only StyledTextFollower attached (new tool Follower1).",
|
|
3441
3441
|
"mitigation": ["fusion_comp.add_modifier", "fusion_comp.add_keyframe"],
|
|
3442
3442
|
},
|
|
3443
|
+
{
|
|
3444
|
+
"symbol": "Tool.AddModifier (NestControl inputs)",
|
|
3445
|
+
"object": "Fusion Tool",
|
|
3446
|
+
"signature": "(inputName, modifierRegID) -> bool",
|
|
3447
|
+
"reality": "Some inputs GetInputList() returns are not values at all: those whose "
|
|
3448
|
+
"INPID_InputControl attribute is 'NestControl' (INPB_Passive true) are "
|
|
3449
|
+
"the fold-down group headers the Fusion UI draws. AddModifier returns "
|
|
3450
|
+
"False for them on every modifier type, and assigning at a time sets "
|
|
3451
|
+
"nothing. Measured on TextPlus Softness1 and on the text Follower's "
|
|
3452
|
+
"TransformSize, Softness1 and Size1. The controls a header folds are "
|
|
3453
|
+
"the next INPI_LabelControl_NumInputs entries in GetInputList() order "
|
|
3454
|
+
"(Softness1 -> SoftnessX1, SoftnessY1, SoftnessOnFillColorToo1, "
|
|
3455
|
+
"SoftnessGlow1, SoftnessBlend1; TransformSize -> Line/Word/Character "
|
|
3456
|
+
"Size X and Y), and those take a BezierSpline normally.",
|
|
3457
|
+
"recommended": "Keyframe the folded controls, never the header. fusion_comp "
|
|
3458
|
+
"add_keyframe and add_modifier refuse a nest control with "
|
|
3459
|
+
"FUSION_INPUT_IS_NEST_CONTROL and list its members.",
|
|
3460
|
+
"tags": ["fusion", "silent-failure", "naming"],
|
|
3461
|
+
"verified_on": "DaVinci Resolve Studio 19.1.3.7",
|
|
3462
|
+
"measured": "2026-09-19 on a disposable timeline: Follower via add_modifier, then "
|
|
3463
|
+
"add_keyframe on TransformSize / Softness1 (FUSION_ADD_MODIFIER_FAILED, "
|
|
3464
|
+
"raw AddModifier False for BezierSpline, Path, TextScramble) versus "
|
|
3465
|
+
"Size / Opacity1 / Delay / SoftnessX1 / SoftnessY1 / SizeX1 / "
|
|
3466
|
+
"CharacterSizeX (BezierSpline attached); TextPlus Softness1 refused too. "
|
|
3467
|
+
"GetAttrs diff: INPID_InputControl NestControl vs SliderControl, "
|
|
3468
|
+
"INPB_Passive true, INPI_LabelControl_NumInputs 6 / 5 / 2.",
|
|
3469
|
+
"mitigation": ["fusion_comp.add_keyframe", "fusion_comp.add_modifier"],
|
|
3470
|
+
},
|
|
3443
3471
|
|
|
3444
3472
|
]
|
|
3445
3473
|
|
|
@@ -3500,7 +3528,8 @@ ACTION_SYMBOLS: Dict[Tuple[str, str], List[str]] = {
|
|
|
3500
3528
|
("timeline_item_color", "safe_export_lut"): ["TimelineItem.ExportLUT"],
|
|
3501
3529
|
("timeline", "duplicate"): ["Timeline.DuplicateTimeline"],
|
|
3502
3530
|
("project_manager", "archive"): ["ProjectManager.ArchiveProject"],
|
|
3503
|
-
("fusion_comp", "add_modifier"): ["Tool.AddModifier"],
|
|
3531
|
+
("fusion_comp", "add_modifier"): ["Tool.AddModifier", "Tool.AddModifier (NestControl inputs)"],
|
|
3532
|
+
("fusion_comp", "add_keyframe"): ["Tool.AddModifier (NestControl inputs)"],
|
|
3504
3533
|
("project_manager", "safe_project_archive"): ["ProjectManager.ArchiveProject"],
|
|
3505
3534
|
}
|
|
3506
3535
|
|