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 +72 -0
- package/README.md +1 -1
- package/README.zh-CN.md +2 -2
- package/docs/SKILL.md +10 -1
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/analysis_dashboard.py +2 -1
- package/src/granular/common.py +36 -2
- package/src/granular/folder.py +26 -15
- package/src/granular/media_pool_item.py +29 -21
- package/src/granular/project.py +11 -6
- package/src/granular/resolve_control.py +18 -4
- package/src/granular/timeline.py +7 -4
- package/src/granular/timeline_item.py +1 -1
- package/src/server.py +37 -15
- package/src/utils/cloud_operations.py +5 -3
- package/src/utils/object_inspection.py +4 -2
- package/src/utils/project_properties.py +3 -1
- package/src/utils/resolve_probe.py +68 -0
- package/src/utils/resolve_versions.py +108 -6
- package/src/utils/timeline_kernel_live_probe.py +3 -1
- package/src/utils/timeline_versioning.py +4 -2
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
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
8
|
[-blue.svg)](#server-modes)
|
package/README.zh-CN.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.md) | 简体中文
|
|
4
4
|
|
|
5
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
8
|
[-blue.svg)](#服务器模式)
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
[](https://www.python.org/downloads/)
|
|
13
13
|
[](https://opensource.org/licenses/MIT)
|
|
14
14
|
|
|
15
|
-
> 本翻译对应 v2.
|
|
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.
|
|
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
|
@@ -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
|
|
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
|
package/src/granular/common.py
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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("__")]
|
package/src/granular/folder.py
CHANGED
|
@@ -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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
387
|
-
|
|
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
|
|
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
|
-
|
|
411
|
-
|
|
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":
|
|
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
|
|
21
|
-
project_id = project.GetUniqueId() if
|
|
22
|
-
clip_id = clip.GetUniqueId() if
|
|
23
|
-
media_id = clip.GetMediaId() if
|
|
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
|
|
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
|
-
|
|
934
|
-
|
|
935
|
-
|
|
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
|
-
|
|
952
|
-
|
|
953
|
-
|
|
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
|
-
|
|
972
|
-
|
|
973
|
-
|
|
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
|
-
|
|
992
|
-
|
|
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
|
|
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
|
-
|
|
1016
|
-
|
|
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
|
-
|
|
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
|
|
package/src/granular/project.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
1644
|
-
|
|
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
|
-
|
|
1668
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
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
|
|
package/src/granular/timeline.py
CHANGED
|
@@ -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
|
|
659
|
-
"unique_id": result.GetUniqueId() if
|
|
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
|
-
|
|
718
|
-
|
|
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 =
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
106
|
-
# reported
|
|
107
|
-
# vendor
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
290
|
+
return str(item.GetUniqueId()) if has_method(item, "GetUniqueId") else None
|
|
289
291
|
except Exception:
|
|
290
292
|
return None
|
|
291
293
|
|