davinci-resolve-mcp 2.34.0 → 2.35.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 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.35.0
6
+
7
+ The Cut-IR executor — transcript-driven editing now closes the loop onto the
8
+ timeline.
9
+
10
+ - **Added** `timeline(action="apply_cuts", cuts, dry_run?, confirm_token?)` applies
11
+ a CutList (from `propose_cuts`) to the timeline as lift / ripple deletes. It is
12
+ **DRY-RUN by default**; applying is destructive and fully governed: confirm-token
13
+ gated, and a timeline version is archived first (so it is reversible), through
14
+ the existing destructive hook. Cuts apply latest-first so ripple deletes do not
15
+ invalidate earlier spans. Live-validated end-to-end (propose -> token -> apply ->
16
+ version archived).
17
+
18
+ ## What's New in v2.34.1
19
+
20
+ Page-switch serialization — the concurrency primitive for safe multi-agent use.
21
+
22
+ - **Added** `src/utils/page_lock.py` — Resolve has a single globally-active page,
23
+ so two agents that flip pages concurrently corrupt each other. `page_lock()`
24
+ serializes page switches: a reentrant intra-process lock plus a best-effort
25
+ inter-process advisory file lock around the outermost section. The `open_page`
26
+ action now routes through it. This must be in place before any concurrent-agent
27
+ feature ships.
28
+ - Networked transport, sandboxed scripting, and capability/role scoping (the rest
29
+ of the safe remote/multi-user design) are security-critical and remain a
30
+ separate, sign-off-gated phase — they are intentionally not shipped here.
31
+
5
32
  ## What's New in v2.34.0
6
33
 
7
34
  First phase of transcript-driven editing: a Cut intermediate representation and a
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.34.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.35.0-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
@@ -731,6 +731,9 @@ Key actions:
731
731
  text `{text, cue_count, has_subtitles, cues}`
732
732
  - `propose_cuts(cues?, long_pause_frames?)` — DRY-RUN: mechanically detect
733
733
  candidate cuts (fillers, long pauses, repeats) from the transcript; proposes only
734
+ - `apply_cuts(cuts, dry_run?, confirm_token?)` — apply a CutList as lift/ripple
735
+ deletes. DRY-RUN by default; applying is destructive (confirm-token gated, a
736
+ timeline version is archived first). Cuts apply latest-first
734
737
  - `add_track(track_type, sub_type?)` / `delete_track(track_type, index)`
735
738
  - `get_items(track_type, index)` — items on a track
736
739
  - `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.34.0"
38
+ VERSION = "2.35.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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.34.0",
3
+ "version": "2.35.0",
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.34.0"
83
+ VERSION = "2.35.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()}")
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.34.0"
14
+ VERSION = "2.35.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -46,6 +46,7 @@ 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
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
49
50
  from src.utils.proc import safe_run
50
51
  from src.utils.readback import verify_by_readback
51
52
  from src.utils.update_check import (
@@ -728,6 +729,7 @@ _destructive_hook.register_preference_provider(_destructive_preference_provider)
728
729
  # a version. Keep in sync with the _issue_confirm_token call sites.
729
730
  _TOKEN_GATED_DESTRUCTIVE_ACTIONS = frozenset({
730
731
  ("timeline", "delete_track"),
732
+ ("timeline", "apply_cuts"),
731
733
  ("graph", "apply_grade_from_drx"),
732
734
  ("graph", "reset_all_grades"),
733
735
  # 21.0 AI ops that render/generate NEW media files (additive, but expensive
@@ -10672,7 +10674,9 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
10672
10674
  valid_pages = ["media", "cut", "edit", "color", "fusion", "fairlight", "deliver"]
10673
10675
  if p["page"] not in valid_pages:
10674
10676
  return _err(f"Invalid page '{p['page']}'. Valid pages: {', '.join(valid_pages)}")
10675
- return {"success": bool(r.OpenPage(p["page"]))}
10677
+ # Serialize page switches so concurrent agents can't flip the single
10678
+ # globally-active page underneath each other.
10679
+ return {"success": bool(_open_page_serialized(r, p["page"]))}
10676
10680
  elif action == "get_keyframe_mode":
10677
10681
  return {"mode": r.GetKeyframeMode()}
10678
10682
  elif action == "set_keyframe_mode":
@@ -15206,6 +15210,10 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
15206
15210
  propose_cuts(cues?, long_pause_frames?) -> {cuts, cut_count, basis_cue_count, pass, note}
15207
15211
  DRY-RUN. Mechanically detect candidate cuts (filler words, long pauses,
15208
15212
  repeated lines) from the subtitle transcript. Proposes only; applies nothing.
15213
+ apply_cuts(cuts, dry_run?, confirm_token?, allow_partial_item_delete?) -> {applied, total, results}
15214
+ Apply a CutList (from propose_cuts) as lift/ripple deletes. DRY-RUN by
15215
+ default; applying is DESTRUCTIVE — confirm-token gated and a timeline
15216
+ version is archived first. Cuts apply latest-first.
15209
15217
  get_mark_in_out() -> {mark}
15210
15218
  set_mark_in_out(mark_in, mark_out, type?) -> {success}
15211
15219
  clear_mark_in_out(type?) -> {success}
@@ -15500,6 +15508,61 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
15500
15508
  if cues is None:
15501
15509
  cues = _timeline_transcript(tl, with_timecodes=True)["cues"]
15502
15510
  return _build_cut_list(cues, long_pause_frames=int(p.get("long_pause_frames", 48)))
15511
+ elif action == "apply_cuts":
15512
+ # Apply a CutList (from propose_cuts) to the timeline. DRY-RUN by default;
15513
+ # applying is destructive (confirm-token gated, a version is archived
15514
+ # first by the destructive hook). Cuts are applied latest-first so ripple
15515
+ # deletes don't invalidate earlier spans.
15516
+ cuts = p.get("cuts")
15517
+ if not isinstance(cuts, list):
15518
+ return _err("apply_cuts requires 'cuts' (a list, e.g. from propose_cuts)")
15519
+ applicable = [
15520
+ c for c in cuts
15521
+ if isinstance(c, dict) and c.get("action") in ("lift", "ripple_delete")
15522
+ and isinstance(c.get("span"), dict)
15523
+ and c["span"].get("start") is not None and c["span"].get("end") is not None
15524
+ ]
15525
+ applicable.sort(key=lambda c: c["span"]["start"], reverse=True)
15526
+ plan = [{"action": c["action"], "span": c["span"], "kind": c.get("kind")} for c in applicable]
15527
+
15528
+ if p.get("dry_run", True):
15529
+ return {
15530
+ "dry_run": True,
15531
+ "would_apply": len(applicable),
15532
+ "plan": plan,
15533
+ "note": "No edits made. Re-run with dry_run=false (and a confirm_token) to apply.",
15534
+ }
15535
+
15536
+ if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required():
15537
+ return _issue_confirm_token(
15538
+ action="timeline.apply_cuts", params=p,
15539
+ preview={
15540
+ "operation": "timeline.apply_cuts",
15541
+ "warning": "Applies lift/ripple deletes to the timeline; ripple closes "
15542
+ "gaps and cannot be selectively undone. A timeline version is "
15543
+ "archived first.",
15544
+ "would_apply": len(applicable),
15545
+ "plan": plan,
15546
+ },
15547
+ )
15548
+ blocked = _consume_confirm_token(action="timeline.apply_cuts", params=p)
15549
+ if blocked:
15550
+ return blocked
15551
+
15552
+ allow_partial = bool(p.get("allow_partial_item_delete", True))
15553
+ results = []
15554
+ for c in applicable:
15555
+ sp = c["span"]
15556
+ res = _timeline_lift_range_impl(tl, {
15557
+ "start_frame": sp["start"],
15558
+ "end_frame": sp["end"],
15559
+ "ripple": c["action"] == "ripple_delete",
15560
+ "allow_partial_item_delete": allow_partial,
15561
+ })
15562
+ results.append({"action": c["action"], "span": sp, "result": res})
15563
+ applied = sum(1 for r in results
15564
+ if isinstance(r["result"], dict) and r["result"].get("success"))
15565
+ return {"success": True, "applied": applied, "total": len(applicable), "results": results}
15503
15566
  elif action == "get_mark_in_out":
15504
15567
  return _ser(tl.GetMarkInOut())
15505
15568
  elif action == "set_mark_in_out":
@@ -15709,7 +15772,7 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
15709
15772
  if mp_err:
15710
15773
  return mp_err
15711
15774
  return _fairlight_boundary_report(proj, mp, tl, p)
15712
- 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])
15775
+ 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])
15713
15776
 
15714
15777
 
15715
15778
  # ═══════════════════════════════════════════════════════════════════════════════
@@ -70,6 +70,7 @@ DESTRUCTIVE_ACTIONS_BY_TOOL: Dict[str, FrozenSet[str]] = {
70
70
  "duplicate_range",
71
71
  "overwrite_range",
72
72
  "lift_range",
73
+ "apply_cuts",
73
74
  "create_stereo_clip",
74
75
  "auto_sync_audio",
75
76
  "create_compound_clip",
@@ -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)