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 +71 -0
- package/README.md +1 -1
- package/README.zh-CN.md +2 -2
- package/docs/SKILL.md +20 -1
- package/docs/reference/readwrite-symmetry.md +3 -3
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/granular/common.py +1 -1
- package/src/server.py +245 -7
- package/src/utils/api_truth.py +27 -0
- package/src/utils/destructive_hook.py +1 -1
- package/src/utils/execution_lifecycle.py +12 -1
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
|
-
[](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.
|
|
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: **
|
|
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):
|
|
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.
|
|
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
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.
|
|
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.
|
|
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
|
-
|
|
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"({
|
|
30728
|
-
"
|
|
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",
|
package/src/utils/api_truth.py
CHANGED
|
@@ -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
|
|
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
|