davinci-resolve-mcp 2.37.2 → 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 +45 -0
- package/README.md +1 -1
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/analysis_dashboard.py +3 -6
- package/src/granular/common.py +1 -1
- package/src/granular/resolve_control.py +1 -1
- package/src/server.py +71 -29
- package/src/utils/resolve_busy.py +171 -0
- package/src/utils/update_check.py +10 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,51 @@
|
|
|
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
|
+
|
|
22
|
+
## What's New in v2.37.3
|
|
23
|
+
|
|
24
|
+
Deep-audit fixes, rounds two and three: agent-facing action discovery,
|
|
25
|
+
crash-safe user-state persistence, and resource hygiene.
|
|
26
|
+
|
|
27
|
+
- **Fixed** three tools' unknown-action error lists drifting from their real
|
|
28
|
+
dispatch: `timeline` omitted `clip_where` and `action_help`;
|
|
29
|
+
`timeline_item_color` and `graph` omitted `action_help`. Agents recovering
|
|
30
|
+
from a typo now see the full action set. A static AST test keeps every
|
|
31
|
+
tool's advertised list in both-way sync with its dispatch (and verifies
|
|
32
|
+
docstring-advertised actions are real).
|
|
33
|
+
- **Fixed** user-state files being vulnerable to truncation by a crash
|
|
34
|
+
mid-write. Affected files all reset to `{}` on corruption, so the next
|
|
35
|
+
save would silently wipe remaining user data. Writers now use atomic
|
|
36
|
+
temp-file + `os.replace`: `media-analysis-preferences.json` (all
|
|
37
|
+
analysis/caps/governance/update defaults — including the
|
|
38
|
+
`set_resolution_tier` and `set_caps_preset` paths, now routed through the
|
|
39
|
+
shared writer), `update-check.json`, the dashboard's `analysis.json`
|
|
40
|
+
transcription patch, and `transcript-corrections.json`.
|
|
41
|
+
- **Fixed** `_safe_project_delete` discarding `SaveProject()`'s result when
|
|
42
|
+
closing the current project before deletion; a failed save now surfaces
|
|
43
|
+
as a warning in the response.
|
|
44
|
+
- **Fixed** a file-descriptor leak: the parent kept its copy of the control
|
|
45
|
+
panel's log handle open after spawning the detached child.
|
|
46
|
+
- Audit classes verified clean: bare excepts, mutable default arguments,
|
|
47
|
+
`asyncio.run` inside running loops, subprocess timeouts, docstring phantom
|
|
48
|
+
actions, silent-success swallows in metadata/marker/archive write paths.
|
|
49
|
+
|
|
5
50
|
## What's New in v2.37.2
|
|
6
51
|
|
|
7
52
|
Static-audit fixes — four undefined-name references that silently fell back
|
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
|
@@ -21,6 +21,7 @@ from typing import Any, Dict, List, Optional, Tuple
|
|
|
21
21
|
from urllib.parse import parse_qs, unquote, urlparse
|
|
22
22
|
|
|
23
23
|
from src.utils.media_analysis import (
|
|
24
|
+
_write_json as _atomic_write_json,
|
|
24
25
|
analysis_index_status,
|
|
25
26
|
analysis_root_coverage,
|
|
26
27
|
build_analysis_index,
|
|
@@ -12605,9 +12606,7 @@ def regenerate_clip_transcript(
|
|
|
12605
12606
|
if payload.get("words"):
|
|
12606
12607
|
updated_report["transcription"]["words"] = payload["words"]
|
|
12607
12608
|
try:
|
|
12608
|
-
|
|
12609
|
-
json.dump(updated_report, handle, indent=2)
|
|
12610
|
-
handle.write("\n")
|
|
12609
|
+
_atomic_write_json(artifacts["analysis_json"], updated_report)
|
|
12611
12610
|
except Exception as exc:
|
|
12612
12611
|
return {"success": False, "error": f"transcript written but analysis.json patch failed: {exc}"}
|
|
12613
12612
|
word_segment_count = sum(1 for seg in (payload.get("segments") or []) if isinstance(seg, dict) and seg.get("words"))
|
|
@@ -12779,9 +12778,7 @@ def save_clip_transcript_corrections(project_root: str, clip_id: str, body: Dict
|
|
|
12779
12778
|
},
|
|
12780
12779
|
}
|
|
12781
12780
|
try:
|
|
12782
|
-
|
|
12783
|
-
json.dump(payload, handle, indent=2)
|
|
12784
|
-
handle.write("\n")
|
|
12781
|
+
_atomic_write_json(path, payload)
|
|
12785
12782
|
except Exception as exc:
|
|
12786
12783
|
return {"success": False, "error": str(exc)}
|
|
12787
12784
|
return {
|
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()}")
|
|
@@ -534,5 +534,5 @@ def quit_resolve() -> Dict[str, Any]:
|
|
|
534
534
|
resolve = get_resolve()
|
|
535
535
|
if resolve is None:
|
|
536
536
|
return {"error": "Not connected to DaVinci Resolve"}
|
|
537
|
-
|
|
537
|
+
resolve.Quit()
|
|
538
538
|
return {"success": True, "message": "DaVinci Resolve is quitting"}
|
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]):
|
|
@@ -6944,11 +6964,22 @@ def _read_media_analysis_preferences() -> Dict[str, Any]:
|
|
|
6944
6964
|
|
|
6945
6965
|
|
|
6946
6966
|
def _write_media_analysis_preferences(preferences: Dict[str, Any]) -> None:
|
|
6967
|
+
# Atomic replace: a crash mid-write must not truncate the file, because the
|
|
6968
|
+
# reader resets to {} on corruption and the next save would then wipe every
|
|
6969
|
+
# remaining user preference.
|
|
6947
6970
|
path = _media_analysis_preferences_path()
|
|
6948
6971
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
6949
|
-
|
|
6950
|
-
|
|
6951
|
-
|
|
6972
|
+
tmp_path = f"{path}.tmp-{os.getpid()}-{threading.get_ident()}-{time.time_ns()}"
|
|
6973
|
+
try:
|
|
6974
|
+
with open(tmp_path, "w", encoding="utf-8") as handle:
|
|
6975
|
+
json.dump(preferences, handle, indent=2, sort_keys=True)
|
|
6976
|
+
handle.write("\n")
|
|
6977
|
+
os.replace(tmp_path, path)
|
|
6978
|
+
finally:
|
|
6979
|
+
try:
|
|
6980
|
+
os.remove(tmp_path)
|
|
6981
|
+
except OSError:
|
|
6982
|
+
pass
|
|
6952
6983
|
|
|
6953
6984
|
|
|
6954
6985
|
def _setup_text_key(value: Any) -> str:
|
|
@@ -11553,6 +11584,11 @@ def _open_control_panel(p: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
11553
11584
|
)
|
|
11554
11585
|
except (OSError, FileNotFoundError) as exc:
|
|
11555
11586
|
return _err(f"Failed to launch control panel: {type(exc).__name__}: {exc}")
|
|
11587
|
+
finally:
|
|
11588
|
+
# The child holds its own copy of the fd; keeping ours open would leak
|
|
11589
|
+
# one descriptor per panel launch.
|
|
11590
|
+
if log_handle is not subprocess.DEVNULL:
|
|
11591
|
+
log_handle.close()
|
|
11556
11592
|
|
|
11557
11593
|
# Verify the child actually came up. Bind errors (port in use), import
|
|
11558
11594
|
# failures, etc. would otherwise leave us reporting "launched" while the
|
|
@@ -12270,11 +12306,14 @@ def _safe_project_delete(pm, p: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
12270
12306
|
if current_name == name:
|
|
12271
12307
|
if not p.get("close_current", False):
|
|
12272
12308
|
return _err("Refusing to delete the currently open project; pass close_current=True")
|
|
12273
|
-
if p.get("save_current", True)
|
|
12274
|
-
pm.SaveProject()
|
|
12309
|
+
saved = bool(pm.SaveProject()) if p.get("save_current", True) else None
|
|
12275
12310
|
closed = bool(pm.CloseProject(current))
|
|
12276
12311
|
if not closed:
|
|
12277
12312
|
return _err(f"Failed to close current project '{name}' before delete")
|
|
12313
|
+
result = {"success": bool(pm.DeleteProject(name))}
|
|
12314
|
+
if saved is False:
|
|
12315
|
+
result["warning"] = "SaveProject returned False before close; unsaved changes may have been discarded"
|
|
12316
|
+
return result
|
|
12278
12317
|
return {"success": bool(pm.DeleteProject(name))}
|
|
12279
12318
|
|
|
12280
12319
|
|
|
@@ -13792,7 +13831,8 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
|
|
|
13792
13831
|
elif action == "setup_multicam_timeline":
|
|
13793
13832
|
return _setup_multicam_timeline(proj, mp, p)
|
|
13794
13833
|
elif action == "import_timeline":
|
|
13795
|
-
|
|
13834
|
+
with long_resolve_op("media_pool.import_timeline"):
|
|
13835
|
+
tl = mp.ImportTimelineFromFile(p["path"], p.get("options", {}))
|
|
13796
13836
|
return _ok(name=tl.GetName()) if tl else _err("Failed to import timeline")
|
|
13797
13837
|
elif action == "delete_timelines":
|
|
13798
13838
|
count = proj.GetTimelineCount()
|
|
@@ -14002,8 +14042,10 @@ def folder(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
|
|
|
14002
14042
|
elif action == "transcribe_audio":
|
|
14003
14043
|
usd = _first_param(p, "use_speaker_detection", "useSpeakerDetection")
|
|
14004
14044
|
if usd is None:
|
|
14005
|
-
|
|
14006
|
-
|
|
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)))}
|
|
14007
14049
|
elif action == "clear_transcription":
|
|
14008
14050
|
return {"success": bool(f.ClearTranscription())}
|
|
14009
14051
|
elif action == "perform_audio_classification":
|
|
@@ -14319,8 +14361,10 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
14319
14361
|
elif action == "transcribe_audio":
|
|
14320
14362
|
usd = _first_param(p, "use_speaker_detection", "useSpeakerDetection")
|
|
14321
14363
|
if usd is None:
|
|
14322
|
-
|
|
14323
|
-
|
|
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)))}
|
|
14324
14368
|
elif action == "clear_transcription":
|
|
14325
14369
|
return {"success": bool(clip.ClearTranscription())}
|
|
14326
14370
|
elif action == "get_transcription":
|
|
@@ -14759,10 +14803,7 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
|
|
|
14759
14803
|
prefs["resolve_ai_governance_preset"] = preset
|
|
14760
14804
|
if isinstance(p.get("overrides"), dict):
|
|
14761
14805
|
prefs["resolve_ai_governance_overrides"] = p["overrides"]
|
|
14762
|
-
|
|
14763
|
-
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
14764
|
-
with open(path, "w", encoding="utf-8") as fh:
|
|
14765
|
-
json.dump(prefs, fh, indent=2)
|
|
14806
|
+
_write_media_analysis_preferences(prefs)
|
|
14766
14807
|
return {"success": True, "tier": preset, "overrides": prefs.get("resolve_ai_governance_overrides") or {}}
|
|
14767
14808
|
if action == "set_caps_preset":
|
|
14768
14809
|
preset = (p.get("preset") or "").strip().lower()
|
|
@@ -14774,10 +14815,7 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
|
|
|
14774
14815
|
prefs["analysis_caps_preset"] = preset
|
|
14775
14816
|
if isinstance(p.get("overrides"), dict):
|
|
14776
14817
|
prefs["analysis_caps_overrides"] = p["overrides"]
|
|
14777
|
-
|
|
14778
|
-
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
14779
|
-
with open(path, "w", encoding="utf-8") as fh:
|
|
14780
|
-
json.dump(prefs, fh, indent=2)
|
|
14818
|
+
_write_media_analysis_preferences(prefs)
|
|
14781
14819
|
return {"success": True, "preset": preset, "overrides": prefs.get("analysis_caps_overrides") or {}}
|
|
14782
14820
|
if action == "get_usage":
|
|
14783
14821
|
from src.utils import analysis_caps as _ac
|
|
@@ -15795,7 +15833,8 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
|
|
|
15795
15833
|
elif action == "import_into_timeline":
|
|
15796
15834
|
return {"success": bool(tl.ImportIntoTimeline(p["path"], p.get("options", {})))}
|
|
15797
15835
|
elif action == "export":
|
|
15798
|
-
|
|
15836
|
+
with long_resolve_op("timeline.export"):
|
|
15837
|
+
return {"success": bool(tl.Export(p["path"], p["type"], p.get("subtype", "")))}
|
|
15799
15838
|
elif action == "get_setting":
|
|
15800
15839
|
return {"settings": _ser(tl.GetSetting(p.get("name", "")))}
|
|
15801
15840
|
elif action == "set_setting":
|
|
@@ -16099,7 +16138,7 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
|
|
|
16099
16138
|
if mp_err:
|
|
16100
16139
|
return mp_err
|
|
16101
16140
|
return _fairlight_boundary_report(proj, mp, tl, p)
|
|
16102
|
-
return _unknown(action, ["list","get_current","set_current","get_name","set_name","get_start_frame","get_end_frame","get_start_timecode","set_start_timecode","get_track_count","add_track","delete_track","get_track_sub_type","set_track_enable","get_track_enabled","set_track_lock","get_track_locked","get_track_name","set_track_name","get_items","delete_clips","set_clips_linked","duplicate","duplicate_clips","copy_clips","move_clips","copy_range","duplicate_range","overwrite_range","lift_range","story_spine_report","create_variant_from_ranges","bulk_set_item_properties","apply_look_to_items","thumbnail_contact_sheet","marker_thumbnail_review","edit_kernel_capabilities","probe_edit_kernel_item","title_property_scan","set_title_text","bulk_set_title_text","create_compound_clip","create_fusion_clip","import_into_timeline","export","get_setting","set_setting","insert_generator","insert_fusion_generator","insert_fusion_composition","insert_ofx_generator","insert_title","insert_fusion_title","get_unique_id","get_node_graph","get_media_pool_item","get_transcript","propose_cuts","apply_cuts","get_mark_in_out","set_mark_in_out","clear_mark_in_out","convert_to_stereo","get_items_in_track","get_voice_isolation_state","set_voice_isolation_state","extract_source_frame_ranges","audio_mix_capability_report",*_TIMELINE_CONFORM_KERNEL_ACTIONS,*_TIMELINE_AUDIO_KERNEL_ACTIONS])
|
|
16141
|
+
return _unknown(action, ["list","get_current","set_current","get_name","set_name","get_start_frame","get_end_frame","get_start_timecode","set_start_timecode","get_track_count","add_track","delete_track","get_track_sub_type","set_track_enable","get_track_enabled","set_track_lock","get_track_locked","get_track_name","set_track_name","get_items","delete_clips","set_clips_linked","duplicate","duplicate_clips","copy_clips","move_clips","copy_range","duplicate_range","overwrite_range","lift_range","story_spine_report","create_variant_from_ranges","bulk_set_item_properties","apply_look_to_items","thumbnail_contact_sheet","marker_thumbnail_review","edit_kernel_capabilities","probe_edit_kernel_item","title_property_scan","set_title_text","bulk_set_title_text","create_compound_clip","create_fusion_clip","import_into_timeline","export","get_setting","set_setting","insert_generator","insert_fusion_generator","insert_fusion_composition","insert_ofx_generator","insert_title","insert_fusion_title","get_unique_id","get_node_graph","get_media_pool_item","get_transcript","propose_cuts","apply_cuts","get_mark_in_out","set_mark_in_out","clear_mark_in_out","convert_to_stereo","get_items_in_track","get_voice_isolation_state","set_voice_isolation_state","extract_source_frame_ranges","audio_mix_capability_report","clip_where","action_help",*_TIMELINE_CONFORM_KERNEL_ACTIONS,*_TIMELINE_AUDIO_KERNEL_ACTIONS])
|
|
16103
16142
|
|
|
16104
16143
|
|
|
16105
16144
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -16236,9 +16275,11 @@ def timeline_ai(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
16236
16275
|
return err
|
|
16237
16276
|
|
|
16238
16277
|
if action == "create_subtitles":
|
|
16239
|
-
|
|
16278
|
+
with long_resolve_op("timeline_ai.create_subtitles"):
|
|
16279
|
+
return {"success": bool(tl.CreateSubtitlesFromAudio(p.get("settings", {})))}
|
|
16240
16280
|
elif action == "detect_scene_cuts":
|
|
16241
|
-
|
|
16281
|
+
with long_resolve_op("timeline_ai.detect_scene_cuts"):
|
|
16282
|
+
return {"success": bool(tl.DetectSceneCuts())}
|
|
16242
16283
|
elif action == "analyze_dolby_vision":
|
|
16243
16284
|
clip_ids = p.get("clip_ids", [])
|
|
16244
16285
|
items = []
|
|
@@ -16249,7 +16290,8 @@ def timeline_ai(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
|
|
|
16249
16290
|
if it.GetUniqueId() in clip_ids:
|
|
16250
16291
|
items.append(it)
|
|
16251
16292
|
analysis_type = p.get("analysis_type")
|
|
16252
|
-
|
|
16293
|
+
with long_resolve_op("timeline_ai.analyze_dolby_vision"):
|
|
16294
|
+
return {"success": bool(tl.AnalyzeDolbyVision(items, analysis_type))}
|
|
16253
16295
|
elif action == "grab_still":
|
|
16254
16296
|
still = tl.GrabStill()
|
|
16255
16297
|
return _ok() if still else _err("Failed to grab still")
|
|
@@ -18018,7 +18060,7 @@ def timeline_item_color(action: str, params: Optional[Dict[str, Any]] = None) ->
|
|
|
18018
18060
|
return {"success": bool(item.CreateMagicMask(p.get("mode", "F")))}
|
|
18019
18061
|
elif action == "regenerate_magic_mask":
|
|
18020
18062
|
return {"success": bool(item.RegenerateMagicMask())}
|
|
18021
|
-
return _unknown(action, ["set_cdl","copy_grades","add_version","get_current_version","get_version_names","load_version","rename_version","delete_version","get_node_graph","get_color_group","assign_color_group","remove_from_color_group","export_lut","get_color_cache","set_color_cache","get_fusion_cache","set_fusion_cache","reset_all_node_colors","stabilize","smart_reframe","create_magic_mask","regenerate_magic_mask",*_COLOR_GRADE_KERNEL_ACTIONS])
|
|
18063
|
+
return _unknown(action, ["set_cdl","copy_grades","add_version","get_current_version","get_version_names","load_version","rename_version","delete_version","get_node_graph","get_color_group","assign_color_group","remove_from_color_group","export_lut","get_color_cache","set_color_cache","get_fusion_cache","set_fusion_cache","reset_all_node_colors","stabilize","smart_reframe","create_magic_mask","regenerate_magic_mask","action_help",*_COLOR_GRADE_KERNEL_ACTIONS])
|
|
18022
18064
|
|
|
18023
18065
|
|
|
18024
18066
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -18402,7 +18444,7 @@ def graph(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
|
|
|
18402
18444
|
if blocked:
|
|
18403
18445
|
return blocked
|
|
18404
18446
|
return {"success": bool(g.ResetAllGrades())}
|
|
18405
|
-
return _unknown(action, ["get_num_nodes","get_lut","set_lut","get_node_cache","set_node_cache","get_node_label","get_tools_in_node","set_node_enabled","apply_grade_from_drx","apply_arri_cdl_lut","reset_all_grades"])
|
|
18447
|
+
return _unknown(action, ["get_num_nodes","get_lut","set_lut","get_node_cache","set_node_cache","get_node_label","get_tools_in_node","set_node_enabled","apply_grade_from_drx","apply_arri_cdl_lut","reset_all_grades","action_help"])
|
|
18406
18448
|
|
|
18407
18449
|
|
|
18408
18450
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -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)
|
|
@@ -631,13 +631,22 @@ def _read_state(path: Path) -> Dict[str, Any]:
|
|
|
631
631
|
|
|
632
632
|
|
|
633
633
|
def _write_state(path: Path, result: Mapping[str, Any]) -> None:
|
|
634
|
+
# Atomic replace: _read_state resets to {} on a corrupt file, so a crash
|
|
635
|
+
# mid-write would silently drop snooze/ignore preferences.
|
|
636
|
+
tmp_path = path.with_name(f"{path.name}.tmp-{os.getpid()}")
|
|
634
637
|
try:
|
|
635
638
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
636
|
-
with
|
|
639
|
+
with tmp_path.open("w", encoding="utf-8") as handle:
|
|
637
640
|
json.dump(result, handle, indent=2, sort_keys=True)
|
|
638
641
|
handle.write("\n")
|
|
642
|
+
os.replace(tmp_path, path)
|
|
639
643
|
except OSError:
|
|
640
644
|
return
|
|
645
|
+
finally:
|
|
646
|
+
try:
|
|
647
|
+
os.remove(tmp_path)
|
|
648
|
+
except OSError:
|
|
649
|
+
pass
|
|
641
650
|
|
|
642
651
|
|
|
643
652
|
def _set_cached_status(result: Mapping[str, Any]) -> None:
|