davinci-resolve-mcp 2.37.3 → 2.38.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 +17 -0
- package/README.md +1 -1
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/granular/common.py +1 -1
- package/src/server.py +42 -13
- package/src/utils/resolve_busy.py +171 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
Release history for the DaVinci Resolve MCP Server. The latest release is summarized in the root README; older entries live here to keep the README focused.
|
|
4
4
|
|
|
5
|
+
## What's New in v2.38.0
|
|
6
|
+
|
|
7
|
+
Busy gate for long DaVinci Resolve operations — the first piece of the
|
|
8
|
+
concurrency design for the stdio + networked two-instance setup.
|
|
9
|
+
|
|
10
|
+
- **Added** cross-process registration for long synchronous Resolve calls
|
|
11
|
+
(timeline export/import, scene-cut detection, subtitle generation,
|
|
12
|
+
Dolby Vision analysis, folder/clip audio transcription). A tool call that
|
|
13
|
+
arrives while one is running now waits up to 5 seconds and then returns a
|
|
14
|
+
structured `RESOLVE_BUSY` error (new `busy` category, retryable, with
|
|
15
|
+
`state.busy_with` and `state.age_seconds`) instead of hanging silently
|
|
16
|
+
inside the scripting bridge. Stale registrations from crashed operations
|
|
17
|
+
are ignored automatically; an operation never gates its own thread.
|
|
18
|
+
- Design decisions recorded: single-editor/multi-client is the supported
|
|
19
|
+
concurrency target; confirm tokens stay per-instance; actor identity is
|
|
20
|
+
deferred to the governance phase.
|
|
21
|
+
|
|
5
22
|
## What's New in v2.37.3
|
|
6
23
|
|
|
7
24
|
Deep-audit fixes, rounds two and three: agent-facing action discovery,
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# DaVinci Resolve MCP Server
|
|
2
2
|
|
|
3
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
4
4
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
5
5
|
[](docs/reference/api-coverage.md)
|
|
6
6
|
[-blue.svg)](#server-modes)
|
package/install.py
CHANGED
|
@@ -35,7 +35,7 @@ from src.utils.update_check import (
|
|
|
35
35
|
|
|
36
36
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
37
37
|
|
|
38
|
-
VERSION = "2.
|
|
38
|
+
VERSION = "2.38.0"
|
|
39
39
|
# Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
|
|
40
40
|
# Resolve's scripting bridge loads into newer interpreters on recent builds
|
|
41
41
|
# (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
|
package/package.json
CHANGED
package/src/granular/common.py
CHANGED
|
@@ -80,7 +80,7 @@ if not logging.getLogger().handlers:
|
|
|
80
80
|
handlers=[logging.StreamHandler()],
|
|
81
81
|
)
|
|
82
82
|
|
|
83
|
-
VERSION = "2.
|
|
83
|
+
VERSION = "2.38.0"
|
|
84
84
|
logger = logging.getLogger("davinci-resolve-mcp")
|
|
85
85
|
logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
|
|
86
86
|
logger.info(f"Detected platform: {get_platform()}")
|
package/src/server.py
CHANGED
|
@@ -11,7 +11,7 @@ Usage:
|
|
|
11
11
|
python src/server.py --full # Start the 341-tool granular server instead
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
|
-
VERSION = "2.
|
|
14
|
+
VERSION = "2.38.0"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -84,6 +84,8 @@ from src.utils.media_analysis import (
|
|
|
84
84
|
summarize_reports as summarize_media_analysis_reports,
|
|
85
85
|
)
|
|
86
86
|
from src.utils.sync_detection import detect_sync_events_for_records as detect_media_sync_events
|
|
87
|
+
from src.utils import resolve_busy
|
|
88
|
+
from src.utils.resolve_busy import long_resolve_op
|
|
87
89
|
from src.utils.media_analysis_jobs import (
|
|
88
90
|
MEDIA_EXTENSIONS,
|
|
89
91
|
batch_job_status as media_analysis_batch_job_status,
|
|
@@ -830,6 +832,7 @@ ERROR_CATEGORIES = (
|
|
|
830
832
|
"wrong_page", # action requires a specific page (Color, Edit, Fairlight…)
|
|
831
833
|
"invalid_input", # caller-side fixable (bad param shape, unknown enum)
|
|
832
834
|
"resolve_api_failed", # Resolve returned None/False for unclear reasons
|
|
835
|
+
"busy", # another long Resolve op holds the bridge; retry later
|
|
833
836
|
"destructive_blocked", # strict-mode/confirm-token refusal
|
|
834
837
|
"pending_user_decision", # confirm_token required
|
|
835
838
|
"unsupported", # feature/version/method not available
|
|
@@ -846,6 +849,7 @@ _CATEGORY_RETRYABLE_DEFAULT: Dict[str, bool] = {
|
|
|
846
849
|
"wrong_page": True, # trivial caller fix; retry after page switch
|
|
847
850
|
"invalid_input": False, # caller fix required
|
|
848
851
|
"resolve_api_failed": True, # often transient; retry once
|
|
852
|
+
"busy": True, # another long Resolve op holds the bridge
|
|
849
853
|
"destructive_blocked": False, # user decision required
|
|
850
854
|
"pending_user_decision": False, # confirm_token required
|
|
851
855
|
"unsupported": False, # API/version mismatch
|
|
@@ -1671,6 +1675,19 @@ def _annotation_boundary_report(tl, p: Dict[str, Any]):
|
|
|
1671
1675
|
}
|
|
1672
1676
|
|
|
1673
1677
|
def _check():
|
|
1678
|
+
busy = resolve_busy.wait_until_free()
|
|
1679
|
+
if busy:
|
|
1680
|
+
return None, None, _err(
|
|
1681
|
+
f"Resolve is busy with a long operation: {busy['label']} "
|
|
1682
|
+
f"(running for {busy['age_seconds']}s). Retry after it completes.",
|
|
1683
|
+
code="RESOLVE_BUSY", category="busy",
|
|
1684
|
+
remediation="Wait for the named operation to finish, then retry this call.",
|
|
1685
|
+
state={
|
|
1686
|
+
"busy_with": busy["label"],
|
|
1687
|
+
"age_seconds": busy["age_seconds"],
|
|
1688
|
+
"same_process": busy["same_process"],
|
|
1689
|
+
},
|
|
1690
|
+
)
|
|
1674
1691
|
resolve = get_resolve()
|
|
1675
1692
|
if resolve is None:
|
|
1676
1693
|
return None, None, _err(
|
|
@@ -4916,7 +4933,8 @@ def _export_timeline_checked(tl, p: Dict[str, Any]):
|
|
|
4916
4933
|
spec = _timeline_export_spec(p, resolve)
|
|
4917
4934
|
if p.get("dry_run"):
|
|
4918
4935
|
return _ok(path=path, would_export=True, spec={k: v for k, v in spec.items() if k != "export_type"})
|
|
4919
|
-
|
|
4936
|
+
with long_resolve_op("timeline.export_timeline_checked"):
|
|
4937
|
+
success = bool(tl.Export(path, spec["export_type"], spec["export_subtype"]))
|
|
4920
4938
|
files = []
|
|
4921
4939
|
primary_file = path
|
|
4922
4940
|
if success and os.path.isdir(path):
|
|
@@ -4969,7 +4987,8 @@ def _import_timeline_checked(proj, mp, p: Dict[str, Any]):
|
|
|
4969
4987
|
tl_existing = proj.GetTimelineByIndex(index)
|
|
4970
4988
|
if tl_existing:
|
|
4971
4989
|
before_ids.add(str(tl_existing.GetUniqueId()))
|
|
4972
|
-
|
|
4990
|
+
with long_resolve_op("timeline.import_timeline_checked"):
|
|
4991
|
+
imported = mp.ImportTimelineFromFile(path, options)
|
|
4973
4992
|
if not imported:
|
|
4974
4993
|
return _err("Failed to import timeline")
|
|
4975
4994
|
imported_id = str(imported.GetUniqueId())
|
|
@@ -5937,7 +5956,8 @@ def _subtitle_generation_probe(tl, p: Dict[str, Any]):
|
|
|
5937
5956
|
return _ok(would_generate=True, settings=settings, note="Pass allow_generate=True to call CreateSubtitlesFromAudio.")
|
|
5938
5957
|
if not _has_method(tl, "CreateSubtitlesFromAudio"):
|
|
5939
5958
|
return _err("CreateSubtitlesFromAudio unavailable")
|
|
5940
|
-
|
|
5959
|
+
with long_resolve_op("timeline.create_subtitles_from_audio"):
|
|
5960
|
+
return {"success": bool(tl.CreateSubtitlesFromAudio(settings)), "settings": settings}
|
|
5941
5961
|
|
|
5942
5962
|
|
|
5943
5963
|
def _fairlight_boundary_report(proj, mp, tl, p: Dict[str, Any]):
|
|
@@ -13811,7 +13831,8 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
|
|
|
13811
13831
|
elif action == "setup_multicam_timeline":
|
|
13812
13832
|
return _setup_multicam_timeline(proj, mp, p)
|
|
13813
13833
|
elif action == "import_timeline":
|
|
13814
|
-
|
|
13834
|
+
with long_resolve_op("media_pool.import_timeline"):
|
|
13835
|
+
tl = mp.ImportTimelineFromFile(p["path"], p.get("options", {}))
|
|
13815
13836
|
return _ok(name=tl.GetName()) if tl else _err("Failed to import timeline")
|
|
13816
13837
|
elif action == "delete_timelines":
|
|
13817
13838
|
count = proj.GetTimelineCount()
|
|
@@ -14021,8 +14042,10 @@ def folder(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
|
|
|
14021
14042
|
elif action == "transcribe_audio":
|
|
14022
14043
|
usd = _first_param(p, "use_speaker_detection", "useSpeakerDetection")
|
|
14023
14044
|
if usd is None:
|
|
14024
|
-
|
|
14025
|
-
|
|
14045
|
+
with long_resolve_op("folder.transcribe_audio"):
|
|
14046
|
+
return {"success": bool(f.TranscribeAudio())}
|
|
14047
|
+
with long_resolve_op("folder.transcribe_audio"):
|
|
14048
|
+
return {"success": bool(f.TranscribeAudio(bool(usd)))}
|
|
14026
14049
|
elif action == "clear_transcription":
|
|
14027
14050
|
return {"success": bool(f.ClearTranscription())}
|
|
14028
14051
|
elif action == "perform_audio_classification":
|
|
@@ -14338,8 +14361,10 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
14338
14361
|
elif action == "transcribe_audio":
|
|
14339
14362
|
usd = _first_param(p, "use_speaker_detection", "useSpeakerDetection")
|
|
14340
14363
|
if usd is None:
|
|
14341
|
-
|
|
14342
|
-
|
|
14364
|
+
with long_resolve_op("media_pool_item.transcribe_audio"):
|
|
14365
|
+
return {"success": bool(clip.TranscribeAudio())}
|
|
14366
|
+
with long_resolve_op("media_pool_item.transcribe_audio"):
|
|
14367
|
+
return {"success": bool(clip.TranscribeAudio(bool(usd)))}
|
|
14343
14368
|
elif action == "clear_transcription":
|
|
14344
14369
|
return {"success": bool(clip.ClearTranscription())}
|
|
14345
14370
|
elif action == "get_transcription":
|
|
@@ -15808,7 +15833,8 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
|
|
|
15808
15833
|
elif action == "import_into_timeline":
|
|
15809
15834
|
return {"success": bool(tl.ImportIntoTimeline(p["path"], p.get("options", {})))}
|
|
15810
15835
|
elif action == "export":
|
|
15811
|
-
|
|
15836
|
+
with long_resolve_op("timeline.export"):
|
|
15837
|
+
return {"success": bool(tl.Export(p["path"], p["type"], p.get("subtype", "")))}
|
|
15812
15838
|
elif action == "get_setting":
|
|
15813
15839
|
return {"settings": _ser(tl.GetSetting(p.get("name", "")))}
|
|
15814
15840
|
elif action == "set_setting":
|
|
@@ -16249,9 +16275,11 @@ def timeline_ai(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
16249
16275
|
return err
|
|
16250
16276
|
|
|
16251
16277
|
if action == "create_subtitles":
|
|
16252
|
-
|
|
16278
|
+
with long_resolve_op("timeline_ai.create_subtitles"):
|
|
16279
|
+
return {"success": bool(tl.CreateSubtitlesFromAudio(p.get("settings", {})))}
|
|
16253
16280
|
elif action == "detect_scene_cuts":
|
|
16254
|
-
|
|
16281
|
+
with long_resolve_op("timeline_ai.detect_scene_cuts"):
|
|
16282
|
+
return {"success": bool(tl.DetectSceneCuts())}
|
|
16255
16283
|
elif action == "analyze_dolby_vision":
|
|
16256
16284
|
clip_ids = p.get("clip_ids", [])
|
|
16257
16285
|
items = []
|
|
@@ -16262,7 +16290,8 @@ def timeline_ai(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
16262
16290
|
if it.GetUniqueId() in clip_ids:
|
|
16263
16291
|
items.append(it)
|
|
16264
16292
|
analysis_type = p.get("analysis_type")
|
|
16265
|
-
|
|
16293
|
+
with long_resolve_op("timeline_ai.analyze_dolby_vision"):
|
|
16294
|
+
return {"success": bool(tl.AnalyzeDolbyVision(items, analysis_type))}
|
|
16266
16295
|
elif action == "grab_still":
|
|
16267
16296
|
still = tl.GrabStill()
|
|
16268
16297
|
return _ok() if still else _err("Failed to grab still")
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Cross-process visibility for long synchronous DaVinci Resolve operations.
|
|
2
|
+
|
|
3
|
+
The Resolve scripting bridge executes one call at a time. Most calls return in
|
|
4
|
+
milliseconds, but a handful block for seconds-to-minutes (timeline export and
|
|
5
|
+
import, scene-cut detection, subtitle generation, audio transcription). A
|
|
6
|
+
second tool call issued during one of those — from another thread of this
|
|
7
|
+
server, or from the other instance when stdio and networked servers share one
|
|
8
|
+
Resolve — simply hangs inside fusionscript with no feedback.
|
|
9
|
+
|
|
10
|
+
This module gives those long operations a name and a presence that every
|
|
11
|
+
instance can see, so the Resolve preflight can wait briefly and then return a
|
|
12
|
+
structured "busy" answer instead of hanging:
|
|
13
|
+
|
|
14
|
+
with long_resolve_op("timeline.export"):
|
|
15
|
+
tl.Export(...)
|
|
16
|
+
|
|
17
|
+
op = wait_until_free(timeout_seconds=5.0) # None when free
|
|
18
|
+
if op:
|
|
19
|
+
... return a RESOLVE_BUSY error naming op["label"] ...
|
|
20
|
+
|
|
21
|
+
Registration is advisory and best-effort: a sidecar JSON file in the temp dir
|
|
22
|
+
(same approach as page_lock) carries {label, pid, thread, started_at}. Records
|
|
23
|
+
are ignored when their pid is dead or they exceed MAX_OP_AGE_SECONDS, so a
|
|
24
|
+
crashed operation can never wedge the server. The registering thread is exempt
|
|
25
|
+
from its own gate — long operations call Resolve helpers internally.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import tempfile
|
|
32
|
+
import threading
|
|
33
|
+
import time
|
|
34
|
+
from contextlib import contextmanager
|
|
35
|
+
from typing import Any, Dict, Optional
|
|
36
|
+
|
|
37
|
+
_SIDECAR = os.path.join(tempfile.gettempdir(), "davinci_resolve_mcp_busy.json")
|
|
38
|
+
# A long op that has run this long is presumed crashed/leaked and ignored.
|
|
39
|
+
MAX_OP_AGE_SECONDS = 2 * 60 * 60
|
|
40
|
+
# Default time a preflight will wait for the bridge to free up before
|
|
41
|
+
# returning a busy error.
|
|
42
|
+
DEFAULT_WAIT_SECONDS = 5.0
|
|
43
|
+
_POLL_SECONDS = 0.25
|
|
44
|
+
|
|
45
|
+
_lock = threading.Lock()
|
|
46
|
+
_local_owner: Optional[int] = None # thread ident of the in-process registrant
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _pid_alive(pid: int) -> bool:
|
|
50
|
+
try:
|
|
51
|
+
os.kill(pid, 0)
|
|
52
|
+
return True
|
|
53
|
+
except ProcessLookupError:
|
|
54
|
+
return False
|
|
55
|
+
except PermissionError:
|
|
56
|
+
return True
|
|
57
|
+
except OSError:
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _read_record() -> Optional[Dict[str, Any]]:
|
|
62
|
+
try:
|
|
63
|
+
with open(_SIDECAR, "r", encoding="utf-8") as handle:
|
|
64
|
+
record = json.load(handle)
|
|
65
|
+
except (OSError, json.JSONDecodeError):
|
|
66
|
+
return None
|
|
67
|
+
if not isinstance(record, dict) or not record.get("label"):
|
|
68
|
+
return None
|
|
69
|
+
started_at = record.get("started_at")
|
|
70
|
+
if not isinstance(started_at, (int, float)):
|
|
71
|
+
return None
|
|
72
|
+
if time.time() - started_at > MAX_OP_AGE_SECONDS:
|
|
73
|
+
return None
|
|
74
|
+
pid = record.get("pid")
|
|
75
|
+
if not isinstance(pid, int) or not _pid_alive(pid):
|
|
76
|
+
return None
|
|
77
|
+
return record
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _write_record(record: Dict[str, Any]) -> None:
|
|
81
|
+
tmp_path = f"{_SIDECAR}.tmp-{os.getpid()}-{threading.get_ident()}"
|
|
82
|
+
try:
|
|
83
|
+
with open(tmp_path, "w", encoding="utf-8") as handle:
|
|
84
|
+
json.dump(record, handle)
|
|
85
|
+
os.replace(tmp_path, _SIDECAR)
|
|
86
|
+
except OSError:
|
|
87
|
+
pass
|
|
88
|
+
finally:
|
|
89
|
+
try:
|
|
90
|
+
os.remove(tmp_path)
|
|
91
|
+
except OSError:
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _clear_record() -> None:
|
|
96
|
+
record = None
|
|
97
|
+
try:
|
|
98
|
+
with open(_SIDECAR, "r", encoding="utf-8") as handle:
|
|
99
|
+
record = json.load(handle)
|
|
100
|
+
except (OSError, json.JSONDecodeError):
|
|
101
|
+
pass
|
|
102
|
+
# Only the owning process removes the sidecar; never clobber another
|
|
103
|
+
# instance's registration.
|
|
104
|
+
if isinstance(record, dict) and record.get("pid") == os.getpid():
|
|
105
|
+
try:
|
|
106
|
+
os.remove(_SIDECAR)
|
|
107
|
+
except OSError:
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def current_long_op() -> Optional[Dict[str, Any]]:
|
|
112
|
+
"""The active long operation visible across instances, or None."""
|
|
113
|
+
record = _read_record()
|
|
114
|
+
if record is None:
|
|
115
|
+
return None
|
|
116
|
+
return {
|
|
117
|
+
"label": record.get("label"),
|
|
118
|
+
"pid": record.get("pid"),
|
|
119
|
+
"same_process": record.get("pid") == os.getpid(),
|
|
120
|
+
"started_at": record.get("started_at"),
|
|
121
|
+
"age_seconds": round(max(0.0, time.time() - float(record.get("started_at", 0.0))), 1),
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@contextmanager
|
|
126
|
+
def long_resolve_op(label: str):
|
|
127
|
+
"""Register a long synchronous Resolve call for its duration."""
|
|
128
|
+
global _local_owner
|
|
129
|
+
ident = threading.get_ident()
|
|
130
|
+
with _lock:
|
|
131
|
+
nested = _local_owner is not None
|
|
132
|
+
if not nested:
|
|
133
|
+
_local_owner = ident
|
|
134
|
+
if not nested:
|
|
135
|
+
_write_record({
|
|
136
|
+
"label": str(label),
|
|
137
|
+
"pid": os.getpid(),
|
|
138
|
+
"thread": ident,
|
|
139
|
+
"started_at": time.time(),
|
|
140
|
+
})
|
|
141
|
+
try:
|
|
142
|
+
yield
|
|
143
|
+
finally:
|
|
144
|
+
if not nested:
|
|
145
|
+
with _lock:
|
|
146
|
+
_local_owner = None
|
|
147
|
+
_clear_record()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def wait_until_free(timeout_seconds: Optional[float] = None) -> Optional[Dict[str, Any]]:
|
|
151
|
+
"""Wait briefly for any long op to finish.
|
|
152
|
+
|
|
153
|
+
Returns None when the bridge is free (or becomes free within the timeout),
|
|
154
|
+
otherwise the still-running operation's info. The thread that registered
|
|
155
|
+
the current in-process op is never gated by it. timeout_seconds defaults
|
|
156
|
+
to DEFAULT_WAIT_SECONDS, read at call time so callers/tests can tune it.
|
|
157
|
+
"""
|
|
158
|
+
if timeout_seconds is None:
|
|
159
|
+
timeout_seconds = DEFAULT_WAIT_SECONDS
|
|
160
|
+
deadline = time.monotonic() + max(0.0, float(timeout_seconds))
|
|
161
|
+
while True:
|
|
162
|
+
op = current_long_op()
|
|
163
|
+
if op is None:
|
|
164
|
+
return None
|
|
165
|
+
if op["same_process"]:
|
|
166
|
+
record = _read_record() or {}
|
|
167
|
+
if record.get("thread") == threading.get_ident():
|
|
168
|
+
return None
|
|
169
|
+
if time.monotonic() >= deadline:
|
|
170
|
+
return op
|
|
171
|
+
time.sleep(_POLL_SECONDS)
|