davinci-resolve-mcp 2.33.2 → 2.33.4

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,33 @@
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.33.4
6
+
7
+ Internal reliability framework.
8
+
9
+ - **Added** `verify_by_readback` (`src/utils/readback.py`) — a primitive for
10
+ mutating Resolve ops that verifies an action by reading the real post-state
11
+ back instead of trusting the API's frequently-unreliable return value. A
12
+ contradiction (reported success but a failing readback) is logged as a
13
+ reliability signal.
14
+ - **Changed** Auto-sync audio now runs through `verify_by_readback` as its first
15
+ user and reports a `verified` field alongside `linked`/`newly_linked`. Behavior
16
+ is unchanged; the bespoke readback loop is replaced by the shared primitive.
17
+
18
+ ## What's New in v2.33.3
19
+
20
+ Two read tools that surface existing project state.
21
+
22
+ - **Added** `project_settings(action="project_summary")` returns a live
23
+ structural readout — current page, timeline count and current timeline, and a
24
+ media-pool inventory (folder/clip counts, clips by type, optional clip
25
+ sample). A cheap "what's in this project right now" snapshot that needs no
26
+ prior analysis.
27
+ - **Added** `timeline(action="get_transcript")` reads the current timeline's
28
+ subtitle track(s) as transcript text `{text, cue_count, has_subtitles, cues}`,
29
+ with optional per-cue timecodes. Complements the clip-level
30
+ `get_transcription`.
31
+
5
32
  ## What's New in v2.33.2
6
33
 
7
34
  Documentation: a guide for hand-authoring DaVinci Resolve `.setting` template
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.33.2-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.33.4-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
@@ -402,7 +402,9 @@ Key actions: `get_name`, `set_name(name)`, `get_setting(name?)`,
402
402
  `set_setting(name, value)`, `get_color_groups`, `add_color_group(name)`,
403
403
  `delete_color_group(name)`, `export_frame_as_still(path)`,
404
404
  `load_burnin_preset(name)`, `insert_audio(media_path, ...)`,
405
- `apply_fairlight_preset(preset_name)`
405
+ `apply_fairlight_preset(preset_name)`,
406
+ `project_summary(include_clips?, clip_limit?)` — live structural readout
407
+ (current page, timeline count, media-pool inventory by type)
406
408
 
407
409
  ---
408
410
 
@@ -723,6 +725,8 @@ Key actions:
723
725
  - `get_current` — current timeline info
724
726
  - `set_current(index)` — switch timeline by 1-based index
725
727
  - `get_track_count(track_type)` — track_type: `"video"`, `"audio"`, `"subtitle"`
728
+ - `get_transcript(with_timecodes?)` — read the subtitle track(s) as transcript
729
+ text `{text, cue_count, has_subtitles, cues}`
726
730
  - `add_track(track_type, sub_type?)` / `delete_track(track_type, index)`
727
731
  - `get_items(track_type, index)` — items on a track
728
732
  - `clip_where(track_type?, track_index?, name_contains?, duration_lt?, duration_gt?)` —
package/install.py CHANGED
@@ -35,7 +35,7 @@ from src.utils.update_check import (
35
35
 
36
36
  # ─── Version ──────────────────────────────────────────────────────────────────
37
37
 
38
- VERSION = "2.33.2"
38
+ VERSION = "2.33.4"
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.33.2",
3
+ "version": "2.33.4",
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.33.2"
83
+ VERSION = "2.33.4"
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.33.2"
14
+ VERSION = "2.33.4"
15
15
 
16
16
  import base64
17
17
  import os
@@ -43,6 +43,7 @@ for p in [current_dir, project_dir]:
43
43
  # Platform-specific Resolve paths
44
44
  from src.utils.cdl import normalize_cdl_payload
45
45
  from src.utils.mcp_stdio import run_fastmcp_stdio
46
+ from src.utils.readback import verify_by_readback
46
47
  from src.utils.update_check import (
47
48
  check_for_updates,
48
49
  clear_update_prompt_preferences,
@@ -1885,6 +1886,89 @@ def _navigate_folder(mp, path):
1885
1886
  return current
1886
1887
 
1887
1888
 
1889
+ def _project_summary(proj, *, include_clips=False, clip_limit=50):
1890
+ """Live structural readout of a project: page, timelines, media-pool inventory.
1891
+
1892
+ Distinct from the analysis-derived bin summary — this is a cheap "what's in
1893
+ this project right now" snapshot that needs no prior analysis.
1894
+ """
1895
+ by_type: Dict[str, int] = {}
1896
+ sample_clips: List[Dict[str, Any]] = []
1897
+ folder_count = 0
1898
+ clip_count = 0
1899
+
1900
+ mp = proj.GetMediaPool()
1901
+ root = mp.GetRootFolder() if mp else None
1902
+
1903
+ def walk(folder):
1904
+ nonlocal folder_count, clip_count
1905
+ folder_count += 1
1906
+ for clip in (folder.GetClipList() or []):
1907
+ clip_count += 1
1908
+ try:
1909
+ ctype = clip.GetClipProperty("Type") or "Unknown"
1910
+ except Exception:
1911
+ ctype = "Unknown"
1912
+ by_type[ctype] = by_type.get(ctype, 0) + 1
1913
+ if include_clips and len(sample_clips) < clip_limit:
1914
+ sample_clips.append({
1915
+ "name": _clip_name(clip),
1916
+ "type": ctype,
1917
+ "id": clip.GetUniqueId(),
1918
+ })
1919
+ for sub in (folder.GetSubFolderList() or []):
1920
+ walk(sub)
1921
+
1922
+ if root:
1923
+ walk(root)
1924
+
1925
+ cur_tl = proj.GetCurrentTimeline()
1926
+ r = get_resolve()
1927
+ return {
1928
+ "project": proj.GetName(),
1929
+ "current_page": r.GetCurrentPage() if r else None,
1930
+ "timeline_count": proj.GetTimelineCount(),
1931
+ "current_timeline": cur_tl.GetName() if cur_tl else None,
1932
+ "media_pool": {
1933
+ "folder_count": folder_count,
1934
+ "clip_count": clip_count,
1935
+ "by_type": by_type,
1936
+ },
1937
+ "clips": sample_clips if include_clips else None,
1938
+ }
1939
+
1940
+
1941
+ def _timeline_transcript(tl, *, with_timecodes=False):
1942
+ """Read a timeline's subtitle track(s) as transcript text."""
1943
+ try:
1944
+ track_count = tl.GetTrackCount("subtitle")
1945
+ except Exception:
1946
+ track_count = 0
1947
+ cues: List[Dict[str, Any]] = []
1948
+ for ti in range(1, (track_count or 0) + 1):
1949
+ for item in (tl.GetItemListInTrack("subtitle", ti) or []):
1950
+ text = ""
1951
+ try:
1952
+ text = item.GetName() or ""
1953
+ except Exception:
1954
+ text = ""
1955
+ cue = {"text": text}
1956
+ if with_timecodes:
1957
+ try:
1958
+ cue["start"] = item.GetStart()
1959
+ cue["end"] = item.GetEnd()
1960
+ except Exception:
1961
+ pass
1962
+ cues.append(cue)
1963
+ full = " ".join(c["text"] for c in cues if c.get("text"))
1964
+ return {
1965
+ "text": full,
1966
+ "cue_count": len(cues),
1967
+ "has_subtitles": bool(cues),
1968
+ "cues": cues,
1969
+ }
1970
+
1971
+
1888
1972
  def _normalize_record_frame(
1889
1973
  ci: Dict[str, Any],
1890
1974
  index: int,
@@ -5452,19 +5536,34 @@ def _safe_auto_sync_audio(mp, p: Dict[str, Any]):
5452
5536
  # Read-back verification: AutoSyncAudio's boolean return is unreliable, so
5453
5537
  # capture each clip's "Synced Audio" linkage before and after and report the
5454
5538
  # delta. Trust `linked`/`newly_linked`, not `success`.
5455
- before = [_synced_audio(c) for c in clips]
5456
- ok = bool(mp.AutoSyncAudio(clips, settings))
5457
- linked, newly_linked, already_linked = [], [], []
5458
- for c, was in zip(clips, before):
5459
- name = _clip_name(c)
5460
- if _synced_audio(c):
5461
- linked.append(name)
5462
- (already_linked if was else newly_linked).append(name)
5539
+ def _categorize(before, observed):
5540
+ linked, newly, already = [], [], []
5541
+ for c, was, now in zip(clips, before, observed):
5542
+ if now:
5543
+ name = _clip_name(c)
5544
+ linked.append(name)
5545
+ (already if was else newly).append(name)
5546
+ return {
5547
+ "linked": linked,
5548
+ "newly_linked": newly,
5549
+ "already_linked": already,
5550
+ "verified": bool(linked),
5551
+ }
5552
+
5553
+ res = verify_by_readback(
5554
+ mutate=lambda: mp.AutoSyncAudio(clips, settings),
5555
+ observe=lambda: [_synced_audio(c) for c in clips],
5556
+ snapshot=lambda: [_synced_audio(c) for c in clips],
5557
+ compare=_categorize,
5558
+ label="auto_sync_audio",
5559
+ intent={"clip_count": len(clips)},
5560
+ )
5463
5561
  return {
5464
- "success": ok,
5465
- "linked": linked,
5466
- "newly_linked": newly_linked,
5467
- "already_linked": already_linked,
5562
+ "success": res["success_raw"],
5563
+ "verified": res["verified"],
5564
+ "linked": res["linked"],
5565
+ "newly_linked": res["newly_linked"],
5566
+ "already_linked": res["already_linked"],
5468
5567
  "count": len(clips),
5469
5568
  "missing": missing,
5470
5569
  "settings": settings,
@@ -12484,6 +12583,8 @@ def project_settings(action: str, params: Optional[Dict[str, Any]] = None) -> Di
12484
12583
  refresh_luts() -> {success}
12485
12584
  get_gallery() -> {available}
12486
12585
  export_frame_as_still(path) -> {success}
12586
+ project_summary(include_clips?, clip_limit?) -> {project, current_page, timeline_count, current_timeline, media_pool}
12587
+ Live structural readout — what's in this project right now (no analysis needed).
12487
12588
  load_burnin_preset(name) -> {success}
12488
12589
  insert_audio(media_path, start_offset?, duration?) -> {success}
12489
12590
  get_color_groups() -> {groups}
@@ -12524,6 +12625,12 @@ def project_settings(action: str, params: Optional[Dict[str, Any]] = None) -> Di
12524
12625
  if parent and not os.path.isdir(parent):
12525
12626
  return _err(f"export_frame_as_still target directory does not exist: {parent}")
12526
12627
  return {"success": bool(proj.ExportCurrentFrameAsStill(still_path))}
12628
+ elif action == "project_summary":
12629
+ return _project_summary(
12630
+ proj,
12631
+ include_clips=bool(p.get("include_clips")),
12632
+ clip_limit=int(p.get("clip_limit", 50)),
12633
+ )
12527
12634
  elif action == "load_burnin_preset":
12528
12635
  return {"success": bool(proj.LoadBurnInPreset(p["name"]))}
12529
12636
  elif action == "insert_audio":
@@ -12584,7 +12691,7 @@ def project_settings(action: str, params: Optional[Dict[str, Any]] = None) -> Di
12584
12691
  return {"success": False}
12585
12692
  return {"success": True, "new": new_item.GetName(), "new_id": new_item.GetUniqueId(),
12586
12693
  "output_path": _rec.output_path, "output_bytes": _rec.output_bytes}
12587
- return _unknown(action, ["get_name","set_name","get_setting","set_setting","get_unique_id","get_presets","set_preset","refresh_luts","get_gallery","export_frame_as_still","load_burnin_preset","insert_audio","get_color_groups","add_color_group","delete_color_group","apply_fairlight_preset","generate_speech"])
12694
+ return _unknown(action, ["get_name","set_name","get_setting","set_setting","get_unique_id","get_presets","set_preset","refresh_luts","get_gallery","export_frame_as_still","project_summary","load_burnin_preset","insert_audio","get_color_groups","add_color_group","delete_color_group","apply_fairlight_preset","generate_speech"])
12588
12695
 
12589
12696
 
12590
12697
  # ═══════════════════════════════════════════════════════════════════════════════
@@ -15078,6 +15185,8 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
15078
15185
  get_unique_id() -> {id}
15079
15186
  get_node_graph() -> {available}
15080
15187
  get_media_pool_item() -> {name, id}
15188
+ get_transcript(with_timecodes?) -> {text, cue_count, has_subtitles, cues}
15189
+ Read the timeline's subtitle track(s) as transcript text.
15081
15190
  get_mark_in_out() -> {mark}
15082
15191
  set_mark_in_out(mark_in, mark_out, type?) -> {success}
15083
15192
  clear_mark_in_out(type?) -> {success}
@@ -15363,6 +15472,8 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
15363
15472
  elif action == "get_media_pool_item":
15364
15473
  mpi = tl.GetMediaPoolItem()
15365
15474
  return {"name": mpi.GetName(), "id": mpi.GetUniqueId()} if mpi else {"name": None, "id": None}
15475
+ elif action == "get_transcript":
15476
+ return _timeline_transcript(tl, with_timecodes=bool(p.get("with_timecodes")))
15366
15477
  elif action == "get_mark_in_out":
15367
15478
  return _ser(tl.GetMarkInOut())
15368
15479
  elif action == "set_mark_in_out":
@@ -15566,7 +15677,7 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
15566
15677
  if mp_err:
15567
15678
  return mp_err
15568
15679
  return _fairlight_boundary_report(proj, mp, tl, p)
15569
- 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_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])
15680
+ 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","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])
15570
15681
 
15571
15682
 
15572
15683
  # ═══════════════════════════════════════════════════════════════════════════════
@@ -0,0 +1,70 @@
1
+ """Readback verification for unreliable Resolve API mutations.
2
+
3
+ The Resolve scripting API frequently returns a success value that does not
4
+ reflect what actually happened: ``AutoSyncAudio`` returns a boolean unrelated to
5
+ whether clips linked, ``AppendToTimeline`` may not hand back the new item id,
6
+ many setters return ``True`` regardless of effect, and Fusion ``Paste()`` can
7
+ report nothing while creating nothing. The defense is to **verify by reading the
8
+ real post-state back** instead of trusting the return value.
9
+
10
+ ``verify_by_readback`` is the small primitive every mutating op can use. A
11
+ mutation that reports success while the readback disagrees is a *contradiction*
12
+ — the single most valuable reliability signal — and is logged.
13
+ """
14
+ import logging
15
+ from typing import Any, Callable, Dict, Optional
16
+
17
+ logger = logging.getLogger("davinci-resolve-mcp")
18
+
19
+
20
+ def verify_by_readback(
21
+ mutate: Callable[[], Any],
22
+ observe: Callable[[], Any],
23
+ *,
24
+ snapshot: Optional[Callable[[], Any]] = None,
25
+ compare: Optional[Callable[[Any, Any], Dict[str, Any]]] = None,
26
+ intent: Optional[Dict[str, Any]] = None,
27
+ label: Optional[str] = None,
28
+ ) -> Dict[str, Any]:
29
+ """Run a mutation and verify it by reading actual state back.
30
+
31
+ Args:
32
+ mutate: performs the Resolve call; its return value is the unreliable
33
+ ``success_raw``.
34
+ observe: reads the relevant state AFTER the mutation.
35
+ snapshot: optional — captures pre-state for delta comparisons (passed to
36
+ ``compare`` as ``before``).
37
+ compare: ``(before, observed) -> dict`` merged into the result. It should
38
+ set ``verified: bool``. Defaults to ``verified = truthy(observed)``.
39
+ intent: optional description of what we meant to do (for the ledger).
40
+ label: optional op name, used in the contradiction log line.
41
+
42
+ Returns a dict with at least ``success_raw`` and ``verified``, plus whatever
43
+ ``compare`` contributed and (if given) ``intent``.
44
+ """
45
+ before = snapshot() if snapshot is not None else None
46
+ raw = mutate()
47
+ observed = observe()
48
+
49
+ result: Dict[str, Any] = {"success_raw": bool(raw), "observed": observed}
50
+ if compare is not None:
51
+ result.update(compare(before, observed))
52
+ else:
53
+ result["verified"] = bool(observed)
54
+ if intent is not None:
55
+ result["intent"] = intent
56
+
57
+ # A contradiction — reported success but the readback disagrees — is the
58
+ # signal worth surfacing loudly.
59
+ if result.get("success_raw") and not result.get("verified"):
60
+ logger.warning(
61
+ "readback contradiction%s: API reported success but post-state "
62
+ "verification failed%s",
63
+ f" [{label}]" if label else "",
64
+ f" (intent={intent})" if intent else "",
65
+ )
66
+ result["contradiction"] = True
67
+ else:
68
+ result["contradiction"] = False
69
+
70
+ return result