davinci-resolve-mcp 4.7.11 → 4.8.1

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,77 @@
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.1 — text modifiers (Follower) attach to TextPlus inputs
6
+
7
+ ### Added
8
+
9
+ - **`fusion_comp add_modifier(tool_name, input_name, modifier)`** — attach any modifier
10
+ to a Fusion input and get back the tool Fusion created for it
11
+ (`modifier_tool`, `modifier_type`), so a TEXT modifier can then be driven with
12
+ `set_input` / `add_keyframe` on that tool. ([#250](https://github.com/samuelgursky/davinci-resolve-mcp/issues/250), reported by @artpavelalex-ux — the first report filed through this server's own `report_issue` action)
13
+ The reporter wanted a Follower on a TextPlus `StyledText` for per-character
14
+ typewriter animation, and `add_keyframe(modifier="Follower")` failed with
15
+ `FUSION_ADD_MODIFIER_FAILED` because Fusion rejects the attach. **Measured on
16
+ Studio 19.1.3.7:** `Tool.AddModifier` wants the modifier's REGISTRY ID, not its
17
+ display name — `AddModifier("StyledText", "Follower")` and `"TextFollower"` return
18
+ False and attach nothing; `"StyledTextFollower"` returns True, creates a `Follower1`
19
+ tool of that ID and connects it to the input. The mapping now lives in
20
+ `_FUSION_MODIFIER_IDS` and both `add_modifier` and `add_keyframe`'s `modifier`
21
+ parameter use it, so `Follower` works as written. Attachment is verified by
22
+ readback (the input's connected output), never by AddModifier's bool, which is
23
+ unreliable through the Lua bridge. An input that already has a modifier is refused
24
+ with `FUSION_INPUT_ALREADY_CONNECTED` and the existing tool named; a modifier
25
+ Fusion rejects is reported with the registry-ID remediation rather than claimed.
26
+ - Rated a LOW bounded reversible edit and registered in the destructive hook, so it
27
+ gets safe-mode, an audit row, and the timeline archive copy before the edit like
28
+ every other compound write; `add_keyframe` itself stays on the ratchet backlog.
29
+ - New `api_truth` entry `Tool.AddModifier` (`verified_on` 19.1.3.7) mapped through
30
+ `ACTION_SYMBOLS`, so `add_modifier` results carry the fact as a `known_limitation`.
31
+ - **Live-validated on landing through the real action** on a disposable timeline:
32
+ `add_modifier(Follower)` → `Follower1` / `StyledTextFollower`; `set_input(Delay=5)`
33
+ on the returned tool read back `5.0`; a second attach → `FUSION_INPUT_ALREADY_CONNECTED`
34
+ naming `Follower1`; a bogus modifier → `FUSION_ADD_MODIFIER_FAILED`. Unit tests in
35
+ `tests/test_fusion_add_modifier.py` against fakes that accept only the registry ID.
36
+ - Not measured: other text modifiers (the mapping table holds only the Follower),
37
+ builds other than 19.1.3.7, and whether the per-character transforms the reporter
38
+ wants animate as expected once driven — that is theirs to confirm.
39
+
40
+ ## What's New in v4.8.0 — one read before planning: `project_manager snapshot`
41
+
42
+ ### Added
43
+
44
+ - **`project_manager(action="snapshot")`** — a read-only readout of the state an agent
45
+ inspects before it plans an edit. ([#251](https://github.com/samuelgursky/davinci-resolve-mcp/pull/251), @tpellet)
46
+ Returns `project`, `timeline` (per-track items), `gaps_overlaps`, `render`
47
+ (`is_rendering` plus each job's status) and `media_pool` counts in one call, instead
48
+ of `get_current` + `timeline.get_current` + `probe_timeline_structure` +
49
+ `detect_gaps_overlaps` + `render.is_rendering` one LLM turn at a time. The
50
+ motivation is measured: in 8,407 mined agent tool turns from one real project,
51
+ 2,222 were state inspection. It composes the existing helpers, so the values match
52
+ what those actions already return, and it never switches page, timeline or folder.
53
+ - `include` picks sections; an unknown name or an empty list is refused rather than
54
+ widened to everything. `item_limit` (default 200) caps the items returned across
55
+ tracks and sets `timeline.items_truncated`, while `item_count` and
56
+ `gaps_overlaps` still cover the whole timeline. A failing section reports
57
+ `{error}` in its own place; the others still return.
58
+ - It saves turns and response size, not read time: the timeline sections cost what
59
+ `probe_timeline_structure` costs and `media_pool` walks every pool clip, so on a
60
+ large project pass `include` with only the sections you need. `docs/SKILL.md` says
61
+ so.
62
+ - Tested against stubs only (16 tests, including parity with the actions it replaces
63
+ and a sixty-item readout kept under 16 KB); not yet run against a live Resolve.
64
+
65
+ ### Fixed
66
+
67
+ - **The risk classifier now recognises `project_manager.snapshot` as a LOW read.** Its
68
+ name carries no read verb, so on the contributed branch it fell to the name-based
69
+ MEDIUM default with a "risk unestablished" reason on every call — noise on the one
70
+ read an agent makes before planning. Safe mode would not have blocked it (only an
71
+ established HIGH/CRITICAL is), but a pure read should not carry that. Explicit
72
+ verbless reads now live in `RiskClassificationHook._READ_ONLY_PAIRS` beside
73
+ `dctl.validate_native`; guard test `tests/test_snapshot_read_rule.py` also pins that
74
+ the table does not widen into a wildcard.
75
+
5
76
  ## What's New in v4.7.11 — `allow_partial_item_delete="false"` no longer lets a range delete take whole clips
6
77
 
7
78
  ### 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.7.11-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.8.1-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.7.11-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.8.1-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.7.11 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v4.8.1 版 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
@@ -821,6 +821,19 @@ Key actions: `list`, `list_attributes`, `get_current`,
821
821
  `notes`, and `liveCollaborationMode` per project in the current folder without
822
822
  loading any of them.
823
823
 
824
+ `snapshot(include?, track_types?, item_limit?)` is the one read to make before
825
+ planning: `project`, `timeline` (per-track items), `gaps_overlaps`, `render`
826
+ (`is_rendering` plus each job's status) and `media_pool` counts in a single
827
+ read-only call, instead of `get_current` + `timeline.get_current` +
828
+ `probe_timeline_structure` + `detect_gaps_overlaps` + `render.is_rendering` one
829
+ turn at a time. `include` picks sections, `item_limit` (default 200) caps the
830
+ items returned and sets `timeline.items_truncated`, and a section that fails
831
+ reports `{error}` in its own place. It saves turns and response size, not read
832
+ time: the timeline sections cost what `probe_timeline_structure` costs and
833
+ `media_pool` walks every pool clip, so on a large project pass `include` with
834
+ only the sections you need. Frame fields are `probe_timeline_structure`'s,
835
+ unchanged.
836
+
824
837
  Project / Database / Archive kernel actions (v2.15.0+) add guarded project
825
838
  lifecycle, settings, database, preset, and archive boundary helpers:
826
839
 
@@ -2025,7 +2038,13 @@ Key actions:
2025
2038
  `get_input(tool_name, input_name, time?)`
2026
2039
  - `get_inputs(tool_name)` / `get_outputs(tool_name)`
2027
2040
  - `set_attrs(tool_name, attrs)` / `get_attrs(tool_name)`
2028
- - `add_keyframe(tool_name, input_name, time, value)`
2041
+ - `add_keyframe(tool_name, input_name, time, value, modifier?)` — attaches a
2042
+ BezierSpline (or `modifier`, e.g. `Path` for Point inputs) on first use
2043
+ - `add_modifier(tool_name, input_name, modifier)` → `{modifier_tool, modifier_type}`
2044
+ — attach any modifier and get back the tool Fusion created, so a text modifier
2045
+ (`Follower` on a TextPlus `StyledText`) can be driven with `set_input` /
2046
+ `add_keyframe` on that tool (e.g. `Delay`). Fusion wants the registry ID
2047
+ (`StyledTextFollower`, measured on Studio 19.1.3.7); `Follower` is mapped for you
2029
2048
  - `get_position(tool_name)` / `set_position(tool_name, x, y)` — read/write a node's
2030
2049
  position on the FlowView canvas; `set_position` returns a position read-back
2031
2050
  - `copy_tool(tool_name, name?, x?, y?)` — duplicate a node (settings copied via a
@@ -2,7 +2,7 @@
2
2
 
3
3
  # Read/Write Symmetry Audit
4
4
 
5
- - write-style action occurrences scanned: **124**
5
+ - write-style action occurrences scanned: **125**
6
6
  - write-style action occurrences with a matching read: **76**
7
7
  - distinct high-signal `set_` actions without a direct/known readback: **4**
8
8
 
@@ -13,6 +13,6 @@
13
13
  - `set_keyframe_interpolation`
14
14
  - `set_node_enabled`
15
15
 
16
- ## Low-signal (create/add/insert/apply/import — usually expected): 43 distinct names
16
+ ## Low-signal (create/add/insert/apply/import — usually expected): 44 distinct names
17
17
 
18
- `add_clip_mattes`, `add_comp`, `add_fusion_mask`, `add_subfolder`, `add_sync_event_markers`, `add_timeline_mattes`, `add_track`, `add_transition`, `add_version`, `apply_arri_cdl_lut`, `apply_cuts`, `apply_fairlight_preset`, `apply_grade_from_drx`, `apply_look_to_items`, `apply_spec`, `apply_trace_plan`, `create_compound_clip`, `create_fusion_clip`, `create_magic_mask`, `create_multicam_clip`, `create_stereo_clip`, `create_subtitles`, `create_timeline`, `create_timeline_from_clips`, `create_variant_from_ranges`, `import_comp`, `import_folder`, `import_from_drp`, `import_into_timeline`, `import_media`, `import_preset`, `import_project`, `import_render`, `import_timeline`, `import_timeline_checked`, `import_to_pool`, `insert_audio`, `insert_fusion_composition`, `insert_fusion_generator`, `insert_fusion_title`, `insert_generator`, `insert_ofx_generator`, `insert_title`
18
+ `add_clip_mattes`, `add_comp`, `add_fusion_mask`, `add_modifier`, `add_subfolder`, `add_sync_event_markers`, `add_timeline_mattes`, `add_track`, `add_transition`, `add_version`, `apply_arri_cdl_lut`, `apply_cuts`, `apply_fairlight_preset`, `apply_grade_from_drx`, `apply_look_to_items`, `apply_spec`, `apply_trace_plan`, `create_compound_clip`, `create_fusion_clip`, `create_magic_mask`, `create_multicam_clip`, `create_stereo_clip`, `create_subtitles`, `create_timeline`, `create_timeline_from_clips`, `create_variant_from_ranges`, `import_comp`, `import_folder`, `import_from_drp`, `import_into_timeline`, `import_media`, `import_preset`, `import_project`, `import_render`, `import_timeline`, `import_timeline_checked`, `import_to_pool`, `insert_audio`, `insert_fusion_composition`, `insert_fusion_generator`, `insert_fusion_title`, `insert_generator`, `insert_ofx_generator`, `insert_title`
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "4.7.11"
40
+ VERSION = "4.8.1"
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.7.11",
3
+ "version": "4.8.1",
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.7.11"
96
+ VERSION = "4.8.1"
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.7.11"
14
+ VERSION = "4.8.1"
15
15
 
16
16
  import base64
17
17
  import os
@@ -19077,6 +19077,124 @@ def _project_lint_live(r, pm) -> Dict[str, Any]:
19077
19077
  return _ok(**_project_lint.lint_report(state))
19078
19078
 
19079
19079
 
19080
+ _SNAPSHOT_SECTIONS = ["project", "timeline", "gaps_overlaps", "render", "media_pool"]
19081
+
19082
+ # The per-item fields a planning read needs. probe_timeline_structure carries
19083
+ # the rest (file path, media status, seconds, clip properties) for a caller
19084
+ # that wants them; repeating them here is what makes a 60-item readout too big
19085
+ # to be the first call of every turn.
19086
+ _SNAPSHOT_ITEM_FIELDS = (
19087
+ "item_index", "timeline_item_id", "name", "start", "end",
19088
+ "source_start", "source_end", "source_fps", "media_pool_item_id",
19089
+ )
19090
+
19091
+
19092
+ def _project_state_snapshot(r, proj, p: Dict[str, Any]) -> Dict[str, Any]:
19093
+ """One read-only readout of the state an agent inspects before planning.
19094
+
19095
+ Composes the helpers behind project_summary, probe_timeline_structure,
19096
+ detect_gaps_overlaps and the render status actions, so the numbers match
19097
+ what those actions return. Each section fails on its own: an exception is
19098
+ reported as that section's {"error"}, never as a whole-call failure.
19099
+ """
19100
+ include = p.get("include")
19101
+ if include is None:
19102
+ include = list(_SNAPSHOT_SECTIONS)
19103
+ # An empty include is refused: falling back to every section would hand a
19104
+ # caller that filtered down to nothing the most expensive read instead.
19105
+ if not isinstance(include, list) or not include:
19106
+ return _err("include must be a non-empty list", category="invalid_input")
19107
+ unknown = [name for name in include if name not in _SNAPSHOT_SECTIONS]
19108
+ if unknown:
19109
+ return _err(
19110
+ f"Unknown snapshot section(s): {unknown}",
19111
+ code="UNKNOWN_SECTION", category="invalid_input",
19112
+ state={"unknown": unknown, "supported": list(_SNAPSHOT_SECTIONS)},
19113
+ )
19114
+ track_types = p.get("track_types")
19115
+ if track_types is not None and not isinstance(track_types, list):
19116
+ return _err("track_types must be a list", category="invalid_input")
19117
+ try:
19118
+ item_limit = int(p.get("item_limit", 200))
19119
+ except (TypeError, ValueError):
19120
+ return _err("item_limit must be an integer", category="invalid_input")
19121
+ if item_limit < 0:
19122
+ return _err("item_limit must be 0 or greater", category="invalid_input")
19123
+
19124
+ out: Dict[str, Any] = {}
19125
+
19126
+ if "project" in include:
19127
+ try:
19128
+ section = _project_object_summary(proj) or {}
19129
+ section["current_page"] = r.GetCurrentPage()
19130
+ section["timeline_count"] = proj.GetTimelineCount()
19131
+ section["settings"] = {
19132
+ key: _ser(proj.GetSetting(key))
19133
+ for key in ("timelineFrameRate", "timelineResolutionWidth", "timelineResolutionHeight")
19134
+ }
19135
+ out["project"] = section
19136
+ except Exception as exc:
19137
+ out["project"] = {"error": str(exc)}
19138
+
19139
+ if "timeline" in include or "gaps_overlaps" in include:
19140
+ wanted = [name for name in ("timeline", "gaps_overlaps") if name in include]
19141
+ try:
19142
+ tl = proj.GetCurrentTimeline()
19143
+ if not tl:
19144
+ for name in wanted:
19145
+ out[name] = {"available": False, "error": "No current timeline"}
19146
+ else:
19147
+ snapshot = _timeline_conform_snapshot(
19148
+ tl, {"track_types": track_types, "include_markers": False})
19149
+ # Gaps are measured on the whole snapshot, before item_limit
19150
+ # trims what is returned.
19151
+ gaps_overlaps = _detect_gaps_overlaps_from_snapshot(
19152
+ snapshot, {"track_types": track_types})
19153
+ if "timeline" in include:
19154
+ snapshot.pop("markers", None)
19155
+ try:
19156
+ snapshot["fps"] = float(tl.GetSetting("timelineFrameRate"))
19157
+ except Exception:
19158
+ snapshot["fps"] = None
19159
+ remaining = item_limit
19160
+ for type_payload in snapshot["tracks"].values():
19161
+ for track in type_payload["tracks"]:
19162
+ kept = track["items"][:remaining]
19163
+ remaining -= len(kept)
19164
+ track["items"] = [
19165
+ {field: item.get(field) for field in _SNAPSHOT_ITEM_FIELDS}
19166
+ for item in kept
19167
+ ]
19168
+ snapshot["items_returned"] = item_limit - remaining
19169
+ snapshot["items_truncated"] = snapshot["items_returned"] < snapshot["item_count"]
19170
+ out["timeline"] = snapshot
19171
+ if "gaps_overlaps" in include:
19172
+ out["gaps_overlaps"] = gaps_overlaps
19173
+ except Exception as exc:
19174
+ for name in wanted:
19175
+ out.setdefault(name, {"error": str(exc)})
19176
+
19177
+ if "render" in include:
19178
+ try:
19179
+ jobs = []
19180
+ for job in (proj.GetRenderJobList() or []):
19181
+ row = _ser(job)
19182
+ if isinstance(row, dict) and row.get("JobId"):
19183
+ row["status"] = _ser(proj.GetRenderJobStatus(row["JobId"]))
19184
+ jobs.append(row)
19185
+ out["render"] = {"is_rendering": bool(proj.IsRenderingInProgress()), "jobs": jobs}
19186
+ except Exception as exc:
19187
+ out["render"] = {"error": str(exc)}
19188
+
19189
+ if "media_pool" in include:
19190
+ try:
19191
+ out["media_pool"] = _project_summary(proj)["media_pool"]
19192
+ except Exception as exc:
19193
+ out["media_pool"] = {"error": str(exc)}
19194
+
19195
+ return out
19196
+
19197
+
19080
19198
  @mcp.tool()
19081
19199
  @_guard_missing_params
19082
19200
  @_destructive_op("project_manager")
@@ -19089,6 +19207,19 @@ def project_manager(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
19089
19207
  — Resolve 21.0.4+. Per-project attributes for the current folder without
19090
19208
  loading any project.
19091
19209
  get_current() -> {name, id}
19210
+ snapshot(include?, track_types?, item_limit?) -> {project, timeline, gaps_overlaps, render, media_pool}
19211
+ One read-only readout of what an agent inspects before planning; it never
19212
+ switches page, timeline or folder. include picks sections (default all);
19213
+ item_limit caps the items returned across tracks (default 200) and sets
19214
+ timeline.items_truncated, while item_count and gaps_overlaps still cover
19215
+ the whole timeline. item_limit bounds the response, not the read: the
19216
+ timeline sections cost what probe_timeline_structure costs (they scale
19217
+ with item_count) and media_pool walks every pool clip, so on a large
19218
+ project leave out the sections you do not need. A section that fails
19219
+ reports {error} in its own place; an empty include is refused. Frame
19220
+ fields are probe_timeline_structure's, unchanged: start/end
19221
+ are TimelineItem GetStart/GetEnd record frames; source_start/source_end
19222
+ are file-relative frames at source_fps, source_end exclusive.
19092
19223
  create(name, media_location_path?) -> {success, name}
19093
19224
  load(name) -> {success}
19094
19225
  save() -> {success}
@@ -19181,6 +19312,11 @@ def project_manager(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
19181
19312
  elif action == "get_current":
19182
19313
  proj = pm.GetCurrentProject()
19183
19314
  return {"name": proj.GetName(), "id": proj.GetUniqueId()} if proj else _err("No project open")
19315
+ elif action == "snapshot":
19316
+ proj = pm.GetCurrentProject()
19317
+ if not proj:
19318
+ return _err("No project open")
19319
+ return _project_state_snapshot(r, proj, p)
19184
19320
  elif action == "create":
19185
19321
  if not p.get("name"):
19186
19322
  return _err("create requires name")
@@ -19280,7 +19416,7 @@ def project_manager(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
19280
19416
  if not p.get("path"):
19281
19417
  return _err("restore requires path")
19282
19418
  return {"success": bool(pm.RestoreProject(p["path"], p.get("name")))}
19283
- return _unknown(action, ["list","list_attributes","get_current","create","load","save","close","delete","import_project","export_project","archive","restore","lint","diff_to_spec","plan_spec","apply_spec", *_PROJECT_KERNEL_ACTIONS])
19419
+ return _unknown(action, ["list","list_attributes","get_current","snapshot","create","load","save","close","delete","import_project","export_project","archive","restore","lint","diff_to_spec","plan_spec","apply_spec", *_PROJECT_KERNEL_ACTIONS])
19284
19420
 
19285
19421
 
19286
19422
  # ═══════════════════════════════════════════════════════════════════════════════
@@ -30276,6 +30412,25 @@ def _fusion_keyframe_frames(inp) -> List[float]:
30276
30412
  return sorted(float(frame) for frame in kfs.values())
30277
30413
 
30278
30414
 
30415
+ #: Modifier names callers use -> the registry ID `Tool.AddModifier` wants.
30416
+ #: Measured on Studio 19.1.3.7 (issue #250) on a TextPlus StyledText input:
30417
+ #: AddModifier("StyledText", "Follower") and "TextFollower" return False and
30418
+ #: attach nothing; "StyledTextFollower" returns True, creates a `Follower1` tool
30419
+ #: of that ID and connects it to the input. Spline modifiers already go by their
30420
+ #: registry ID (BezierSpline, Path). Keys are compared case-insensitively.
30421
+ _FUSION_MODIFIER_IDS = {
30422
+ "follower": "StyledTextFollower",
30423
+ "textfollower": "StyledTextFollower",
30424
+ "styledtextfollower": "StyledTextFollower",
30425
+ }
30426
+
30427
+
30428
+ def _fusion_modifier_id(name: Any) -> str:
30429
+ """The registry ID for a modifier a caller named; unknown names pass through."""
30430
+ text = str(name or "").strip()
30431
+ return _FUSION_MODIFIER_IDS.get(text.lower(), text)
30432
+
30433
+
30279
30434
  def _fusion_input_spline(inp):
30280
30435
  """The modifier/spline tool driving `inp`, or None when it is not animated.
30281
30436
 
@@ -30429,7 +30584,17 @@ def fusion_comp(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
30429
30584
  get_input(tool_name, input_name, time?) -> {value}
30430
30585
  set_attrs(tool_name, attrs) -> {success}
30431
30586
  get_attrs(tool_name) -> {attrs}
30432
- add_keyframe(tool_name, input_name, time, value) -> {success}
30587
+ add_keyframe(tool_name, input_name, time, value, modifier?) -> {success}
30588
+ Attaches a BezierSpline (or `modifier`, e.g. 'Path' for Point inputs)
30589
+ the first time an input is animated. Modifier names are mapped to
30590
+ their registry ID ('Follower' -> 'StyledTextFollower').
30591
+ add_modifier(tool_name, input_name, modifier) -> {success, modifier_tool, modifier_type}
30592
+ Attach any modifier and return the tool Fusion created for it, so a
30593
+ TEXT modifier (Follower on a TextPlus StyledText) can then be driven
30594
+ with set_input / add_keyframe on that tool (e.g. Delay). Refuses an
30595
+ input that already has a modifier. Measured on Studio 19.1.3.7:
30596
+ Fusion wants the registry ID ('StyledTextFollower'); 'Follower' is
30597
+ mapped for you.
30433
30598
  get_keyframes(tool_name, input_name) -> {keyframes}
30434
30599
  delete_keyframe(tool_name, input_name, time) -> {success, time, remaining_keyframes}
30435
30600
  Deletes on the spline attached to the input. Structured errors when the
@@ -30708,7 +30873,8 @@ def fusion_comp(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
30708
30873
  # input still has no connected output, the assignment on the next
30709
30874
  # line would set a STATIC value and _ok() would report a keyframe
30710
30875
  # that does not exist.
30711
- tool.AddModifier(p["input_name"], p.get("modifier", "BezierSpline"))
30876
+ _modifier_id = _fusion_modifier_id(p.get("modifier", "BezierSpline"))
30877
+ tool.AddModifier(p["input_name"], _modifier_id)
30712
30878
  try:
30713
30879
  attached = tool[p["input_name"]].GetConnectedOutput() is not None
30714
30880
  except Exception:
@@ -30724,14 +30890,86 @@ def fusion_comp(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
30724
30890
  "Reporting success here would claim a keyframe that "
30725
30891
  "does not exist.",
30726
30892
  remediation="Check the input accepts the modifier type "
30727
- f"({p.get('modifier', 'BezierSpline')!r}); Point "
30728
- "inputs take 'Path'.",
30893
+ f"({_modifier_id!r}); Point inputs take 'Path'. "
30894
+ "A TEXT modifier (Follower) is not a spline: "
30895
+ "attach it with add_modifier, then drive the "
30896
+ "modifier tool it returns.",
30729
30897
  )
30730
30898
  tool[p["input_name"]][p["time"]] = p["value"]
30731
30899
  return _ok()
30732
30900
  finally:
30733
30901
  comp.EndUndo(True)
30734
30902
 
30903
+ elif action == "add_modifier":
30904
+ tool = comp.FindTool(p["tool_name"])
30905
+ if not tool:
30906
+ return _err(f"Tool '{p['tool_name']}' not found")
30907
+ input_name = p.get("input_name")
30908
+ requested = p.get("modifier")
30909
+ if not input_name or not requested:
30910
+ return _err("add_modifier requires input_name and modifier",
30911
+ category="invalid_input")
30912
+ inp = tool[input_name]
30913
+ if not inp:
30914
+ return _err(f"Input '{input_name}' not found on tool '{p['tool_name']}'")
30915
+ modifier_id = _fusion_modifier_id(requested)
30916
+ try:
30917
+ existing = inp.GetConnectedOutput()
30918
+ except Exception:
30919
+ existing = None
30920
+ if existing is not None:
30921
+ existing_tool = existing.GetTool()
30922
+ attrs = (existing_tool.GetAttrs() or {}) if existing_tool else {}
30923
+ return _err(
30924
+ f"'{p['tool_name']}.{input_name}' already has a modifier attached.",
30925
+ code="FUSION_INPUT_ALREADY_CONNECTED", category="precondition",
30926
+ retryable=False,
30927
+ state={"modifier_tool": attrs.get("TOOLS_Name", ""),
30928
+ "modifier_type": attrs.get("TOOLS_RegID", "")},
30929
+ remediation="Drive the existing modifier tool, or "
30930
+ "disconnect(tool_name, input_name) first.",
30931
+ )
30932
+ # Same undo bracket as add_keyframe; no comp.Lock() — a lock is for
30933
+ # value writes and this is a graph edit, but see _FUSION_VALUE_WRITE_NOTE
30934
+ # for why writes under a lock are not trusted here.
30935
+ comp.StartUndo(f"Add modifier {modifier_id} to {input_name}")
30936
+ try:
30937
+ tool.AddModifier(input_name, modifier_id)
30938
+ # AddModifier's bool is unreliable through the Lua bridge (an unknown
30939
+ # attribute resolves to None). The readback is the evidence: the
30940
+ # input must now be connected to a modifier tool.
30941
+ try:
30942
+ out = tool[input_name].GetConnectedOutput()
30943
+ except Exception:
30944
+ out = None
30945
+ modifier_tool = out.GetTool() if out else None
30946
+ if modifier_tool is None:
30947
+ return _err(
30948
+ f"Could not attach {modifier_id!r} to "
30949
+ f"'{p['tool_name']}.{input_name}': no modifier is connected "
30950
+ "after AddModifier.",
30951
+ code="FUSION_ADD_MODIFIER_FAILED", category="api_error",
30952
+ retryable=False,
30953
+ reason="Fusion rejects a modifier whose registry ID it does not "
30954
+ "know for that input type, and reports it only as False.",
30955
+ remediation="Pass the modifier's REGISTRY ID, not its display "
30956
+ "name: the text Follower is 'StyledTextFollower' "
30957
+ "(add_modifier maps 'Follower' to it; measured on "
30958
+ "Studio 19.1.3.7). Spline modifiers are "
30959
+ "'BezierSpline' and, for Point inputs, 'Path'.",
30960
+ )
30961
+ attrs = modifier_tool.GetAttrs() or {}
30962
+ return _ok(
30963
+ tool_name=p["tool_name"], input_name=input_name,
30964
+ requested=requested, modifier_id=modifier_id,
30965
+ modifier_tool=attrs.get("TOOLS_Name", ""),
30966
+ modifier_type=attrs.get("TOOLS_RegID", modifier_id),
30967
+ next="Drive the modifier tool with set_input / add_keyframe "
30968
+ "(e.g. its Delay input for a per-character stagger).",
30969
+ )
30970
+ finally:
30971
+ comp.EndUndo(True)
30972
+
30735
30973
  elif action == "get_keyframes":
30736
30974
  tool = comp.FindTool(p["tool_name"])
30737
30975
  if not tool:
@@ -30921,7 +31159,7 @@ def fusion_comp(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
30921
31159
  "add_tool","delete_tool","get_tool_list","find_tool",
30922
31160
  "connect","disconnect","get_inputs","get_outputs",
30923
31161
  "set_input","get_input","set_attrs","get_attrs",
30924
- "add_keyframe","get_keyframes","delete_keyframe",
31162
+ "add_keyframe","add_modifier","get_keyframes","delete_keyframe",
30925
31163
  "get_comp_info","set_frame_range","get_frame_range","render",
30926
31164
  "start_undo","end_undo",
30927
31165
  "get_position","set_position","copy_tool","auto_arrange",
@@ -3414,6 +3414,32 @@ API_TRUTH: List[Dict[str, Any]] = [
3414
3414
  "mitigation": ["project_manager.archive", "project_manager.safe_project_archive",
3415
3415
  "archive_project"],
3416
3416
  },
3417
+ {
3418
+ "symbol": "Tool.AddModifier",
3419
+ "object": "Fusion Tool",
3420
+ "signature": "(inputName, modifierRegID) -> bool",
3421
+ "reality": "The second argument is the modifier's REGISTRY ID, not its "
3422
+ "display name. On a TextPlus StyledText input, "
3423
+ "AddModifier('StyledText', 'Follower') and 'TextFollower' return "
3424
+ "False and attach nothing; 'StyledTextFollower' returns True, "
3425
+ "creates a tool named Follower1 of that ID and connects it to the "
3426
+ "input. Spline modifiers already go by registry ID (BezierSpline, "
3427
+ "Path). Through the Lua bridge the bool is not reliable evidence; "
3428
+ "the input's connected-output readback is.",
3429
+ "recommended": "Pass the registry ID. fusion_comp add_modifier maps 'Follower' "
3430
+ "to 'StyledTextFollower', verifies by readback and returns the "
3431
+ "created modifier tool so it can be driven with set_input / "
3432
+ "add_keyframe (Delay for a per-character stagger).",
3433
+ "tags": ["fusion", "naming", "silent-failure"],
3434
+ "verified_on": "DaVinci Resolve Studio 19.1.3.7",
3435
+ "measured": "2026-09-19 on a disposable timeline: "
3436
+ "InsertFusionCompositionIntoTimeline, AddTool('TextPlus'), then "
3437
+ "AddModifier('StyledText', X) for X in Follower / "
3438
+ "StyledTextFollower / TextFollower, with a GetToolList diff and "
3439
+ "StyledText.GetConnectedOutput().GetTool() readback after each; "
3440
+ "only StyledTextFollower attached (new tool Follower1).",
3441
+ "mitigation": ["fusion_comp.add_modifier", "fusion_comp.add_keyframe"],
3442
+ },
3417
3443
 
3418
3444
  ]
3419
3445
 
@@ -3474,6 +3500,7 @@ ACTION_SYMBOLS: Dict[Tuple[str, str], List[str]] = {
3474
3500
  ("timeline_item_color", "safe_export_lut"): ["TimelineItem.ExportLUT"],
3475
3501
  ("timeline", "duplicate"): ["Timeline.DuplicateTimeline"],
3476
3502
  ("project_manager", "archive"): ["ProjectManager.ArchiveProject"],
3503
+ ("fusion_comp", "add_modifier"): ["Tool.AddModifier"],
3477
3504
  ("project_manager", "safe_project_archive"): ["ProjectManager.ArchiveProject"],
3478
3505
  }
3479
3506
 
@@ -96,7 +96,7 @@ DESTRUCTIVE_ACTIONS_BY_TOOL: Dict[str, FrozenSet[str]] = {
96
96
  # them. Plus the 21.0 AI deblur, rated MEDIUM: it creates media, and is
97
97
  # registered so it is audited and its dry run is honest.
98
98
  "folder": frozenset({"remove_motion_blur"}),
99
- "fusion_comp": frozenset({"delete_keyframe", "delete_tool"}),
99
+ "fusion_comp": frozenset({"add_modifier", "delete_keyframe", "delete_tool"}),
100
100
  "gallery_stills": frozenset({"delete_stills"}),
101
101
  "media_pool_item": frozenset({"remove_motion_blur"}),
102
102
  "media_pool_item_markers": frozenset({
@@ -203,6 +203,9 @@ class RiskClassificationHook(LifecycleHook):
203
203
  #: them unrecognised, i.e. it warns that the risk is unestablished for the
204
204
  #: actions whose risk is the best established of any we dispatch.
205
205
  _LOW_RISK_ACTIONS: Set[Tuple[str, str]] = {
206
+ # Attaching a modifier to one Fusion input: one undo step, and
207
+ # disconnect(tool, input) removes it. (#250)
208
+ ("fusion_comp", "add_modifier"),
206
209
  ("dctl", "encrypt_native"), # Creates a new file; never replaces existing content.
207
210
  ("timeline_markers", "add"),
208
211
  ("timeline_markers", "update_custom_data"),
@@ -378,6 +381,14 @@ class RiskClassificationHook(LifecycleHook):
378
381
  return BlastRadius.TIMELINE, "the timeline node graph (every clip on the timeline)"
379
382
 
380
383
  _READ_ONLY_PREFIXES = ("get_", "list_", "query_", "probe_", "inspect_", "export_", "check_")
384
+ #: Reads whose action name carries no read verb. Without an entry here a
385
+ #: pure read falls to the name-based MEDIUM default and every call reports
386
+ #: "risk unestablished" — noise on the one call an agent makes before
387
+ #: planning (project_manager.snapshot, #251).
388
+ _READ_ONLY_PAIRS = frozenset({
389
+ ("dctl", "validate_native"),
390
+ ("project_manager", "snapshot"),
391
+ })
381
392
 
382
393
  @classmethod
383
394
  def classify(cls, tool_name: str, action: str, params: Dict[str, Any]) -> RiskAssessment:
@@ -448,7 +459,7 @@ class RiskClassificationHook(LifecycleHook):
448
459
  else BlastRadius.ITEM
449
460
  )
450
461
  reasons.append(f"Recoverable edit to existing state: {action}")
451
- elif any(action.startswith(p) for p in cls._READ_ONLY_PREFIXES) or action in {"read", "status", "info"} or pair == ("dctl", "validate_native"):
462
+ elif any(action.startswith(p) for p in cls._READ_ONLY_PREFIXES) or action in {"read", "status", "info"} or pair in cls._READ_ONLY_PAIRS:
452
463
  level = RiskLevel.LOW
453
464
  destructive = False
454
465
  radius = BlastRadius.ITEM