davinci-resolve-mcp 2.87.2 → 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 +122 -0
- package/README.md +4 -4
- package/README.zh-CN.md +5 -5
- package/docs/SKILL.md +11 -2
- package/docs/contributing.md +1 -1
- package/docs/install.md +3 -3
- package/docs/reference/api-coverage.md +2 -2
- 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 +67 -21
- package/src/granular/project.py +29 -6
- package/src/granular/resolve_control.py +187 -4
- package/src/granular/timeline.py +37 -4
- package/src/granular/timeline_item.py +1 -1
- package/src/resolve_mcp_server.py +1 -1
- package/src/server.py +40 -18
- 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/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,6 +1665,28 @@ 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()}
|
|
1675
|
+
|
|
1676
|
+
|
|
1677
|
+
@mcp.tool()
|
|
1678
|
+
def get_project_attributes_in_current_folder() -> Dict[str, Any]:
|
|
1679
|
+
"""Get per-project attributes for every project in the current folder (Resolve 21.0.4+).
|
|
1680
|
+
|
|
1681
|
+
Calls ProjectManager.GetProjectAttributesInCurrentFolder(). Returns a dict
|
|
1682
|
+
keyed by project name with 'lastModifiedDate', 'creationDate', 'notes' and
|
|
1683
|
+
'liveCollaborationMode' — without loading any project.
|
|
1684
|
+
"""
|
|
1685
|
+
project_manager = get_project_manager()
|
|
1686
|
+
if not project_manager:
|
|
1687
|
+
return {"error": "Failed to get Project Manager"}
|
|
1688
|
+
missing = _requires_method(project_manager, "GetProjectAttributesInCurrentFolder", "21.0.4")
|
|
1689
|
+
if missing:
|
|
1690
|
+
return missing
|
|
1691
|
+
attributes = project_manager.GetProjectAttributesInCurrentFolder()
|
|
1692
|
+
return {"projects": attributes if attributes else {}}
|
|
@@ -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
|
|
|
@@ -536,3 +550,172 @@ def quit_resolve() -> Dict[str, Any]:
|
|
|
536
550
|
return {"error": "Not connected to DaVinci Resolve"}
|
|
537
551
|
resolve.Quit()
|
|
538
552
|
return {"success": True, "message": "DaVinci Resolve is quitting"}
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
@mcp.tool()
|
|
556
|
+
def get_layout_preset_list() -> Dict[str, Any]:
|
|
557
|
+
"""Get the names of all saved UI layout presets (Resolve 21.0.4+).
|
|
558
|
+
|
|
559
|
+
Calls Resolve.GetLayoutPresetList(). These are the names accepted by
|
|
560
|
+
load_layout_preset_tool, update_layout_preset, export_layout_preset_tool
|
|
561
|
+
and delete_layout_preset_tool.
|
|
562
|
+
"""
|
|
563
|
+
resolve = get_resolve()
|
|
564
|
+
if resolve is None:
|
|
565
|
+
return {"error": "Not connected to DaVinci Resolve"}
|
|
566
|
+
missing = _requires_method(resolve, "GetLayoutPresetList", "21.0.4")
|
|
567
|
+
if missing:
|
|
568
|
+
return missing
|
|
569
|
+
presets = resolve.GetLayoutPresetList()
|
|
570
|
+
return {"presets": presets if presets else []}
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
@mcp.tool()
|
|
574
|
+
def get_burn_in_preset_list() -> Dict[str, Any]:
|
|
575
|
+
"""Get the names of all saved data burn-in presets (Resolve 21.0.4+).
|
|
576
|
+
|
|
577
|
+
Calls Resolve.GetBurnInPresetList(). These are the names accepted by the
|
|
578
|
+
'DataBurnIn' render setting, project load_burn_in_preset, and
|
|
579
|
+
export_burn_in_preset.
|
|
580
|
+
"""
|
|
581
|
+
resolve = get_resolve()
|
|
582
|
+
if resolve is None:
|
|
583
|
+
return {"error": "Not connected to DaVinci Resolve"}
|
|
584
|
+
missing = _requires_method(resolve, "GetBurnInPresetList", "21.0.4")
|
|
585
|
+
if missing:
|
|
586
|
+
return missing
|
|
587
|
+
presets = resolve.GetBurnInPresetList()
|
|
588
|
+
return {"presets": presets if presets else []}
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
@mcp.tool()
|
|
592
|
+
def delete_burn_in_preset(preset_name: str) -> Dict[str, Any]:
|
|
593
|
+
"""Delete a data burn-in preset by name (Resolve 21.0.4+).
|
|
594
|
+
|
|
595
|
+
Args:
|
|
596
|
+
preset_name: Name of the burn-in preset to delete.
|
|
597
|
+
"""
|
|
598
|
+
resolve = get_resolve()
|
|
599
|
+
if resolve is None:
|
|
600
|
+
return {"error": "Not connected to DaVinci Resolve"}
|
|
601
|
+
missing = _requires_method(resolve, "DeleteBurnInPreset", "21.0.4")
|
|
602
|
+
if missing:
|
|
603
|
+
return missing
|
|
604
|
+
result = resolve.DeleteBurnInPreset(preset_name)
|
|
605
|
+
return {"success": bool(result), "preset_name": preset_name}
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
@mcp.tool()
|
|
609
|
+
def get_user_preferences_preset_list() -> Dict[str, Any]:
|
|
610
|
+
"""Get the names of all saved user-preferences presets (Resolve 21.0.4+).
|
|
611
|
+
|
|
612
|
+
Calls Resolve.GetUserPreferencesPresetList().
|
|
613
|
+
"""
|
|
614
|
+
resolve = get_resolve()
|
|
615
|
+
if resolve is None:
|
|
616
|
+
return {"error": "Not connected to DaVinci Resolve"}
|
|
617
|
+
missing = _requires_method(resolve, "GetUserPreferencesPresetList", "21.0.4")
|
|
618
|
+
if missing:
|
|
619
|
+
return missing
|
|
620
|
+
presets = resolve.GetUserPreferencesPresetList()
|
|
621
|
+
return {"presets": presets if presets else []}
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
@mcp.tool()
|
|
625
|
+
def save_user_preferences_preset(preset_name: str) -> Dict[str, Any]:
|
|
626
|
+
"""Save the current user preferences as a named preset (Resolve 21.0.4+).
|
|
627
|
+
|
|
628
|
+
Args:
|
|
629
|
+
preset_name: Name for the new user-preferences preset.
|
|
630
|
+
"""
|
|
631
|
+
resolve = get_resolve()
|
|
632
|
+
if resolve is None:
|
|
633
|
+
return {"error": "Not connected to DaVinci Resolve"}
|
|
634
|
+
missing = _requires_method(resolve, "SaveUserPreferencesPreset", "21.0.4")
|
|
635
|
+
if missing:
|
|
636
|
+
return missing
|
|
637
|
+
result = resolve.SaveUserPreferencesPreset(preset_name)
|
|
638
|
+
return {"success": bool(result), "preset_name": preset_name}
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
@mcp.tool()
|
|
642
|
+
def load_user_preferences_preset(preset_name: str) -> Dict[str, Any]:
|
|
643
|
+
"""Load a user-preferences preset (Resolve 21.0.4+).
|
|
644
|
+
|
|
645
|
+
SESSION-WIDE: this swaps the user's global Resolve preferences, not a
|
|
646
|
+
project setting. It affects every project open in this Resolve instance.
|
|
647
|
+
Only call when the user explicitly asked for the switch.
|
|
648
|
+
|
|
649
|
+
Args:
|
|
650
|
+
preset_name: Name of the user-preferences preset to load.
|
|
651
|
+
"""
|
|
652
|
+
resolve = get_resolve()
|
|
653
|
+
if resolve is None:
|
|
654
|
+
return {"error": "Not connected to DaVinci Resolve"}
|
|
655
|
+
missing = _requires_method(resolve, "LoadUserPreferencesPreset", "21.0.4")
|
|
656
|
+
if missing:
|
|
657
|
+
return missing
|
|
658
|
+
result = resolve.LoadUserPreferencesPreset(preset_name)
|
|
659
|
+
return {"success": bool(result), "preset_name": preset_name}
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
@mcp.tool()
|
|
663
|
+
def delete_user_preferences_preset(preset_name: str) -> Dict[str, Any]:
|
|
664
|
+
"""Delete a user-preferences preset by name (Resolve 21.0.4+).
|
|
665
|
+
|
|
666
|
+
Args:
|
|
667
|
+
preset_name: Name of the user-preferences preset to delete.
|
|
668
|
+
"""
|
|
669
|
+
resolve = get_resolve()
|
|
670
|
+
if resolve is None:
|
|
671
|
+
return {"error": "Not connected to DaVinci Resolve"}
|
|
672
|
+
missing = _requires_method(resolve, "DeleteUserPreferencesPreset", "21.0.4")
|
|
673
|
+
if missing:
|
|
674
|
+
return missing
|
|
675
|
+
result = resolve.DeleteUserPreferencesPreset(preset_name)
|
|
676
|
+
return {"success": bool(result), "preset_name": preset_name}
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
@mcp.tool()
|
|
680
|
+
def import_user_preferences_preset(import_path: str, preset_name: str = None) -> Dict[str, Any]:
|
|
681
|
+
"""Import a user-preferences preset from a file (Resolve 21.0.4+).
|
|
682
|
+
|
|
683
|
+
The imported preset is NOT auto-loaded; it takes its name from the file
|
|
684
|
+
when preset_name is omitted (measured on Studio 21.0.4.5). Follow with
|
|
685
|
+
load_user_preferences_preset to activate it.
|
|
686
|
+
|
|
687
|
+
Args:
|
|
688
|
+
import_path: Absolute path to the preset file to import.
|
|
689
|
+
preset_name: Name to save the imported preset as (filename if None).
|
|
690
|
+
"""
|
|
691
|
+
resolve = get_resolve()
|
|
692
|
+
if resolve is None:
|
|
693
|
+
return {"error": "Not connected to DaVinci Resolve"}
|
|
694
|
+
missing = _requires_method(resolve, "ImportUserPreferencesPreset", "21.0.4")
|
|
695
|
+
if missing:
|
|
696
|
+
return missing
|
|
697
|
+
if preset_name:
|
|
698
|
+
result = resolve.ImportUserPreferencesPreset(import_path, preset_name)
|
|
699
|
+
else:
|
|
700
|
+
result = resolve.ImportUserPreferencesPreset(import_path)
|
|
701
|
+
preset_name = os.path.splitext(os.path.basename(import_path))[0]
|
|
702
|
+
return {"success": bool(result), "preset_name": preset_name, "import_path": import_path,
|
|
703
|
+
"note": "The imported preset is not auto-loaded; use load_user_preferences_preset to activate it."}
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
@mcp.tool()
|
|
707
|
+
def export_user_preferences_preset(preset_name: str, export_path: str) -> Dict[str, Any]:
|
|
708
|
+
"""Export a user-preferences preset to a file (Resolve 21.0.4+).
|
|
709
|
+
|
|
710
|
+
Args:
|
|
711
|
+
preset_name: Name of the user-preferences preset to export.
|
|
712
|
+
export_path: Absolute path where the preset file will be saved.
|
|
713
|
+
"""
|
|
714
|
+
resolve = get_resolve()
|
|
715
|
+
if resolve is None:
|
|
716
|
+
return {"error": "Not connected to DaVinci Resolve"}
|
|
717
|
+
missing = _requires_method(resolve, "ExportUserPreferencesPreset", "21.0.4")
|
|
718
|
+
if missing:
|
|
719
|
+
return missing
|
|
720
|
+
result = resolve.ExportUserPreferencesPreset(preset_name, export_path)
|
|
721
|
+
return {"success": bool(result), "preset_name": preset_name, "export_path": export_path}
|
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
|
|
@@ -1087,3 +1090,33 @@ def set_timeline_setting(setting_name: str, setting_value: str) -> Dict[str, Any
|
|
|
1087
1090
|
return err
|
|
1088
1091
|
result = tl.SetSetting(setting_name, setting_value)
|
|
1089
1092
|
return {"success": bool(result), "setting_name": setting_name, "setting_value": setting_value}
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
@mcp.tool()
|
|
1096
|
+
def get_selected_timeline_items() -> Dict[str, Any]:
|
|
1097
|
+
"""Get the timeline items currently selected in the timeline (Resolve 21.0.4+).
|
|
1098
|
+
|
|
1099
|
+
Calls Timeline.GetSelectedClips() on the current timeline. An empty list
|
|
1100
|
+
means nothing is selected — that is an answer, not a failure. (Distinct
|
|
1101
|
+
from get_selected_clips, which reads the Media Pool selection.)
|
|
1102
|
+
"""
|
|
1103
|
+
_, tl, err = _get_timeline()
|
|
1104
|
+
if err:
|
|
1105
|
+
return err
|
|
1106
|
+
missing = _requires_method(tl, "GetSelectedClips", "21.0.4")
|
|
1107
|
+
if missing:
|
|
1108
|
+
return missing
|
|
1109
|
+
items = tl.GetSelectedClips() or []
|
|
1110
|
+
summaries = []
|
|
1111
|
+
for item in items:
|
|
1112
|
+
entry = {}
|
|
1113
|
+
for getter, key in (("GetName", "name"), ("GetUniqueId", "unique_id"),
|
|
1114
|
+
("GetStart", "start"), ("GetEnd", "end")):
|
|
1115
|
+
method = getattr(item, getter, None)
|
|
1116
|
+
if callable(method):
|
|
1117
|
+
try:
|
|
1118
|
+
entry[key] = method()
|
|
1119
|
+
except Exception:
|
|
1120
|
+
pass
|
|
1121
|
+
summaries.append(entry)
|
|
1122
|
+
return {"count": len(summaries), "items": summaries}
|
|
@@ -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))}
|
|
@@ -34,7 +34,7 @@ from src.utils.update_check import start_background_update_check
|
|
|
34
34
|
if __name__ == "__main__":
|
|
35
35
|
try:
|
|
36
36
|
start_background_update_check(VERSION, project_dir, logger)
|
|
37
|
-
logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION} (
|
|
37
|
+
logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION} (353 granular tools)")
|
|
38
38
|
run_fastmcp_stdio(mcp)
|
|
39
39
|
except KeyboardInterrupt:
|
|
40
40
|
logger.info("Server shutdown requested")
|
package/src/server.py
CHANGED
|
@@ -8,10 +8,10 @@ Each tool groups related operations via an 'action' parameter.
|
|
|
8
8
|
|
|
9
9
|
Usage:
|
|
10
10
|
python src/server.py # Start the MCP server
|
|
11
|
-
python src/server.py --full # Start the
|
|
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(),
|
|
@@ -27796,9 +27818,9 @@ if __name__ == "__main__":
|
|
|
27796
27818
|
start_background_update_check(VERSION, project_dir, logger, env=_setup_update_env())
|
|
27797
27819
|
_install_threaded_tool_dispatch(mcp)
|
|
27798
27820
|
|
|
27799
|
-
# Support --full flag to run the
|
|
27821
|
+
# Support --full flag to run the 353-tool granular server instead
|
|
27800
27822
|
if "--full" in sys.argv:
|
|
27801
|
-
logger.info("Starting full
|
|
27823
|
+
logger.info("Starting full 353-tool granular server...")
|
|
27802
27824
|
sys.argv = [arg for arg in sys.argv if arg != "--full"]
|
|
27803
27825
|
from src.granular import mcp as granular_mcp
|
|
27804
27826
|
|
|
@@ -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
|