davinci-resolve-mcp 2.71.0 → 2.72.0

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/install.py CHANGED
@@ -36,7 +36,7 @@ from src.utils.update_check import (
36
36
 
37
37
  # ─── Version ──────────────────────────────────────────────────────────────────
38
38
 
39
- VERSION = "2.71.0"
39
+ VERSION = "2.72.0"
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.71.0",
3
+ "version": "2.72.0",
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.71.0"
88
+ VERSION = "2.72.0"
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.71.0"
14
+ VERSION = "2.72.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -1784,6 +1784,36 @@ def _requires_method(obj, method_name, min_version):
1784
1784
  return None
1785
1785
  return _err(f"{method_name} requires DaVinci Resolve {min_version}+")
1786
1786
 
1787
+ def _ai_result(returned):
1788
+ """Normalize a Resolve 21 AI-method return into (ok, message).
1789
+
1790
+ The AI methods do not agree on how they report a missing Extras pack, and
1791
+ one of the two shapes is a trap. Verified live on Studio 21.0.2.4 with only
1792
+ AI Motion Deblur installed:
1793
+
1794
+ - `AnalyzeForSlate` -> False
1795
+ - `AnalyzeForIntellisearch` -> "Required package 'AI Intellisearch -
1796
+ Faster' is not installed."
1797
+ - `GenerateSpeech` -> "Required Package, 'AI Speech Generator' is not
1798
+ Installed."
1799
+
1800
+ A non-empty string is truthy, so `bool(returned)` reports success for a call
1801
+ that definitively did not run, and treating the return as a MediaPoolItem
1802
+ raises AttributeError. Route every AI return through here instead: a string
1803
+ is always a failure, and its text is the reason worth surfacing.
1804
+ """
1805
+ if isinstance(returned, str):
1806
+ return False, returned.strip() or None
1807
+ return bool(returned), None
1808
+
1809
+ def _ai_result_payload(returned):
1810
+ """`{"success": ...}` plus the Resolve-supplied reason when there is one."""
1811
+ ok, message = _ai_result(returned)
1812
+ payload = {"success": ok}
1813
+ if message:
1814
+ payload["error"] = message
1815
+ return payload
1816
+
1787
1817
  def _is_truncated(text):
1788
1818
  """True if a transcription preview was cut off.
1789
1819
 
@@ -3795,6 +3825,81 @@ def _timeline_item_ids(items):
3795
3825
  return ids
3796
3826
 
3797
3827
 
3828
+ def _timeline_items_presence(tl, items):
3829
+ """Are these timeline items still on the timeline? present/absent/unknown.
3830
+
3831
+ 'absent' is a positive finding: every item was identifiable and a
3832
+ completed track walk did not see any of them. A walk that raised, that
3833
+ could not enumerate a single track, or items whose unique ID cannot be
3834
+ read all yield 'unknown' — the readback saw nothing, which is not the
3835
+ same as nothing being there. Callers must never treat 'unknown' as
3836
+ verified-gone.
3837
+ """
3838
+ target_ids = []
3839
+ unreadable_item = False
3840
+ for item in items:
3841
+ item_id = _safe_timeline_item_id(item)
3842
+ if item_id:
3843
+ target_ids.append(item_id)
3844
+ else:
3845
+ unreadable_item = True
3846
+ target_ids = set(target_ids)
3847
+
3848
+ tracks_walked = 0
3849
+ walk_failed = False
3850
+ for track_type in ("video", "audio", "subtitle"):
3851
+ try:
3852
+ track_count = int(tl.GetTrackCount(track_type) or 0)
3853
+ except Exception:
3854
+ walk_failed = True
3855
+ continue
3856
+ for index in range(1, track_count + 1):
3857
+ try:
3858
+ track_items = tl.GetItemListInTrack(track_type, index) or []
3859
+ except Exception:
3860
+ walk_failed = True
3861
+ continue
3862
+ tracks_walked += 1
3863
+ for track_item in track_items:
3864
+ # A sighting is definitive even if another track failed.
3865
+ if _safe_timeline_item_id(track_item) in target_ids:
3866
+ return "present"
3867
+
3868
+ if walk_failed or tracks_walked == 0 or unreadable_item or not target_ids:
3869
+ return "unknown"
3870
+ return "absent"
3871
+
3872
+
3873
+ def _timeline_delete_clips_verified(tl, items, ripple):
3874
+ """Timeline.DeleteClips with readback-and-retry.
3875
+
3876
+ api_truth 'Timeline.DeleteClips (flaky first attempt)': the call can
3877
+ return False while every item is still present, and an identical retry
3878
+ then succeeds. On a False, read the tracks back:
3879
+
3880
+ absent -> the delete landed despite the False; report success.
3881
+ present -> retry the identical call once, then read back again.
3882
+ unknown -> report failure and do NOT retry. An unverifiable delete must
3883
+ not be claimed as success, and a retry whose outcome we
3884
+ equally cannot read is a second destructive call bought with
3885
+ no information.
3886
+
3887
+ ripple=True caveat: a retry is not idempotent in principle. If the first
3888
+ call deleted some items and left others, the readback reports 'present'
3889
+ for the survivors and the retry passes the original list back in — stale
3890
+ handles to already-deleted items included. That could not be made to
3891
+ misbehave against a fake; it is recorded, not resolved.
3892
+ """
3893
+ if bool(tl.DeleteClips(items, ripple)):
3894
+ return True
3895
+ presence = _timeline_items_presence(tl, items)
3896
+ if presence != "present":
3897
+ return presence == "absent"
3898
+ if bool(tl.DeleteClips(items, ripple)):
3899
+ return True
3900
+ return _timeline_items_presence(tl, items) == "absent"
3901
+
3902
+
3798
3903
  def _timeline_items_by_ids(tl, ids, track_types=("video", "audio", "subtitle")):
3799
3904
  ids_set = {str(item_id) for item_id in ids if item_id is not None}
3800
3905
  found = []
@@ -4168,7 +4273,7 @@ def _timeline_duplicate_clips_impl(proj, tl, p: Dict[str, Any], *, delete_source
4168
4273
  seen_delete_ids.add(item_id)
4169
4274
  if delete_items:
4170
4275
  try:
4171
- out["deleted_sources"] = bool(tl.DeleteClips(delete_items, bool(p.get("ripple", False))))
4276
+ out["deleted_sources"] = _timeline_delete_clips_verified(tl, delete_items, bool(p.get("ripple", False)))
4172
4277
  out["deleted_source_ids"] = _timeline_item_ids(delete_items)
4173
4278
  except Exception as exc:
4174
4279
  out["deleted_sources"] = False
@@ -4283,7 +4388,7 @@ def _timeline_copy_range_impl(proj, tl, p: Dict[str, Any], *, overwrite: bool =
4283
4388
  if existing_start < dest_end and existing_end > dest_start:
4284
4389
  delete_targets.append(existing)
4285
4390
  if delete_targets:
4286
- deleted = bool(tl.DeleteClips(delete_targets, False))
4391
+ deleted = _timeline_delete_clips_verified(tl, delete_targets, False)
4287
4392
 
4288
4393
  results = []
4289
4394
  for track_type, source_track, item, overlap_start, overlap_end in items:
@@ -4376,7 +4481,7 @@ def _timeline_lift_range_impl(tl, p: Dict[str, Any]):
4376
4481
  return {"success": True, "deleted": 0, "range": {"start": start, "end": end}}
4377
4482
  deleted_ids = _timeline_item_ids(delete_items)
4378
4483
  return {
4379
- "success": bool(tl.DeleteClips(delete_items, bool(p.get("ripple", False)))),
4484
+ "success": _timeline_delete_clips_verified(tl, delete_items, bool(p.get("ripple", False))),
4380
4485
  "deleted": len(delete_items),
4381
4486
  "deleted_ids": deleted_ids,
4382
4487
  "range": {"start": start, "end": end},
@@ -15384,6 +15489,7 @@ def project_settings(action: str, params: Optional[Dict[str, Any]] = None) -> Di
15384
15489
  delete_color_group(name) -> {success}
15385
15490
  apply_fairlight_preset(preset_name) -> {success}
15386
15491
  generate_speech(speech_generation_settings, timecode?) -> {success, new, new_id} — Resolve 21+, AI Speech Generator; creates new audio media (confirm-gated)
15492
+ reset_intellisearch_analysis() -> {success} — Resolve 21+; clears the project's IntelliSearch analysis data
15387
15493
  """
15388
15494
  p = _params(params)
15389
15495
  _, proj, err = _check()
@@ -15482,16 +15588,29 @@ def project_settings(action: str, params: Optional[Dict[str, Any]] = None) -> Di
15482
15588
  return blocked
15483
15589
  with _ai_ledger_timed("generate_speech") as _rec:
15484
15590
  new_item = proj.GenerateSpeech(settings, timecode)
15485
- _rec.success = bool(new_item)
15486
- if new_item:
15591
+ # GenerateSpeech returns an error STRING when the AI Speech Generator
15592
+ # Extra is absent (verified on Studio 21.0.2.4), not a MediaPoolItem.
15593
+ # A bare truthiness test lets that string through to .GetName() and
15594
+ # raises AttributeError, so normalize before touching the result.
15595
+ ok, message = _ai_result(new_item)
15596
+ _rec.success = ok
15597
+ if ok:
15487
15598
  path, nbytes = _clip_file_size(new_item)
15488
15599
  _rec.output_path = path
15489
15600
  _rec.output_bytes = nbytes
15490
- if not new_item:
15491
- return {"success": False}
15601
+ if not ok:
15602
+ return {"success": False, "error": message} if message else {"success": False}
15492
15603
  return {"success": True, "new": new_item.GetName(), "new_id": new_item.GetUniqueId(),
15493
15604
  "output_path": _rec.output_path, "output_bytes": _rec.output_bytes}
15494
- return _unknown(action, ["get_name","set_name","get_setting","set_setting","get_unique_id","get_presets","set_preset","refresh_luts","get_gallery","export_frame_as_still","project_summary","load_burnin_preset","insert_audio","get_color_groups","add_color_group","delete_color_group","apply_fairlight_preset","generate_speech"])
15605
+ elif action == "reset_intellisearch_analysis":
15606
+ missing = _requires_method(proj, "ResetIntellisearchAnalysis", "21.0")
15607
+ if missing:
15608
+ return missing
15609
+ with _ai_ledger_timed("reset_intellisearch_analysis") as _rec:
15610
+ result = _ai_result_payload(proj.ResetIntellisearchAnalysis())
15611
+ _rec.success = result["success"]
15612
+ return result
15613
+ return _unknown(action, ["get_name","set_name","get_setting","set_setting","get_unique_id","get_presets","set_preset","refresh_luts","get_gallery","export_frame_as_still","project_summary","load_burnin_preset","insert_audio","get_color_groups","add_color_group","delete_color_group","apply_fairlight_preset","generate_speech","reset_intellisearch_analysis"])
15495
15614
 
15496
15615
 
15497
15616
  # ═══════════════════════════════════════════════════════════════════════════════
@@ -16931,17 +17050,17 @@ def folder(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
16931
17050
  if missing:
16932
17051
  return missing
16933
17052
  with _ai_ledger_timed("perform_audio_classification") as _rec:
16934
- ok = bool(f.PerformAudioClassification())
16935
- _rec.success = ok
16936
- return {"success": ok}
17053
+ result = _ai_result_payload(f.PerformAudioClassification())
17054
+ _rec.success = result["success"]
17055
+ return result
16937
17056
  elif action == "clear_audio_classification":
16938
17057
  missing = _requires_method(f, "ClearAudioClassification", "21.0")
16939
17058
  if missing:
16940
17059
  return missing
16941
17060
  with _ai_ledger_timed("clear_audio_classification") as _rec:
16942
- ok = bool(f.ClearAudioClassification())
16943
- _rec.success = ok
16944
- return {"success": ok}
17061
+ result = _ai_result_payload(f.ClearAudioClassification())
17062
+ _rec.success = result["success"]
17063
+ return result
16945
17064
  elif action == "analyze_for_intellisearch":
16946
17065
  missing = _requires_method(f, "AnalyzeForIntellisearch", "21.0")
16947
17066
  if missing:
@@ -16949,9 +17068,9 @@ def folder(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
16949
17068
  identify_faces = bool(_first_param(p, "identify_faces", "identifyFaces", default=False))
16950
17069
  is_better_mode = bool(_first_param(p, "is_better_mode", "isBetterMode", default=False))
16951
17070
  with _ai_ledger_timed("analyze_for_intellisearch") as _rec:
16952
- ok = bool(f.AnalyzeForIntellisearch(identify_faces, is_better_mode))
16953
- _rec.success = ok
16954
- return {"success": ok}
17071
+ result = _ai_result_payload(f.AnalyzeForIntellisearch(identify_faces, is_better_mode))
17072
+ _rec.success = result["success"]
17073
+ return result
16955
17074
  elif action == "analyze_for_slate":
16956
17075
  missing = _requires_method(f, "AnalyzeForSlate", "21.0")
16957
17076
  if missing:
@@ -16960,9 +17079,9 @@ def folder(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
16960
17079
  if marker_color not in _MARKER_COLORS:
16961
17080
  return _err(f"Invalid marker_color {marker_color!r}. Valid colors: {', '.join(_MARKER_COLORS)}")
16962
17081
  with _ai_ledger_timed("analyze_for_slate") as _rec:
16963
- ok = bool(f.AnalyzeForSlate(marker_color))
16964
- _rec.success = ok
16965
- return {"success": ok}
17082
+ result = _ai_result_payload(f.AnalyzeForSlate(marker_color))
17083
+ _rec.success = result["success"]
17084
+ return result
16966
17085
  elif action == "remove_motion_blur":
16967
17086
  missing = _requires_method(f, "RemoveMotionBlur", "21.0")
16968
17087
  if missing:
@@ -16987,11 +17106,18 @@ def folder(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
16987
17106
  if blocked:
16988
17107
  return blocked
16989
17108
  with _ai_ledger_timed("remove_motion_blur") as _rec:
17109
+ # RemoveMotionBlur needs the AI Motion Deblur Extra, so it belongs to
17110
+ # the same family as the methods above: absent the pack, the return
17111
+ # can be an error STRING rather than the documented list. Iterating a
17112
+ # string yields characters, the pair-unpack raises, `except Exception`
17113
+ # swallows it, and the action reported success:true with created:[]
17114
+ # — a silent lie in the confirm-gated path that renders new media.
16990
17115
  result = f.RemoveMotionBlur(deblur)
16991
- _rec.success = bool(result)
17116
+ ok, message = _ai_result(result)
17117
+ _rec.success = ok
16992
17118
  created = []
16993
17119
  total_bytes = 0
16994
- for pair in (result or []):
17120
+ for pair in (result or []) if ok else []:
16995
17121
  try:
16996
17122
  orig, new = pair
16997
17123
  path, nbytes = _clip_file_size(new)
@@ -17005,7 +17131,10 @@ def folder(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
17005
17131
  if created:
17006
17132
  _rec.output_path = created[0].get("output_path")
17007
17133
  _rec.output_bytes = total_bytes or None
17008
- return {"success": bool(result), "created": created}
17134
+ payload = {"success": ok, "created": created}
17135
+ if message:
17136
+ payload["error"] = message
17137
+ return payload
17009
17138
  return _unknown(action, ["get_clips","get_name","get_subfolders","is_stale","get_unique_id","export","transcribe_audio","clear_transcription","perform_audio_classification","clear_audio_classification","analyze_for_intellisearch","analyze_for_slate","remove_motion_blur"])
17010
17139
 
17011
17140
 
@@ -17329,17 +17458,17 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
17329
17458
  if missing:
17330
17459
  return missing
17331
17460
  with _ai_ledger_timed("perform_audio_classification", clip_id=p.get("clip_id")) as _rec:
17332
- ok = bool(clip.PerformAudioClassification())
17333
- _rec.success = ok
17334
- return {"success": ok}
17461
+ result = _ai_result_payload(clip.PerformAudioClassification())
17462
+ _rec.success = result["success"]
17463
+ return result
17335
17464
  elif action == "clear_audio_classification":
17336
17465
  missing = _requires_method(clip, "ClearAudioClassification", "21.0")
17337
17466
  if missing:
17338
17467
  return missing
17339
17468
  with _ai_ledger_timed("clear_audio_classification", clip_id=p.get("clip_id")) as _rec:
17340
- ok = bool(clip.ClearAudioClassification())
17341
- _rec.success = ok
17342
- return {"success": ok}
17469
+ result = _ai_result_payload(clip.ClearAudioClassification())
17470
+ _rec.success = result["success"]
17471
+ return result
17343
17472
  elif action == "analyze_for_intellisearch":
17344
17473
  missing = _requires_method(clip, "AnalyzeForIntellisearch", "21.0")
17345
17474
  if missing:
@@ -17347,9 +17476,9 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
17347
17476
  identify_faces = bool(_first_param(p, "identify_faces", "identifyFaces", default=False))
17348
17477
  is_better_mode = bool(_first_param(p, "is_better_mode", "isBetterMode", default=False))
17349
17478
  with _ai_ledger_timed("analyze_for_intellisearch", clip_id=p.get("clip_id")) as _rec:
17350
- ok = bool(clip.AnalyzeForIntellisearch(identify_faces, is_better_mode))
17351
- _rec.success = ok
17352
- return {"success": ok}
17479
+ result = _ai_result_payload(clip.AnalyzeForIntellisearch(identify_faces, is_better_mode))
17480
+ _rec.success = result["success"]
17481
+ return result
17353
17482
  elif action == "analyze_for_slate":
17354
17483
  missing = _requires_method(clip, "AnalyzeForSlate", "21.0")
17355
17484
  if missing:
@@ -17358,9 +17487,9 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
17358
17487
  if marker_color not in _MARKER_COLORS:
17359
17488
  return _err(f"Invalid marker_color {marker_color!r}. Valid colors: {', '.join(_MARKER_COLORS)}")
17360
17489
  with _ai_ledger_timed("analyze_for_slate", clip_id=p.get("clip_id")) as _rec:
17361
- ok = bool(clip.AnalyzeForSlate(marker_color))
17362
- _rec.success = ok
17363
- return {"success": ok}
17490
+ result = _ai_result_payload(clip.AnalyzeForSlate(marker_color))
17491
+ _rec.success = result["success"]
17492
+ return result
17364
17493
  elif action == "remove_motion_blur":
17365
17494
  missing = _requires_method(clip, "RemoveMotionBlur", "21.0")
17366
17495
  if missing:
@@ -17385,14 +17514,19 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
17385
17514
  if blocked:
17386
17515
  return blocked
17387
17516
  with _ai_ledger_timed("remove_motion_blur", clip_id=p.get("clip_id")) as _rec:
17517
+ # Same shape as generate_speech: a MediaPoolItem return, and an error
17518
+ # STRING when the AI Motion Deblur Extra is absent. `_clip_file_size`
17519
+ # swallows its own AttributeError, so the string survived to
17520
+ # `.GetName()` and raised there instead.
17388
17521
  new_clip = clip.RemoveMotionBlur(deblur)
17389
- _rec.success = bool(new_clip)
17390
- if new_clip:
17522
+ ok, message = _ai_result(new_clip)
17523
+ _rec.success = ok
17524
+ if ok:
17391
17525
  path, nbytes = _clip_file_size(new_clip)
17392
17526
  _rec.output_path = path
17393
17527
  _rec.output_bytes = nbytes
17394
- if not new_clip:
17395
- return {"success": False}
17528
+ if not ok:
17529
+ return {"success": False, "error": message} if message else {"success": False}
17396
17530
  return {"success": True, "new": new_clip.GetName(), "new_id": new_clip.GetUniqueId(),
17397
17531
  "output_path": _rec.output_path, "output_bytes": _rec.output_bytes}
17398
17532
  elif action == "get_audio_mapping":
@@ -20773,7 +20907,7 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
20773
20907
  blocked = _consume_confirm_token(action="timeline.delete_clips_ripple", params=p)
20774
20908
  if blocked:
20775
20909
  return blocked
20776
- return {"success": bool(tl.DeleteClips(found, ripple))}
20910
+ return {"success": _timeline_delete_clips_verified(tl, found, ripple)}
20777
20911
  elif action == "set_clips_linked":
20778
20912
  ids_set = set(p["clip_ids"])
20779
20913
  found = []
@@ -26,7 +26,7 @@ When you add or change a ``submit``-tagged entry, regenerate the report
26
26
  """
27
27
  from typing import Any, Dict, List, Optional
28
28
 
29
- VERIFIED_ON = "DaVinci Resolve Studio 21.0.0"
29
+ VERIFIED_ON = "DaVinci Resolve Studio 21.0.2"
30
30
 
31
31
  # Each entry: symbol, object, reality, recommended, tags. `signature` optional.
32
32
  API_TRUTH: List[Dict[str, Any]] = [
@@ -717,18 +717,143 @@ API_TRUTH: List[Dict[str, Any]] = [
717
717
  {
718
718
  "symbol": "hasattr() / getattr() on Resolve API objects (attribute fabrication)",
719
719
  "object": "(all Resolve scripting objects)",
720
- "reality": "The Python bridge returns a callable for ANY attribute name, so "
721
- "hasattr(obj, 'TotallyMadeUpMethod') is always True and getattr "
722
- "never raises. This makes capability detection by hasattr "
723
- "impossible — verified on 21.0.0 (hasattr reported SetStart, "
724
- "Razor, AddNode, GenerateProxy, AddSmartBin etc. as present "
725
- "though none exist). Only dir() lists the real methods.",
726
- "recommended": "Never probe method existence with hasattr/getattr; test "
727
- "membership against dir(obj) instead. Calling a fabricated "
728
- "method typically returns None/False with no error.",
720
+ "reality": "UNRESOLVED — the two measurements do not test the same thing. "
721
+ "On 21.0.0 the bridge was recorded as returning a callable for "
722
+ "ANY attribute name, making capability detection by hasattr "
723
+ "impossible; the evidence was REAL API method names borrowed from "
724
+ "other object types (SetStart, Razor, AddNode, GenerateProxy, "
725
+ "AddSmartBin reported present on objects that do not have them). "
726
+ "A 21.0.2.4 control probe of the invented name "
727
+ "'TotallyMadeUpMethod_xyz123' returned getattr-callable False on "
728
+ "all eight object types, matching dir() in every case. That does "
729
+ "NOT refute the 21.0.0 record: if the bridge resolves any name "
730
+ "known to the RemoteObject method table rather than literally any "
731
+ "string, an invented name is correctly rejected on both builds and "
732
+ "the probe never exercised the failing case. Re-running the probe "
733
+ "with those five real names is what would settle it; until then, "
734
+ "assume fabrication is possible.",
735
+ "recommended": "Use dir(obj) membership for capability probes. It is correct "
736
+ "on every build measured, and it is the only form not affected "
737
+ "by whichever way this resolves. server._has_method uses "
738
+ "hasattr/getattr and so may over-report on builds where "
739
+ "fabrication is live — that is the case _requires_method gates "
740
+ "guard, so it matters most exactly where it is least tested. "
741
+ "Calling a fabricated method typically returns None/False with "
742
+ "no error.",
729
743
  "tags": ["bridge", "introspection", "silent-failure"],
730
744
  "submit": "bug",
731
745
  },
746
+ {
747
+ "symbol": "Resolve 21 AI methods (AnalyzeForIntellisearch, GenerateSpeech, "
748
+ "AnalyzeForSlate) — inconsistent failure return type",
749
+ "object": "MediaPoolItem / Folder / Project",
750
+ "signature": "-> Bool (documented)",
751
+ "reality": "When the required Extras pack is not installed, these methods do "
752
+ "not agree on how they say so, and the documented Bool is not what "
753
+ "you get. Verified live on Studio 21.0.2.4 with only AI Motion "
754
+ "Deblur installed: AnalyzeForSlate returned False, but "
755
+ "AnalyzeForIntellisearch returned the STRING \"Required package 'AI "
756
+ "Intellisearch - Faster' is not installed.\" and GenerateSpeech "
757
+ "returned the STRING \"Required Package, 'AI Speech Generator' is "
758
+ "not Installed.\". A non-empty string is truthy in Python, so "
759
+ "bool(result) reports SUCCESS for a call that definitively did not "
760
+ "run, and treating GenerateSpeech's return as a MediaPoolItem "
761
+ "raises AttributeError: 'str' object has no attribute 'GetName'.",
762
+ "recommended": "Never bool() an AI-method return directly. Route it through "
763
+ "server._ai_result / _ai_result_payload, which treat any string "
764
+ "as a failure and surface its text as the error — the message is "
765
+ "the only machine-readable signal that an Extras pack is "
766
+ "missing, since there is no scripting API to enumerate "
767
+ "installed Extras.",
768
+ "tags": ["ai", "extras", "unreliable-return", "silent-failure", "resolve-21"],
769
+ "submit": "bug",
770
+ "mitigation": ["_ai_result", "_ai_result_payload"],
771
+ },
772
+ {
773
+ "symbol": "Installed AI Extras packs are not discoverable from scripting",
774
+ "object": "Resolve",
775
+ "reality": "AnalyzeForIntellisearch, AnalyzeForSlate, GenerateSpeech and "
776
+ "RemoveMotionBlur each require a separately-downloaded Extras pack, "
777
+ "but nothing in the scripting API reports which packs are installed. "
778
+ "A caller cannot distinguish 'the Extra is missing' from 'the "
779
+ "analysis ran and found nothing' ahead of time; on 21.0.2.4 two of "
780
+ "the four leak the reason only as free text in the return value, and "
781
+ "AnalyzeForSlate's bare False carries no reason at all.",
782
+ "recommended": "Until an API exists, treat a string return as the reason and "
783
+ "read the pack names out of the Extras directory "
784
+ "(Blackmagic Design/DaVinci Resolve/Extras/*/log.dpl1) for "
785
+ "diagnostics only — that path is undocumented and may change.",
786
+ "tags": ["ai", "extras", "introspection", "resolve-21"],
787
+ "submit": "missing",
788
+ },
789
+ {
790
+ "symbol": "Folder.AnalyzeForSlate / MediaPoolItem.AnalyzeForSlate markerColor",
791
+ "object": "MediaPoolItem / Folder",
792
+ "signature": "(markerColor) -> Bool",
793
+ "reality": "The shipped 21.0.2 scripting README says markerColor must be one of "
794
+ "the resolve.MARKER_* constants (resolve.MARKER_BLUE etc.). Those "
795
+ "constants do not exist: on Studio 21.0.2.4, "
796
+ "[c for c in dir(resolve) if c.startswith('MARKER_')] is empty. "
797
+ "There is therefore no documented-correct way to call this method. "
798
+ "The plain colour string the server passes is the only option "
799
+ "available, and it returns False here — though with AI Slate ID "
800
+ "absent, a string-rejection bug cannot be distinguished from the "
801
+ "missing pack on this machine.",
802
+ "recommended": "Keep passing the plain colour name (server._MARKER_COLORS) — "
803
+ "the documented constants are unavailable. Re-test on a machine "
804
+ "with the AI Slate ID Extra installed before concluding the "
805
+ "string form is rejected.",
806
+ # Deliberately NOT tagged `enum`: that tag denotes the issue-#70 class,
807
+ # where plain strings are rejected and a resolver must translate them
808
+ # into live enum constants. Here the documented constants do not exist
809
+ # on the handle at all, so there is nothing to resolve — the defect is
810
+ # in the documentation, not in a missing resolver.
811
+ "tags": ["ai", "extras", "missing-constant", "documentation", "resolve-21"],
812
+ "submit": "bug",
813
+ },
814
+ {
815
+ "symbol": "Project.ResetIntellisearchAnalysis",
816
+ "object": "Project",
817
+ "signature": "() -> Bool",
818
+ "reality": "Documented in the scripting README shipped with Resolve 21.0.2 "
819
+ "(dated 26 May 2026) but absent from the 5 May 2026 copy the repo "
820
+ "bundled, so it was missing from the coverage tables. Present in "
821
+ "dir(project) and returns True on Studio 21.0.2.4.",
822
+ "recommended": "Exposed as project_settings('reset_intellisearch_analysis').",
823
+ "tags": ["resolve-21", "documentation"],
824
+ },
825
+ {
826
+ "symbol": "Resolve.DisableBackgroundTasksForCurrentResolveSession",
827
+ "object": "Resolve",
828
+ "signature": "() -> None",
829
+ "reality": "Returns None, so a caller cannot tell whether it took effect, and "
830
+ "there is no Enable... counterpart anywhere in the shipped 21.0.2 "
831
+ "scripting README — the only documented way back is restarting "
832
+ "Resolve. The scope is the whole session, so a script disables "
833
+ "background tasks for every project open in that instance, not just "
834
+ "its own. Present in dir(resolve) on Studio 21.0.2.4; deliberately "
835
+ "not executed during validation for exactly that reason.",
836
+ "recommended": "Treat as irreversible within a session. server returns _ok() "
837
+ "unconditionally because there is nothing to check.",
838
+ "tags": ["resolve-21", "unreliable-return", "irreversible", "session-wide"],
839
+ "submit": "missing",
840
+ },
841
+ {
842
+ "symbol": "MediaPoolItem.PerformAudioClassification / ClearAudioClassification",
843
+ "object": "MediaPoolItem / Folder",
844
+ "signature": "() -> Bool",
845
+ "reality": "Both work without any Extras pack and the effect is observable, "
846
+ "which is unusual for this family. Verified on Studio 21.0.2.4 "
847
+ "against a synthetic speech clip: PerformAudioClassification "
848
+ "returned True and set the clip property 'Category' from '' to "
849
+ "'Dialogue' (also surfacing Category/Subcategory in GetMetadata); "
850
+ "ClearAudioClassification returned True and reset 'Category' to "
851
+ "'Uncategorized' — note the cleared state is 'Uncategorized', NOT "
852
+ "the original empty string.",
853
+ "recommended": "Read back GetClipProperty('Category'); treat both '' and "
854
+ "'Uncategorized' as unclassified.",
855
+ "tags": ["ai", "audio", "resolve-21", "readback"],
856
+ },
732
857
  {
733
858
  "symbol": "subprocess inheriting stdin under the MCP stdio server",
734
859
  "object": "(server runtime)",
@@ -807,6 +932,81 @@ API_TRUTH: List[Dict[str, Any]] = [
807
932
  "when mirroring keep-ranges into clipInfos.",
808
933
  "tags": ["timeline", "edit", "off-by-one", "readback"],
809
934
  },
935
+ {
936
+ "symbol": "Timeline.DeleteClips (flaky first attempt)",
937
+ "object": "Timeline",
938
+ "signature": "([TimelineItem], ripple) -> bool",
939
+ "reality": "Can return False on the first call even when every item in "
940
+ "the list is a valid, present TimelineItem; an identical "
941
+ "immediate retry succeeds. Observed once, on Studio 21.0 "
942
+ "during a cut-video edit session (items confirmed still "
943
+ "present after the False, deleted cleanly on retry). Cause "
944
+ "unknown — do NOT read this as the ProjectManager."
945
+ "DeleteProject shape: that one has an identified mechanism "
946
+ "(the project being, or recently having been, current) that "
947
+ "retrying does not clear, whereas a single retry cleared "
948
+ "this in the one instance seen. One observation is not a "
949
+ "mechanism; if a retry is ever seen to fail repeatedly here, "
950
+ "this entry needs revisiting.",
951
+ "recommended": "Treat a False return as advisory: re-list the track and "
952
+ "check whether the items are actually gone; if still "
953
+ "present, retry the identical call once before failing. "
954
+ "A readback that raised, enumerated nothing, or covered "
955
+ "items whose unique ID cannot be read is UNKNOWN, not "
956
+ "gone — never report an unverifiable delete as success, "
957
+ "and do not spend a second destructive call on an "
958
+ "outcome you equally cannot read.",
959
+ "tags": ["unreliable-return", "flaky", "timeline", "edit"],
960
+ "submit": "bug",
961
+ "mitigation": ["_timeline_delete_clips_verified", "_timeline_items_presence"],
962
+ },
963
+ {
964
+ "symbol": "Timeline.DeleteClips (linked audio not deleted)",
965
+ "object": "Timeline",
966
+ "signature": "([TimelineItem], ripple) -> bool",
967
+ "reality": "Deleting video items does NOT delete their linked audio "
968
+ "items — the UI's linked-selection behavior does not apply "
969
+ "to the API, which deletes exactly the items passed. The "
970
+ "orphaned audio stays on its track and collides with any "
971
+ "later append into the same record range.",
972
+ "recommended": "When deleting a video item that has linked audio, list "
973
+ "the audio track(s) (timeline get_items, track_type "
974
+ "'audio'), find the overlapping linked items, and pass "
975
+ "their IDs in the same delete. Verify with "
976
+ "detect_gaps_overlaps across both track types.",
977
+ "tags": ["timeline", "edit", "audio", "silent-failure"],
978
+ },
979
+ {
980
+ "symbol": "MediaPool.AppendToTimeline with mixed-fps sources (duration floor)",
981
+ "object": "MediaPool",
982
+ "signature": "([{mediaPoolItem, startFrame, endFrame, recordFrame, ...}]) -> [TimelineItem]",
983
+ "reality": "start/endFrame are in SOURCE frames. When the source fps "
984
+ "differs from the timeline fps (e.g. 24.0 or 29.97 source in "
985
+ "a 23.976 timeline), Resolve converts the source range to "
986
+ "timeline frames by flooring — so a range planned to fill an "
987
+ "exact record slot lands one frame short, leaving a 1-frame "
988
+ "gap before the next clip.",
989
+ "recommended": "Plan durations in timeline frames "
990
+ "(floor(src_frames * timeline_fps / source_fps)); if the "
991
+ "floored duration misses the slot, extend endFrame by a "
992
+ "source frame and re-check. Always finish with "
993
+ "detect_gaps_overlaps.",
994
+ "tags": ["timeline", "edit", "off-by-one", "mixed-fps"],
995
+ "submit": "bug",
996
+ },
997
+ {
998
+ "symbol": "MediaPool.ImportMedia (current-folder destination only)",
999
+ "object": "MediaPool",
1000
+ "signature": "([paths] | [clipInfos]) -> [MediaPoolItem]",
1001
+ "reality": "Imports always land in the CURRENT media pool folder; the "
1002
+ "call has no destination-folder parameter, and passing an "
1003
+ "unrecognized one to the MCP tool is silently ignored.",
1004
+ "recommended": "SetCurrentFolder to the target bin first (media_pool "
1005
+ "set_current_folder), import, then restore the previous "
1006
+ "current folder if it matters.",
1007
+ "tags": ["media-pool", "import"],
1008
+ "submit": "missing",
1009
+ },
810
1010
  {
811
1011
  "symbol": "Graph.SetLUT (master-LUT-dir-only resolution)",
812
1012
  "object": "Graph",