davinci-resolve-mcp 2.88.0 → 2.89.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/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.89.0
6
+
7
+ The build gates this server already enforced are now gates an agent can ask
8
+ about, and the capability probe those gates run on no longer lies. Issue #132,
9
+ reported by @magwa101.
10
+
11
+ ### Added
12
+
13
+ - **`get_version` reports what the connected build is missing.** A new `build`
14
+ block carries `unavailable_on_this_build` — every recorded API surface this
15
+ build does not have — plus `known_gates` and the caveat that an absence from
16
+ that list is not a promise a method exists. #132 happened in a session that
17
+ opened with `get_version` and was told a number and nothing the number ruled
18
+ out. `get_resolve_version_fields` gains the same list on the granular server.
19
+ - **The version-gate registry went from 7 recorded surfaces to 41.**
20
+ `_requires_method(obj, "GetLayoutPresetList", "21.0.4")` appears 44 times
21
+ across the two servers and gates 31 distinct symbols, but
22
+ `check_version_support` — the call `resolve-session` step 2 tells an agent to
23
+ make — answered from a ledger that knew seven of them. So the one question an
24
+ agent is instructed to ask returned `unknown` for surfaces this server was
25
+ already routing on. On Studio 19.1.3.7 the session preflight named 7 missing
26
+ surfaces; it now names 40. The new floors are labelled `documented` rather
27
+ than `measured`: they come from Blackmagic's release documentation, not a live
28
+ bisect here, and an agent relaying one should be able to say which.
29
+ - **`tests/test_version_gate_drift.py`** fails when a call site and the ledger
30
+ disagree, and when a bare method name is gated at two different builds on two
31
+ different classes (which would make a bare-name lookup a coin flip). A table
32
+ copied by hand is exactly what drifts back apart.
33
+ - **`src/utils/resolve_probe.py`** — `has_method` and `api_constant`, with the
34
+ measurement behind them. A companion guard fails the suite on a bare `hasattr`
35
+ with a Resolve-shaped attribute name.
36
+
37
+ ### Fixed
38
+
39
+ - **29 capability probes across `src/` used `hasattr` on Resolve API objects,
40
+ where it is a constant `True`** (measured on Studio 19.1.3.7 across 42 checks,
41
+ recorded in `api_truth`; re-confirmed live for this release —
42
+ `hasattr(project, 'GenerateSpeech')` returns `True` on a build that has no
43
+ such method). It failed in two shapes that look nothing alike:
44
+ - `if not hasattr(clip, "RemoveMotionBlur")` is a dead branch, so the
45
+ "requires Resolve 21+" refusal never fired and the call below it raised
46
+ `AttributeError` on an older build. Eleven of these were the granular
47
+ server's Resolve 21 AI guards.
48
+ - `getattr(r, n) if hasattr(r, n) else n` never reaches its `else`, so a build
49
+ without the constant got `None` where the author wrote a string fallback —
50
+ no exception, no refusal, just a `None` travelling on into `Export()`. This
51
+ reached granular timeline export and `ExportLUT`, and three constant
52
+ lookups on the compound server. Confirmed live: a name Resolve does not
53
+ define returned `None` under the old form and now falls back correctly.
54
+ The fallback keys on `is None`, not truthiness, because `EXPORT_AAF` is
55
+ genuinely `0.0`.
56
+ - **The granular AI tools reported a missing Extras pack as success.** Resolve's
57
+ AI methods return the reason as a *string* when the pack is absent, and
58
+ `bool("Required package ... is not installed.")` is `True`. The compound
59
+ server has normalized that since the 21.0.2.4 measurement; the granular one
60
+ had not, so folder and clip audio classification returned `{"success": true}`
61
+ for a call that did not run, and `RemoveMotionBlur` / `GenerateSpeech` walked
62
+ a string into `.GetName()`. All now route through `_ai_result`, which reports
63
+ the failure and carries Resolve's own reason.
64
+ - **`Project.ApplyFairlightPresetToCurrentTimeline` was recorded on `Timeline`.**
65
+ The shipped README lists it under Project and the server calls it there; the
66
+ method's name is what made the wrong attribution look right.
67
+
68
+ ### Validation
69
+
70
+ Suite 2560 → 2580. Live-checked against the running Studio 19.1.3.7 for the
71
+ probe semantics, the refusal path, the constant fallback, and the `get_version`
72
+ preflight (40 of 41 gates unavailable, as expected on that build). The Resolve 21
73
+ and 21.0.4 surfaces themselves remain untested here — this machine cannot run
74
+ them — and the Extras-pack failure paths are pinned by a stub that reproduces
75
+ the measured attribute-fabrication behaviour rather than by a live 21 build.
76
+
5
77
  ## What's New in v2.88.0
6
78
 
7
79
  The twelve Resolve 21.0.4 surfaces that only existed on the compound server now
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-2.88.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.89.0-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-34%20(353%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-2.88.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.89.0-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-34%20(353%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
- > 本翻译对应 v2.88.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v2.89.0 版 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
@@ -489,7 +489,16 @@ you are on the correct page first.
489
489
  Key actions:
490
490
  - `launch` — connect to or start Resolve; call this first if any tool returns a
491
491
  "Not connected" error
492
- - `get_version` — returns `{product, version, version_string}`
492
+ - `get_version` — returns `{product, version, version_string, build, mcp}`.
493
+ `build.unavailable_on_this_build` lists every recorded API surface this build
494
+ does **not** have; read it before offering anything version-gated. An absence
495
+ from that list is not a promise a method exists — most of the API has never
496
+ been version-bisected, so `check_version_support` answers `unknown` for it,
497
+ and `unknown` means probe with `name in dir(obj)`, never bare `hasattr`
498
+ (constant `True` on Resolve objects)
499
+ - `check_version_support(symbol?, resolve_version?)` — is one named symbol on
500
+ this build? Without `symbol`, the same missing-surface list `get_version`
501
+ carries. No connection needed when `resolve_version` is passed
493
502
  - `api_truth(query?)` — look up behaviorally-verified facts about quirky/unreliable
494
503
  Resolve API behavior (no connection needed); filter by substring
495
504
  - `verification_stats` — readback-verification tally (verified/contradicted/
package/install.py CHANGED
@@ -36,7 +36,7 @@ from src.utils.update_check import (
36
36
 
37
37
  # ─── Version ──────────────────────────────────────────────────────────────────
38
38
 
39
- VERSION = "2.88.0"
39
+ VERSION = "2.89.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.88.0",
3
+ "version": "2.89.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -45,6 +45,7 @@ from src.utils.media_analysis_jobs import (
45
45
  )
46
46
  from src.utils.platform import setup_environment
47
47
  from src.utils.resolve_connection import connect_resolve
48
+ from src.utils.resolve_probe import has_method
48
49
  from src.utils.analysis_memory import read_panel_state, write_panel_state
49
50
  from src.utils import brain_edits as _brain_edits
50
51
  from src.utils import timeline_versioning as _timeline_versioning
@@ -11917,7 +11918,7 @@ HTML = HTML.replace("/* CONTROL_PANEL_I18N */", localization_script())
11917
11918
 
11918
11919
 
11919
11920
  def _safe_call(obj: Any, method_name: str, *args: Any) -> Tuple[Any, Optional[str]]:
11920
- if obj is None or not hasattr(obj, method_name):
11921
+ if not has_method(obj, method_name):
11921
11922
  return None, f"{method_name} unavailable"
11922
11923
  try:
11923
11924
  return getattr(obj, method_name)(*args), None
@@ -49,6 +49,8 @@ from src.utils.render_ids import (
49
49
  render_format_id_from_formats,
50
50
  )
51
51
  from src.utils.resolve_connection import connect_resolve
52
+ from src.utils.resolve_probe import api_constant as _api_constant, has_method
53
+ from src.utils.resolve_versions import availability, gates_unavailable_on
52
54
  from src.utils.project_properties import (
53
55
  get_all_project_properties,
54
56
  get_color_settings,
@@ -85,7 +87,7 @@ if not logging.getLogger().handlers:
85
87
  handlers=[logging.StreamHandler()],
86
88
  )
87
89
 
88
- VERSION = "2.88.0"
90
+ VERSION = "2.89.0"
89
91
  logger = logging.getLogger("davinci-resolve-mcp")
90
92
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
91
93
  logger.info(f"Detected platform: {get_platform()}")
@@ -741,11 +743,43 @@ def _get_timeline_item(track_type="video", track_index=1, item_index=0):
741
743
  return items[item_index], None
742
744
 
743
745
  def _has_method(obj, method_name):
744
- return callable(getattr(obj, method_name, None))
746
+ # `hasattr` is a constant True on Resolve objects — see src/utils/resolve_probe.
747
+ return has_method(obj, method_name)
745
748
 
746
749
  def _requires_method(obj, method_name, min_version):
747
750
  if _has_method(obj, method_name):
748
751
  return None
749
752
  return {"error": f"{method_name} requires DaVinci Resolve {min_version}+"}
750
753
 
754
+ def _ai_result(returned):
755
+ """Normalize a Resolve 21 AI-method return into (ok, message).
756
+
757
+ The AI methods do not agree on how they report a missing Extras pack, and
758
+ one of the two shapes is a trap. Verified live on Studio 21.0.2.4 with only
759
+ AI Motion Deblur installed:
760
+
761
+ - `AnalyzeForSlate` -> False
762
+ - `AnalyzeForIntellisearch` -> "Required package 'AI Intellisearch -
763
+ Faster' is not installed."
764
+ - `GenerateSpeech` -> "Required Package, 'AI Speech Generator' is not
765
+ Installed."
766
+
767
+ A non-empty string is truthy, so `bool(returned)` reports success for a call
768
+ that definitively did not run, and treating the return as a MediaPoolItem
769
+ raises AttributeError. This is the compound server's `_ai_result`, kept
770
+ identical on purpose: the granular tools called the same AI methods with a
771
+ bare `bool()` and inherited the whole trap.
772
+ """
773
+ if isinstance(returned, str):
774
+ return False, returned.strip() or None
775
+ return bool(returned), None
776
+
777
+ def _ai_result_payload(returned):
778
+ """`{"success": ...}` plus the Resolve-supplied reason when there is one."""
779
+ ok, message = _ai_result(returned)
780
+ payload = {"success": ok}
781
+ if message:
782
+ payload["error"] = message
783
+ return payload
784
+
751
785
  __all__ = [name for name in globals() if not name.startswith("__")]
@@ -325,9 +325,10 @@ def folder_perform_audio_classification(folder_path: str = "") -> Dict[str, Any]
325
325
  folder, err = _resolve_folder(mp, folder_path)
326
326
  if err:
327
327
  return err
328
- if not hasattr(folder, "PerformAudioClassification"):
329
- return {"error": "PerformAudioClassification requires DaVinci Resolve 21+"}
330
- return {"success": bool(folder.PerformAudioClassification())}
328
+ missing = _requires_method(folder, "PerformAudioClassification", "21.0")
329
+ if missing:
330
+ return missing
331
+ return _ai_result_payload(folder.PerformAudioClassification())
331
332
 
332
333
 
333
334
  @mcp.tool()
@@ -343,9 +344,10 @@ def folder_clear_audio_classification(folder_path: str = "") -> Dict[str, Any]:
343
344
  folder, err = _resolve_folder(mp, folder_path)
344
345
  if err:
345
346
  return err
346
- if not hasattr(folder, "ClearAudioClassification"):
347
- return {"error": "ClearAudioClassification requires DaVinci Resolve 21+"}
348
- return {"success": bool(folder.ClearAudioClassification())}
347
+ missing = _requires_method(folder, "ClearAudioClassification", "21.0")
348
+ if missing:
349
+ return missing
350
+ return _ai_result_payload(folder.ClearAudioClassification())
349
351
 
350
352
 
351
353
  @mcp.tool()
@@ -363,9 +365,10 @@ def folder_analyze_for_intellisearch(folder_path: str = "", identify_faces: bool
363
365
  folder, err = _resolve_folder(mp, folder_path)
364
366
  if err:
365
367
  return err
366
- if not hasattr(folder, "AnalyzeForIntellisearch"):
367
- return {"error": "AnalyzeForIntellisearch requires DaVinci Resolve 21+"}
368
- return {"success": bool(folder.AnalyzeForIntellisearch(bool(identify_faces), bool(is_better_mode)))}
368
+ missing = _requires_method(folder, "AnalyzeForIntellisearch", "21.0")
369
+ if missing:
370
+ return missing
371
+ return _ai_result_payload(folder.AnalyzeForIntellisearch(bool(identify_faces), bool(is_better_mode)))
369
372
 
370
373
 
371
374
  @mcp.tool()
@@ -383,11 +386,12 @@ def folder_analyze_for_slate(folder_path: str = "", marker_color: str = "Blue")
383
386
  folder, err = _resolve_folder(mp, folder_path)
384
387
  if err:
385
388
  return err
386
- if not hasattr(folder, "AnalyzeForSlate"):
387
- return {"error": "AnalyzeForSlate requires DaVinci Resolve 21+"}
389
+ missing = _requires_method(folder, "AnalyzeForSlate", "21.0")
390
+ if missing:
391
+ return missing
388
392
  if marker_color not in _MARKER_COLORS:
389
393
  return {"error": f"Invalid marker_color '{marker_color}'. Valid: {', '.join(_MARKER_COLORS)}"}
390
- return {"success": bool(folder.AnalyzeForSlate(marker_color))}
394
+ return _ai_result_payload(folder.AnalyzeForSlate(marker_color))
391
395
 
392
396
 
393
397
  @mcp.tool()
@@ -407,9 +411,16 @@ def folder_remove_motion_blur(folder_path: str = "", deblur_option: Optional[Dic
407
411
  folder, err = _resolve_folder(mp, folder_path)
408
412
  if err:
409
413
  return err
410
- if not hasattr(folder, "RemoveMotionBlur"):
411
- return {"error": "RemoveMotionBlur requires DaVinci Resolve 21+"}
414
+ missing = _requires_method(folder, "RemoveMotionBlur", "21.0")
415
+ if missing:
416
+ return missing
412
417
  result = folder.RemoveMotionBlur(deblur_option or {})
418
+ # A missing Extras pack comes back as an error STRING, which is truthy and
419
+ # also iterable — the loop below would walk it character by character and
420
+ # report success. Normalize before either.
421
+ ok, message = _ai_result(result)
422
+ if not ok:
423
+ return {"success": False, "error": message} if message else {"success": False}
413
424
  created = []
414
425
  for pair in (result or []):
415
426
  try:
@@ -417,4 +428,4 @@ def folder_remove_motion_blur(folder_path: str = "", deblur_option: Optional[Dic
417
428
  created.append({"original": orig.GetName(), "new": new.GetName(), "new_id": new.GetUniqueId()})
418
429
  except Exception:
419
430
  continue
420
- return {"success": bool(result), "created": created}
431
+ return {"success": True, "created": created}
@@ -17,13 +17,13 @@ def _invalidate_analysis_registry_for_clip(project, clip, *, reason: str) -> Opt
17
17
  if project is None or clip is None:
18
18
  return None
19
19
  try:
20
- project_name = project.GetName() if hasattr(project, "GetName") else None
21
- project_id = project.GetUniqueId() if hasattr(project, "GetUniqueId") else None
22
- clip_id = clip.GetUniqueId() if hasattr(clip, "GetUniqueId") else None
23
- media_id = clip.GetMediaId() if hasattr(clip, "GetMediaId") else None
20
+ project_name = project.GetName() if _has_method(project, "GetName") else None
21
+ project_id = project.GetUniqueId() if _has_method(project, "GetUniqueId") else None
22
+ clip_id = clip.GetUniqueId() if _has_method(clip, "GetUniqueId") else None
23
+ media_id = clip.GetMediaId() if _has_method(clip, "GetMediaId") else None
24
24
  source_file = None
25
25
  try:
26
- source_file = clip.GetClipProperty("File Path") if hasattr(clip, "GetClipProperty") else None
26
+ source_file = clip.GetClipProperty("File Path") if _has_method(clip, "GetClipProperty") else None
27
27
  except Exception:
28
28
  source_file = None
29
29
  return _mark_analysis_registry_stale(
@@ -930,9 +930,10 @@ def perform_clip_audio_classification(clip_id: str) -> Dict[str, Any]:
930
930
  clip = _find_clip_by_id(mp.GetRootFolder(), clip_id)
931
931
  if not clip:
932
932
  return {"error": f"Clip {clip_id} not found"}
933
- if not hasattr(clip, "PerformAudioClassification"):
934
- return {"error": "PerformAudioClassification requires DaVinci Resolve 21+"}
935
- return {"success": bool(clip.PerformAudioClassification())}
933
+ missing = _requires_method(clip, "PerformAudioClassification", "21.0")
934
+ if missing:
935
+ return missing
936
+ return _ai_result_payload(clip.PerformAudioClassification())
936
937
 
937
938
 
938
939
  @mcp.tool()
@@ -948,9 +949,10 @@ def clear_clip_audio_classification(clip_id: str) -> Dict[str, Any]:
948
949
  clip = _find_clip_by_id(mp.GetRootFolder(), clip_id)
949
950
  if not clip:
950
951
  return {"error": f"Clip {clip_id} not found"}
951
- if not hasattr(clip, "ClearAudioClassification"):
952
- return {"error": "ClearAudioClassification requires DaVinci Resolve 21+"}
953
- return {"success": bool(clip.ClearAudioClassification())}
952
+ missing = _requires_method(clip, "ClearAudioClassification", "21.0")
953
+ if missing:
954
+ return missing
955
+ return _ai_result_payload(clip.ClearAudioClassification())
954
956
 
955
957
 
956
958
  @mcp.tool()
@@ -968,9 +970,10 @@ def analyze_clip_for_intellisearch(clip_id: str, identify_faces: bool = False, i
968
970
  clip = _find_clip_by_id(mp.GetRootFolder(), clip_id)
969
971
  if not clip:
970
972
  return {"error": f"Clip {clip_id} not found"}
971
- if not hasattr(clip, "AnalyzeForIntellisearch"):
972
- return {"error": "AnalyzeForIntellisearch requires DaVinci Resolve 21+"}
973
- return {"success": bool(clip.AnalyzeForIntellisearch(bool(identify_faces), bool(is_better_mode)))}
973
+ missing = _requires_method(clip, "AnalyzeForIntellisearch", "21.0")
974
+ if missing:
975
+ return missing
976
+ return _ai_result_payload(clip.AnalyzeForIntellisearch(bool(identify_faces), bool(is_better_mode)))
974
977
 
975
978
 
976
979
  @mcp.tool()
@@ -988,11 +991,12 @@ def analyze_clip_for_slate(clip_id: str, marker_color: str = "Blue") -> Dict[str
988
991
  clip = _find_clip_by_id(mp.GetRootFolder(), clip_id)
989
992
  if not clip:
990
993
  return {"error": f"Clip {clip_id} not found"}
991
- if not hasattr(clip, "AnalyzeForSlate"):
992
- return {"error": "AnalyzeForSlate requires DaVinci Resolve 21+"}
994
+ missing = _requires_method(clip, "AnalyzeForSlate", "21.0")
995
+ if missing:
996
+ return missing
993
997
  if marker_color not in _MARKER_COLORS:
994
998
  return {"error": f"Invalid marker_color '{marker_color}'. Valid: {', '.join(_MARKER_COLORS)}"}
995
- return {"success": bool(clip.AnalyzeForSlate(marker_color))}
999
+ return _ai_result_payload(clip.AnalyzeForSlate(marker_color))
996
1000
 
997
1001
 
998
1002
  @mcp.tool()
@@ -1012,11 +1016,15 @@ def remove_clip_motion_blur(clip_id: str, deblur_option: Optional[Dict[str, Any]
1012
1016
  clip = _find_clip_by_id(mp.GetRootFolder(), clip_id)
1013
1017
  if not clip:
1014
1018
  return {"error": f"Clip {clip_id} not found"}
1015
- if not hasattr(clip, "RemoveMotionBlur"):
1016
- return {"error": "RemoveMotionBlur requires DaVinci Resolve 21+"}
1019
+ missing = _requires_method(clip, "RemoveMotionBlur", "21.0")
1020
+ if missing:
1021
+ return missing
1017
1022
  new_clip = clip.RemoveMotionBlur(deblur_option or {})
1018
- if not new_clip:
1019
- return {"success": False}
1023
+ # An error string is truthy, so a bare `if not new_clip` let a missing
1024
+ # Extras pack through to .GetName() and raised AttributeError.
1025
+ ok, message = _ai_result(new_clip)
1026
+ if not ok:
1027
+ return {"success": False, "error": message} if message else {"success": False}
1020
1028
  return {"success": True, "new": new_clip.GetName(), "new_id": new_clip.GetUniqueId()}
1021
1029
 
1022
1030
 
@@ -192,7 +192,7 @@ def save_project() -> str:
192
192
  try:
193
193
  # Method 1: Try direct save method if available
194
194
  try:
195
- if hasattr(current_project, "SaveProject"):
195
+ if _has_method(current_project, "SaveProject"):
196
196
  result = current_project.SaveProject()
197
197
  if result:
198
198
  logger.info(f"Project '{project_name}' saved using SaveProject method")
@@ -204,7 +204,7 @@ def save_project() -> str:
204
204
  # Method 2: Try project manager save method
205
205
  if not success:
206
206
  try:
207
- if hasattr(project_manager, "SaveProject"):
207
+ if _has_method(project_manager, "SaveProject"):
208
208
  result = project_manager.SaveProject()
209
209
  if result:
210
210
  logger.info(f"Project '{project_name}' saved using ProjectManager.SaveProject method")
@@ -1640,8 +1640,9 @@ def generate_speech(text_input: str, voice_model: str = "", timecode: str = "",
1640
1640
  pm, current_project = get_current_project()
1641
1641
  if not current_project:
1642
1642
  return {"error": "No project currently open"}
1643
- if not hasattr(current_project, "GenerateSpeech"):
1644
- return {"error": "GenerateSpeech requires DaVinci Resolve 21+ and the AI Speech Generator Extra"}
1643
+ missing = _requires_method(current_project, "GenerateSpeech", "21.0")
1644
+ if missing:
1645
+ return missing
1645
1646
  if not text_input:
1646
1647
  return {"error": "text_input is required"}
1647
1648
  settings: Dict[str, Any] = {"TextInput": text_input}
@@ -1664,8 +1665,12 @@ def generate_speech(text_input: str, voice_model: str = "", timecode: str = "",
1664
1665
  if audio_track is not None:
1665
1666
  settings["AudioTrack"] = audio_track
1666
1667
  new_item = current_project.GenerateSpeech(settings, timecode or "")
1667
- if not new_item:
1668
- return {"success": False, "error": "GenerateSpeech returned no media item"}
1668
+ # Without the AI Speech Generator Extra this returns the reason as a STRING,
1669
+ # which is truthy the old check passed it through to .GetName().
1670
+ ok, message = _ai_result(new_item)
1671
+ if not ok:
1672
+ return {"success": False,
1673
+ "error": message or "GenerateSpeech returned no media item"}
1669
1674
  return {"success": True, "new": new_item.GetName(), "new_id": new_item.GetUniqueId()}
1670
1675
 
1671
1676
 
@@ -186,10 +186,12 @@ def inspect_custom_object(object_path: str) -> Dict[str, Any]:
186
186
  return {"error": f"Method '{method_name}' not found or not callable"}
187
187
  else:
188
188
  # It's an attribute access
189
- if hasattr(obj, part):
190
- obj = getattr(obj, part)
191
- else:
189
+ # getattr, not hasattr: on a Resolve object hasattr is a
190
+ # constant True, so the not-found branch could never run.
191
+ value = getattr(obj, part, None)
192
+ if value is None:
192
193
  return {"error": f"Attribute '{part}' not found"}
194
+ obj = value
193
195
 
194
196
  # Inspect the object we've retrieved
195
197
  return inspect_object(obj)
@@ -399,7 +401,19 @@ def get_resolve_version_fields() -> Dict[str, Any]:
399
401
  return {"error": "Not connected to DaVinci Resolve"}
400
402
  version = resolve.GetVersion()
401
403
  if version:
402
- return {"major": version[0], "minor": version[1], "patch": version[2], "build": version[3], "suffix": version[4] if len(version) > 4 else ""}
404
+ # Carry what this build is MISSING, not just what it is. An agent that
405
+ # only learns the number still has to know which surfaces that number
406
+ # rules out, and guessing that is issue #132.
407
+ missing = gates_unavailable_on(resolve.GetVersionString())
408
+ return {"major": version[0], "minor": version[1], "patch": version[2], "build": version[3],
409
+ "suffix": version[4] if len(version) > 4 else "",
410
+ "unavailable_on_this_build": missing,
411
+ "note": ("Recorded surfaces absent here — do not offer them. An absence "
412
+ "from this list is not a promise a method exists; most of the "
413
+ "API has never been version-bisected."
414
+ if missing else
415
+ "Clears every recorded version gate. Surfaces nobody has "
416
+ "bisected are still unknown — probe before offering them.")}
403
417
  return {"error": "Failed to get version"}
404
418
 
405
419
 
@@ -655,8 +655,8 @@ def timeline_create_compound_clip(
655
655
  return {"success": False, "error": "Failed to create compound clip"}
656
656
  return {
657
657
  "success": True,
658
- "name": result.GetName() if hasattr(result, "GetName") else None,
659
- "unique_id": result.GetUniqueId() if hasattr(result, "GetUniqueId") else None,
658
+ "name": result.GetName() if _has_method(result, "GetName") else None,
659
+ "unique_id": result.GetUniqueId() if _has_method(result, "GetUniqueId") else None,
660
660
  }
661
661
 
662
662
 
@@ -714,8 +714,11 @@ def timeline_export(file_path: str, export_type: str, export_subtype: str = "EXP
714
714
  return err
715
715
  # Map string constants to resolve constants
716
716
  try:
717
- etype = getattr(resolve, export_type) if hasattr(resolve, export_type) else export_type
718
- esub = getattr(resolve, export_subtype) if hasattr(resolve, export_subtype) else export_subtype
717
+ # Fall back on the VALUE, not on hasattr: hasattr is a constant True
718
+ # here, so a build without the constant used to hand Export a None
719
+ # instead of the string name the fallback was written to pass through.
720
+ etype = _api_constant(resolve, export_type, export_type)
721
+ esub = _api_constant(resolve, export_subtype, export_subtype)
719
722
  except Exception:
720
723
  logger.debug("Could not resolve timeline export constants", exc_info=True)
721
724
  etype = export_type
@@ -2118,7 +2118,7 @@ def ti_export_lut(export_type: str, path: str, item_index: int = 0, track_type:
2118
2118
  if err:
2119
2119
  return err
2120
2120
  try:
2121
- etype = getattr(resolve, export_type) if hasattr(resolve, export_type) else export_type
2121
+ etype = _api_constant(resolve, export_type, export_type)
2122
2122
  except Exception:
2123
2123
  etype = export_type
2124
2124
  return {"success": bool(item.ExportLUT(etype, path))}
package/src/server.py CHANGED
@@ -11,7 +11,7 @@ Usage:
11
11
  python src/server.py --full # Start the 353-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.88.0"
14
+ VERSION = "2.89.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -48,6 +48,7 @@ from src.utils.mcp_stdio import run_fastmcp_stdio
48
48
  from src.utils.api_truth import lookup_api_truth, VERIFIED_ON as _API_TRUTH_VERIFIED_ON
49
49
  from src.utils import clip_colors as _clip_colors
50
50
  from src.utils import resolve_versions as _resolve_versions
51
+ from src.utils.resolve_probe import api_constant as _api_constant, has_method as _probe_has_method
51
52
  from src.utils.contracts import validate as _validate_params
52
53
  from src.utils.cut_ir import build_cut_list as _build_cut_list
53
54
  from src.utils.page_lock import (
@@ -1032,7 +1033,7 @@ def _destructive_versioning_provider() -> Optional[Tuple[Any, Any, str, Optional
1032
1033
  except Exception:
1033
1034
  project_name = None
1034
1035
  try:
1035
- project_id = proj.GetUniqueId() if hasattr(proj, "GetUniqueId") else None
1036
+ project_id = proj.GetUniqueId() if _has_method(proj, "GetUniqueId") else None
1036
1037
  except Exception:
1037
1038
  project_id = None
1038
1039
  root = resolve_media_analysis_output_root(
@@ -1785,7 +1786,8 @@ def _send_resolve_keystroke_go_to_mark_in() -> Dict[str, Any]:
1785
1786
  return {"sent": False, "error": f"{type(exc).__name__}: {exc}"}
1786
1787
 
1787
1788
  def _has_method(obj, method_name):
1788
- return callable(getattr(obj, method_name, None))
1789
+ # `hasattr` is a constant True on Resolve objects — see src/utils/resolve_probe.
1790
+ return _probe_has_method(obj, method_name)
1789
1791
 
1790
1792
  def _requires_method(obj, method_name, min_version):
1791
1793
  if _has_method(obj, method_name):
@@ -5901,10 +5903,11 @@ def _timeline_export_value(value, resolve_obj=None):
5901
5903
  if not raw:
5902
5904
  return "", None
5903
5905
  const_name = raw if raw.startswith("EXPORT_") else None
5904
- if const_name and resolve_obj is not None and hasattr(resolve_obj, const_name):
5905
- return getattr(resolve_obj, const_name), const_name
5906
5906
  if const_name:
5907
- return const_name, const_name
5907
+ # hasattr is a constant True on a Resolve object, so the old presence
5908
+ # test always won and handed Export a None from getattr on a build
5909
+ # without the constant. Fall back on the value instead.
5910
+ return _api_constant(resolve_obj, const_name, const_name), const_name
5908
5911
  return raw, None
5909
5912
 
5910
5913
 
@@ -7383,9 +7386,7 @@ def _safe_auto_sync_audio(mp, p: Dict[str, Any]):
7383
7386
 
7384
7387
 
7385
7388
  def _resolve_audio_constant(resolve_obj, name: str, fallback):
7386
- if resolve_obj is not None and hasattr(resolve_obj, name):
7387
- return getattr(resolve_obj, name)
7388
- return fallback
7389
+ return _api_constant(resolve_obj, name, fallback)
7389
7390
 
7390
7391
 
7391
7392
  def _normalize_auto_sync_settings(settings: Dict[str, Any], resolve_obj=None):
@@ -13787,10 +13788,33 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
13787
13788
  if action == "get_version":
13788
13789
  update_env = _setup_update_env()
13789
13790
  mcp_update = get_cached_update_status(project_dir, VERSION, env=update_env)
13791
+ version_string = r.GetVersionString()
13792
+ # The first call of nearly every session. Issue #132 is the report of an
13793
+ # agent describing a surface that was not on the user's build, and the
13794
+ # reason it could happen is that nothing in the session ever said which
13795
+ # build that was in terms of what is missing from it. So the answer to
13796
+ # "what am I connected to" now carries what this build does not have,
13797
+ # rather than waiting to be asked.
13798
+ missing = _resolve_versions.gates_unavailable_on(version_string)
13790
13799
  return {
13791
13800
  "product": r.GetProductName(),
13792
13801
  "version": r.GetVersion(),
13793
- "version_string": r.GetVersionString(),
13802
+ "version_string": version_string,
13803
+ "build": {
13804
+ "unavailable_on_this_build": missing,
13805
+ "known_gates": len(_resolve_versions.VERSION_GATES),
13806
+ "note": (
13807
+ f"{len(missing)} recorded surface(s) are absent on this build. "
13808
+ "Do not offer them. An absence from this list is NOT a promise "
13809
+ "the method exists — most of the scripting API has never been "
13810
+ "version-bisected, so ask check_version_support for a specific "
13811
+ "symbol and probe when it answers `unknown`."
13812
+ if missing else
13813
+ "This build clears every recorded version gate. That is not a "
13814
+ "promise about surfaces nobody has bisected — ask "
13815
+ "check_version_support for a specific symbol before offering it."
13816
+ ),
13817
+ },
13794
13818
  "mcp": {
13795
13819
  "version": VERSION,
13796
13820
  "update": mcp_update,
@@ -22792,9 +22816,7 @@ def _resolve_lut_export_type(export_type, resolve_obj=None):
22792
22816
  const_name = raw
22793
22817
  if not const_name:
22794
22818
  return None, _err(f"Unknown LUT export type: {raw}")
22795
- if resolve_obj and hasattr(resolve_obj, const_name):
22796
- return getattr(resolve_obj, const_name), None
22797
- return const_name, None
22819
+ return _api_constant(resolve_obj, const_name, const_name), None
22798
22820
 
22799
22821
 
22800
22822
  def _validate_cdl_payload(cdl):
@@ -27636,7 +27658,7 @@ def _resource_current_project() -> Dict[str, Any]:
27636
27658
  return {
27637
27659
  "open": True,
27638
27660
  "name": proj.GetName(),
27639
- "id": proj.GetUniqueId() if hasattr(proj, "GetUniqueId") else None,
27661
+ "id": proj.GetUniqueId() if _has_method(proj, "GetUniqueId") else None,
27640
27662
  }
27641
27663
 
27642
27664
 
@@ -27659,7 +27681,7 @@ def _resource_current_timeline() -> Dict[str, Any]:
27659
27681
  return {
27660
27682
  "open": True,
27661
27683
  "name": tl.GetName(),
27662
- "id": tl.GetUniqueId() if hasattr(tl, "GetUniqueId") else None,
27684
+ "id": tl.GetUniqueId() if _has_method(tl, "GetUniqueId") else None,
27663
27685
  "start_frame": tl.GetStartFrame(),
27664
27686
  "end_frame": tl.GetEndFrame(),
27665
27687
  "start_timecode": tl.GetStartTimecode(),
@@ -9,6 +9,8 @@ the Resolve scripting API.
9
9
  import logging
10
10
  from typing import Any, Dict, Optional
11
11
 
12
+ from src.utils.resolve_probe import has_method
13
+
12
14
  logger = logging.getLogger("davinci-resolve-mcp.cloud_operations")
13
15
 
14
16
 
@@ -63,7 +65,7 @@ def _project_manager(resolve_obj, method_name: str):
63
65
  pm = resolve_obj.GetProjectManager()
64
66
  if not pm:
65
67
  return None, {"success": False, "error": "Failed to get Project Manager"}
66
- if not hasattr(pm, method_name):
68
+ if not has_method(pm, method_name):
67
69
  return None, {
68
70
  "success": False,
69
71
  "error": f"{method_name} not available in this version of DaVinci Resolve",
@@ -98,7 +100,7 @@ def create_cloud_project(
98
100
  return {
99
101
  "success": True,
100
102
  "project_name": project.GetName(),
101
- "project_id": project.GetUniqueId() if hasattr(project, "GetUniqueId") else None,
103
+ "project_id": project.GetUniqueId() if has_method(project, "GetUniqueId") else None,
102
104
  }
103
105
 
104
106
 
@@ -132,7 +134,7 @@ def load_cloud_project(
132
134
  return {
133
135
  "success": True,
134
136
  "project_name": project.GetName(),
135
- "project_id": project.GetUniqueId() if hasattr(project, "GetUniqueId") else None,
137
+ "project_id": project.GetUniqueId() if has_method(project, "GetUniqueId") else None,
136
138
  }
137
139
 
138
140
 
@@ -14,6 +14,8 @@ import inspect
14
14
  import logging
15
15
  from typing import Any, Dict, List, Optional, Union, Callable
16
16
 
17
+ from src.utils.resolve_probe import has_method
18
+
17
19
  logger = logging.getLogger(__name__)
18
20
 
19
21
 
@@ -170,7 +172,7 @@ def get_lua_table_keys(lua_table: Any) -> List[str]:
170
172
  keys = []
171
173
 
172
174
  # Check for DaVinci-specific Lua table iteration methods
173
- if hasattr(lua_table, 'GetKeyList'):
175
+ if has_method(lua_table, 'GetKeyList'):
174
176
  try:
175
177
  # Some DaVinci Resolve objects have a GetKeyList() method
176
178
  return lua_table.GetKeyList()
@@ -210,7 +212,7 @@ def convert_lua_to_python(lua_obj: Any) -> Any:
210
212
  return lua_obj
211
213
 
212
214
  # Try to convert Lua tables to Python dicts or lists
213
- if hasattr(lua_obj, 'GetKeyList') or hasattr(lua_obj, '__iter__'):
215
+ if has_method(lua_obj, 'GetKeyList') or hasattr(lua_obj, '__iter__'):
214
216
  keys = get_lua_table_keys(lua_obj)
215
217
 
216
218
  # If we found keys, convert to dict
@@ -13,6 +13,8 @@ import logging
13
13
  import json
14
14
  from typing import Dict, List, Any, Optional, Union
15
15
 
16
+ from src.utils.resolve_probe import has_method
17
+
16
18
  # Configure logging
17
19
  logger = logging.getLogger("davinci-resolve-mcp.project_properties")
18
20
 
@@ -521,7 +523,7 @@ def get_project_metadata(project_obj) -> Dict[str, Any]:
521
523
  metadata["name"] = project_obj.GetName()
522
524
 
523
525
  # Add project path if available
524
- if hasattr(project_obj, "GetPath"):
526
+ if has_method(project_obj, "GetPath"):
525
527
  metadata["path"] = project_obj.GetPath()
526
528
 
527
529
  # Get current timeline
@@ -0,0 +1,68 @@
1
+ """Capability probes for DaVinci Resolve API objects, because `hasattr` lies.
2
+
3
+ Measured on Studio 19.1.3.7, direct connection, 42 checks across Resolve,
4
+ ProjectManager, Project, MediaPool, Timeline, Folder and TimelineItem: bare
5
+ `hasattr(obj, name)` returns **True for every name** — real, borrowed from
6
+ another object type, or entirely invented — while `getattr(obj, name)` returns
7
+ `None` for the ones that do not exist. The full record is in `api_truth` under
8
+ "hasattr() / getattr() on Resolve API objects (attribute fabrication)".
9
+
10
+ So `hasattr` on a Resolve object is a constant `True`. It carries no
11
+ information, and it fails in two directions that look nothing alike:
12
+
13
+ if not hasattr(clip, "RemoveMotionBlur"): # never taken
14
+ return {"error": "requires Resolve 21+"} # dead branch
15
+ clip.RemoveMotionBlur(...) # AttributeError on 19.x
16
+
17
+ etype = getattr(r, name) if hasattr(r, name) else name # else never taken
18
+ # -> etype is None on a build without that constant, not the string
19
+ # fallback the author wrote, and None flows on into the export call.
20
+
21
+ The second shape is the dangerous one: no exception, no refusal, just a `None`
22
+ travelling further from the mistake with every line.
23
+
24
+ Use `has_method` where the intent is "can I call this", and `api_constant`
25
+ where the intent is "does this build define this constant, else use my
26
+ fallback". Both take the form that agreed with `dir()` in all 42 checks.
27
+
28
+ One caveat worth keeping: those 42 checks were on the DIRECT connection. The
29
+ 21.0.0 record of the in-app bridge fabricating callables for any name was never
30
+ re-run, so on a bridge build `callable(getattr(...))` may still over-report.
31
+ `dir()` membership is the form unaffected either way — but it is not free on
32
+ every object, and switching to it wholesale would change behaviour on a path we
33
+ have not measured. That trade is recorded here rather than made silently.
34
+ """
35
+
36
+ from typing import Any, Optional
37
+
38
+ __all__ = ["has_method", "api_constant", "MISSING"]
39
+
40
+ #: Sentinel for "no attribute", distinct from a legitimately-None attribute.
41
+ MISSING = object()
42
+
43
+
44
+ def has_method(obj: Any, name: str) -> bool:
45
+ """True when `name` is a callable on `obj`. The replacement for `hasattr`.
46
+
47
+ Returns False for a missing object rather than raising, because every
48
+ caller here is asking "is this reachable" and a None handle is one of the
49
+ ways it is not.
50
+ """
51
+ if obj is None or not name:
52
+ return False
53
+ return callable(getattr(obj, name, None))
54
+
55
+
56
+ def api_constant(obj: Any, name: str, default: Optional[Any] = None) -> Any:
57
+ """The Resolve constant `name`, or `default` when this build lacks it.
58
+
59
+ Resolve exposes export/audio type constants as plain attributes
60
+ (`resolve.EXPORT_AAF`), and callers pass the string name through unchanged
61
+ when the constant is absent. `getattr` returning None is exactly the signal
62
+ `hasattr` destroys, so the fallback is driven off the value, not off a
63
+ presence test.
64
+ """
65
+ if obj is None or not name:
66
+ return default
67
+ value = getattr(obj, name, None)
68
+ return default if value is None else value
@@ -102,11 +102,13 @@ def at_least(live: Any, required: Any) -> Optional[bool]:
102
102
  # measured from what a reporter measured, because the two carry different
103
103
  # weight and an agent relaying the fact should be able to say which it is.
104
104
  #
105
- # measured — probed live by this project
106
- # reported — a user's live probe, credited, not independently reproduced
107
- # vendor — stated by Blackmagic's shipped Developer/Scripting/README.txt
105
+ # measured — probed live by this project
106
+ # reported — a user's live probe, credited, not independently reproduced
107
+ # vendor — stated by Blackmagic's shipped Developer/Scripting/README.txt
108
+ # documented — a floor this server already enforces at the call site, taken
109
+ # from Blackmagic's release documentation. See CODE_FLOORS.
108
110
 
109
- VERSION_GATES: List[Dict[str, Any]] = [
111
+ _EVIDENCE_GATES: List[Dict[str, Any]] = [
110
112
  {
111
113
  "symbol": "Resolve.GetFairlightPresets",
112
114
  "introduced_in": "20.2.2",
@@ -117,7 +119,11 @@ VERSION_GATES: List[Dict[str, Any]] = [
117
119
  "issue": 128,
118
120
  },
119
121
  {
120
- "symbol": "Timeline.ApplyFairlightPresetToCurrentTimeline",
122
+ # Named on Project, not Timeline: the shipped README lists it in the
123
+ # Project section and src/server.py calls it on the project handle. The
124
+ # method's name says "CurrentTimeline", which is what made the earlier
125
+ # Timeline attribution here look right.
126
+ "symbol": "Project.ApplyFairlightPresetToCurrentTimeline",
121
127
  "introduced_in": "20.2.2",
122
128
  "source": "measured",
123
129
  "note": "Same gate as GetFairlightPresets; the two are only useful together.",
@@ -166,6 +172,90 @@ VERSION_GATES: List[Dict[str, Any]] = [
166
172
  },
167
173
  ]
168
174
 
175
+
176
+ # ── Floors this server already enforces ─────────────────────────────────────
177
+ # `_requires_method(obj, "Method", "X.Y")` in src/server.py and src/granular/
178
+ # refuses a call when the method is absent and names a floor. Those floors were
179
+ # real routing knowledge that `check_version_support` could not see: the ledger
180
+ # above knew seven symbols while the two servers gated thirty-one, so the one
181
+ # call an agent is told to make answered `unknown` for surfaces this repo was
182
+ # already routing on. That is the shape of issue #132 — guidance that does not
183
+ # vary with the build it is connected to — showing up inside our own tooling.
184
+ #
185
+ # The `documented` label is deliberately weaker than `measured`. These floors
186
+ # come from Blackmagic's release documentation (the build whose shipped
187
+ # Developer/Scripting README first carried the method), not from a live bisect
188
+ # by this project, so an agent relaying one should say "documented as requiring
189
+ # 21.0.4+", never "measured absent on your build". A floor being wrong in the
190
+ # vendor's direction is survivable — the call-site guard still probes the live
191
+ # object before refusing, so the floor only ever supplies the *explanation*.
192
+ #
193
+ # tests/test_version_gate_drift.py fails when a call site and this table
194
+ # disagree, which is what keeps the two from drifting apart again.
195
+
196
+ CODE_FLOORS: Dict[str, str] = {
197
+ "MediaPoolItem.LinkProxyMedia": "17.0",
198
+ "MediaPoolItem.LinkFullResolutionMedia": "20.0",
199
+ "MediaPoolItem.MonitorGrowingFile": "20.0",
200
+ "MediaPoolItem.ReplaceClipPreserveSubClip": "20.0",
201
+ "Timeline.GetVoiceIsolationState": "20.1",
202
+ "Timeline.SetVoiceIsolationState": "20.1",
203
+ "TimelineItem.GetVoiceIsolationState": "20.1",
204
+ "TimelineItem.SetVoiceIsolationState": "20.1",
205
+ "MediaPoolItem.SetName": "20.2",
206
+ "TimelineItem.SetName": "20.2",
207
+ "TimelineItem.ResetAllNodeColors": "20.2",
208
+ "Resolve.DisableBackgroundTasksForCurrentResolveSession": "21.0",
209
+ "Project.GenerateSpeech": "21.0",
210
+ "Project.ResetIntellisearchAnalysis": "21.0",
211
+ "MediaPoolItem.PerformAudioClassification": "21.0",
212
+ "MediaPoolItem.ClearAudioClassification": "21.0",
213
+ "MediaPoolItem.AnalyzeForIntellisearch": "21.0",
214
+ "MediaPoolItem.AnalyzeForSlate": "21.0",
215
+ "MediaPoolItem.RemoveMotionBlur": "21.0",
216
+ "Folder.PerformAudioClassification": "21.0",
217
+ "Folder.ClearAudioClassification": "21.0",
218
+ "Folder.AnalyzeForIntellisearch": "21.0",
219
+ "Folder.AnalyzeForSlate": "21.0",
220
+ "Folder.RemoveMotionBlur": "21.0",
221
+ "Resolve.GetLayoutPresetList": "21.0.4",
222
+ "Resolve.GetBurnInPresetList": "21.0.4",
223
+ "Resolve.DeleteBurnInPreset": "21.0.4",
224
+ "Resolve.GetUserPreferencesPresetList": "21.0.4",
225
+ "Resolve.SaveUserPreferencesPreset": "21.0.4",
226
+ "Resolve.LoadUserPreferencesPreset": "21.0.4",
227
+ "Resolve.DeleteUserPreferencesPreset": "21.0.4",
228
+ "Resolve.ImportUserPreferencesPreset": "21.0.4",
229
+ "Resolve.ExportUserPreferencesPreset": "21.0.4",
230
+ "ProjectManager.GetProjectAttributesInCurrentFolder": "21.0.4",
231
+ }
232
+
233
+ #: Methods whose call-site guard names no usable floor. Recorded rather than
234
+ #: silently dropped, because "we gate this and cannot say since when" is itself
235
+ #: worth an agent knowing — it is a candidate for the next live bisect.
236
+ UNDATED_FLOORS: Tuple[str, ...] = ("Project.GetRenderSettings",)
237
+
238
+ _EVIDENCE_SYMBOLS = {gate["symbol"] for gate in _EVIDENCE_GATES}
239
+
240
+ VERSION_GATES: List[Dict[str, Any]] = _EVIDENCE_GATES + [
241
+ {
242
+ "symbol": symbol,
243
+ "introduced_in": floor,
244
+ "source": "documented",
245
+ "note": (
246
+ "Floor enforced at this server's call site, from Blackmagic's "
247
+ "release documentation rather than a live bisect here. The guard "
248
+ "probes the live object first, so this supplies the explanation, "
249
+ "not the refusal."
250
+ ),
251
+ "issue": 132,
252
+ }
253
+ for symbol, floor in sorted(CODE_FLOORS.items())
254
+ # An evidence-backed entry always wins: it says how we know, and these
255
+ # would overwrite that with the weaker label.
256
+ if symbol not in _EVIDENCE_SYMBOLS
257
+ ]
258
+
169
259
  _GATES_BY_SYMBOL = {gate["symbol"].lower(): gate for gate in VERSION_GATES}
170
260
 
171
261
  AVAILABLE = "available"
@@ -174,12 +264,24 @@ UNKNOWN = "unknown"
174
264
 
175
265
 
176
266
  def gate_for(symbol: str) -> Optional[Dict[str, Any]]:
177
- """The recorded gate for `symbol`, matched loosely. None if we have none."""
267
+ """The recorded gate for `symbol`, matched loosely. None if we have none.
268
+
269
+ Tried in order — exact, then bare method name, then substring — because the
270
+ registry now carries the same method on several classes (`SetName` is gated
271
+ on four). Iteration order decided which one a bare `"SetName"` lookup hit
272
+ when the registry was small enough for that not to matter; it now is not.
273
+ A bare name matching several classes only stays honest while their floors
274
+ agree, which `test_version_gate_drift` asserts.
275
+ """
178
276
  if not symbol:
179
277
  return None
180
278
  needle = symbol.strip().lower()
181
279
  if needle in _GATES_BY_SYMBOL:
182
280
  return _GATES_BY_SYMBOL[needle]
281
+ suffix = "." + needle
282
+ for key, gate in _GATES_BY_SYMBOL.items():
283
+ if key.endswith(suffix):
284
+ return gate
183
285
  for key, gate in _GATES_BY_SYMBOL.items():
184
286
  if needle in key or key in needle:
185
287
  return gate
@@ -17,6 +17,8 @@ import platform
17
17
  import subprocess
18
18
  import sys
19
19
  import tempfile
20
+
21
+ from src.utils.resolve_probe import has_method
20
22
  import time
21
23
  import types
22
24
  import traceback
@@ -153,7 +155,7 @@ def _frame_int(value) -> int:
153
155
 
154
156
 
155
157
  def _source_start(item) -> int:
156
- if hasattr(item, "GetSourceStartFrame"):
158
+ if has_method(item, "GetSourceStartFrame"):
157
159
  try:
158
160
  value = item.GetSourceStartFrame()
159
161
  if value is not None:
@@ -30,6 +30,8 @@ import os
30
30
  import re
31
31
  import time
32
32
  import uuid
33
+
34
+ from src.utils.resolve_probe import has_method
33
35
  from typing import Any, Dict, List, Optional, Tuple
34
36
 
35
37
  from src.utils import actor_identity, timeline_brain_db
@@ -280,12 +282,12 @@ def _resolve_media_pool_item_id(item: Any) -> Optional[str]:
280
282
  """
281
283
  try:
282
284
  mpi = item.GetMediaPoolItem()
283
- if mpi is not None and hasattr(mpi, "GetUniqueId"):
285
+ if has_method(mpi, "GetUniqueId"):
284
286
  return str(mpi.GetUniqueId())
285
287
  except Exception:
286
288
  pass
287
289
  try:
288
- return str(item.GetUniqueId()) if hasattr(item, "GetUniqueId") else None
290
+ return str(item.GetUniqueId()) if has_method(item, "GetUniqueId") else None
289
291
  except Exception:
290
292
  return None
291
293