davinci-resolve-mcp 2.33.8 → 2.34.1

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,34 @@
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.34.1
6
+
7
+ Page-switch serialization — the concurrency primitive for safe multi-agent use.
8
+
9
+ - **Added** `src/utils/page_lock.py` — Resolve has a single globally-active page,
10
+ so two agents that flip pages concurrently corrupt each other. `page_lock()`
11
+ serializes page switches: a reentrant intra-process lock plus a best-effort
12
+ inter-process advisory file lock around the outermost section. The `open_page`
13
+ action now routes through it. This must be in place before any concurrent-agent
14
+ feature ships.
15
+ - Networked transport, sandboxed scripting, and capability/role scoping (the rest
16
+ of the safe remote/multi-user design) are security-critical and remain a
17
+ separate, sign-off-gated phase — they are intentionally not shipped here.
18
+
19
+ ## What's New in v2.34.0
20
+
21
+ First phase of transcript-driven editing: a Cut intermediate representation and a
22
+ mechanical cut proposer.
23
+
24
+ - **Added** `src/utils/cut_ir.py` — the Cut-IR: a typed representation of an
25
+ editorial cut ({kind, span, action, confidence, rationale, evidence}) plus a
26
+ deterministic Pass-1 detector that flags filler words, long pauses, and
27
+ repeated lines from a timestamped transcript. No LLM.
28
+ - **Added** `timeline(action="propose_cuts", cues?, long_pause_frames?)` — a
29
+ DRY-RUN that runs Pass-1 over the timeline's subtitle transcript (or provided
30
+ cues) and returns a CutList. It proposes only; it applies nothing. The semantic
31
+ Pass-2 and the governed timeline executor are subsequent phases.
32
+
5
33
  ## What's New in v2.33.8
6
34
 
7
35
  Bridge-call performance instrumentation.
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.8-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.34.1-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
@@ -729,6 +729,8 @@ Key actions:
729
729
  - `get_track_count(track_type)` — track_type: `"video"`, `"audio"`, `"subtitle"`
730
730
  - `get_transcript(with_timecodes?)` — read the subtitle track(s) as transcript
731
731
  text `{text, cue_count, has_subtitles, cues}`
732
+ - `propose_cuts(cues?, long_pause_frames?)` — DRY-RUN: mechanically detect
733
+ candidate cuts (fillers, long pauses, repeats) from the transcript; proposes only
732
734
  - `add_track(track_type, sub_type?)` / `delete_track(track_type, index)`
733
735
  - `get_items(track_type, index)` — items on a track
734
736
  - `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.8"
38
+ VERSION = "2.34.1"
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.8",
3
+ "version": "2.34.1",
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.8"
83
+ VERSION = "2.34.1"
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.8"
14
+ VERSION = "2.34.1"
15
15
 
16
16
  import base64
17
17
  import os
@@ -45,6 +45,8 @@ from src.utils.cdl import normalize_cdl_payload
45
45
  from src.utils.mcp_stdio import run_fastmcp_stdio
46
46
  from src.utils.api_truth import lookup_api_truth, VERIFIED_ON as _API_TRUTH_VERIFIED_ON
47
47
  from src.utils.contracts import validate as _validate_params
48
+ from src.utils.cut_ir import build_cut_list as _build_cut_list
49
+ from src.utils.page_lock import open_page_serialized as _open_page_serialized
48
50
  from src.utils.proc import safe_run
49
51
  from src.utils.readback import verify_by_readback
50
52
  from src.utils.update_check import (
@@ -10671,7 +10673,9 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
10671
10673
  valid_pages = ["media", "cut", "edit", "color", "fusion", "fairlight", "deliver"]
10672
10674
  if p["page"] not in valid_pages:
10673
10675
  return _err(f"Invalid page '{p['page']}'. Valid pages: {', '.join(valid_pages)}")
10674
- return {"success": bool(r.OpenPage(p["page"]))}
10676
+ # Serialize page switches so concurrent agents can't flip the single
10677
+ # globally-active page underneath each other.
10678
+ return {"success": bool(_open_page_serialized(r, p["page"]))}
10675
10679
  elif action == "get_keyframe_mode":
10676
10680
  return {"mode": r.GetKeyframeMode()}
10677
10681
  elif action == "set_keyframe_mode":
@@ -15202,6 +15206,9 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
15202
15206
  get_media_pool_item() -> {name, id}
15203
15207
  get_transcript(with_timecodes?) -> {text, cue_count, has_subtitles, cues}
15204
15208
  Read the timeline's subtitle track(s) as transcript text.
15209
+ propose_cuts(cues?, long_pause_frames?) -> {cuts, cut_count, basis_cue_count, pass, note}
15210
+ DRY-RUN. Mechanically detect candidate cuts (filler words, long pauses,
15211
+ repeated lines) from the subtitle transcript. Proposes only; applies nothing.
15205
15212
  get_mark_in_out() -> {mark}
15206
15213
  set_mark_in_out(mark_in, mark_out, type?) -> {success}
15207
15214
  clear_mark_in_out(type?) -> {success}
@@ -15489,6 +15496,13 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
15489
15496
  return {"name": mpi.GetName(), "id": mpi.GetUniqueId()} if mpi else {"name": None, "id": None}
15490
15497
  elif action == "get_transcript":
15491
15498
  return _timeline_transcript(tl, with_timecodes=bool(p.get("with_timecodes")))
15499
+ elif action == "propose_cuts":
15500
+ # Dry-run only: detect mechanical cuts (fillers, long pauses, repeats)
15501
+ # from the timeline's subtitle transcript. Proposes; never edits.
15502
+ cues = p.get("cues")
15503
+ if cues is None:
15504
+ cues = _timeline_transcript(tl, with_timecodes=True)["cues"]
15505
+ return _build_cut_list(cues, long_pause_frames=int(p.get("long_pause_frames", 48)))
15492
15506
  elif action == "get_mark_in_out":
15493
15507
  return _ser(tl.GetMarkInOut())
15494
15508
  elif action == "set_mark_in_out":
@@ -15698,7 +15712,7 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
15698
15712
  if mp_err:
15699
15713
  return mp_err
15700
15714
  return _fairlight_boundary_report(proj, mp, tl, p)
15701
- 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])
15715
+ 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","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])
15702
15716
 
15703
15717
 
15704
15718
  # ═══════════════════════════════════════════════════════════════════════════════
@@ -0,0 +1,115 @@
1
+ """Cut intermediate representation (Cut-IR) and the mechanical (Pass-1) detector.
2
+
3
+ The Cut-IR is the typed contract between a timestamped transcript and concrete,
4
+ governed timeline operations. Producers emit Cuts; an executor (a later phase)
5
+ consumes them as governed, versioned edits; a review UI shows them. This module
6
+ provides the schema plus the deterministic Pass-1 detector — filler words, long
7
+ pauses, and repeated lines — with no LLM. The semantic Pass-2 and the timeline
8
+ executor are separate phases (see local/design/research/r1-cut-ir.md).
9
+
10
+ A Cut:
11
+ {
12
+ "kind": "filler" | "long_pause" | "stammer" | "false_start" | "semantic",
13
+ "span": {"start": <frame>, "end": <frame>},
14
+ "action": "lift" | "ripple_delete" | "keep" | "reorder" | "swap",
15
+ "confidence": 0.0..1.0,
16
+ "rationale": str,
17
+ "evidence": {...},
18
+ }
19
+ """
20
+ from typing import Any, Dict, List, Optional
21
+
22
+ # Common English fillers (single tokens and short phrases).
23
+ FILLER_WORDS = {
24
+ "um", "uh", "er", "ah", "eh", "hmm", "mm", "uhh", "umm",
25
+ "like", "so", "well", "right", "okay", "ok",
26
+ }
27
+ FILLER_PHRASES = {"you know", "i mean", "sort of", "kind of", "you see"}
28
+
29
+
30
+ def make_cut(
31
+ kind: str,
32
+ start: Optional[int],
33
+ end: Optional[int],
34
+ action: str,
35
+ confidence: float,
36
+ rationale: str,
37
+ evidence: Optional[Dict[str, Any]] = None,
38
+ ) -> Dict[str, Any]:
39
+ return {
40
+ "kind": kind,
41
+ "span": {"start": start, "end": end},
42
+ "action": action,
43
+ "confidence": round(float(confidence), 2),
44
+ "rationale": rationale,
45
+ "evidence": evidence or {},
46
+ }
47
+
48
+
49
+ def _norm(text: str) -> str:
50
+ return (text or "").strip().lower().strip(".,!?;:")
51
+
52
+
53
+ def _is_filler_only(text: str) -> bool:
54
+ low = _norm(text)
55
+ if not low:
56
+ return False
57
+ if low in FILLER_PHRASES:
58
+ return True
59
+ tokens = [t.strip(".,!?;:") for t in low.split()]
60
+ tokens = [t for t in tokens if t]
61
+ return bool(tokens) and all(t in FILLER_WORDS for t in tokens)
62
+
63
+
64
+ def detect_cuts_pass1(
65
+ cues: List[Dict[str, Any]],
66
+ *,
67
+ long_pause_frames: int = 48,
68
+ ) -> List[Dict[str, Any]]:
69
+ """Mechanical Pass-1 detection over timestamped cues.
70
+
71
+ cues: list of {"text", "start", "end"} in frames. Returns a list of Cuts.
72
+ """
73
+ cuts: List[Dict[str, Any]] = []
74
+ for i, cue in enumerate(cues):
75
+ text = (cue.get("text") or "").strip()
76
+ start, end = cue.get("start"), cue.get("end")
77
+
78
+ if _is_filler_only(text):
79
+ cuts.append(make_cut(
80
+ "filler", start, end, "lift", 0.8,
81
+ f"Filler-only cue: {text!r}", {"text": text},
82
+ ))
83
+
84
+ if i > 0:
85
+ prev_text = (cues[i - 1].get("text") or "").strip()
86
+ if _norm(prev_text) and _norm(text) == _norm(prev_text):
87
+ cuts.append(make_cut(
88
+ "stammer", start, end, "lift", 0.6,
89
+ f"Repeated line: {text!r}", {"text": text},
90
+ ))
91
+ prev_end = cues[i - 1].get("end")
92
+ if (prev_end is not None and start is not None
93
+ and start - prev_end > long_pause_frames):
94
+ cuts.append(make_cut(
95
+ "long_pause", prev_end, start, "lift", 0.5,
96
+ f"Pause of {start - prev_end} frames before {text!r}",
97
+ {"frames": start - prev_end},
98
+ ))
99
+ return cuts
100
+
101
+
102
+ def build_cut_list(
103
+ cues: List[Dict[str, Any]],
104
+ *,
105
+ long_pause_frames: int = 48,
106
+ ) -> Dict[str, Any]:
107
+ """Run Pass-1 and wrap the result as a (dry-run) CutList."""
108
+ cuts = detect_cuts_pass1(cues, long_pause_frames=long_pause_frames)
109
+ return {
110
+ "cuts": cuts,
111
+ "cut_count": len(cuts),
112
+ "basis_cue_count": len(cues),
113
+ "pass": "mechanical",
114
+ "note": "Dry-run proposal. Review before applying; no edits were made.",
115
+ }
@@ -0,0 +1,75 @@
1
+ """Serialize DaVinci Resolve page switches across threads and processes.
2
+
3
+ Resolve has a single globally-active page (Edit / Color / Fusion / Fairlight /
4
+ Deliver / Cut). An operation that switches the page, does work there, and reads a
5
+ result is only correct if no other operation flips the page underneath it. With a
6
+ single stdio client that never happens, but the moment two agents (threads, or
7
+ separate server processes) drive one Resolve, concurrent page switches corrupt
8
+ each other. This primitive serializes the critical section, so it must be in
9
+ place before any concurrent-agent feature ships.
10
+
11
+ - Intra-process: a reentrant lock (nested page_lock() calls are safe).
12
+ - Inter-process: a best-effort advisory file lock around the OUTERMOST section
13
+ (fcntl). On platforms without fcntl (Windows) the inter-process guard is a
14
+ no-op and only the intra-process lock applies.
15
+
16
+ Usage:
17
+
18
+ with page_lock():
19
+ resolve.OpenPage("color")
20
+ ... do color-page work, read results ...
21
+ """
22
+ import os
23
+ import tempfile
24
+ import threading
25
+ from contextlib import contextmanager
26
+
27
+ try:
28
+ import fcntl # type: ignore
29
+ _HAS_FCNTL = True
30
+ except ImportError: # pragma: no cover - Windows
31
+ _HAS_FCNTL = False
32
+
33
+ _INTRA = threading.RLock()
34
+ _LOCKFILE = os.path.join(tempfile.gettempdir(), "davinci_resolve_mcp_page.lock")
35
+
36
+ # Nesting depth and the held file handle, both guarded by _INTRA. The file lock
37
+ # is taken only at the outermost level — a second fcntl.flock() on a new fd from
38
+ # the same process would block on the first, deadlocking nested page_lock()s.
39
+ _depth = 0
40
+ _fh = None
41
+
42
+
43
+ @contextmanager
44
+ def page_lock():
45
+ """Hold the page-switch lock for the duration of the block (reentrant)."""
46
+ global _depth, _fh
47
+ _INTRA.acquire()
48
+ _depth += 1
49
+ try:
50
+ if _depth == 1 and _HAS_FCNTL:
51
+ try:
52
+ _fh = open(_LOCKFILE, "w")
53
+ fcntl.flock(_fh, fcntl.LOCK_EX)
54
+ except OSError:
55
+ # Advisory lock is best-effort; never block real work on it.
56
+ if _fh is not None:
57
+ _fh.close()
58
+ _fh = None
59
+ yield
60
+ finally:
61
+ _depth -= 1
62
+ if _depth == 0 and _fh is not None:
63
+ try:
64
+ fcntl.flock(_fh, fcntl.LOCK_UN)
65
+ except OSError:
66
+ pass
67
+ _fh.close()
68
+ _fh = None
69
+ _INTRA.release()
70
+
71
+
72
+ def open_page_serialized(resolve, page):
73
+ """Switch Resolve to `page` under the page lock. Returns OpenPage's result."""
74
+ with page_lock():
75
+ return resolve.OpenPage(page)