davinci-resolve-mcp 2.70.4 → 2.71.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,78 @@
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 v2.71.1
6
+
7
+ `Timeline.DeleteClips` can lie about whether it worked. #111 recorded four
8
+ behaviours from a live edit session; #114 mitigates the first of them. Both by
9
+ @billcarroll.
10
+
11
+ ### DeleteClips readback-and-retry
12
+
13
+ `Timeline.DeleteClips` can return `False` on a first call even when every item
14
+ passed is a valid, present `TimelineItem`, with an identical retry succeeding.
15
+ `_timeline_delete_clips_verified` reads the tracks back on a `False` and retries
16
+ once if the items are still there. All four timeline call sites route through
17
+ it: the `delete_clips` action, `lift_range`, `duplicate_clips` and `copy_range`.
18
+
19
+ The readback is deliberately **tri-state**. A walk that raised, enumerated no
20
+ track at all, or covered items whose unique ID cannot be read is `unknown`, not
21
+ `absent` — so an unverifiable delete is never reported as success, and never
22
+ spends a second destructive call buying information it cannot read. An earlier
23
+ draft collapsed unknown into absent, which turned a failed delete into a
24
+ reported success; that is the exact silent-lie class this series exists to
25
+ remove, so it is worth naming.
26
+
27
+ The `ripple=True` non-idempotence of a retry is recorded in the docstring rather
28
+ than claimed to be solved: if the first call deleted some items and left others,
29
+ the retry passes the original list back in, stale handles included. It could not
30
+ be made to misbehave against a fake.
31
+
32
+ ### Four edit-session behaviours recorded
33
+
34
+ - **`DeleteClips` flaky first attempt.** The entry states plainly that the cause
35
+ is **unknown**, and specifically that this is *not* the
36
+ `ProjectManager.DeleteProject` shape — that one has an identified mechanism
37
+ which retrying does not clear, whereas a single retry cleared this in the one
38
+ instance seen. One observation is not a mechanism.
39
+ - **`DeleteClips` leaves linked audio.** The API deletes exactly the items
40
+ passed; the UI's linked-selection behaviour does not apply, so orphaned audio
41
+ collides with later appends.
42
+ - **`AppendToTimeline` mixed-fps duration floor.** Source-to-timeline frame
43
+ conversion rounds down, landing a planned range one frame short.
44
+ - **`ImportMedia` current-folder only.** No destination parameter; imports land
45
+ in the current bin.
46
+
47
+ ## What's New in v2.71.0
48
+
49
+ Keyed metadata getters honor a list of keys, and `delete_timelines` names the
50
+ parameter it wants. Reported and fixed by @billcarroll in #113, from live
51
+ cataloguing work.
52
+
53
+ ### Keyed getters silently returned everything
54
+
55
+ Resolve's keyed getters take one string. Handed a list they ignore it and return
56
+ the full dict, so a caller asking for three fields silently received all of them
57
+ with no signal that the request had been dropped — the kind of thing that reads
58
+ as working until someone counts.
59
+
60
+ `get_metadata`, `get_third_party_metadata` and `get_clip_property` now share a
61
+ `_keyed_get` helper that subsets locally, since there is no batch getter to
62
+ delegate to. An empty or non-string list is a clear error rather than a silent
63
+ superset.
64
+
65
+ Missing keys deliberately report differently through the two forms: the list
66
+ form maps them to `null`, which separates "absent" from "present but empty"; the
67
+ string form still returns Resolve's `""`. All three actions document both the
68
+ list form and that divergence.
69
+
70
+ ### `delete_timelines` leaked a KeyError
71
+
72
+ Called with `timeline_names`, or without `timeline_ids` at all, it raised a bare
73
+ `KeyError('timeline_ids')`. It now returns a proper error naming the expected
74
+ parameter, and when `timeline_names` was passed it says explicitly that
75
+ timelines are matched by unique ID rather than by name.
76
+
5
77
  ## What's New in v2.70.4
6
78
 
7
79
  Three silent-failure fixes from community reports, plus the Windows bridge
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # DaVinci Resolve MCP Server
2
2
 
3
- [![Version](https://img.shields.io/badge/version-2.70.4-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.71.1-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
4
4
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
5
5
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
6
6
  [![Tools](https://img.shields.io/badge/MCP%20Tools-34%20(341%20full)-blue.svg)](#server-modes)
package/docs/SKILL.md CHANGED
@@ -1307,7 +1307,12 @@ Key actions:
1307
1307
  instead of walking tracks by hand. Filters may be passed inline or as a
1308
1308
  `filters` dict; a mistyped filter name is rejected rather than silently
1309
1309
  matching everything. Returns `{clips, match_count, total_clips}`.
1310
- - `delete_clips(clip_ids, ripple?)` — IDs are unique IDs from `get_items`
1310
+ - `delete_clips(clip_ids, ripple?)` — IDs are unique IDs from `get_items`.
1311
+ Two verified quirks (see `api_truth`): the call can return `success: false`
1312
+ on the first attempt with valid IDs — re-list and retry once before failing;
1313
+ and deleting a video item does NOT delete its linked audio — pass the linked
1314
+ audio item IDs explicitly, then `detect_gaps_overlaps` across both track
1315
+ types.
1311
1316
  - `duplicate_clips(clip_ids?, selected?, target_track_index?, track_offset?, placement?, record_frame?, record_frame_offset?, copy_properties?, include_linked?)` —
1312
1317
  duplicate existing video timeline items by re-appending the same Media Pool
1313
1318
  item with the same source trim; `selected=True` uses Resolve's selected/current
@@ -1723,6 +1728,14 @@ media_pool(action="append_to_timeline", params={"clip_infos": [
1723
1728
  ]})
1724
1729
  ```
1725
1730
 
1731
+ Mixed-fps caution: `start_frame`/`end_frame` are SOURCE frames, and a source
1732
+ whose fps differs from the timeline's rounds DOWN on conversion — a 24.0 or
1733
+ 29.97 clip appended into a 23.976 timeline can land one frame short of its
1734
+ slot. Plan durations in timeline frames, extend `end_frame` by a source frame
1735
+ when the floor misses, and finish with `detect_gaps_overlaps` (see
1736
+ `api_truth`). `import_media` always lands in the CURRENT bin — call
1737
+ `set_current_folder` first; there is no destination parameter.
1738
+
1726
1739
  ### 4. Inspect and annotate timeline items
1727
1740
 
1728
1741
  ```
@@ -12,7 +12,7 @@ that none exists).
12
12
 
13
13
  **Verified on:** DaVinci Resolve Studio 21.0.0
14
14
 
15
- **Totals:** 22 missing capabilities, 20 bugs / unreliable behaviors.
15
+ **Totals:** 23 missing capabilities, 22 bugs / unreliable behaviors.
16
16
 
17
17
  The authoritative source is the runtime-queryable `api_truth` ledger
18
18
  (`resolve_control api_truth "<query>"`); this document is generated from
@@ -192,6 +192,14 @@ equivalent, blocking full automation.
192
192
  - **Workaround / current handling:** Delete and recreate the folder with the desired name, or rename in the Resolve UI.
193
193
  - **Tags:** missing-method, media-pool, folder
194
194
 
195
+ ### MediaPool.ImportMedia (current-folder destination only)
196
+
197
+ - **Object:** `MediaPool`
198
+ - **Signature:** `([paths] | [clipInfos]) -> [MediaPoolItem]`
199
+ - **Behavior:** Imports always land in the CURRENT media pool folder; the call has no destination-folder parameter, and passing an unrecognized one to the MCP tool is silently ignored.
200
+ - **Workaround / current handling:** SetCurrentFolder to the target bin first (media_pool set_current_folder), import, then restore the previous current folder if it matters.
201
+ - **Tags:** media-pool, import
202
+
195
203
  ### Project.SetCurrentRenderFormatAndCodec
196
204
 
197
205
  - **Object:** `Project`
@@ -327,6 +335,22 @@ values, or automation-hostile modal prompts.
327
335
  - **Reference:** [issue #77](https://github.com/samuelgursky/davinci-resolve-mcp/issues/77)
328
336
  - **Tags:** unreliable-return, silent-failure, metadata, reel-name
329
337
 
338
+ ### Timeline.DeleteClips (flaky first attempt)
339
+
340
+ - **Object:** `Timeline`
341
+ - **Signature:** `([TimelineItem], ripple) -> bool`
342
+ - **Behavior:** Can return False on the first call even when every item in the list is a valid, present TimelineItem; an identical immediate retry succeeds. Observed once, on Studio 21.0 during a cut-video edit session (items confirmed still present after the False, deleted cleanly on retry). Cause unknown — do NOT read this as the ProjectManager.DeleteProject shape: that one has an identified mechanism (the project being, or recently having been, current) that retrying does not clear, whereas a single retry cleared this in the one instance seen. One observation is not a mechanism; if a retry is ever seen to fail repeatedly here, this entry needs revisiting.
343
+ - **Workaround / current handling:** Treat a False return as advisory: re-list the track and check whether the items are actually gone; if still present, retry the identical call once before failing. A readback that raised, enumerated nothing, or covered items whose unique ID cannot be read is UNKNOWN, not gone — never report an unverifiable delete as success, and do not spend a second destructive call on an outcome you equally cannot read.
344
+ - **Tags:** unreliable-return, flaky, timeline, edit
345
+
346
+ ### MediaPool.AppendToTimeline with mixed-fps sources (duration floor)
347
+
348
+ - **Object:** `MediaPool`
349
+ - **Signature:** `([{mediaPoolItem, startFrame, endFrame, recordFrame, ...}]) -> [TimelineItem]`
350
+ - **Behavior:** start/endFrame are in SOURCE frames. When the source fps differs from the timeline fps (e.g. 24.0 or 29.97 source in a 23.976 timeline), Resolve converts the source range to timeline frames by flooring — so a range planned to fill an exact record slot lands one frame short, leaving a 1-frame gap before the next clip.
351
+ - **Workaround / current handling:** Plan durations in timeline frames (floor(src_frames * timeline_fps / source_fps)); if the floored duration misses the slot, extend endFrame by a source frame and re-check. Always finish with detect_gaps_overlaps.
352
+ - **Tags:** timeline, edit, off-by-one, mixed-fps
353
+
330
354
  ### Graph.SetLUT (master-LUT-dir-only resolution)
331
355
 
332
356
  - **Object:** `Graph`
package/install.py CHANGED
@@ -36,7 +36,7 @@ from src.utils.update_check import (
36
36
 
37
37
  # ─── Version ──────────────────────────────────────────────────────────────────
38
38
 
39
- VERSION = "2.70.4"
39
+ VERSION = "2.71.1"
40
40
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
41
41
  # Resolve's scripting bridge loads into newer interpreters on recent builds
42
42
  # (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": "2.70.4",
3
+ "version": "2.71.1",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -85,7 +85,7 @@ if not logging.getLogger().handlers:
85
85
  handlers=[logging.StreamHandler()],
86
86
  )
87
87
 
88
- VERSION = "2.70.4"
88
+ VERSION = "2.71.1"
89
89
  logger = logging.getLogger("davinci-resolve-mcp")
90
90
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
91
91
  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 341-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.70.4"
14
+ VERSION = "2.71.1"
15
15
 
16
16
  import base64
17
17
  import os
@@ -3795,6 +3795,81 @@ def _timeline_item_ids(items):
3795
3795
  return ids
3796
3796
 
3797
3797
 
3798
+ def _timeline_items_presence(tl, items):
3799
+ """Are these timeline items still on the timeline? present/absent/unknown.
3800
+
3801
+ 'absent' is a positive finding: every item was identifiable and a
3802
+ completed track walk did not see any of them. A walk that raised, that
3803
+ could not enumerate a single track, or items whose unique ID cannot be
3804
+ read all yield 'unknown' — the readback saw nothing, which is not the
3805
+ same as nothing being there. Callers must never treat 'unknown' as
3806
+ verified-gone.
3807
+ """
3808
+ target_ids = []
3809
+ unreadable_item = False
3810
+ for item in items:
3811
+ item_id = _safe_timeline_item_id(item)
3812
+ if item_id:
3813
+ target_ids.append(item_id)
3814
+ else:
3815
+ unreadable_item = True
3816
+ target_ids = set(target_ids)
3817
+
3818
+ tracks_walked = 0
3819
+ walk_failed = False
3820
+ for track_type in ("video", "audio", "subtitle"):
3821
+ try:
3822
+ track_count = int(tl.GetTrackCount(track_type) or 0)
3823
+ except Exception:
3824
+ walk_failed = True
3825
+ continue
3826
+ for index in range(1, track_count + 1):
3827
+ try:
3828
+ track_items = tl.GetItemListInTrack(track_type, index) or []
3829
+ except Exception:
3830
+ walk_failed = True
3831
+ continue
3832
+ tracks_walked += 1
3833
+ for track_item in track_items:
3834
+ # A sighting is definitive even if another track failed.
3835
+ if _safe_timeline_item_id(track_item) in target_ids:
3836
+ return "present"
3837
+
3838
+ if walk_failed or tracks_walked == 0 or unreadable_item or not target_ids:
3839
+ return "unknown"
3840
+ return "absent"
3841
+
3842
+
3843
+ def _timeline_delete_clips_verified(tl, items, ripple):
3844
+ """Timeline.DeleteClips with readback-and-retry.
3845
+
3846
+ api_truth 'Timeline.DeleteClips (flaky first attempt)': the call can
3847
+ return False while every item is still present, and an identical retry
3848
+ then succeeds. On a False, read the tracks back:
3849
+
3850
+ absent -> the delete landed despite the False; report success.
3851
+ present -> retry the identical call once, then read back again.
3852
+ unknown -> report failure and do NOT retry. An unverifiable delete must
3853
+ not be claimed as success, and a retry whose outcome we
3854
+ equally cannot read is a second destructive call bought with
3855
+ no information.
3856
+
3857
+ ripple=True caveat: a retry is not idempotent in principle. If the first
3858
+ call deleted some items and left others, the readback reports 'present'
3859
+ for the survivors and the retry passes the original list back in — stale
3860
+ handles to already-deleted items included. That could not be made to
3861
+ misbehave against a fake; it is recorded, not resolved.
3862
+ """
3863
+ if bool(tl.DeleteClips(items, ripple)):
3864
+ return True
3865
+ presence = _timeline_items_presence(tl, items)
3866
+ if presence != "present":
3867
+ return presence == "absent"
3868
+ if bool(tl.DeleteClips(items, ripple)):
3869
+ return True
3870
+ return _timeline_items_presence(tl, items) == "absent"
3871
+
3872
+
3798
3873
  def _timeline_items_by_ids(tl, ids, track_types=("video", "audio", "subtitle")):
3799
3874
  ids_set = {str(item_id) for item_id in ids if item_id is not None}
3800
3875
  found = []
@@ -4168,7 +4243,7 @@ def _timeline_duplicate_clips_impl(proj, tl, p: Dict[str, Any], *, delete_source
4168
4243
  seen_delete_ids.add(item_id)
4169
4244
  if delete_items:
4170
4245
  try:
4171
- out["deleted_sources"] = bool(tl.DeleteClips(delete_items, bool(p.get("ripple", False))))
4246
+ out["deleted_sources"] = _timeline_delete_clips_verified(tl, delete_items, bool(p.get("ripple", False)))
4172
4247
  out["deleted_source_ids"] = _timeline_item_ids(delete_items)
4173
4248
  except Exception as exc:
4174
4249
  out["deleted_sources"] = False
@@ -4283,7 +4358,7 @@ def _timeline_copy_range_impl(proj, tl, p: Dict[str, Any], *, overwrite: bool =
4283
4358
  if existing_start < dest_end and existing_end > dest_start:
4284
4359
  delete_targets.append(existing)
4285
4360
  if delete_targets:
4286
- deleted = bool(tl.DeleteClips(delete_targets, False))
4361
+ deleted = _timeline_delete_clips_verified(tl, delete_targets, False)
4287
4362
 
4288
4363
  results = []
4289
4364
  for track_type, source_track, item, overlap_start, overlap_end in items:
@@ -4376,7 +4451,7 @@ def _timeline_lift_range_impl(tl, p: Dict[str, Any]):
4376
4451
  return {"success": True, "deleted": 0, "range": {"start": start, "end": end}}
4377
4452
  deleted_ids = _timeline_item_ids(delete_items)
4378
4453
  return {
4379
- "success": bool(tl.DeleteClips(delete_items, bool(p.get("ripple", False)))),
4454
+ "success": _timeline_delete_clips_verified(tl, delete_items, bool(p.get("ripple", False))),
4380
4455
  "deleted": len(delete_items),
4381
4456
  "deleted_ids": deleted_ids,
4382
4457
  "range": {"start": start, "end": end},
@@ -16626,11 +16701,18 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
16626
16701
 
16627
16702
  return _run_maybe_background("media_pool.import_timeline", p, _work)
16628
16703
  elif action == "delete_timelines":
16704
+ ids = p.get("timeline_ids")
16705
+ if not isinstance(ids, list) or not ids:
16706
+ hint = (" ('timeline_names' is not supported — timelines are matched"
16707
+ " by unique ID, e.g. from timeline.get_unique_id)"
16708
+ if "timeline_names" in p else "")
16709
+ return _err("delete_timelines requires 'timeline_ids', a non-empty"
16710
+ " list of timeline unique IDs" + hint)
16629
16711
  count = proj.GetTimelineCount()
16630
16712
  timelines = []
16631
16713
  for i in range(1, count + 1):
16632
16714
  tl = proj.GetTimelineByIndex(i)
16633
- if tl and tl.GetUniqueId() in p["timeline_ids"]:
16715
+ if tl and tl.GetUniqueId() in ids:
16634
16716
  timelines.append(tl)
16635
16717
  if not timelines:
16636
16718
  return _err("No timelines found")
@@ -17006,6 +17088,22 @@ def folder(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
17006
17088
  # TOOL 13: media_pool_item
17007
17089
  # ═══════════════════════════════════════════════════════════════════════════════
17008
17090
 
17091
+ def _keyed_get(getter, key):
17092
+ """Resolve's keyed getters take one string key; passed a list they silently
17093
+ ignore it and return the full dict. Subset it ourselves instead.
17094
+
17095
+ Returns (value, error) — exactly one is non-None unless value is legitimately
17096
+ empty."""
17097
+ if isinstance(key, list):
17098
+ if not key or not all(isinstance(k, str) for k in key):
17099
+ return None, _err("'key' must be a string or a non-empty list of strings")
17100
+ full = getter("")
17101
+ if not isinstance(full, dict):
17102
+ full = {}
17103
+ return {k: full.get(k) for k in key}, None
17104
+ return getter(key), None
17105
+
17106
+
17009
17107
  @mcp.tool()
17010
17108
  @_guard_missing_params
17011
17109
  def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
@@ -17014,11 +17112,26 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
17014
17112
  Actions:
17015
17113
  get_name(clip_id) -> {name}
17016
17114
  get_metadata(clip_id, key?) -> {metadata}
17115
+ — key: one string, or a list of strings to get just that subset.
17116
+ Missing keys: the list form maps them to null (distinguishing
17117
+ absent from empty); the string form passes Resolve's own answer
17118
+ through unchanged, which is "" or null depending on the getter
17119
+ and build (get_clip_property returns null on Studio 19.1.3.7).
17017
17120
  set_metadata(clip_id, key, value) OR set_metadata(clip_id, metadata) -> {success}
17018
17121
  get_third_party_metadata(clip_id, key?) -> {metadata}
17122
+ — key: one string, or a list of strings to get just that subset.
17123
+ Missing keys: the list form maps them to null (distinguishing
17124
+ absent from empty); the string form passes Resolve's own answer
17125
+ through unchanged, which is "" or null depending on the getter
17126
+ and build (get_clip_property returns null on Studio 19.1.3.7).
17019
17127
  set_third_party_metadata(clip_id, key, value) -> {success}
17020
17128
  get_media_id(clip_id) -> {media_id}
17021
17129
  get_clip_property(clip_id, key?) -> {properties}
17130
+ — key: one string, or a list of strings to get just that subset.
17131
+ Missing keys: the list form maps them to null (distinguishing
17132
+ absent from empty); the string form passes Resolve's own answer
17133
+ through unchanged, which is "" or null depending on the getter
17134
+ and build (get_clip_property returns null on Studio 19.1.3.7).
17022
17135
  set_clip_property(clip_id, key, value) -> {success}
17023
17136
  get_clip_color(clip_id) -> {color}
17024
17137
  set_clip_color(clip_id, color) -> {success}
@@ -17180,7 +17293,10 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
17180
17293
  if action == "get_name":
17181
17294
  return {"name": clip.GetName()}
17182
17295
  elif action == "get_metadata":
17183
- return {"metadata": _ser(clip.GetMetadata(p.get("key", "")))}
17296
+ value, key_err = _keyed_get(clip.GetMetadata, p.get("key", ""))
17297
+ if key_err:
17298
+ return key_err
17299
+ return {"metadata": _ser(value)}
17184
17300
  elif action == "set_metadata":
17185
17301
  if "metadata" in p:
17186
17302
  ok = bool(clip.SetMetadata(p["metadata"]))
@@ -17196,13 +17312,19 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
17196
17312
  return silent
17197
17313
  return {"success": ok}
17198
17314
  elif action == "get_third_party_metadata":
17199
- return {"metadata": _ser(clip.GetThirdPartyMetadata(p.get("key", "")))}
17315
+ value, key_err = _keyed_get(clip.GetThirdPartyMetadata, p.get("key", ""))
17316
+ if key_err:
17317
+ return key_err
17318
+ return {"metadata": _ser(value)}
17200
17319
  elif action == "set_third_party_metadata":
17201
17320
  return {"success": bool(clip.SetThirdPartyMetadata(p["key"], p["value"]))}
17202
17321
  elif action == "get_media_id":
17203
17322
  return {"media_id": clip.GetMediaId()}
17204
17323
  elif action == "get_clip_property":
17205
- return {"properties": _ser(clip.GetClipProperty(p.get("key", "")))}
17324
+ value, key_err = _keyed_get(clip.GetClipProperty, p.get("key", ""))
17325
+ if key_err:
17326
+ return key_err
17327
+ return {"properties": _ser(value)}
17206
17328
  elif action == "set_clip_property":
17207
17329
  ok = bool(clip.SetClipProperty(p["key"], p["value"]))
17208
17330
  if ok:
@@ -20726,7 +20848,7 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
20726
20848
  blocked = _consume_confirm_token(action="timeline.delete_clips_ripple", params=p)
20727
20849
  if blocked:
20728
20850
  return blocked
20729
- return {"success": bool(tl.DeleteClips(found, ripple))}
20851
+ return {"success": _timeline_delete_clips_verified(tl, found, ripple)}
20730
20852
  elif action == "set_clips_linked":
20731
20853
  ids_set = set(p["clip_ids"])
20732
20854
  found = []
@@ -807,6 +807,81 @@ API_TRUTH: List[Dict[str, Any]] = [
807
807
  "when mirroring keep-ranges into clipInfos.",
808
808
  "tags": ["timeline", "edit", "off-by-one", "readback"],
809
809
  },
810
+ {
811
+ "symbol": "Timeline.DeleteClips (flaky first attempt)",
812
+ "object": "Timeline",
813
+ "signature": "([TimelineItem], ripple) -> bool",
814
+ "reality": "Can return False on the first call even when every item in "
815
+ "the list is a valid, present TimelineItem; an identical "
816
+ "immediate retry succeeds. Observed once, on Studio 21.0 "
817
+ "during a cut-video edit session (items confirmed still "
818
+ "present after the False, deleted cleanly on retry). Cause "
819
+ "unknown — do NOT read this as the ProjectManager."
820
+ "DeleteProject shape: that one has an identified mechanism "
821
+ "(the project being, or recently having been, current) that "
822
+ "retrying does not clear, whereas a single retry cleared "
823
+ "this in the one instance seen. One observation is not a "
824
+ "mechanism; if a retry is ever seen to fail repeatedly here, "
825
+ "this entry needs revisiting.",
826
+ "recommended": "Treat a False return as advisory: re-list the track and "
827
+ "check whether the items are actually gone; if still "
828
+ "present, retry the identical call once before failing. "
829
+ "A readback that raised, enumerated nothing, or covered "
830
+ "items whose unique ID cannot be read is UNKNOWN, not "
831
+ "gone — never report an unverifiable delete as success, "
832
+ "and do not spend a second destructive call on an "
833
+ "outcome you equally cannot read.",
834
+ "tags": ["unreliable-return", "flaky", "timeline", "edit"],
835
+ "submit": "bug",
836
+ "mitigation": ["_timeline_delete_clips_verified", "_timeline_items_presence"],
837
+ },
838
+ {
839
+ "symbol": "Timeline.DeleteClips (linked audio not deleted)",
840
+ "object": "Timeline",
841
+ "signature": "([TimelineItem], ripple) -> bool",
842
+ "reality": "Deleting video items does NOT delete their linked audio "
843
+ "items — the UI's linked-selection behavior does not apply "
844
+ "to the API, which deletes exactly the items passed. The "
845
+ "orphaned audio stays on its track and collides with any "
846
+ "later append into the same record range.",
847
+ "recommended": "When deleting a video item that has linked audio, list "
848
+ "the audio track(s) (timeline get_items, track_type "
849
+ "'audio'), find the overlapping linked items, and pass "
850
+ "their IDs in the same delete. Verify with "
851
+ "detect_gaps_overlaps across both track types.",
852
+ "tags": ["timeline", "edit", "audio", "silent-failure"],
853
+ },
854
+ {
855
+ "symbol": "MediaPool.AppendToTimeline with mixed-fps sources (duration floor)",
856
+ "object": "MediaPool",
857
+ "signature": "([{mediaPoolItem, startFrame, endFrame, recordFrame, ...}]) -> [TimelineItem]",
858
+ "reality": "start/endFrame are in SOURCE frames. When the source fps "
859
+ "differs from the timeline fps (e.g. 24.0 or 29.97 source in "
860
+ "a 23.976 timeline), Resolve converts the source range to "
861
+ "timeline frames by flooring — so a range planned to fill an "
862
+ "exact record slot lands one frame short, leaving a 1-frame "
863
+ "gap before the next clip.",
864
+ "recommended": "Plan durations in timeline frames "
865
+ "(floor(src_frames * timeline_fps / source_fps)); if the "
866
+ "floored duration misses the slot, extend endFrame by a "
867
+ "source frame and re-check. Always finish with "
868
+ "detect_gaps_overlaps.",
869
+ "tags": ["timeline", "edit", "off-by-one", "mixed-fps"],
870
+ "submit": "bug",
871
+ },
872
+ {
873
+ "symbol": "MediaPool.ImportMedia (current-folder destination only)",
874
+ "object": "MediaPool",
875
+ "signature": "([paths] | [clipInfos]) -> [MediaPoolItem]",
876
+ "reality": "Imports always land in the CURRENT media pool folder; the "
877
+ "call has no destination-folder parameter, and passing an "
878
+ "unrecognized one to the MCP tool is silently ignored.",
879
+ "recommended": "SetCurrentFolder to the target bin first (media_pool "
880
+ "set_current_folder), import, then restore the previous "
881
+ "current folder if it matters.",
882
+ "tags": ["media-pool", "import"],
883
+ "submit": "missing",
884
+ },
810
885
  {
811
886
  "symbol": "Graph.SetLUT (master-LUT-dir-only resolution)",
812
887
  "object": "Graph",