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.
@@ -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()}
@@ -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} (329 granular tools)")
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 329-tool granular server instead
11
+ python src/server.py --full # Start the 341-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.28.1"
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 transcription actions to mutate disposable or approved clips.",
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
- 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","open_control_panel","control_panel_status","close_control_panel","save_state","restore_state"])
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
- 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"])
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
- return {"success": bool(f.TranscribeAudio())}
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
- return _unknown(action, ["get_clips","get_name","get_subfolders","is_stale","get_unique_id","export","transcribe_audio","clear_transcription"])
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
- return {"success": bool(clip.TranscribeAudio())}
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 329-tool granular server instead
20388
+ # Support --full flag to run the 341-tool granular server instead
20110
20389
  if "--full" in sys.argv:
20111
- logger.info("Starting full 329-tool granular server...")
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