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 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
- [![Version](https://img.shields.io/badge/version-4.8.5-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.8.7-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-37%20(389%20full)-blue.svg)](#server-modes)
package/README.zh-CN.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [English](README.md) | 简体中文
4
4
 
5
- [![Version](https://img.shields.io/badge/version-4.8.5-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.8.7-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-37%20(389%20full)-blue.svg)](#服务器模式)
@@ -12,7 +12,7 @@
12
12
  [![Python](https://img.shields.io/badge/python-3.10+-green.svg)](https://www.python.org/downloads/)
13
13
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT)
14
14
 
15
- > 本翻译对应 v4.8.5 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
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
- `delete_clips`, `move_clips`, `relink` and `unlink` are all-or-nothing: if any id
926
- in `clip_ids` matches no clip, the call fails with `CLIP_NOT_FOUND` (the
927
- unresolved and resolved ids are in `error.state`) and no clip is changed. Drop the
928
- stale ids and retry; do not read a partial batch as done. `delete_folders(folder_ids)`
929
- and `move_folders(folder_ids, target_path)` work the same way with
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.5"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "4.8.5",
3
+ "version": "4.8.7",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -93,7 +93,7 @@ if not logging.getLogger().handlers:
93
93
  handlers=[logging.StreamHandler()],
94
94
  )
95
95
 
96
- VERSION = "4.8.5"
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
- root = mp.GetRootFolder()
200
- clips = [_find_clip_by_id(root, cid) for cid in clip_ids]
201
- clips = [c for c in clips if c]
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
- return {"success": True, "count": len(result) if result else 0}
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
- root = mp.GetRootFolder()
362
- clips = [_find_clip_by_id(root, cid) for cid in clip_ids]
363
- clips = [c for c in clips if c]
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 = _find_clips_by_ids(mp.GetRootFolder(), set(clip_ids))
474
- if not clips:
475
- return {"error": "No matching clips found"}
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 = _find_clips_by_ids(mp.GetRootFolder(), set(clip_ids))
525
- if not clips:
526
- return {"error": "No matching clips found"}
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.5"
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 move_clips, relink
21190
- and unlink.
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 = [_find_clip(root, cid) for cid in clip_ids]
21342
- clips = [c for c in clips if c]
21343
- if not clips:
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 _err("Failed to append clip_infos to timeline")
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
- clips = [_find_clip(root, cid) for cid in clip_ids]
21454
- clips = [c for c in clips if c]
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
- out = _ok(count=len(result) if result else 0)
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
- clip_ids = p.get("clip_ids")
21528
- if clip_ids:
21529
- clips = [_find_clip(root, cid) for cid in clip_ids]
21530
- clips = [c for c in clips if c]
21531
- return {"success": bool(mp.ExportMetadata(p["path"], clips))}
21532
- return {"success": bool(mp.ExportMetadata(p["path"]))}
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 = [_find_clip(root, cid) for cid in p["clip_ids"]]
21544
- clips = [c for c in clips if c]
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())