davinci-resolve-mcp 4.8.5 → 4.8.7
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 +167 -0
- package/README.md +1 -1
- package/README.zh-CN.md +2 -2
- package/docs/SKILL.md +19 -5
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/granular/common.py +1 -1
- package/src/granular/media_pool.py +54 -20
- package/src/server.py +64 -19
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,173 @@
|
|
|
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.7 — an append Resolve refuses is reported as a failure
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **`media_pool` `append_to_timeline` (legacy `clip_ids` mode) no longer answers
|
|
10
|
+
`{"success": true, "count": 0}` when Resolve appends nothing.** When
|
|
11
|
+
`MediaPool.AppendToTimeline` returned `None`, `False` or `[]`, the action still
|
|
12
|
+
built a success payload; only `verified_operation.verification_status: "api_failed"`
|
|
13
|
+
said otherwise. This is the gap the previous entry recorded from its live run
|
|
14
|
+
(`append_to_timeline([probe]) -> success=True` against a tripwire answering
|
|
15
|
+
`False`). It now fails with `APPEND_TO_TIMELINE_FAILED` / `resolve_api_failed`,
|
|
16
|
+
`error.reason` naming what Resolve returned, `error.state` carrying
|
|
17
|
+
`expected_item_count_delta` and `item_count_delta`, and `verified_operation` kept
|
|
18
|
+
on the error so the before/after readback is not lost.
|
|
19
|
+
- **`retryable` follows the readback, not the category default.**
|
|
20
|
+
`resolve_api_failed` defaults to retryable, but Resolve's answer does not prove
|
|
21
|
+
nothing landed, and a retry after an append that did land puts the clips on the
|
|
22
|
+
timeline twice. The error is retryable only when the readback shows the current
|
|
23
|
+
timeline's item count unchanged. With no current timeline to read, or a changed
|
|
24
|
+
count, it is not, and the remediation says to inspect the timeline first.
|
|
25
|
+
- **The positioned `clip_infos` mode fails the same way.** It already returned an
|
|
26
|
+
error, but as `UNSPECIFIED` with the readback dropped. Its message is unchanged;
|
|
27
|
+
the code, the `retryable` rule and the kept `verified_operation` are now shared.
|
|
28
|
+
- **Granular `append_to_timeline` (`clip_ids` form)** answered
|
|
29
|
+
`{"success": True, "count": 0}` as well. It now returns
|
|
30
|
+
`{"success": false, "error": "Failed to append clip_ids to timeline"}`, the granular
|
|
31
|
+
server's flat shape, matching its own `clip_infos` form.
|
|
32
|
+
|
|
33
|
+
### Not changed
|
|
34
|
+
|
|
35
|
+
- A truthy but short answer (fewer timeline items than clips requested) is still
|
|
36
|
+
`success` with the real `count`; `verified_operation` reads
|
|
37
|
+
`api_success_unverified` when the item count falls short.
|
|
38
|
+
- The other `AppendToTimeline` call sites in `src/` already treat an empty answer as
|
|
39
|
+
a failure or report `success: bool(appended)`. In the live check, the six other
|
|
40
|
+
whole-batch actions that reach the tripwire already report its `False` as a failure
|
|
41
|
+
(`success: false` or an error); `append_to_timeline` was the only one reporting
|
|
42
|
+
success.
|
|
43
|
+
|
|
44
|
+
### Validation
|
|
45
|
+
|
|
46
|
+
- `tests/test_media_pool_clip_ids.py` adds `FalsyAppendTest` (4 tests) against a fake
|
|
47
|
+
Media Pool whose `AppendToTimeline` returns `None` / `False` / `[]`: the error code,
|
|
48
|
+
category and message with the call recorded once; the readback kept on the error;
|
|
49
|
+
`retryable` true only when nothing landed (items that land anyway, and no current
|
|
50
|
+
timeline, are not retryable); `clip_infos` failing with the same code. Against the
|
|
51
|
+
pre-fix code all 4 fail; all pass after.
|
|
52
|
+
- `tests/test_granular_media_pool_clip_ids.py` adds `FalsyAppendTest` (1 test, the same
|
|
53
|
+
three answers); it fails on the pre-fix code and passes after.
|
|
54
|
+
- `tests/live_clip_ids_check.py` now expects `APPEND_TO_TIMELINE_FAILED` for the
|
|
55
|
+
whole-batch `append_to_timeline([probe])` (the tripwire answers `False`), with
|
|
56
|
+
`verified_operation.verification_status == "api_failed"` and `retryable` exactly when
|
|
57
|
+
`item_count_delta == 0`. Driven offline against a fake Resolve: PASS on this code;
|
|
58
|
+
on the pre-fix code 3 findings, reproducing `success=True`.
|
|
59
|
+
- Offline suite (`unittest discover -s tests -t .`), run with Resolve open on the
|
|
60
|
+
machine and sealed off it: `DaVinciResolveScript` blocked in `sys.modules`, the
|
|
61
|
+
bridge config pointed at a path that does not exist, the granular launcher
|
|
62
|
+
replaced with a refusal. On top of v4.8.6: 3,762 tests run, 0 failures, 84
|
|
63
|
+
skipped, 13 errors, all environment gaps: `numpy` / `requests` absent from the
|
|
64
|
+
venv (five modules fail to import, one LUT test), `node_modules` not installed
|
|
65
|
+
(five `test_offline_fallback` cases), and `test_live_api` / `test_resolve20_api`, which import
|
|
66
|
+
`DaVinciResolveScript` directly and were kept from connecting. None touch
|
|
67
|
+
`media_pool`. The granular launcher was reached 10 times during the run:
|
|
68
|
+
`tests/offline_guard.py` still guards `src/server.py` only. Static checks,
|
|
69
|
+
api-parity, api-limitations, read/write symmetry, agent-rule generation and the
|
|
70
|
+
drift guards clean; `test_static_undefined_names` skipped because pyflakes is not
|
|
71
|
+
installed.
|
|
72
|
+
- **Live-validated on Resolve Studio 21.1.0.14** with
|
|
73
|
+
`venv/bin/python tests/live_clip_ids_check.py` against the project the previous
|
|
74
|
+
release was checked on (144 folders, 2,600 clips, 24 timelines), probing a clip
|
|
75
|
+
seven folders deep: PASS. The whole-batch `append_to_timeline([probe])` now comes
|
|
76
|
+
back `APPEND_TO_TIMELINE_FAILED` for the tripwire's `False` (it answered
|
|
77
|
+
`success=True` before), with `verified_operation.verification_status ==
|
|
78
|
+
"api_failed"` and `retryable` matching the readback, and its operation envelope is
|
|
79
|
+
logged as `status: failed`. The other seven actions answer as in the previous
|
|
80
|
+
run. Folder tree, clip placement, the probe's File Path, the timeline count and
|
|
81
|
+
the current timeline's items read back identical, and no metadata file was
|
|
82
|
+
written. That run was on this change stacked on v4.8.3; v4.8.4 underneath
|
|
83
|
+
touches only Fusion nest controls (`fusion_comp`).
|
|
84
|
+
|
|
85
|
+
## What's New in v4.8.6 — every clip_ids batch is all-or-nothing
|
|
86
|
+
|
|
87
|
+
### Fixed
|
|
88
|
+
|
|
89
|
+
- **The four `media_pool` actions v4.8.2 left alone no longer act on the part of a
|
|
90
|
+
batch that happened to resolve.** They used the same
|
|
91
|
+
`[_find_clip(root, cid) for cid in clip_ids]` + drop-the-misses pattern, and now go
|
|
92
|
+
through the same `_clips_from_ids` resolver, so an id matching no clip fails the call
|
|
93
|
+
with `CLIP_NOT_FOUND` / `invalid_input` (ids in `error.state.unresolved_clip_ids` /
|
|
94
|
+
`resolved_clip_ids`) before anything reaches Resolve:
|
|
95
|
+
- `create_timeline_from_clips` (simple `clip_ids` mode) built the timeline from the
|
|
96
|
+
subset. Now no timeline is created.
|
|
97
|
+
- `append_to_timeline` (legacy `clip_ids` mode) appended the subset and set the
|
|
98
|
+
readback's `expected_count` to the *resolved* count, so a partial append came back
|
|
99
|
+
`readback_verified`; an all-missing batch reached `AppendToTimeline([])` and
|
|
100
|
+
answered `{"success": true, "count": 0}`. Now nothing is appended, and a whole
|
|
101
|
+
batch verifies against the requested count.
|
|
102
|
+
- `export_metadata` with `clip_ids` exported the subset; an all-missing batch
|
|
103
|
+
reached `ExportMetadata(path, [])`, whose behaviour on an empty list is unmeasured
|
|
104
|
+
and may be "every clip". Now nothing is exported. **Behaviour change:** an
|
|
105
|
+
explicitly empty `clip_ids: []` used to fall through to `ExportMetadata(path)` and
|
|
106
|
+
export every clip; it is now `INVALID_CLIP_IDS`, so a selection that came back
|
|
107
|
+
empty cannot widen into a whole-pool export. Omitting `clip_ids` (or passing
|
|
108
|
+
`null`) still exports every clip.
|
|
109
|
+
- `auto_sync_audio` synced the subset, or called `AutoSyncAudio([], settings)`. Now
|
|
110
|
+
nothing is synced. `clip_ids` stays an item lookup, so a missing key is still
|
|
111
|
+
`MISSING_CLIP_IDS`.
|
|
112
|
+
- A bare-string `clip_ids` is `INVALID_CLIP_IDS` in all four instead of one id per
|
|
113
|
+
character. `create_timeline_from_clips` and `append_to_timeline` keep their
|
|
114
|
+
"Provide clip_ids or clip_infos" error when neither form is given.
|
|
115
|
+
- **Granular server: `append_to_timeline` (clip_ids), `auto_sync_audio`,
|
|
116
|
+
`delete_media_pool_clips` and `move_clips_to_folder` are all-or-nothing too.** They
|
|
117
|
+
errored only when *every* id missed; a mixed batch acted on the subset (delete and
|
|
118
|
+
move did report a count). A granular `_clips_from_ids` now refuses the call with
|
|
119
|
+
`{"success": false, "error": "Clip(s) not found: …", "unresolved_clip_ids": […],
|
|
120
|
+
"resolved_clip_ids": […]}` in the granular server's flat error shape. It walks the
|
|
121
|
+
pool once, returns clips in request order, and keeps the old one-clip-per-id
|
|
122
|
+
collapsing for delete and move.
|
|
123
|
+
|
|
124
|
+
### Validation
|
|
125
|
+
|
|
126
|
+
- `tests/test_media_pool_clip_ids.py` now runs its partial / all-missing / bare-string
|
|
127
|
+
/ happy-path cases over all eight compound actions and adds eight cases for where
|
|
128
|
+
the new four differ (append readback count, `export_metadata` without and with an
|
|
129
|
+
empty `clip_ids`, a missing `path`, `auto_sync_audio`'s `MISSING_CLIP_IDS` /
|
|
130
|
+
`INVALID_CLIP_IDS`, the kept "Provide clip_ids" message). Against the v4.8.2 code it
|
|
131
|
+
fails 14 cases (9 failures, 5 errors — every partial and all-missing case of the new
|
|
132
|
+
four, both shape cases); all 18 tests pass after.
|
|
133
|
+
- New `tests/test_granular_media_pool_clip_ids.py` (9 tests) pins the four granular
|
|
134
|
+
tools the same way against a recording fake Media Pool; against the old code 8 fail
|
|
135
|
+
(every partial and all-missing case), all 9 pass after.
|
|
136
|
+
- `tests/live_clip_ids_check.py` covers all eight compound actions. Its tripwire also
|
|
137
|
+
intercepts `CreateTimelineFromClips`, `AppendToTimeline`, `ExportMetadata` and
|
|
138
|
+
`AutoSyncAudio`, and it additionally checks that the project's timeline count, the
|
|
139
|
+
current timeline's item count and the absence of the export file are unchanged.
|
|
140
|
+
Driven offline against a fake Resolve (`_try_connect` / `get_resolve` replaced, no
|
|
141
|
+
connection possible): PASS on this code; 20 findings on the v4.8.2 code.
|
|
142
|
+
- Offline suite (`python -m unittest discover -s tests -t .`) on top of v4.8.3:
|
|
143
|
+
3,749 tests run, 84 skipped, 11 errors — the same environment gaps v4.8.2 recorded (`numpy` and
|
|
144
|
+
`requests` absent from the venv, `node_modules` not installed for
|
|
145
|
+
`test_offline_fallback`); none touch `media_pool`. Static checks, api-parity,
|
|
146
|
+
api-limitations, read/write symmetry, agent-rule generation and the drift guards
|
|
147
|
+
clean; `test_static_undefined_names` skipped because pyflakes is not installed.
|
|
148
|
+
- The suite was run with Resolve open on the machine, so it ran behind an import
|
|
149
|
+
hook that turns `DaVinciResolveScript` / `fusionscript` into an empty stub and a
|
|
150
|
+
bridge config path that does not exist: it logged 1,498 `scriptapp` lookups, none
|
|
151
|
+
of which could connect. `tests/offline_guard.py` guards `src/server.py` only, and
|
|
152
|
+
`src/granular/common.py` connects at import time — a gap in the offline guard,
|
|
153
|
+
not in this change.
|
|
154
|
+
- **Live-validated on Resolve Studio 21.1.0.14** with
|
|
155
|
+
`venv/bin/python tests/live_clip_ids_check.py` against a real project of 144
|
|
156
|
+
folders, 2,600 clips and 24 timelines, probing a clip seven folders deep: PASS.
|
|
157
|
+
Partial and all-missing batches came back `CLIP_NOT_FOUND` for all eight actions
|
|
158
|
+
with nothing reaching the Media Pool; bare string / empty list →
|
|
159
|
+
`INVALID_CLIP_IDS`, no key → `MISSING_CLIP_IDS` for `move_clips` and
|
|
160
|
+
`auto_sync_audio`; a whole batch handed the tripwire exactly the probe clip for
|
|
161
|
+
all seven non-delete actions. Folder tree, clip placement, the probe's File
|
|
162
|
+
Path, the timeline count and the current timeline's items read back identical,
|
|
163
|
+
and no metadata file was written. That run was on this change applied to v4.8.2;
|
|
164
|
+
v4.8.3 and v4.8.4, which it now sits on, change only the folder-id actions
|
|
165
|
+
(`_folders_from_ids`) and Fusion nest controls (`fusion_comp`), none of which
|
|
166
|
+
the eight clip actions call.
|
|
167
|
+
- Seen during the live run, not changed here: legacy `append_to_timeline`
|
|
168
|
+
(`clip_ids`) answers `{"success": true, "count": 0}` when `AppendToTimeline`
|
|
169
|
+
itself returns nothing; `verified_operation.verification_status` does say
|
|
170
|
+
`api_failed`.
|
|
171
|
+
|
|
5
172
|
## What's New in v4.8.5 — the offline suite no longer reaches Resolve through the granular server
|
|
6
173
|
|
|
7
174
|
No tool or action changed. One runtime change outside the tests: importing
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
English | [简体中文](README.zh-CN.md)
|
|
4
4
|
|
|
5
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
8
|
[-blue.svg)](#server-modes)
|
package/README.zh-CN.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.md) | 简体中文
|
|
4
4
|
|
|
5
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
8
|
[-blue.svg)](#服务器模式)
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
[](https://www.python.org/downloads/)
|
|
13
13
|
[](https://opensource.org/licenses/MIT)
|
|
14
14
|
|
|
15
|
-
> 本翻译对应 v4.8.
|
|
15
|
+
> 本翻译对应 v4.8.7 版 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
|
@@ -922,11 +922,18 @@ Key actions: `get_root_folder`, `get_current_folder`, `set_current_folder(path)`
|
|
|
922
922
|
`setup_multicam_timeline(name, clip_ids|angles, sync_mode?, include_audio?, dry_run?)`,
|
|
923
923
|
`get_selected`, `set_selected(clip_id)`, `export_metadata(path, clip_ids?)`
|
|
924
924
|
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
925
|
+
Every `clip_ids` batch is all-or-nothing: `delete_clips`, `move_clips`, `relink`,
|
|
926
|
+
`unlink`, `create_timeline_from_clips`, `append_to_timeline`, `export_metadata`
|
|
927
|
+
and `auto_sync_audio`. If any id in `clip_ids` matches no clip, the call fails
|
|
928
|
+
with `CLIP_NOT_FOUND` (the unresolved and resolved ids are in `error.state`) and
|
|
929
|
+
nothing reaches Resolve: no clip is changed, no timeline is created, nothing is
|
|
930
|
+
appended, exported or synced. Drop the stale ids and retry; do not read a partial
|
|
931
|
+
batch as done. `export_metadata` without `clip_ids` still exports every clip, but
|
|
932
|
+
an empty `clip_ids: []` is `INVALID_CLIP_IDS`, not "everything". The granular
|
|
933
|
+
server's `append_to_timeline`, `auto_sync_audio`, `delete_media_pool_clips` and
|
|
934
|
+
`move_clips_to_folder` behave the same way, with the ids in
|
|
935
|
+
`unresolved_clip_ids` / `resolved_clip_ids`. `delete_folders(folder_ids)` and
|
|
936
|
+
`move_folders(folder_ids, target_path)` work the same way with
|
|
930
937
|
`FOLDER_NOT_FOUND`; they resolve `folder_ids` at any depth (pass the ids
|
|
931
938
|
`folder get_subfolders` returns) and refuse the Master folder itself.
|
|
932
939
|
|
|
@@ -2185,6 +2192,13 @@ media_pool(action="append_to_timeline", params={"clip_infos": [
|
|
|
2185
2192
|
]})
|
|
2186
2193
|
```
|
|
2187
2194
|
|
|
2195
|
+
When Resolve answers `AppendToTimeline` with None/False/[], either form fails
|
|
2196
|
+
with `APPEND_TO_TIMELINE_FAILED`, not `success` with `count: 0`, and keeps
|
|
2197
|
+
`verified_operation` (the current timeline's item count before and after) on
|
|
2198
|
+
the error. `error.retryable` is true only when that readback shows nothing was
|
|
2199
|
+
appended; otherwise inspect the timeline before retrying, or the clips can land
|
|
2200
|
+
twice. The granular `append_to_timeline` answers `{"success": false, "error": ...}`.
|
|
2201
|
+
|
|
2188
2202
|
Mixed-fps caution: `start_frame`/`end_frame` are SOURCE frames, and a source
|
|
2189
2203
|
whose fps differs from the timeline's rounds DOWN on conversion — a 24.0 or
|
|
2190
2204
|
29.97 clip appended into a 23.976 timeline can land one frame short of its
|
package/install.py
CHANGED
|
@@ -37,7 +37,7 @@ from src.utils.update_check import (
|
|
|
37
37
|
|
|
38
38
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
39
39
|
|
|
40
|
-
VERSION = "4.8.
|
|
40
|
+
VERSION = "4.8.7"
|
|
41
41
|
# Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
|
|
42
42
|
# Resolve's scripting bridge loads into newer interpreters on recent builds
|
|
43
43
|
# (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
|
package/package.json
CHANGED
package/src/granular/common.py
CHANGED
|
@@ -93,7 +93,7 @@ if not logging.getLogger().handlers:
|
|
|
93
93
|
handlers=[logging.StreamHandler()],
|
|
94
94
|
)
|
|
95
95
|
|
|
96
|
-
VERSION = "4.8.
|
|
96
|
+
VERSION = "4.8.7"
|
|
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()}")
|
|
@@ -71,6 +71,37 @@ def _appended_item_summary(item):
|
|
|
71
71
|
name = None
|
|
72
72
|
return {"timeline_item_id": item_id, "name": name}
|
|
73
73
|
|
|
74
|
+
|
|
75
|
+
def _clips_from_ids(root, clip_ids, *, verb, distinct=False):
|
|
76
|
+
"""Resolve Media Pool clip unique IDs to clips, all or nothing. Returns (clips, error).
|
|
77
|
+
|
|
78
|
+
The granular twin of src/server.py's _clips_from_ids. append_to_timeline,
|
|
79
|
+
auto_sync_audio, delete_media_pool_clips and move_clips_to_folder used to drop
|
|
80
|
+
every id that matched no clip and hand Resolve the rest, so a mixed batch acted
|
|
81
|
+
on a subset and answered like a full one. Clips come back in request order;
|
|
82
|
+
distinct=True collapses repeated ids, as the old set-based lookup did for
|
|
83
|
+
delete and move.
|
|
84
|
+
"""
|
|
85
|
+
if not isinstance(clip_ids, list) or not clip_ids:
|
|
86
|
+
return None, {"success": False,
|
|
87
|
+
"error": "clip_ids must be a non-empty list of Media Pool clip unique IDs"}
|
|
88
|
+
wanted = [str(cid) for cid in clip_ids]
|
|
89
|
+
if distinct:
|
|
90
|
+
wanted = list(dict.fromkeys(wanted))
|
|
91
|
+
by_id = {c.GetUniqueId(): c for c in _find_clips_by_ids(root, set(wanted))}
|
|
92
|
+
unresolved = [cid for cid in wanted if cid not in by_id]
|
|
93
|
+
if unresolved:
|
|
94
|
+
return None, {
|
|
95
|
+
"success": False,
|
|
96
|
+
"error": (f"Clip(s) not found: {', '.join(unresolved)}. {len(unresolved)} of "
|
|
97
|
+
f"{len(wanted)} clip_ids matched no clip anywhere in the Media Pool; "
|
|
98
|
+
f"nothing was {verb}. Pass only ids that still exist."),
|
|
99
|
+
"unresolved_clip_ids": unresolved,
|
|
100
|
+
"resolved_clip_ids": [cid for cid in wanted if cid in by_id],
|
|
101
|
+
}
|
|
102
|
+
return [by_id[cid] for cid in wanted], None
|
|
103
|
+
|
|
104
|
+
|
|
74
105
|
@mcp.resource("resolve://media-pool-clips")
|
|
75
106
|
def list_media_pool_clips() -> List[Dict[str, Any]]:
|
|
76
107
|
"""List all clips in the root folder of the media pool."""
|
|
@@ -156,6 +187,8 @@ def append_to_timeline(
|
|
|
156
187
|
|
|
157
188
|
Args:
|
|
158
189
|
clip_ids: Simple form — list of MediaPoolItem unique IDs to append in order.
|
|
190
|
+
Every id must resolve: one that matches no clip fails the call and
|
|
191
|
+
nothing is appended.
|
|
159
192
|
clip_infos: Positioned form — list of dicts with keys clip_id (or
|
|
160
193
|
media_pool_item_id), start_frame, end_frame, record_frame, track_index,
|
|
161
194
|
and optional media_type (1=video only, 2=audio only). record_frame is
|
|
@@ -196,13 +229,13 @@ def append_to_timeline(
|
|
|
196
229
|
return {"success": True, "count": len(items_out), "items": items_out}
|
|
197
230
|
if not clip_ids:
|
|
198
231
|
return {"error": "Provide clip_ids (simple) or clip_infos (positioned)"}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
if not clips:
|
|
203
|
-
return {"error": "No valid clips found"}
|
|
232
|
+
clips, clips_err = _clips_from_ids(mp.GetRootFolder(), clip_ids, verb="appended")
|
|
233
|
+
if clips_err:
|
|
234
|
+
return clips_err
|
|
204
235
|
result = mp.AppendToTimeline(clips)
|
|
205
|
-
|
|
236
|
+
if not result:
|
|
237
|
+
return {"success": False, "error": "Failed to append clip_ids to timeline"}
|
|
238
|
+
return {"success": True, "count": len(result)}
|
|
206
239
|
|
|
207
240
|
|
|
208
241
|
@mcp.tool()
|
|
@@ -343,7 +376,8 @@ def auto_sync_audio(
|
|
|
343
376
|
Mirrors MediaPool.AutoSyncAudio([items], {audioSyncSettings}) per docs lines 600-614.
|
|
344
377
|
|
|
345
378
|
Args:
|
|
346
|
-
clip_ids: List of MediaPoolItem unique IDs to sync.
|
|
379
|
+
clip_ids: List of MediaPoolItem unique IDs to sync. Every id must resolve:
|
|
380
|
+
one that matches no clip fails the call and nothing is synced.
|
|
347
381
|
sync_mode: 'waveform' or 'timecode' (default on Resolve side: 'timecode').
|
|
348
382
|
channel_number: int >= 1 for channel offset, or 'automatic' (-1) / 'mix' (-2).
|
|
349
383
|
Only used in waveform mode.
|
|
@@ -358,11 +392,9 @@ def auto_sync_audio(
|
|
|
358
392
|
return err
|
|
359
393
|
if not clip_ids:
|
|
360
394
|
return {"error": "clip_ids must be a non-empty list"}
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
if not clips:
|
|
365
|
-
return {"error": "No valid clips found"}
|
|
395
|
+
clips, clips_err = _clips_from_ids(mp.GetRootFolder(), clip_ids, verb="synced")
|
|
396
|
+
if clips_err:
|
|
397
|
+
return clips_err
|
|
366
398
|
settings, settings_err = _build_audio_sync_settings(
|
|
367
399
|
r, sync_mode=sync_mode, channel_number=channel_number,
|
|
368
400
|
retain_embedded_audio=retain_embedded_audio,
|
|
@@ -465,14 +497,15 @@ def delete_media_pool_clips(clip_ids: List[str]) -> Dict[str, Any]:
|
|
|
465
497
|
"""Delete clips from the Media Pool by their unique IDs.
|
|
466
498
|
|
|
467
499
|
Args:
|
|
468
|
-
clip_ids: List of clip unique IDs to delete.
|
|
500
|
+
clip_ids: List of clip unique IDs to delete. Every id must resolve: one
|
|
501
|
+
that matches no clip fails the call and nothing is deleted.
|
|
469
502
|
"""
|
|
470
503
|
_, mp, err = _get_mp()
|
|
471
504
|
if err:
|
|
472
505
|
return err
|
|
473
|
-
clips =
|
|
474
|
-
if
|
|
475
|
-
return
|
|
506
|
+
clips, clips_err = _clips_from_ids(mp.GetRootFolder(), clip_ids, verb="deleted", distinct=True)
|
|
507
|
+
if clips_err:
|
|
508
|
+
return clips_err
|
|
476
509
|
result = mp.DeleteClips(clips)
|
|
477
510
|
return {"success": bool(result), "deleted_count": len(clips)}
|
|
478
511
|
|
|
@@ -515,15 +548,16 @@ def move_clips_to_folder(clip_ids: List[str], target_folder_path: str) -> Dict[s
|
|
|
515
548
|
"""Move clips to a different Media Pool folder.
|
|
516
549
|
|
|
517
550
|
Args:
|
|
518
|
-
clip_ids: List of clip unique IDs to move.
|
|
551
|
+
clip_ids: List of clip unique IDs to move. Every id must resolve: one
|
|
552
|
+
that matches no clip fails the call and nothing is moved.
|
|
519
553
|
target_folder_path: Path to target folder (e.g. 'Master/Footage').
|
|
520
554
|
"""
|
|
521
555
|
_, mp, err = _get_mp()
|
|
522
556
|
if err:
|
|
523
557
|
return err
|
|
524
|
-
clips =
|
|
525
|
-
if
|
|
526
|
-
return
|
|
558
|
+
clips, clips_err = _clips_from_ids(mp.GetRootFolder(), clip_ids, verb="moved", distinct=True)
|
|
559
|
+
if clips_err:
|
|
560
|
+
return clips_err
|
|
527
561
|
target = _navigate_to_folder(mp, target_folder_path)
|
|
528
562
|
if not target:
|
|
529
563
|
return {"error": f"Target folder '{target_folder_path}' not found"}
|
package/src/server.py
CHANGED
|
@@ -11,7 +11,7 @@ Usage:
|
|
|
11
11
|
python src/server.py --full # Start the 377-tool granular server instead
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
|
-
VERSION = "4.8.
|
|
14
|
+
VERSION = "4.8.7"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -14266,6 +14266,35 @@ def _append_to_timeline_verified_operation(requested: Dict[str, Any], verificati
|
|
|
14266
14266
|
)
|
|
14267
14267
|
|
|
14268
14268
|
|
|
14269
|
+
def _append_to_timeline_failed(message: str, result, requested: Dict[str, Any], verification: Dict[str, Any], before):
|
|
14270
|
+
"""AppendToTimeline answered None/False/[]: an error that keeps the readback.
|
|
14271
|
+
|
|
14272
|
+
The answer alone does not prove nothing landed, so the call is retryable only
|
|
14273
|
+
when the readback shows the timeline's item count unchanged; a retry after an
|
|
14274
|
+
append that did land puts the clips on the timeline twice.
|
|
14275
|
+
"""
|
|
14276
|
+
delta = verification.get("item_count_delta")
|
|
14277
|
+
nothing_landed = delta == 0
|
|
14278
|
+
out = _err(
|
|
14279
|
+
message,
|
|
14280
|
+
code="APPEND_TO_TIMELINE_FAILED",
|
|
14281
|
+
category="resolve_api_failed",
|
|
14282
|
+
retryable=nothing_landed,
|
|
14283
|
+
reason=f"MediaPool.AppendToTimeline returned {result!r}",
|
|
14284
|
+
remediation=(
|
|
14285
|
+
"Nothing was appended. Check that the intended timeline is current and the clips can go on it, then retry."
|
|
14286
|
+
if nothing_landed else
|
|
14287
|
+
"The readback cannot rule out that clips were appended; inspect the current timeline before retrying so nothing is appended twice."
|
|
14288
|
+
),
|
|
14289
|
+
state={
|
|
14290
|
+
"expected_item_count_delta": verification.get("expected_item_count_delta"),
|
|
14291
|
+
"item_count_delta": delta,
|
|
14292
|
+
},
|
|
14293
|
+
)
|
|
14294
|
+
out["verified_operation"] = _append_to_timeline_verified_operation(requested, verification, before)
|
|
14295
|
+
return out
|
|
14296
|
+
|
|
14297
|
+
|
|
14269
14298
|
def _link_proxy_checked(root, p: Dict[str, Any]):
|
|
14270
14299
|
clip = _find_clip(root, p.get("clip_id", ""))
|
|
14271
14300
|
if not clip:
|
|
@@ -21175,6 +21204,9 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
|
|
|
21175
21204
|
record_frame is relative to the current timeline start frame by default;
|
|
21176
21205
|
pass record_frame_mode="absolute" for raw Resolve recordFrame values.
|
|
21177
21206
|
Returns timeline_item_id per item.
|
|
21207
|
+
Either form: Resolve answering None/False/[] is APPEND_TO_TIMELINE_FAILED,
|
|
21208
|
+
with verified_operation (the timeline readback) kept on the error;
|
|
21209
|
+
retryable only when the readback shows nothing was appended.
|
|
21178
21210
|
import_media(paths) -> {imported}
|
|
21179
21211
|
UNSAFE. No dry_run. Prefer safe_import_media.
|
|
21180
21212
|
— simple: params.paths is a list of file/folder paths
|
|
@@ -21186,14 +21218,16 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
|
|
|
21186
21218
|
delete_clips(clip_ids) -> {success}
|
|
21187
21219
|
DESTRUCTIVE. Removes clips from the Media Pool (does not touch source files).
|
|
21188
21220
|
An id matching no clip fails the whole call (CLIP_NOT_FOUND) rather than
|
|
21189
|
-
deleting the ids that did resolve; the same holds for
|
|
21190
|
-
|
|
21221
|
+
deleting the ids that did resolve; the same holds for every clip_ids
|
|
21222
|
+
batch in this tool (move_clips, relink, unlink, create_timeline_from_clips,
|
|
21223
|
+
append_to_timeline, export_metadata, auto_sync_audio).
|
|
21191
21224
|
move_clips(clip_ids, target_path) -> {success}
|
|
21192
21225
|
relink(clip_ids, folder_path) -> {success}
|
|
21193
21226
|
UNSAFE. No dry_run. Prefer safe_relink.
|
|
21194
21227
|
unlink(clip_ids) -> {success}
|
|
21195
21228
|
UNSAFE. No dry_run. Prefer safe_unlink.
|
|
21196
21229
|
export_metadata(path, clip_ids?) -> {success}
|
|
21230
|
+
— clip_ids omitted exports every clip; an empty clip_ids is INVALID_CLIP_IDS.
|
|
21197
21231
|
get_unique_id() -> {id}
|
|
21198
21232
|
create_stereo_clip(left_id, right_id) -> {success, name}
|
|
21199
21233
|
auto_sync_audio(clip_ids, settings?) -> {success}
|
|
@@ -21338,10 +21372,9 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
|
|
|
21338
21372
|
clip_ids = p.get("clip_ids")
|
|
21339
21373
|
if not clip_ids:
|
|
21340
21374
|
return _err("Provide clip_ids (simple) or clip_infos (positioned)")
|
|
21341
|
-
clips =
|
|
21342
|
-
|
|
21343
|
-
|
|
21344
|
-
return _err("No valid clips found")
|
|
21375
|
+
clips, clips_err = _clips_from_ids(root, clip_ids, verb="created")
|
|
21376
|
+
if clips_err:
|
|
21377
|
+
return clips_err
|
|
21345
21378
|
tl = mp.CreateTimelineFromClips(create_name, clips)
|
|
21346
21379
|
return _ok(
|
|
21347
21380
|
name=tl.GetName(),
|
|
@@ -21433,7 +21466,8 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
|
|
|
21433
21466
|
len(built),
|
|
21434
21467
|
)
|
|
21435
21468
|
if not result:
|
|
21436
|
-
return
|
|
21469
|
+
return _append_to_timeline_failed(
|
|
21470
|
+
"Failed to append clip_infos to timeline", result, requested, verification, before)
|
|
21437
21471
|
items_out = []
|
|
21438
21472
|
for i, item in enumerate(result):
|
|
21439
21473
|
item_out, item_err = _serialize_appended_timeline_item(item, i)
|
|
@@ -21450,8 +21484,11 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
|
|
|
21450
21484
|
clip_ids = p.get("clip_ids")
|
|
21451
21485
|
if not clip_ids:
|
|
21452
21486
|
return _err("Provide clip_ids (simple append) or clip_infos (positioned append)")
|
|
21453
|
-
|
|
21454
|
-
|
|
21487
|
+
# All or nothing: expected_count below used to be the RESOLVED count, so a
|
|
21488
|
+
# partial append read back as verified.
|
|
21489
|
+
clips, clips_err = _clips_from_ids(root, clip_ids, verb="appended")
|
|
21490
|
+
if clips_err:
|
|
21491
|
+
return clips_err
|
|
21455
21492
|
requested = {
|
|
21456
21493
|
"mode": "clip_ids",
|
|
21457
21494
|
"clip_ids": list(clip_ids),
|
|
@@ -21465,7 +21502,10 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
|
|
|
21465
21502
|
requested,
|
|
21466
21503
|
len(clips),
|
|
21467
21504
|
)
|
|
21468
|
-
|
|
21505
|
+
if not result:
|
|
21506
|
+
return _append_to_timeline_failed(
|
|
21507
|
+
"Failed to append clip_ids to timeline", result, requested, verification, before)
|
|
21508
|
+
out = _ok(count=len(result))
|
|
21469
21509
|
out["verified_operation"] = _append_to_timeline_verified_operation(
|
|
21470
21510
|
requested,
|
|
21471
21511
|
verification,
|
|
@@ -21524,12 +21564,16 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
|
|
|
21524
21564
|
return clips_err
|
|
21525
21565
|
return {"success": bool(mp.UnlinkClips(clips))}
|
|
21526
21566
|
elif action == "export_metadata":
|
|
21527
|
-
|
|
21528
|
-
if clip_ids:
|
|
21529
|
-
|
|
21530
|
-
|
|
21531
|
-
|
|
21532
|
-
|
|
21567
|
+
path = p["path"]
|
|
21568
|
+
if p.get("clip_ids") is None:
|
|
21569
|
+
return {"success": bool(mp.ExportMetadata(path))}
|
|
21570
|
+
# Passing clip_ids at all asks for exactly those clips. An empty or
|
|
21571
|
+
# all-missing list must not reach ExportMetadata(path, []), whose
|
|
21572
|
+
# behaviour on an empty list is unmeasured and may be "export everything".
|
|
21573
|
+
clips, clips_err = _clips_from_ids(root, p["clip_ids"], verb="exported")
|
|
21574
|
+
if clips_err:
|
|
21575
|
+
return clips_err
|
|
21576
|
+
return {"success": bool(mp.ExportMetadata(path, clips))}
|
|
21533
21577
|
elif action == "get_unique_id":
|
|
21534
21578
|
return {"id": mp.GetUniqueId()}
|
|
21535
21579
|
elif action == "create_stereo_clip":
|
|
@@ -21540,8 +21584,9 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
|
|
|
21540
21584
|
result = mp.CreateStereoClip(left, right)
|
|
21541
21585
|
return _ok(name=result.GetName()) if result else _err("Failed to create stereo clip")
|
|
21542
21586
|
elif action == "auto_sync_audio":
|
|
21543
|
-
clips =
|
|
21544
|
-
|
|
21587
|
+
clips, clips_err = _clips_from_ids(root, p["clip_ids"], verb="synced")
|
|
21588
|
+
if clips_err:
|
|
21589
|
+
return clips_err
|
|
21545
21590
|
# Normalize string settings into live AUDIO_SYNC_* enum keys; passing raw
|
|
21546
21591
|
# human-readable keys makes AutoSyncAudio silently reject the call.
|
|
21547
21592
|
settings, ignored = _normalize_auto_sync_settings(dict(p.get("settings") or {}), get_resolve())
|