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 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
- [![Version](https://img.shields.io/badge/version-4.8.2-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.8.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-37%20(389%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-4.8.2-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.8.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-37%20(389%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
- > 本翻译对应 v4.8.2 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
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.2"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "4.8.2",
3
+ "version": "4.8.4",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -93,7 +93,7 @@ if not logging.getLogger().handlers:
93
93
  handlers=[logging.StreamHandler()],
94
94
  )
95
95
 
96
- VERSION = "4.8.2"
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.2"
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 = ("List folders with folder get_subfolders (walking down from "
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
- for fid in p["folder_ids"]:
21201
- # Search for folder by ID (simplified - searches root subfolders)
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
- folders = []
21224
- for fid in p["folder_ids"]:
21225
- for sub in (root.GetSubFolderList() or []):
21226
- if sub.GetUniqueId() == fid:
21227
- folders.append(sub)
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:
@@ -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