davinci-resolve-mcp 2.64.0 → 2.66.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 +80 -0
- package/README.md +1 -1
- package/docs/guides/media-analysis-guide.md +46 -0
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/analysis_dashboard.py +154 -17
- package/src/control_panel_i18n.py +780 -0
- package/src/granular/common.py +1 -1
- package/src/server.py +1 -1
- package/src/utils/media_analysis.py +231 -3
package/src/granular/common.py
CHANGED
|
@@ -81,7 +81,7 @@ if not logging.getLogger().handlers:
|
|
|
81
81
|
handlers=[logging.StreamHandler()],
|
|
82
82
|
)
|
|
83
83
|
|
|
84
|
-
VERSION = "2.
|
|
84
|
+
VERSION = "2.66.0"
|
|
85
85
|
logger = logging.getLogger("davinci-resolve-mcp")
|
|
86
86
|
logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
|
|
87
87
|
logger.info(f"Detected platform: {get_platform()}")
|
package/src/server.py
CHANGED
|
@@ -22,6 +22,9 @@ import subprocess
|
|
|
22
22
|
import sys
|
|
23
23
|
import threading
|
|
24
24
|
import time
|
|
25
|
+
import urllib.error
|
|
26
|
+
import urllib.parse
|
|
27
|
+
import urllib.request
|
|
25
28
|
from datetime import datetime, timezone
|
|
26
29
|
from pathlib import Path
|
|
27
30
|
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple
|
|
@@ -354,6 +357,9 @@ _ensure_path_includes_standard_tool_dirs()
|
|
|
354
357
|
ANALYSIS_DIR_NAME = "davinci-resolve-mcp-analysis"
|
|
355
358
|
HIDDEN_ANALYSIS_DIR_NAME = ".davinci-resolve-mcp-analysis"
|
|
356
359
|
ANALYSIS_VERSION = "0.2"
|
|
360
|
+
|
|
361
|
+
HTTP_TRANSCRIPTION_PROVIDERS_ENV = "DAVINCI_RESOLVE_MCP_TRANSCRIPTION_HTTP_PROVIDERS"
|
|
362
|
+
HTTP_TRANSCRIPTION_BACKEND_PREFIX = "http:"
|
|
357
363
|
ANALYSIS_INDEX_FILENAME = "index.sqlite"
|
|
358
364
|
ANALYSIS_REGISTRY_FILENAME = "analysis_registry.json"
|
|
359
365
|
ANALYSIS_INDEX_SCHEMA_VERSION = 1
|
|
@@ -1606,6 +1612,115 @@ def install_plan_for(tool_name: str, platform_id: Optional[str] = None) -> Dict[
|
|
|
1606
1612
|
}
|
|
1607
1613
|
|
|
1608
1614
|
|
|
1615
|
+
def _http_provider_endpoint(provider: Dict[str, Any], path_key: str) -> str:
|
|
1616
|
+
path = str(provider[path_key]).strip()
|
|
1617
|
+
if path.startswith(("http://", "https://")):
|
|
1618
|
+
return path
|
|
1619
|
+
return f"{provider['base_url']}/{path.lstrip('/')}"
|
|
1620
|
+
|
|
1621
|
+
|
|
1622
|
+
def _load_http_transcription_providers(env: Dict[str, str]) -> Tuple[List[Dict[str, Any]], Optional[str]]:
|
|
1623
|
+
raw = (env.get(HTTP_TRANSCRIPTION_PROVIDERS_ENV) or "").strip()
|
|
1624
|
+
if not raw:
|
|
1625
|
+
return [], None
|
|
1626
|
+
try:
|
|
1627
|
+
entries = json.loads(raw)
|
|
1628
|
+
except json.JSONDecodeError as exc:
|
|
1629
|
+
return [], f"{HTTP_TRANSCRIPTION_PROVIDERS_ENV} is not valid JSON: {exc}"
|
|
1630
|
+
if not isinstance(entries, list):
|
|
1631
|
+
return [], f"{HTTP_TRANSCRIPTION_PROVIDERS_ENV} must be a JSON array"
|
|
1632
|
+
|
|
1633
|
+
providers: List[Dict[str, Any]] = []
|
|
1634
|
+
seen_ids = set()
|
|
1635
|
+
for index, entry in enumerate(entries):
|
|
1636
|
+
if not isinstance(entry, dict):
|
|
1637
|
+
return [], f"HTTP transcription provider at index {index} must be an object"
|
|
1638
|
+
provider_id = str(entry.get("id") or "").strip()
|
|
1639
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}", provider_id):
|
|
1640
|
+
return [], f"HTTP transcription provider at index {index} has an invalid id"
|
|
1641
|
+
if provider_id in seen_ids:
|
|
1642
|
+
return [], f"Duplicate HTTP transcription provider id: {provider_id}"
|
|
1643
|
+
seen_ids.add(provider_id)
|
|
1644
|
+
|
|
1645
|
+
base_url = str(entry.get("base_url") or "").strip().rstrip("/")
|
|
1646
|
+
parsed_url = urllib.parse.urlparse(base_url)
|
|
1647
|
+
if parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc:
|
|
1648
|
+
return [], f"HTTP transcription provider '{provider_id}' base_url must use http:// or https://"
|
|
1649
|
+
headers = entry.get("headers") or {}
|
|
1650
|
+
request_body = entry.get("request_body") or {}
|
|
1651
|
+
field_map = entry.get("field_map") or {}
|
|
1652
|
+
if not isinstance(headers, dict) or not all(isinstance(k, str) and isinstance(v, str) for k, v in headers.items()):
|
|
1653
|
+
return [], f"HTTP transcription provider '{provider_id}' headers must be a string map"
|
|
1654
|
+
if not isinstance(request_body, dict):
|
|
1655
|
+
return [], f"HTTP transcription provider '{provider_id}' request_body must be an object"
|
|
1656
|
+
if not isinstance(field_map, dict) or not all(isinstance(k, str) and isinstance(v, str) for k, v in field_map.items()):
|
|
1657
|
+
return [], f"HTTP transcription provider '{provider_id}' field_map must be a string map"
|
|
1658
|
+
|
|
1659
|
+
label = str(entry.get("label") or provider_id).strip() or provider_id
|
|
1660
|
+
providers.append({
|
|
1661
|
+
"id": provider_id,
|
|
1662
|
+
"label": label,
|
|
1663
|
+
"base_url": base_url,
|
|
1664
|
+
"model": str(entry.get("model") or "").strip() or None,
|
|
1665
|
+
"health_path": str(entry.get("health_path") or "/health").strip(),
|
|
1666
|
+
"transcribe_path": str(entry.get("transcribe_path") or "/stt").strip(),
|
|
1667
|
+
"health_field": str(entry.get("health_field") or "status").strip(),
|
|
1668
|
+
"health_value": entry.get("health_value", "ok"),
|
|
1669
|
+
"response_field": str(entry.get("response_field", "transcript")).strip(),
|
|
1670
|
+
"headers": dict(headers),
|
|
1671
|
+
"request_body": dict(request_body),
|
|
1672
|
+
"field_map": dict(field_map),
|
|
1673
|
+
})
|
|
1674
|
+
return providers, None
|
|
1675
|
+
|
|
1676
|
+
|
|
1677
|
+
def _detect_http_transcription_providers(env: Dict[str, str]) -> Dict[str, Any]:
|
|
1678
|
+
providers, config_error = _load_http_transcription_providers(env)
|
|
1679
|
+
if config_error:
|
|
1680
|
+
return {
|
|
1681
|
+
"available": False,
|
|
1682
|
+
"configured": True,
|
|
1683
|
+
"config_env": HTTP_TRANSCRIPTION_PROVIDERS_ENV,
|
|
1684
|
+
"error": config_error,
|
|
1685
|
+
"providers": [],
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
detected = []
|
|
1689
|
+
for provider in providers:
|
|
1690
|
+
public = {
|
|
1691
|
+
"id": provider["id"],
|
|
1692
|
+
"label": provider["label"],
|
|
1693
|
+
"url": provider["base_url"],
|
|
1694
|
+
"model": provider["model"],
|
|
1695
|
+
"backend": f"{HTTP_TRANSCRIPTION_BACKEND_PREFIX}{provider['id']}",
|
|
1696
|
+
}
|
|
1697
|
+
try:
|
|
1698
|
+
request = urllib.request.Request(
|
|
1699
|
+
_http_provider_endpoint(provider, "health_path"),
|
|
1700
|
+
headers=provider["headers"],
|
|
1701
|
+
method="GET",
|
|
1702
|
+
)
|
|
1703
|
+
with urllib.request.urlopen(request, timeout=1.0) as response:
|
|
1704
|
+
payload = json.loads(response.read().decode("utf-8"))
|
|
1705
|
+
if not isinstance(payload, dict):
|
|
1706
|
+
raise ValueError("health response must be a JSON object")
|
|
1707
|
+
if payload.get(provider["health_field"]) != provider["health_value"]:
|
|
1708
|
+
raise ValueError(
|
|
1709
|
+
f"health field '{provider['health_field']}' did not equal the configured ready value"
|
|
1710
|
+
)
|
|
1711
|
+
public.update({"available": True, "api_version": payload.get("api_version")})
|
|
1712
|
+
except (OSError, ValueError) as exc:
|
|
1713
|
+
public.update({"available": False, "error": str(exc)})
|
|
1714
|
+
detected.append(public)
|
|
1715
|
+
|
|
1716
|
+
return {
|
|
1717
|
+
"available": any(provider["available"] for provider in detected),
|
|
1718
|
+
"configured": bool(providers),
|
|
1719
|
+
"config_env": HTTP_TRANSCRIPTION_PROVIDERS_ENV,
|
|
1720
|
+
"providers": detected,
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
|
|
1609
1724
|
def detect_capabilities(env: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
|
|
1610
1725
|
"""Detect available analysis helpers without installing or downloading."""
|
|
1611
1726
|
env = env if env is not None else os.environ
|
|
@@ -1618,6 +1733,7 @@ def detect_capabilities(env: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
|
|
|
1618
1733
|
mlx_whisper = importlib.util.find_spec("mlx_whisper") is not None
|
|
1619
1734
|
cv2 = importlib.util.find_spec("cv2") is not None
|
|
1620
1735
|
provider = env.get("DAVINCI_RESOLVE_MCP_VISION_PROVIDER")
|
|
1736
|
+
http_transcription = _detect_http_transcription_providers(env)
|
|
1621
1737
|
|
|
1622
1738
|
sync_events = detect_sync_event_capabilities()
|
|
1623
1739
|
|
|
@@ -1653,6 +1769,7 @@ def detect_capabilities(env: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
|
|
|
1653
1769
|
"whisper_cli": _tool_entry("whisper_cli", bool(whisper_cli), {"path": whisper_cli}),
|
|
1654
1770
|
"whisper_cpp": _tool_entry("whisper_cpp", bool(whisper_cpp), {"path": whisper_cpp}),
|
|
1655
1771
|
"mlx_whisper": _tool_entry("mlx_whisper", bool(mlx_whisper), {"python_module": "mlx_whisper"}),
|
|
1772
|
+
"http_transcription": http_transcription,
|
|
1656
1773
|
"opencv": _tool_entry("opencv", bool(cv2), {"python_module": "cv2"}),
|
|
1657
1774
|
"ollama_embeddings": _tool_entry(
|
|
1658
1775
|
"ollama_embeddings",
|
|
@@ -1672,8 +1789,12 @@ def detect_capabilities(env: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
|
|
|
1672
1789
|
},
|
|
1673
1790
|
"embeddings": embedding_caps,
|
|
1674
1791
|
"transcription": {
|
|
1675
|
-
"available": bool(whisper_cli or whisper_cpp or mlx_whisper),
|
|
1792
|
+
"available": bool(http_transcription["available"] or whisper_cli or whisper_cpp or mlx_whisper),
|
|
1676
1793
|
"backends": [
|
|
1794
|
+
provider["backend"]
|
|
1795
|
+
for provider in http_transcription["providers"]
|
|
1796
|
+
if provider["available"]
|
|
1797
|
+
] + [
|
|
1677
1798
|
name for name, available in (
|
|
1678
1799
|
("whisper_cli", bool(whisper_cli)),
|
|
1679
1800
|
("whisper_cpp", bool(whisper_cpp)),
|
|
@@ -1730,12 +1851,13 @@ def install_guidance(capabilities: Optional[Dict[str, Any]] = None) -> Dict[str,
|
|
|
1730
1851
|
missing["transcription"] = {
|
|
1731
1852
|
"required_for": ["transcription analysis", "default Resolve media analysis"],
|
|
1732
1853
|
"options": [
|
|
1854
|
+
f"Configure one or more HTTP providers with {HTTP_TRANSCRIPTION_PROVIDERS_ENV}",
|
|
1733
1855
|
"Install/configure whisper CLI",
|
|
1734
1856
|
"Install/configure whisper-cpp",
|
|
1735
1857
|
"Install mlx-whisper on supported Apple Silicon systems",
|
|
1736
1858
|
],
|
|
1737
1859
|
"macos": "Ask the user before running: brew install whisper-cpp, or configure another supported local Whisper backend.",
|
|
1738
|
-
"note": "The MCP server must not install
|
|
1860
|
+
"note": "The MCP server must not install backends or download model files automatically.",
|
|
1739
1861
|
}
|
|
1740
1862
|
if not tools.get("opencv", {}).get("available"):
|
|
1741
1863
|
missing["opencv"] = {
|
|
@@ -3934,6 +4056,92 @@ def _transcribe_with_mlx_whisper(path: str, artifacts: Dict[str, Any], transcrip
|
|
|
3934
4056
|
return payload
|
|
3935
4057
|
|
|
3936
4058
|
|
|
4059
|
+
def _transcribe_with_http_provider(
|
|
4060
|
+
path: str,
|
|
4061
|
+
artifacts: Dict[str, Any],
|
|
4062
|
+
transcription: Dict[str, Any],
|
|
4063
|
+
provider: Dict[str, Any],
|
|
4064
|
+
) -> Dict[str, Any]:
|
|
4065
|
+
backend = f"{HTTP_TRANSCRIPTION_BACKEND_PREFIX}{provider['id']}"
|
|
4066
|
+
model = str(transcription.get("model") or provider.get("model") or "").strip()
|
|
4067
|
+
transcript_json = artifacts.get("transcript_json") or artifacts["analysis_json"]
|
|
4068
|
+
output_base = os.path.splitext(transcript_json)[0]
|
|
4069
|
+
canonical_payload = {
|
|
4070
|
+
"audio": path,
|
|
4071
|
+
"output_path": output_base,
|
|
4072
|
+
"format": "json",
|
|
4073
|
+
"verbose": False,
|
|
4074
|
+
"allow_download": _coerce_bool(transcription.get("allow_model_download"), default=False),
|
|
4075
|
+
}
|
|
4076
|
+
if model:
|
|
4077
|
+
canonical_payload["model"] = model
|
|
4078
|
+
if transcription.get("language"):
|
|
4079
|
+
canonical_payload["language"] = transcription["language"]
|
|
4080
|
+
request_payload = dict(provider.get("request_body") or {})
|
|
4081
|
+
field_map = provider.get("field_map") or {}
|
|
4082
|
+
for key, value in canonical_payload.items():
|
|
4083
|
+
request_payload[field_map.get(key, key)] = value
|
|
4084
|
+
headers = {"Content-Type": "application/json"}
|
|
4085
|
+
headers.update(provider.get("headers") or {})
|
|
4086
|
+
request = urllib.request.Request(
|
|
4087
|
+
_http_provider_endpoint(provider, "transcribe_path"),
|
|
4088
|
+
data=json.dumps(request_payload).encode("utf-8"),
|
|
4089
|
+
headers=headers,
|
|
4090
|
+
method="POST",
|
|
4091
|
+
)
|
|
4092
|
+
try:
|
|
4093
|
+
with urllib.request.urlopen(request, timeout=int(transcription.get("timeout", 1800))) as response:
|
|
4094
|
+
response_payload = json.loads(response.read().decode("utf-8"))
|
|
4095
|
+
except urllib.error.HTTPError as exc:
|
|
4096
|
+
detail = exc.read().decode("utf-8", errors="replace").strip()
|
|
4097
|
+
return {
|
|
4098
|
+
"success": False,
|
|
4099
|
+
"backend": backend,
|
|
4100
|
+
"provider": provider["label"],
|
|
4101
|
+
"error": detail or f"HTTP transcription provider returned HTTP {exc.code}",
|
|
4102
|
+
}
|
|
4103
|
+
except (OSError, ValueError) as exc:
|
|
4104
|
+
return {"success": False, "backend": backend, "provider": provider["label"], "error": str(exc)}
|
|
4105
|
+
|
|
4106
|
+
if not isinstance(response_payload, dict):
|
|
4107
|
+
return {
|
|
4108
|
+
"success": False,
|
|
4109
|
+
"backend": backend,
|
|
4110
|
+
"provider": provider["label"],
|
|
4111
|
+
"error": "HTTP transcription response must be a JSON object.",
|
|
4112
|
+
}
|
|
4113
|
+
response_field = provider.get("response_field")
|
|
4114
|
+
if response_field and response_field not in response_payload:
|
|
4115
|
+
return {
|
|
4116
|
+
"success": False,
|
|
4117
|
+
"backend": backend,
|
|
4118
|
+
"provider": provider["label"],
|
|
4119
|
+
"error": f"HTTP transcription response did not include '{response_field}'.",
|
|
4120
|
+
}
|
|
4121
|
+
raw_transcript = response_payload[response_field] if response_field else response_payload
|
|
4122
|
+
if isinstance(raw_transcript, str):
|
|
4123
|
+
try:
|
|
4124
|
+
raw = json.loads(raw_transcript)
|
|
4125
|
+
except json.JSONDecodeError:
|
|
4126
|
+
raw = {"text": raw_transcript, "segments": []}
|
|
4127
|
+
else:
|
|
4128
|
+
raw = raw_transcript
|
|
4129
|
+
if not isinstance(raw, dict):
|
|
4130
|
+
return {
|
|
4131
|
+
"success": False,
|
|
4132
|
+
"backend": backend,
|
|
4133
|
+
"provider": provider["label"],
|
|
4134
|
+
"error": "HTTP transcription payload must resolve to a JSON object or text string.",
|
|
4135
|
+
}
|
|
4136
|
+
|
|
4137
|
+
payload = _normalize_transcript_payload(raw, backend, transcription.get("language"))
|
|
4138
|
+
payload["provider"] = provider["label"]
|
|
4139
|
+
if response_payload.get("model") or model:
|
|
4140
|
+
payload["model"] = response_payload.get("model") or model
|
|
4141
|
+
_write_transcript_artifacts(payload, artifacts)
|
|
4142
|
+
return payload
|
|
4143
|
+
|
|
4144
|
+
|
|
3937
4145
|
def _transcribe(path: str, artifacts: Dict[str, Any], options: Dict[str, Any], capabilities: Dict[str, Any]) -> Dict[str, Any]:
|
|
3938
4146
|
transcription = options.get("transcription") or {}
|
|
3939
4147
|
if not _coerce_bool(transcription.get("enabled"), default=DEFAULT_TRANSCRIPTION_ENABLED):
|
|
@@ -3975,6 +4183,20 @@ def _transcribe(path: str, artifacts: Dict[str, Any], options: Dict[str, Any], c
|
|
|
3975
4183
|
payload = {"success": True, "backend": backend, "language": transcription.get("language", "unknown"), "segments": segments, "text": " ".join(s.get("text", "") for s in segments)}
|
|
3976
4184
|
_write_transcript_artifacts(payload, artifacts)
|
|
3977
4185
|
return payload
|
|
4186
|
+
if isinstance(backend, str) and backend.startswith(HTTP_TRANSCRIPTION_BACKEND_PREFIX):
|
|
4187
|
+
providers, config_error = _load_http_transcription_providers(os.environ)
|
|
4188
|
+
if config_error:
|
|
4189
|
+
return {"success": False, "backend": backend, "error": config_error}
|
|
4190
|
+
provider_id = backend[len(HTTP_TRANSCRIPTION_BACKEND_PREFIX):]
|
|
4191
|
+
provider = next((item for item in providers if item["id"] == provider_id), None)
|
|
4192
|
+
if provider is None:
|
|
4193
|
+
return {
|
|
4194
|
+
"success": False,
|
|
4195
|
+
"status": "skipped",
|
|
4196
|
+
"backend": backend,
|
|
4197
|
+
"reason": f"HTTP transcription provider '{provider_id}' is not configured.",
|
|
4198
|
+
}
|
|
4199
|
+
return _transcribe_with_http_provider(path, artifacts, transcription, provider)
|
|
3978
4200
|
if backend in {"whisper_cli", "mlx_whisper"}:
|
|
3979
4201
|
if not _coerce_bool(transcription.get("allow_model_download"), default=False):
|
|
3980
4202
|
return {
|
|
@@ -4016,7 +4238,13 @@ def _transcribe(path: str, artifacts: Dict[str, Any], options: Dict[str, Any], c
|
|
|
4016
4238
|
# original branches below so behaviour stays identical for those.
|
|
4017
4239
|
if result is not None and result.get("status") != "fallthrough":
|
|
4018
4240
|
return result
|
|
4019
|
-
if
|
|
4241
|
+
if (
|
|
4242
|
+
backend in {"mock", "local_mock", "whisper_cli", "mlx_whisper"}
|
|
4243
|
+
or (
|
|
4244
|
+
isinstance(backend, str)
|
|
4245
|
+
and backend.startswith(HTTP_TRANSCRIPTION_BACKEND_PREFIX)
|
|
4246
|
+
)
|
|
4247
|
+
):
|
|
4020
4248
|
return result if result is not None else {"success": False, "backend": backend}
|
|
4021
4249
|
elif backend == "whisper_cpp":
|
|
4022
4250
|
if not transcription.get("model_path"):
|