davinci-resolve-mcp 2.35.0 → 2.35.2
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 +22 -0
- package/README.md +1 -1
- package/docs/SKILL.md +2 -0
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/granular/common.py +1 -1
- package/src/server.py +72 -8
- package/src/utils/readback.py +21 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,28 @@
|
|
|
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.35.2
|
|
6
|
+
|
|
7
|
+
Verification observability and a validator consolidation.
|
|
8
|
+
|
|
9
|
+
- **Added** `resolve_control(action="verification_stats")` returns a
|
|
10
|
+
process-level tally of readback-verification outcomes
|
|
11
|
+
(verified / contradicted / unverified) since server start. A rising
|
|
12
|
+
`contradicted` count means the Resolve API reported success but a readback
|
|
13
|
+
disagreed. No connection required.
|
|
14
|
+
- **Changed** `open_page` now validates its `page` argument through the declarative
|
|
15
|
+
contract layer (`contracts.validate`) instead of a hand-written enum check —
|
|
16
|
+
behavior unchanged; part of folding scattered validation into one place.
|
|
17
|
+
|
|
18
|
+
## What's New in v2.35.1
|
|
19
|
+
|
|
20
|
+
- **Added** `media_pool_item(action="extract_frames", clip_id, timestamps, output_dir?)`
|
|
21
|
+
extracts still JPEGs from a clip's source media at the given timestamps (seconds)
|
|
22
|
+
via ffmpeg. Source-safe: it reads the source and writes only to a scratch output
|
|
23
|
+
directory — it never modifies, transcodes, or proxies the source. (Closes a
|
|
24
|
+
read/write-symmetry gap: the analysis sampler existed internally but had no
|
|
25
|
+
standalone frame-extraction tool.)
|
|
26
|
+
|
|
5
27
|
## What's New in v2.35.0
|
|
6
28
|
|
|
7
29
|
The Cut-IR executor — transcript-driven editing now closes the loop onto the
|
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/docs/SKILL.md
CHANGED
|
@@ -320,6 +320,8 @@ Key actions:
|
|
|
320
320
|
- `get_version` — returns `{product, version, version_string}`
|
|
321
321
|
- `api_truth(query?)` — look up behaviorally-verified facts about quirky/unreliable
|
|
322
322
|
Resolve API behavior (no connection needed); filter by substring
|
|
323
|
+
- `verification_stats` — readback-verification tally (verified/contradicted/
|
|
324
|
+
unverified) since server start (no connection needed)
|
|
323
325
|
- `get_page` / `open_page(page)` — read or switch the active page
|
|
324
326
|
- `get_keyframe_mode` / `set_keyframe_mode(mode)`
|
|
325
327
|
- `get_fairlight_presets` — Resolve 20.2.2+; returns available Fairlight
|
package/install.py
CHANGED
|
@@ -35,7 +35,7 @@ from src.utils.update_check import (
|
|
|
35
35
|
|
|
36
36
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
37
37
|
|
|
38
|
-
VERSION = "2.35.
|
|
38
|
+
VERSION = "2.35.2"
|
|
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.35.
|
|
83
|
+
VERSION = "2.35.2"
|
|
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.35.
|
|
14
|
+
VERSION = "2.35.2"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -48,7 +48,7 @@ from src.utils.contracts import validate as _validate_params
|
|
|
48
48
|
from src.utils.cut_ir import build_cut_list as _build_cut_list
|
|
49
49
|
from src.utils.page_lock import open_page_serialized as _open_page_serialized
|
|
50
50
|
from src.utils.proc import safe_run
|
|
51
|
-
from src.utils.readback import verify_by_readback
|
|
51
|
+
from src.utils.readback import verify_by_readback, verification_stats as _verification_stats
|
|
52
52
|
from src.utils.update_check import (
|
|
53
53
|
check_for_updates,
|
|
54
54
|
clear_update_prompt_preferences,
|
|
@@ -5510,6 +5510,55 @@ def _audio_mapping_report(mp, tl, p: Dict[str, Any]):
|
|
|
5510
5510
|
return {"timeline_items": timeline_items, "media_pool_items": clip_rows}
|
|
5511
5511
|
|
|
5512
5512
|
|
|
5513
|
+
def _extract_clip_frames(clip, p: Dict[str, Any]) -> Dict[str, Any]:
|
|
5514
|
+
"""Extract still frames from a clip's source media at given timestamps.
|
|
5515
|
+
|
|
5516
|
+
Source-safe: reads the source file with ffmpeg and writes JPEGs to a scratch
|
|
5517
|
+
output directory only — it never modifies, transcodes, or proxies the source.
|
|
5518
|
+
`timestamps` is a list of seconds. Returns the written frame paths.
|
|
5519
|
+
"""
|
|
5520
|
+
try:
|
|
5521
|
+
src = clip.GetClipProperty("File Path")
|
|
5522
|
+
except Exception:
|
|
5523
|
+
src = None
|
|
5524
|
+
if not src or not os.path.isfile(src):
|
|
5525
|
+
return _err("Clip has no readable source file (File Path)")
|
|
5526
|
+
timestamps = p.get("timestamps")
|
|
5527
|
+
if not isinstance(timestamps, list) or not timestamps:
|
|
5528
|
+
return _err("extract_frames requires 'timestamps' (a non-empty list of seconds)")
|
|
5529
|
+
if not shutil.which("ffmpeg"):
|
|
5530
|
+
return _err("ffmpeg not found on PATH")
|
|
5531
|
+
out_dir = p.get("output_dir") or tempfile.mkdtemp(prefix="mcp_frames_")
|
|
5532
|
+
try:
|
|
5533
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
5534
|
+
except OSError as exc:
|
|
5535
|
+
return _err(f"Could not create output_dir: {exc}")
|
|
5536
|
+
paths, errors = [], []
|
|
5537
|
+
for i, ts in enumerate(timestamps):
|
|
5538
|
+
out_path = os.path.join(out_dir, f"frame_{i:04d}.jpg")
|
|
5539
|
+
try:
|
|
5540
|
+
safe_run(
|
|
5541
|
+
["ffmpeg", "-y", "-ss", str(float(ts)), "-i", src,
|
|
5542
|
+
"-frames:v", "1", "-q:v", "2", out_path],
|
|
5543
|
+
capture_output=True, timeout=60,
|
|
5544
|
+
)
|
|
5545
|
+
except (OSError, subprocess.SubprocessError, ValueError) as exc:
|
|
5546
|
+
errors.append({"timestamp": ts, "error": str(exc)})
|
|
5547
|
+
continue
|
|
5548
|
+
if os.path.isfile(out_path):
|
|
5549
|
+
paths.append(out_path)
|
|
5550
|
+
else:
|
|
5551
|
+
errors.append({"timestamp": ts, "error": "no frame written (timestamp out of range?)"})
|
|
5552
|
+
return {
|
|
5553
|
+
"success": bool(paths),
|
|
5554
|
+
"source": src,
|
|
5555
|
+
"output_dir": out_dir,
|
|
5556
|
+
"frame_paths": paths,
|
|
5557
|
+
"count": len(paths),
|
|
5558
|
+
"errors": errors,
|
|
5559
|
+
}
|
|
5560
|
+
|
|
5561
|
+
|
|
5513
5562
|
def _clip_name(clip):
|
|
5514
5563
|
try:
|
|
5515
5564
|
return clip.GetName()
|
|
@@ -10583,6 +10632,8 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
10583
10632
|
clear_mcp_update_preferences() -> {success, version, update, decision}
|
|
10584
10633
|
api_truth(query?) -> {verified_on, count, facts} — look up behaviorally-verified
|
|
10585
10634
|
facts about quirky/unreliable Resolve API behavior (no connection needed).
|
|
10635
|
+
verification_stats() -> {stats} — readback-verification tally
|
|
10636
|
+
(verified/contradicted/unverified) since server start (no connection needed).
|
|
10586
10637
|
get_page() -> {page}
|
|
10587
10638
|
open_page(page) -> {success} — page: edit, cut, color, fusion, fairlight, deliver
|
|
10588
10639
|
get_keyframe_mode() -> {mode}
|
|
@@ -10607,6 +10658,11 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
10607
10658
|
if action == "api_truth":
|
|
10608
10659
|
facts = lookup_api_truth(p.get("query"))
|
|
10609
10660
|
return {"verified_on": _API_TRUTH_VERIFIED_ON, "count": len(facts), "facts": facts}
|
|
10661
|
+
if action == "verification_stats":
|
|
10662
|
+
# Process-level readback-verification tally — no connection needed.
|
|
10663
|
+
stats = _verification_stats()
|
|
10664
|
+
return {"stats": stats, "note": "Counts since server start. A rising "
|
|
10665
|
+
"'contradicted' count means the API reported success but a readback disagreed."}
|
|
10610
10666
|
|
|
10611
10667
|
# Control-panel actions don't require Resolve to be running.
|
|
10612
10668
|
if action == "open_control_panel":
|
|
@@ -10671,12 +10727,15 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
10671
10727
|
elif action == "get_page":
|
|
10672
10728
|
return {"page": r.GetCurrentPage()}
|
|
10673
10729
|
elif action == "open_page":
|
|
10674
|
-
|
|
10675
|
-
|
|
10676
|
-
|
|
10730
|
+
err, clean = _validate_params(p, {
|
|
10731
|
+
"page": {"enum": ["media", "cut", "edit", "color", "fusion", "fairlight", "deliver"],
|
|
10732
|
+
"required": True},
|
|
10733
|
+
})
|
|
10734
|
+
if err:
|
|
10735
|
+
return _err(err)
|
|
10677
10736
|
# Serialize page switches so concurrent agents can't flip the single
|
|
10678
10737
|
# globally-active page underneath each other.
|
|
10679
|
-
return {"success": bool(_open_page_serialized(r,
|
|
10738
|
+
return {"success": bool(_open_page_serialized(r, clean["page"]))}
|
|
10680
10739
|
elif action == "get_keyframe_mode":
|
|
10681
10740
|
return {"mode": r.GetKeyframeMode()}
|
|
10682
10741
|
elif action == "set_keyframe_mode":
|
|
@@ -10697,7 +10756,7 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
10697
10756
|
return missing
|
|
10698
10757
|
r.DisableBackgroundTasksForCurrentResolveSession()
|
|
10699
10758
|
return _ok()
|
|
10700
|
-
return _unknown(action, ["launch","get_version","api_truth","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"])
|
|
10759
|
+
return _unknown(action, ["launch","get_version","api_truth","verification_stats","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"])
|
|
10701
10760
|
|
|
10702
10761
|
|
|
10703
10762
|
# ─── V2 C4: Per-field corrections with provenance + changelog ────────────────
|
|
@@ -13797,6 +13856,9 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
13797
13856
|
get_transcription(clip_id) -> {text, truncated, status, has_transcription}
|
|
13798
13857
|
Read a clip's transcription. `truncated` flags when Resolve's preview
|
|
13799
13858
|
property cut the text off (the full transcript is longer).
|
|
13859
|
+
extract_frames(clip_id, timestamps, output_dir?) -> {frame_paths, output_dir, count, errors}
|
|
13860
|
+
Extract still JPEGs from the clip's source at the given timestamps (seconds)
|
|
13861
|
+
via ffmpeg. Source-safe: reads source, writes only to a scratch dir.
|
|
13800
13862
|
perform_audio_classification(clip_id) -> {success} — Resolve 21+
|
|
13801
13863
|
clear_audio_classification(clip_id) -> {success} — Resolve 21+
|
|
13802
13864
|
analyze_for_intellisearch(clip_id, identify_faces?, is_better_mode?) -> {success} — Resolve 21+, AI IntelliSearch Extra
|
|
@@ -14012,6 +14074,8 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
14012
14074
|
"status": status or None,
|
|
14013
14075
|
"has_transcription": bool(text.strip()),
|
|
14014
14076
|
}
|
|
14077
|
+
elif action == "extract_frames":
|
|
14078
|
+
return _extract_clip_frames(clip, p)
|
|
14015
14079
|
elif action == "perform_audio_classification":
|
|
14016
14080
|
missing = _requires_method(clip, "PerformAudioClassification", "21.0")
|
|
14017
14081
|
if missing:
|
|
@@ -14096,7 +14160,7 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
14096
14160
|
return {"success": bool(clip.SetMarkInOut(clean["mark_in"], clean["mark_out"], p.get("type", "all")))}
|
|
14097
14161
|
elif action == "clear_mark_in_out":
|
|
14098
14162
|
return {"success": bool(clip.ClearMarkInOut(p.get("type", "all")))}
|
|
14099
|
-
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_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"])
|
|
14163
|
+
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_transcription","extract_frames","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"])
|
|
14100
14164
|
|
|
14101
14165
|
|
|
14102
14166
|
# ═══════════════════════════════════════════════════════════════════════════════
|
package/src/utils/readback.py
CHANGED
|
@@ -16,6 +16,21 @@ from typing import Any, Callable, Dict, Optional
|
|
|
16
16
|
|
|
17
17
|
logger = logging.getLogger("davinci-resolve-mcp")
|
|
18
18
|
|
|
19
|
+
# Process-level tally of verification outcomes — lightweight observability into
|
|
20
|
+
# how often the Resolve API's self-reported success matches reality. A rising
|
|
21
|
+
# `contradicted` count is the signal worth watching.
|
|
22
|
+
_STATS = {"total": 0, "verified": 0, "contradicted": 0, "unverified": 0}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def verification_stats():
|
|
26
|
+
"""Return a copy of the process-level verification tally."""
|
|
27
|
+
return dict(_STATS)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def reset_verification_stats():
|
|
31
|
+
for k in _STATS:
|
|
32
|
+
_STATS[k] = 0
|
|
33
|
+
|
|
19
34
|
|
|
20
35
|
def verify_by_readback(
|
|
21
36
|
mutate: Callable[[], Any],
|
|
@@ -56,6 +71,7 @@ def verify_by_readback(
|
|
|
56
71
|
|
|
57
72
|
# A contradiction — reported success but the readback disagrees — is the
|
|
58
73
|
# signal worth surfacing loudly.
|
|
74
|
+
_STATS["total"] += 1
|
|
59
75
|
if result.get("success_raw") and not result.get("verified"):
|
|
60
76
|
logger.warning(
|
|
61
77
|
"readback contradiction%s: API reported success but post-state "
|
|
@@ -64,7 +80,12 @@ def verify_by_readback(
|
|
|
64
80
|
f" (intent={intent})" if intent else "",
|
|
65
81
|
)
|
|
66
82
|
result["contradiction"] = True
|
|
83
|
+
_STATS["contradicted"] += 1
|
|
67
84
|
else:
|
|
68
85
|
result["contradiction"] = False
|
|
86
|
+
if result.get("verified"):
|
|
87
|
+
_STATS["verified"] += 1
|
|
88
|
+
else:
|
|
89
|
+
_STATS["unverified"] += 1
|
|
69
90
|
|
|
70
91
|
return result
|