davinci-resolve-mcp 2.28.1 → 2.30.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 +75 -0
- package/README.md +5 -5
- package/docs/SKILL.md +18 -3
- package/docs/contributing.md +1 -1
- package/docs/install.md +2 -2
- package/docs/reference/api-coverage.md +2 -2
- package/docs/reference/resolve_scripting_api.txt +15 -3
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/analysis_dashboard.py +126 -0
- package/src/granular/common.py +1 -1
- package/src/granular/folder.py +124 -0
- package/src/granular/media_pool_item.py +109 -0
- package/src/granular/project.py +56 -0
- package/src/granular/resolve_control.py +17 -0
- package/src/resolve_mcp_server.py +1 -1
- package/src/server.py +293 -14
- package/src/utils/resolve_ai_ledger.py +242 -0
- package/src/utils/timeline_brain_db.py +43 -1
|
@@ -909,3 +909,112 @@ def clear_clip_mark_in_out(clip_id: str) -> Dict[str, Any]:
|
|
|
909
909
|
return {"error": f"Clip {clip_id} not found"}
|
|
910
910
|
result = clip.ClearMarkInOut()
|
|
911
911
|
return {"success": bool(result)}
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
_MARKER_COLORS = [
|
|
915
|
+
"Blue", "Cyan", "Green", "Yellow", "Red", "Pink", "Purple", "Fuchsia",
|
|
916
|
+
"Rose", "Lavender", "Sky", "Mint", "Lemon", "Sand", "Cocoa", "Cream",
|
|
917
|
+
]
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
@mcp.tool()
|
|
921
|
+
def perform_clip_audio_classification(clip_id: str) -> Dict[str, Any]:
|
|
922
|
+
"""Classify a clip's audio into categories and subcategories (Resolve 21+).
|
|
923
|
+
|
|
924
|
+
Args:
|
|
925
|
+
clip_id: Unique ID of the clip.
|
|
926
|
+
"""
|
|
927
|
+
_, mp, err = _get_mp()
|
|
928
|
+
if err:
|
|
929
|
+
return err
|
|
930
|
+
clip = _find_clip_by_id(mp.GetRootFolder(), clip_id)
|
|
931
|
+
if not clip:
|
|
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())}
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
@mcp.tool()
|
|
939
|
+
def clear_clip_audio_classification(clip_id: str) -> Dict[str, Any]:
|
|
940
|
+
"""Clear a clip's audio classification (Resolve 21+).
|
|
941
|
+
|
|
942
|
+
Args:
|
|
943
|
+
clip_id: Unique ID of the clip.
|
|
944
|
+
"""
|
|
945
|
+
_, mp, err = _get_mp()
|
|
946
|
+
if err:
|
|
947
|
+
return err
|
|
948
|
+
clip = _find_clip_by_id(mp.GetRootFolder(), clip_id)
|
|
949
|
+
if not clip:
|
|
950
|
+
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())}
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
@mcp.tool()
|
|
957
|
+
def analyze_clip_for_intellisearch(clip_id: str, identify_faces: bool = False, is_better_mode: bool = False) -> Dict[str, Any]:
|
|
958
|
+
"""Run IntelliSearch analysis on a clip (Resolve 21+, requires AI IntelliSearch Extra).
|
|
959
|
+
|
|
960
|
+
Args:
|
|
961
|
+
clip_id: Unique ID of the clip.
|
|
962
|
+
identify_faces: Whether to identify faces.
|
|
963
|
+
is_better_mode: Use Better mode (vs Faster).
|
|
964
|
+
"""
|
|
965
|
+
_, mp, err = _get_mp()
|
|
966
|
+
if err:
|
|
967
|
+
return err
|
|
968
|
+
clip = _find_clip_by_id(mp.GetRootFolder(), clip_id)
|
|
969
|
+
if not clip:
|
|
970
|
+
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)))}
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
@mcp.tool()
|
|
977
|
+
def analyze_clip_for_slate(clip_id: str, marker_color: str = "Blue") -> Dict[str, Any]:
|
|
978
|
+
"""Run Slate analysis on a clip (Resolve 21+, requires AI Slate ID Extra).
|
|
979
|
+
|
|
980
|
+
Args:
|
|
981
|
+
clip_id: Unique ID of the clip.
|
|
982
|
+
marker_color: Marker color for detected slates (Blue, Cyan, Green, Yellow, Red, Pink,
|
|
983
|
+
Purple, Fuchsia, Rose, Lavender, Sky, Mint, Lemon, Sand, Cocoa, Cream).
|
|
984
|
+
"""
|
|
985
|
+
_, mp, err = _get_mp()
|
|
986
|
+
if err:
|
|
987
|
+
return err
|
|
988
|
+
clip = _find_clip_by_id(mp.GetRootFolder(), clip_id)
|
|
989
|
+
if not clip:
|
|
990
|
+
return {"error": f"Clip {clip_id} not found"}
|
|
991
|
+
if not hasattr(clip, "AnalyzeForSlate"):
|
|
992
|
+
return {"error": "AnalyzeForSlate requires DaVinci Resolve 21+"}
|
|
993
|
+
if marker_color not in _MARKER_COLORS:
|
|
994
|
+
return {"error": f"Invalid marker_color '{marker_color}'. Valid: {', '.join(_MARKER_COLORS)}"}
|
|
995
|
+
return {"success": bool(clip.AnalyzeForSlate(marker_color))}
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
@mcp.tool()
|
|
999
|
+
def remove_clip_motion_blur(clip_id: str, deblur_option: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
1000
|
+
"""Render a motion-deblurred copy of a clip (Resolve 21+).
|
|
1001
|
+
|
|
1002
|
+
Creates a NEW media file; the source clip is not modified.
|
|
1003
|
+
|
|
1004
|
+
Args:
|
|
1005
|
+
clip_id: Unique ID of the clip.
|
|
1006
|
+
deblur_option: Settings dict (FileName, Format, Codec, EncodingProfile,
|
|
1007
|
+
UseExtremeMode, UseMarkInMarkOut, RenderAtSourceRes, UseMoreGpuMemory).
|
|
1008
|
+
"""
|
|
1009
|
+
_, mp, err = _get_mp()
|
|
1010
|
+
if err:
|
|
1011
|
+
return err
|
|
1012
|
+
clip = _find_clip_by_id(mp.GetRootFolder(), clip_id)
|
|
1013
|
+
if not clip:
|
|
1014
|
+
return {"error": f"Clip {clip_id} not found"}
|
|
1015
|
+
if not hasattr(clip, "RemoveMotionBlur"):
|
|
1016
|
+
return {"error": "RemoveMotionBlur requires DaVinci Resolve 21+"}
|
|
1017
|
+
new_clip = clip.RemoveMotionBlur(deblur_option or {})
|
|
1018
|
+
if not new_clip:
|
|
1019
|
+
return {"success": False}
|
|
1020
|
+
return {"success": True, "new": new_clip.GetName(), "new_id": new_clip.GetUniqueId()}
|
package/src/granular/project.py
CHANGED
|
@@ -1592,3 +1592,59 @@ def load_cloud_project(project_name: str, project_media_path: str, sync_mode: st
|
|
|
1592
1592
|
if project:
|
|
1593
1593
|
return {"success": True, "project_name": project.GetName()}
|
|
1594
1594
|
return {"success": False, "error": "Failed to load cloud project. Check cloud settings and connectivity."}
|
|
1595
|
+
|
|
1596
|
+
|
|
1597
|
+
@mcp.tool()
|
|
1598
|
+
def generate_speech(text_input: str, voice_model: str = "", timecode: str = "",
|
|
1599
|
+
add_to_timeline: bool = False, audio_track: Optional[int] = None,
|
|
1600
|
+
custom_voice_file: str = "", speed: Optional[int] = None,
|
|
1601
|
+
variation: Optional[int] = None, pitch: Optional[int] = None,
|
|
1602
|
+
generation_id: Optional[int] = None, filename: str = "") -> Dict[str, Any]:
|
|
1603
|
+
"""Generate AI text-to-speech audio and add it to the media pool (Resolve 21+).
|
|
1604
|
+
|
|
1605
|
+
Requires the AI Speech Generator Extra. Creates a NEW audio MediaPoolItem; if
|
|
1606
|
+
add_to_timeline is True it is placed on the timeline at the given timecode.
|
|
1607
|
+
|
|
1608
|
+
Args:
|
|
1609
|
+
text_input: Text to synthesize (required).
|
|
1610
|
+
voice_model: Voice model name (e.g. "Female 1", "Male 1", "Custom Voice").
|
|
1611
|
+
timecode: Timeline timecode to place the clip at when add_to_timeline is True.
|
|
1612
|
+
add_to_timeline: Whether to add the generated clip to the timeline.
|
|
1613
|
+
audio_track: Audio track index for timeline placement.
|
|
1614
|
+
custom_voice_file: Full path to a custom voice file (for "Custom Voice").
|
|
1615
|
+
speed: Speech speed.
|
|
1616
|
+
variation: Voice variation.
|
|
1617
|
+
pitch: Voice pitch.
|
|
1618
|
+
generation_id: Generation ID.
|
|
1619
|
+
filename: Output filename.
|
|
1620
|
+
"""
|
|
1621
|
+
pm, current_project = get_current_project()
|
|
1622
|
+
if not current_project:
|
|
1623
|
+
return {"error": "No project currently open"}
|
|
1624
|
+
if not hasattr(current_project, "GenerateSpeech"):
|
|
1625
|
+
return {"error": "GenerateSpeech requires DaVinci Resolve 21+ and the AI Speech Generator Extra"}
|
|
1626
|
+
if not text_input:
|
|
1627
|
+
return {"error": "text_input is required"}
|
|
1628
|
+
settings: Dict[str, Any] = {"TextInput": text_input}
|
|
1629
|
+
if voice_model:
|
|
1630
|
+
settings["VoiceModel"] = voice_model
|
|
1631
|
+
if custom_voice_file:
|
|
1632
|
+
settings["CustomVoiceFile"] = custom_voice_file
|
|
1633
|
+
if speed is not None:
|
|
1634
|
+
settings["Speed"] = speed
|
|
1635
|
+
if variation is not None:
|
|
1636
|
+
settings["Variation"] = variation
|
|
1637
|
+
if pitch is not None:
|
|
1638
|
+
settings["Pitch"] = pitch
|
|
1639
|
+
if generation_id is not None:
|
|
1640
|
+
settings["GenerationID"] = generation_id
|
|
1641
|
+
if filename:
|
|
1642
|
+
settings["Filename"] = filename
|
|
1643
|
+
if add_to_timeline:
|
|
1644
|
+
settings["AddToTimeline"] = True
|
|
1645
|
+
if audio_track is not None:
|
|
1646
|
+
settings["AudioTrack"] = audio_track
|
|
1647
|
+
new_item = current_project.GenerateSpeech(settings, timecode or "")
|
|
1648
|
+
if not new_item:
|
|
1649
|
+
return {"success": False, "error": "GenerateSpeech returned no media item"}
|
|
1650
|
+
return {"success": True, "new": new_item.GetName(), "new_id": new_item.GetUniqueId()}
|
|
@@ -293,6 +293,23 @@ def delete_layout_preset_tool(preset_name: str) -> Dict[str, Any]:
|
|
|
293
293
|
return {"success": bool(result), "preset_name": preset_name}
|
|
294
294
|
|
|
295
295
|
|
|
296
|
+
@mcp.tool()
|
|
297
|
+
def disable_background_tasks_for_current_session() -> Dict[str, Any]:
|
|
298
|
+
"""Disable all background tasks for the current Resolve session (Resolve 21+).
|
|
299
|
+
|
|
300
|
+
Useful before heavy scripted operations so Resolve does not run background
|
|
301
|
+
work that competes for resources. Resets when Resolve restarts.
|
|
302
|
+
"""
|
|
303
|
+
resolve = get_resolve()
|
|
304
|
+
if resolve is None:
|
|
305
|
+
return {"error": "Not connected to DaVinci Resolve"}
|
|
306
|
+
missing = _requires_method(resolve, "DisableBackgroundTasksForCurrentResolveSession", "21.0")
|
|
307
|
+
if missing:
|
|
308
|
+
return missing
|
|
309
|
+
resolve.DisableBackgroundTasksForCurrentResolveSession()
|
|
310
|
+
return {"success": True}
|
|
311
|
+
|
|
312
|
+
|
|
296
313
|
@mcp.resource("resolve://app/state")
|
|
297
314
|
def get_app_state_endpoint() -> Dict[str, Any]:
|
|
298
315
|
"""Get DaVinci Resolve application state information."""
|
|
@@ -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} (341 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 341-tool granular server instead
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
|
-
VERSION = "2.
|
|
14
|
+
VERSION = "2.30.0"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -639,6 +639,42 @@ def _destructive_versioning_provider() -> Optional[Tuple[Any, Any, str, Optional
|
|
|
639
639
|
_destructive_hook.register_project_root_provider(_destructive_versioning_provider)
|
|
640
640
|
|
|
641
641
|
|
|
642
|
+
# ─── Resolve 21 AI-ops ledger plumbing ────────────────────────────────────────
|
|
643
|
+
|
|
644
|
+
import uuid as _ledger_uuid
|
|
645
|
+
from src.utils import resolve_ai_ledger as _resolve_ai_ledger
|
|
646
|
+
|
|
647
|
+
# One id per server process so the ledger / dashboard can scope "this session".
|
|
648
|
+
_AI_LEDGER_SESSION_ID = _ledger_uuid.uuid4().hex
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _ai_ledger_root() -> Optional[str]:
|
|
652
|
+
"""Best-effort project_root for the AI-ops ledger. None disables recording."""
|
|
653
|
+
try:
|
|
654
|
+
provider = _destructive_versioning_provider()
|
|
655
|
+
return provider[2] if provider else None
|
|
656
|
+
except Exception:
|
|
657
|
+
return None
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _clip_file_size(item: Any) -> Tuple[Optional[str], Optional[int]]:
|
|
661
|
+
"""(file_path, size_bytes) for a MediaPoolItem, or (path|None, None)."""
|
|
662
|
+
try:
|
|
663
|
+
path = item.GetClipProperty("File Path")
|
|
664
|
+
if isinstance(path, str) and path and os.path.exists(path):
|
|
665
|
+
return path, os.path.getsize(path)
|
|
666
|
+
return (path if isinstance(path, str) and path else None), None
|
|
667
|
+
except Exception:
|
|
668
|
+
return None, None
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _ai_ledger_timed(op: str, *, clip_id: Optional[str] = None):
|
|
672
|
+
"""Ledger context manager bound to the current project_root + session."""
|
|
673
|
+
return _resolve_ai_ledger.timed(
|
|
674
|
+
_ai_ledger_root(), op, clip_id=clip_id, session_id=_AI_LEDGER_SESSION_ID
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
|
|
642
678
|
def _destructive_preference_provider(key: str) -> Any:
|
|
643
679
|
"""Reader for C6 preferences out of the existing media-analysis prefs file."""
|
|
644
680
|
try:
|
|
@@ -658,6 +694,12 @@ _TOKEN_GATED_DESTRUCTIVE_ACTIONS = frozenset({
|
|
|
658
694
|
("timeline", "delete_track"),
|
|
659
695
|
("graph", "apply_grade_from_drx"),
|
|
660
696
|
("graph", "reset_all_grades"),
|
|
697
|
+
# 21.0 AI ops that render/generate NEW media files (additive, but expensive
|
|
698
|
+
# and irreversible without manual cleanup) — gated so they never run by
|
|
699
|
+
# surprise. They never modify source media.
|
|
700
|
+
("folder", "remove_motion_blur"),
|
|
701
|
+
("media_pool_item", "remove_motion_blur"),
|
|
702
|
+
("project_settings", "generate_speech"),
|
|
661
703
|
})
|
|
662
704
|
|
|
663
705
|
|
|
@@ -5404,6 +5446,11 @@ def _transcription_capabilities(mp, p: Dict[str, Any]):
|
|
|
5404
5446
|
"summary": _media_pool_item_summary(clip),
|
|
5405
5447
|
"transcribe_audio": _has_method(clip, "TranscribeAudio"),
|
|
5406
5448
|
"clear_transcription": _has_method(clip, "ClearTranscription"),
|
|
5449
|
+
"perform_audio_classification": _has_method(clip, "PerformAudioClassification"),
|
|
5450
|
+
"clear_audio_classification": _has_method(clip, "ClearAudioClassification"),
|
|
5451
|
+
"analyze_for_intellisearch": _has_method(clip, "AnalyzeForIntellisearch"),
|
|
5452
|
+
"analyze_for_slate": _has_method(clip, "AnalyzeForSlate"),
|
|
5453
|
+
"remove_motion_blur": _has_method(clip, "RemoveMotionBlur"),
|
|
5407
5454
|
}
|
|
5408
5455
|
for clip in clips
|
|
5409
5456
|
],
|
|
@@ -5411,10 +5458,16 @@ def _transcription_capabilities(mp, p: Dict[str, Any]):
|
|
|
5411
5458
|
"name": current_folder.GetName() if current_folder else None,
|
|
5412
5459
|
"transcribe_audio": _has_method(current_folder, "TranscribeAudio") if current_folder else False,
|
|
5413
5460
|
"clear_transcription": _has_method(current_folder, "ClearTranscription") if current_folder else False,
|
|
5461
|
+
"perform_audio_classification": _has_method(current_folder, "PerformAudioClassification") if current_folder else False,
|
|
5462
|
+
"clear_audio_classification": _has_method(current_folder, "ClearAudioClassification") if current_folder else False,
|
|
5463
|
+
"analyze_for_intellisearch": _has_method(current_folder, "AnalyzeForIntellisearch") if current_folder else False,
|
|
5464
|
+
"analyze_for_slate": _has_method(current_folder, "AnalyzeForSlate") if current_folder else False,
|
|
5465
|
+
"remove_motion_blur": _has_method(current_folder, "RemoveMotionBlur") if current_folder else False,
|
|
5414
5466
|
},
|
|
5415
5467
|
"notes": [
|
|
5416
|
-
"This action reports capability only; use media_pool_item/folder
|
|
5417
|
-
"Transcription may require Resolve Studio AI components and can run asynchronously.",
|
|
5468
|
+
"This action reports capability only; use media_pool_item/folder actions to mutate disposable or approved clips.",
|
|
5469
|
+
"Transcription/audio-classification may require Resolve Studio AI components and can run asynchronously.",
|
|
5470
|
+
"analyze_for_intellisearch requires the 'AI IntelliSearch' Extra; analyze_for_slate requires 'AI Slate ID'; remove_motion_blur creates new media and is confirm-gated (Resolve 21+).",
|
|
5418
5471
|
],
|
|
5419
5472
|
}
|
|
5420
5473
|
|
|
@@ -10336,6 +10389,7 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
10336
10389
|
quit() -> {success}
|
|
10337
10390
|
get_fairlight_presets() -> {presets}
|
|
10338
10391
|
set_high_priority() -> {success}
|
|
10392
|
+
disable_background_tasks_for_current_session() -> {success} — Resolve 21+
|
|
10339
10393
|
open_control_panel(port?, host?, open_browser?) -> {success, url, pid, port, status}
|
|
10340
10394
|
— Launches the analysis control panel (src/analysis_dashboard.py) as a background process.
|
|
10341
10395
|
Idempotent: returns the existing URL if already running.
|
|
@@ -10429,7 +10483,13 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
10429
10483
|
return {"presets": _ser(r.GetFairlightPresets())}
|
|
10430
10484
|
elif action == "set_high_priority":
|
|
10431
10485
|
return {"success": bool(r.SetHighPriority())}
|
|
10432
|
-
|
|
10486
|
+
elif action == "disable_background_tasks_for_current_session":
|
|
10487
|
+
missing = _requires_method(r, "DisableBackgroundTasksForCurrentResolveSession", "21.0")
|
|
10488
|
+
if missing:
|
|
10489
|
+
return missing
|
|
10490
|
+
r.DisableBackgroundTasksForCurrentResolveSession()
|
|
10491
|
+
return _ok()
|
|
10492
|
+
return _unknown(action, ["launch","get_version","mcp_update_status","set_mcp_update_policy","ignore_mcp_update","snooze_mcp_update","clear_mcp_update_preferences","get_page","open_page","get_keyframe_mode","set_keyframe_mode","quit","get_fairlight_presets","set_high_priority","disable_background_tasks_for_current_session","open_control_panel","control_panel_status","close_control_panel","save_state","restore_state"])
|
|
10433
10493
|
|
|
10434
10494
|
|
|
10435
10495
|
# ─── V2 C4: Per-field corrections with provenance + changelog ────────────────
|
|
@@ -12327,6 +12387,7 @@ def project_settings(action: str, params: Optional[Dict[str, Any]] = None) -> Di
|
|
|
12327
12387
|
add_color_group(name) -> {success, name}
|
|
12328
12388
|
delete_color_group(name) -> {success}
|
|
12329
12389
|
apply_fairlight_preset(preset_name) -> {success}
|
|
12390
|
+
generate_speech(speech_generation_settings, timecode?) -> {success, new, new_id} — Resolve 21+, AI Speech Generator; creates new audio media (confirm-gated)
|
|
12330
12391
|
"""
|
|
12331
12392
|
p = params or {}
|
|
12332
12393
|
_, proj, err = _check()
|
|
@@ -12376,7 +12437,44 @@ def project_settings(action: str, params: Optional[Dict[str, Any]] = None) -> Di
|
|
|
12376
12437
|
if missing:
|
|
12377
12438
|
return missing
|
|
12378
12439
|
return {"success": bool(proj.ApplyFairlightPresetToCurrentTimeline(p["preset_name"]))}
|
|
12379
|
-
|
|
12440
|
+
elif action == "generate_speech":
|
|
12441
|
+
missing = _requires_method(proj, "GenerateSpeech", "21.0")
|
|
12442
|
+
if missing:
|
|
12443
|
+
return missing
|
|
12444
|
+
settings = _first_param(p, "speech_generation_settings", "speechGenerationSettings", "settings", default=None) or {}
|
|
12445
|
+
if not isinstance(settings, dict) or not settings.get("TextInput"):
|
|
12446
|
+
return _err("generate_speech requires speech_generation_settings with a 'TextInput' string. "
|
|
12447
|
+
"Optional keys: VoiceModel, CustomVoiceFile, Speed, Variation, Pitch, GenerationID, Filename, AddToTimeline, AudioTrack.")
|
|
12448
|
+
timecode = _first_param(p, "timecode", default="") or ""
|
|
12449
|
+
if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required():
|
|
12450
|
+
return _issue_confirm_token(
|
|
12451
|
+
action="project_settings.generate_speech",
|
|
12452
|
+
params=p,
|
|
12453
|
+
preview={
|
|
12454
|
+
"operation": "project_settings.generate_speech",
|
|
12455
|
+
"warning": "Generates a NEW AI text-to-speech audio item"
|
|
12456
|
+
+ (" and adds it to the timeline." if settings.get("AddToTimeline") else "."),
|
|
12457
|
+
"text_input": settings.get("TextInput"),
|
|
12458
|
+
"voice_model": settings.get("VoiceModel"),
|
|
12459
|
+
"add_to_timeline": bool(settings.get("AddToTimeline")),
|
|
12460
|
+
"timecode": timecode,
|
|
12461
|
+
},
|
|
12462
|
+
)
|
|
12463
|
+
blocked = _consume_confirm_token(action="project_settings.generate_speech", params=p)
|
|
12464
|
+
if blocked:
|
|
12465
|
+
return blocked
|
|
12466
|
+
with _ai_ledger_timed("generate_speech") as _rec:
|
|
12467
|
+
new_item = proj.GenerateSpeech(settings, timecode)
|
|
12468
|
+
_rec.success = bool(new_item)
|
|
12469
|
+
if new_item:
|
|
12470
|
+
path, nbytes = _clip_file_size(new_item)
|
|
12471
|
+
_rec.output_path = path
|
|
12472
|
+
_rec.output_bytes = nbytes
|
|
12473
|
+
if not new_item:
|
|
12474
|
+
return {"success": False}
|
|
12475
|
+
return {"success": True, "new": new_item.GetName(), "new_id": new_item.GetUniqueId(),
|
|
12476
|
+
"output_path": _rec.output_path, "output_bytes": _rec.output_bytes}
|
|
12477
|
+
return _unknown(action, ["get_name","set_name","get_setting","set_setting","get_unique_id","get_presets","set_preset","refresh_luts","get_gallery","export_frame_as_still","load_burnin_preset","insert_audio","get_color_groups","add_color_group","delete_color_group","apply_fairlight_preset","generate_speech"])
|
|
12380
12478
|
|
|
12381
12479
|
|
|
12382
12480
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -13316,8 +13414,13 @@ def folder(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
|
|
|
13316
13414
|
is_stale(path?) -> {stale}
|
|
13317
13415
|
get_unique_id(path?) -> {id}
|
|
13318
13416
|
export(path?, export_path) -> {success}
|
|
13319
|
-
transcribe_audio(path?) -> {success}
|
|
13417
|
+
transcribe_audio(path?, use_speaker_detection?) -> {success} — use_speaker_detection is Resolve 21+
|
|
13320
13418
|
clear_transcription(path?) -> {success}
|
|
13419
|
+
perform_audio_classification(path?) -> {success} — Resolve 21+
|
|
13420
|
+
clear_audio_classification(path?) -> {success} — Resolve 21+
|
|
13421
|
+
analyze_for_intellisearch(path?, identify_faces?, is_better_mode?) -> {success} — Resolve 21+, AI IntelliSearch Extra
|
|
13422
|
+
analyze_for_slate(path?, marker_color?) -> {success} — Resolve 21+, AI Slate ID Extra
|
|
13423
|
+
remove_motion_blur(path?, deblur_option?) -> {success, created} — Resolve 21+; renders NEW media (confirm-gated)
|
|
13321
13424
|
"""
|
|
13322
13425
|
p = params or {}
|
|
13323
13426
|
_, _, mp, err = _get_mp()
|
|
@@ -13344,10 +13447,89 @@ def folder(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
|
|
|
13344
13447
|
elif action == "export":
|
|
13345
13448
|
return {"success": bool(f.Export(p["export_path"]))}
|
|
13346
13449
|
elif action == "transcribe_audio":
|
|
13347
|
-
|
|
13450
|
+
usd = _first_param(p, "use_speaker_detection", "useSpeakerDetection")
|
|
13451
|
+
if usd is None:
|
|
13452
|
+
return {"success": bool(f.TranscribeAudio())}
|
|
13453
|
+
return {"success": bool(f.TranscribeAudio(bool(usd)))}
|
|
13348
13454
|
elif action == "clear_transcription":
|
|
13349
13455
|
return {"success": bool(f.ClearTranscription())}
|
|
13350
|
-
|
|
13456
|
+
elif action == "perform_audio_classification":
|
|
13457
|
+
missing = _requires_method(f, "PerformAudioClassification", "21.0")
|
|
13458
|
+
if missing:
|
|
13459
|
+
return missing
|
|
13460
|
+
with _ai_ledger_timed("perform_audio_classification") as _rec:
|
|
13461
|
+
ok = bool(f.PerformAudioClassification())
|
|
13462
|
+
_rec.success = ok
|
|
13463
|
+
return {"success": ok}
|
|
13464
|
+
elif action == "clear_audio_classification":
|
|
13465
|
+
missing = _requires_method(f, "ClearAudioClassification", "21.0")
|
|
13466
|
+
if missing:
|
|
13467
|
+
return missing
|
|
13468
|
+
with _ai_ledger_timed("clear_audio_classification") as _rec:
|
|
13469
|
+
ok = bool(f.ClearAudioClassification())
|
|
13470
|
+
_rec.success = ok
|
|
13471
|
+
return {"success": ok}
|
|
13472
|
+
elif action == "analyze_for_intellisearch":
|
|
13473
|
+
missing = _requires_method(f, "AnalyzeForIntellisearch", "21.0")
|
|
13474
|
+
if missing:
|
|
13475
|
+
return missing
|
|
13476
|
+
identify_faces = bool(_first_param(p, "identify_faces", "identifyFaces", default=False))
|
|
13477
|
+
is_better_mode = bool(_first_param(p, "is_better_mode", "isBetterMode", default=False))
|
|
13478
|
+
with _ai_ledger_timed("analyze_for_intellisearch") as _rec:
|
|
13479
|
+
ok = bool(f.AnalyzeForIntellisearch(identify_faces, is_better_mode))
|
|
13480
|
+
_rec.success = ok
|
|
13481
|
+
return {"success": ok}
|
|
13482
|
+
elif action == "analyze_for_slate":
|
|
13483
|
+
missing = _requires_method(f, "AnalyzeForSlate", "21.0")
|
|
13484
|
+
if missing:
|
|
13485
|
+
return missing
|
|
13486
|
+
marker_color = _first_param(p, "marker_color", "markerColor", default="Blue")
|
|
13487
|
+
if marker_color not in _MARKER_COLORS:
|
|
13488
|
+
return _err(f"Invalid marker_color {marker_color!r}. Valid colors: {', '.join(_MARKER_COLORS)}")
|
|
13489
|
+
with _ai_ledger_timed("analyze_for_slate") as _rec:
|
|
13490
|
+
ok = bool(f.AnalyzeForSlate(marker_color))
|
|
13491
|
+
_rec.success = ok
|
|
13492
|
+
return {"success": ok}
|
|
13493
|
+
elif action == "remove_motion_blur":
|
|
13494
|
+
missing = _requires_method(f, "RemoveMotionBlur", "21.0")
|
|
13495
|
+
if missing:
|
|
13496
|
+
return missing
|
|
13497
|
+
deblur = _first_param(p, "deblur_option", "deblurOption", default=None) or {}
|
|
13498
|
+
if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required():
|
|
13499
|
+
return _issue_confirm_token(
|
|
13500
|
+
action="folder.remove_motion_blur",
|
|
13501
|
+
params=p,
|
|
13502
|
+
preview={
|
|
13503
|
+
"operation": "folder.remove_motion_blur",
|
|
13504
|
+
"warning": "Renders NEW deblurred media files for clips in the folder; source media is not modified.",
|
|
13505
|
+
"folder": f.GetName(),
|
|
13506
|
+
"deblur_option": deblur,
|
|
13507
|
+
},
|
|
13508
|
+
)
|
|
13509
|
+
blocked = _consume_confirm_token(action="folder.remove_motion_blur", params=p)
|
|
13510
|
+
if blocked:
|
|
13511
|
+
return blocked
|
|
13512
|
+
with _ai_ledger_timed("remove_motion_blur") as _rec:
|
|
13513
|
+
result = f.RemoveMotionBlur(deblur)
|
|
13514
|
+
_rec.success = bool(result)
|
|
13515
|
+
created = []
|
|
13516
|
+
total_bytes = 0
|
|
13517
|
+
for pair in (result or []):
|
|
13518
|
+
try:
|
|
13519
|
+
orig, new = pair
|
|
13520
|
+
path, nbytes = _clip_file_size(new)
|
|
13521
|
+
if nbytes:
|
|
13522
|
+
total_bytes += nbytes
|
|
13523
|
+
created.append({"original": orig.GetName(), "new": new.GetName(),
|
|
13524
|
+
"new_id": new.GetUniqueId(), "output_path": path, "output_bytes": nbytes})
|
|
13525
|
+
except Exception:
|
|
13526
|
+
continue
|
|
13527
|
+
# Folder deblur creates many files; record the first path + summed bytes.
|
|
13528
|
+
if created:
|
|
13529
|
+
_rec.output_path = created[0].get("output_path")
|
|
13530
|
+
_rec.output_bytes = total_bytes or None
|
|
13531
|
+
return {"success": bool(result), "created": created}
|
|
13532
|
+
return _unknown(action, ["get_clips","get_name","get_subfolders","is_stale","get_unique_id","export","transcribe_audio","clear_transcription","perform_audio_classification","clear_audio_classification","analyze_for_intellisearch","analyze_for_slate","remove_motion_blur"])
|
|
13351
13533
|
|
|
13352
13534
|
|
|
13353
13535
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -13378,8 +13560,13 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
13378
13560
|
monitor_growing_file(clip_id) -> {success}
|
|
13379
13561
|
replace_clip_preserve_sub_clip(clip_id, path) -> {success}
|
|
13380
13562
|
get_unique_id(clip_id) -> {id}
|
|
13381
|
-
transcribe_audio(clip_id) -> {success}
|
|
13563
|
+
transcribe_audio(clip_id, use_speaker_detection?) -> {success} — use_speaker_detection is Resolve 21+
|
|
13382
13564
|
clear_transcription(clip_id) -> {success}
|
|
13565
|
+
perform_audio_classification(clip_id) -> {success} — Resolve 21+
|
|
13566
|
+
clear_audio_classification(clip_id) -> {success} — Resolve 21+
|
|
13567
|
+
analyze_for_intellisearch(clip_id, identify_faces?, is_better_mode?) -> {success} — Resolve 21+, AI IntelliSearch Extra
|
|
13568
|
+
analyze_for_slate(clip_id, marker_color?) -> {success} — Resolve 21+, AI Slate ID Extra
|
|
13569
|
+
remove_motion_blur(clip_id, deblur_option?) -> {success, new, new_id} — Resolve 21+; renders NEW media (confirm-gated)
|
|
13383
13570
|
get_audio_mapping(clip_id) -> {mapping}
|
|
13384
13571
|
get_mark_in_out(clip_id) -> {mark}
|
|
13385
13572
|
set_mark_in_out(clip_id, mark_in, mark_out, type?) -> {success}
|
|
@@ -13570,9 +13757,79 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
13570
13757
|
elif action == "get_unique_id":
|
|
13571
13758
|
return {"id": clip.GetUniqueId()}
|
|
13572
13759
|
elif action == "transcribe_audio":
|
|
13573
|
-
|
|
13760
|
+
usd = _first_param(p, "use_speaker_detection", "useSpeakerDetection")
|
|
13761
|
+
if usd is None:
|
|
13762
|
+
return {"success": bool(clip.TranscribeAudio())}
|
|
13763
|
+
return {"success": bool(clip.TranscribeAudio(bool(usd)))}
|
|
13574
13764
|
elif action == "clear_transcription":
|
|
13575
13765
|
return {"success": bool(clip.ClearTranscription())}
|
|
13766
|
+
elif action == "perform_audio_classification":
|
|
13767
|
+
missing = _requires_method(clip, "PerformAudioClassification", "21.0")
|
|
13768
|
+
if missing:
|
|
13769
|
+
return missing
|
|
13770
|
+
with _ai_ledger_timed("perform_audio_classification", clip_id=p.get("clip_id")) as _rec:
|
|
13771
|
+
ok = bool(clip.PerformAudioClassification())
|
|
13772
|
+
_rec.success = ok
|
|
13773
|
+
return {"success": ok}
|
|
13774
|
+
elif action == "clear_audio_classification":
|
|
13775
|
+
missing = _requires_method(clip, "ClearAudioClassification", "21.0")
|
|
13776
|
+
if missing:
|
|
13777
|
+
return missing
|
|
13778
|
+
with _ai_ledger_timed("clear_audio_classification", clip_id=p.get("clip_id")) as _rec:
|
|
13779
|
+
ok = bool(clip.ClearAudioClassification())
|
|
13780
|
+
_rec.success = ok
|
|
13781
|
+
return {"success": ok}
|
|
13782
|
+
elif action == "analyze_for_intellisearch":
|
|
13783
|
+
missing = _requires_method(clip, "AnalyzeForIntellisearch", "21.0")
|
|
13784
|
+
if missing:
|
|
13785
|
+
return missing
|
|
13786
|
+
identify_faces = bool(_first_param(p, "identify_faces", "identifyFaces", default=False))
|
|
13787
|
+
is_better_mode = bool(_first_param(p, "is_better_mode", "isBetterMode", default=False))
|
|
13788
|
+
with _ai_ledger_timed("analyze_for_intellisearch", clip_id=p.get("clip_id")) as _rec:
|
|
13789
|
+
ok = bool(clip.AnalyzeForIntellisearch(identify_faces, is_better_mode))
|
|
13790
|
+
_rec.success = ok
|
|
13791
|
+
return {"success": ok}
|
|
13792
|
+
elif action == "analyze_for_slate":
|
|
13793
|
+
missing = _requires_method(clip, "AnalyzeForSlate", "21.0")
|
|
13794
|
+
if missing:
|
|
13795
|
+
return missing
|
|
13796
|
+
marker_color = _first_param(p, "marker_color", "markerColor", default="Blue")
|
|
13797
|
+
if marker_color not in _MARKER_COLORS:
|
|
13798
|
+
return _err(f"Invalid marker_color {marker_color!r}. Valid colors: {', '.join(_MARKER_COLORS)}")
|
|
13799
|
+
with _ai_ledger_timed("analyze_for_slate", clip_id=p.get("clip_id")) as _rec:
|
|
13800
|
+
ok = bool(clip.AnalyzeForSlate(marker_color))
|
|
13801
|
+
_rec.success = ok
|
|
13802
|
+
return {"success": ok}
|
|
13803
|
+
elif action == "remove_motion_blur":
|
|
13804
|
+
missing = _requires_method(clip, "RemoveMotionBlur", "21.0")
|
|
13805
|
+
if missing:
|
|
13806
|
+
return missing
|
|
13807
|
+
deblur = _first_param(p, "deblur_option", "deblurOption", default=None) or {}
|
|
13808
|
+
if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required():
|
|
13809
|
+
return _issue_confirm_token(
|
|
13810
|
+
action="media_pool_item.remove_motion_blur",
|
|
13811
|
+
params=p,
|
|
13812
|
+
preview={
|
|
13813
|
+
"operation": "media_pool_item.remove_motion_blur",
|
|
13814
|
+
"warning": "Renders a NEW deblurred media file; the source clip is not modified.",
|
|
13815
|
+
"clip": clip.GetName(),
|
|
13816
|
+
"deblur_option": deblur,
|
|
13817
|
+
},
|
|
13818
|
+
)
|
|
13819
|
+
blocked = _consume_confirm_token(action="media_pool_item.remove_motion_blur", params=p)
|
|
13820
|
+
if blocked:
|
|
13821
|
+
return blocked
|
|
13822
|
+
with _ai_ledger_timed("remove_motion_blur", clip_id=p.get("clip_id")) as _rec:
|
|
13823
|
+
new_clip = clip.RemoveMotionBlur(deblur)
|
|
13824
|
+
_rec.success = bool(new_clip)
|
|
13825
|
+
if new_clip:
|
|
13826
|
+
path, nbytes = _clip_file_size(new_clip)
|
|
13827
|
+
_rec.output_path = path
|
|
13828
|
+
_rec.output_bytes = nbytes
|
|
13829
|
+
if not new_clip:
|
|
13830
|
+
return {"success": False}
|
|
13831
|
+
return {"success": True, "new": new_clip.GetName(), "new_id": new_clip.GetUniqueId(),
|
|
13832
|
+
"output_path": _rec.output_path, "output_bytes": _rec.output_bytes}
|
|
13576
13833
|
elif action == "get_audio_mapping":
|
|
13577
13834
|
return {"mapping": clip.GetAudioMapping()}
|
|
13578
13835
|
elif action == "get_mark_in_out":
|
|
@@ -13581,7 +13838,7 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
13581
13838
|
return {"success": bool(clip.SetMarkInOut(p["mark_in"], p["mark_out"], p.get("type", "all")))}
|
|
13582
13839
|
elif action == "clear_mark_in_out":
|
|
13583
13840
|
return {"success": bool(clip.ClearMarkInOut(p.get("type", "all")))}
|
|
13584
|
-
return _unknown(action, ["get_name","get_metadata","set_metadata","get_third_party_metadata","set_third_party_metadata","get_media_id","get_clip_property","set_clip_property","get_clip_color","set_clip_color","clear_clip_color","link_proxy","unlink_proxy","replace_clip","set_name","link_full_resolution_media","monitor_growing_file","replace_clip_preserve_sub_clip","get_unique_id","transcribe_audio","clear_transcription","get_audio_mapping","get_mark_in_out","set_mark_in_out","clear_mark_in_out","open_in_viewer"])
|
|
13841
|
+
return _unknown(action, ["get_name","get_metadata","set_metadata","get_third_party_metadata","set_third_party_metadata","get_media_id","get_clip_property","set_clip_property","get_clip_color","set_clip_color","clear_clip_color","link_proxy","unlink_proxy","replace_clip","set_name","link_full_resolution_media","monitor_growing_file","replace_clip_preserve_sub_clip","get_unique_id","transcribe_audio","clear_transcription","perform_audio_classification","clear_audio_classification","analyze_for_intellisearch","analyze_for_slate","remove_motion_blur","get_audio_mapping","get_mark_in_out","set_mark_in_out","clear_mark_in_out","open_in_viewer"])
|
|
13585
13842
|
|
|
13586
13843
|
|
|
13587
13844
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -13721,6 +13978,7 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
|
|
|
13721
13978
|
get_caps(clip_id?, job_id?) -> {preset, caps, presets_available, usage?} — effective caps + usage rollup (vision tokens consumed per scope, percent of budget).
|
|
13722
13979
|
set_caps_preset(preset, overrides?) -> {success, preset, overrides} — preset is minimal | standard | generous | unlimited. Overrides is a dict of {field: int|"unlimited"} that wins over the preset.
|
|
13723
13980
|
get_usage(scope?, scope_key?, clip_id?, job_id?) -> {scope, usage} — raw usage rollup for one scope (clip | job | day).
|
|
13981
|
+
get_resolve_ai_usage(session_only?, op?, limit?) -> {summary, recent} — ledger of Resolve 21 local AI ops (audio classification, IntelliSearch, slate, motion-deblur, speech). Tracks invocations, wall-clock, and files/bytes created by the two media-creating ops. Separate from get_caps/get_usage (those meter Claude-side tokens; these ops don't spend them).
|
|
13724
13982
|
resolve_output_root(analysis_root?, source_paths?) -> {project_root}
|
|
13725
13983
|
plan(target, depth?, analysis_root?, transcription?, vision?, dry_run?) -> {clips, artifacts}
|
|
13726
13984
|
analyze_file(path|file_path, dry_run?, session_only?, persist?) -> {clips, manifest}
|
|
@@ -13873,6 +14131,27 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
|
|
|
13873
14131
|
except Exception as exc:
|
|
13874
14132
|
out["usage_error"] = f"{type(exc).__name__}: {exc}"
|
|
13875
14133
|
return out
|
|
14134
|
+
if action == "get_resolve_ai_usage":
|
|
14135
|
+
# Ledger of Resolve-local 21.0 AI ops (audio classification, IntelliSearch,
|
|
14136
|
+
# slate, motion-deblur, speech generation). Distinct from get_caps/get_usage,
|
|
14137
|
+
# which meter Claude-side analysis tokens — these ops don't spend those.
|
|
14138
|
+
try:
|
|
14139
|
+
vctx = _destructive_versioning_provider()
|
|
14140
|
+
if vctx is None:
|
|
14141
|
+
return _err("No project context — open a Resolve project first")
|
|
14142
|
+
_r, _proj, project_root, _name = vctx
|
|
14143
|
+
session_only = bool(p.get("session_only", False))
|
|
14144
|
+
session_id = _AI_LEDGER_SESSION_ID if session_only else None
|
|
14145
|
+
limit = int(p.get("limit", 50))
|
|
14146
|
+
return {
|
|
14147
|
+
"success": True,
|
|
14148
|
+
"session_id": _AI_LEDGER_SESSION_ID,
|
|
14149
|
+
"scope": "session" if session_only else "project",
|
|
14150
|
+
"summary": _resolve_ai_ledger.get_summary(project_root=project_root, session_id=session_id),
|
|
14151
|
+
"recent": _resolve_ai_ledger.get_usage(project_root=project_root, session_id=session_id, op=p.get("op"), limit=limit),
|
|
14152
|
+
}
|
|
14153
|
+
except Exception as exc:
|
|
14154
|
+
return _err(f"{type(exc).__name__}: {exc}")
|
|
13876
14155
|
if action == "set_caps_preset":
|
|
13877
14156
|
preset = (p.get("preset") or "").strip().lower()
|
|
13878
14157
|
from src.utils import analysis_caps as _ac
|
|
@@ -20106,9 +20385,9 @@ def _resource_install_guidance() -> Dict[str, Any]:
|
|
|
20106
20385
|
if __name__ == "__main__":
|
|
20107
20386
|
start_background_update_check(VERSION, project_dir, logger, env=_setup_update_env())
|
|
20108
20387
|
|
|
20109
|
-
# Support --full flag to run the
|
|
20388
|
+
# Support --full flag to run the 341-tool granular server instead
|
|
20110
20389
|
if "--full" in sys.argv:
|
|
20111
|
-
logger.info("Starting full
|
|
20390
|
+
logger.info("Starting full 341-tool granular server...")
|
|
20112
20391
|
sys.argv = [arg for arg in sys.argv if arg != "--full"]
|
|
20113
20392
|
from src.granular import mcp as granular_mcp
|
|
20114
20393
|
|