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 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
- [![Version](https://img.shields.io/badge/version-2.35.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.35.2-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
4
4
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
5
5
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
6
6
  [![Tools](https://img.shields.io/badge/MCP%20Tools-32%20(341%20full)-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.0"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.35.0",
3
+ "version": "2.35.2",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -80,7 +80,7 @@ if not logging.getLogger().handlers:
80
80
  handlers=[logging.StreamHandler()],
81
81
  )
82
82
 
83
- VERSION = "2.35.0"
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.0"
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
- valid_pages = ["media", "cut", "edit", "color", "fusion", "fairlight", "deliver"]
10675
- if p["page"] not in valid_pages:
10676
- return _err(f"Invalid page '{p['page']}'. Valid pages: {', '.join(valid_pages)}")
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, p["page"]))}
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
  # ═══════════════════════════════════════════════════════════════════════════════
@@ -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