davinci-resolve-mcp 2.34.1 → 2.35.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,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.1
6
+
7
+ - **Added** `media_pool_item(action="extract_frames", clip_id, timestamps, output_dir?)`
8
+ extracts still JPEGs from a clip's source media at the given timestamps (seconds)
9
+ via ffmpeg. Source-safe: it reads the source and writes only to a scratch output
10
+ directory — it never modifies, transcodes, or proxies the source. (Closes a
11
+ read/write-symmetry gap: the analysis sampler existed internally but had no
12
+ standalone frame-extraction tool.)
13
+
14
+ ## What's New in v2.35.0
15
+
16
+ The Cut-IR executor — transcript-driven editing now closes the loop onto the
17
+ timeline.
18
+
19
+ - **Added** `timeline(action="apply_cuts", cuts, dry_run?, confirm_token?)` applies
20
+ a CutList (from `propose_cuts`) to the timeline as lift / ripple deletes. It is
21
+ **DRY-RUN by default**; applying is destructive and fully governed: confirm-token
22
+ gated, and a timeline version is archived first (so it is reversible), through
23
+ the existing destructive hook. Cuts apply latest-first so ripple deletes do not
24
+ invalidate earlier spans. Live-validated end-to-end (propose -> token -> apply ->
25
+ version archived).
26
+
5
27
  ## What's New in v2.34.1
6
28
 
7
29
  Page-switch serialization — the concurrency primitive for safe multi-agent use.
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.1-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.35.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
@@ -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.1"
38
+ VERSION = "2.35.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.34.1",
3
+ "version": "2.35.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.34.1"
83
+ VERSION = "2.35.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.34.1"
14
+ VERSION = "2.35.1"
15
15
 
16
16
  import base64
17
17
  import os
@@ -729,6 +729,7 @@ _destructive_hook.register_preference_provider(_destructive_preference_provider)
729
729
  # a version. Keep in sync with the _issue_confirm_token call sites.
730
730
  _TOKEN_GATED_DESTRUCTIVE_ACTIONS = frozenset({
731
731
  ("timeline", "delete_track"),
732
+ ("timeline", "apply_cuts"),
732
733
  ("graph", "apply_grade_from_drx"),
733
734
  ("graph", "reset_all_grades"),
734
735
  # 21.0 AI ops that render/generate NEW media files (additive, but expensive
@@ -5509,6 +5510,55 @@ def _audio_mapping_report(mp, tl, p: Dict[str, Any]):
5509
5510
  return {"timeline_items": timeline_items, "media_pool_items": clip_rows}
5510
5511
 
5511
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
+
5512
5562
  def _clip_name(clip):
5513
5563
  try:
5514
5564
  return clip.GetName()
@@ -13796,6 +13846,9 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
13796
13846
  get_transcription(clip_id) -> {text, truncated, status, has_transcription}
13797
13847
  Read a clip's transcription. `truncated` flags when Resolve's preview
13798
13848
  property cut the text off (the full transcript is longer).
13849
+ extract_frames(clip_id, timestamps, output_dir?) -> {frame_paths, output_dir, count, errors}
13850
+ Extract still JPEGs from the clip's source at the given timestamps (seconds)
13851
+ via ffmpeg. Source-safe: reads source, writes only to a scratch dir.
13799
13852
  perform_audio_classification(clip_id) -> {success} — Resolve 21+
13800
13853
  clear_audio_classification(clip_id) -> {success} — Resolve 21+
13801
13854
  analyze_for_intellisearch(clip_id, identify_faces?, is_better_mode?) -> {success} — Resolve 21+, AI IntelliSearch Extra
@@ -14011,6 +14064,8 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
14011
14064
  "status": status or None,
14012
14065
  "has_transcription": bool(text.strip()),
14013
14066
  }
14067
+ elif action == "extract_frames":
14068
+ return _extract_clip_frames(clip, p)
14014
14069
  elif action == "perform_audio_classification":
14015
14070
  missing = _requires_method(clip, "PerformAudioClassification", "21.0")
14016
14071
  if missing:
@@ -14095,7 +14150,7 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
14095
14150
  return {"success": bool(clip.SetMarkInOut(clean["mark_in"], clean["mark_out"], p.get("type", "all")))}
14096
14151
  elif action == "clear_mark_in_out":
14097
14152
  return {"success": bool(clip.ClearMarkInOut(p.get("type", "all")))}
14098
- 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"])
14153
+ 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"])
14099
14154
 
14100
14155
 
14101
14156
  # ═══════════════════════════════════════════════════════════════════════════════
@@ -15209,6 +15264,10 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
15209
15264
  propose_cuts(cues?, long_pause_frames?) -> {cuts, cut_count, basis_cue_count, pass, note}
15210
15265
  DRY-RUN. Mechanically detect candidate cuts (filler words, long pauses,
15211
15266
  repeated lines) from the subtitle transcript. Proposes only; applies nothing.
15267
+ apply_cuts(cuts, dry_run?, confirm_token?, allow_partial_item_delete?) -> {applied, total, results}
15268
+ Apply a CutList (from propose_cuts) as lift/ripple deletes. DRY-RUN by
15269
+ default; applying is DESTRUCTIVE — confirm-token gated and a timeline
15270
+ version is archived first. Cuts apply latest-first.
15212
15271
  get_mark_in_out() -> {mark}
15213
15272
  set_mark_in_out(mark_in, mark_out, type?) -> {success}
15214
15273
  clear_mark_in_out(type?) -> {success}
@@ -15503,6 +15562,61 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
15503
15562
  if cues is None:
15504
15563
  cues = _timeline_transcript(tl, with_timecodes=True)["cues"]
15505
15564
  return _build_cut_list(cues, long_pause_frames=int(p.get("long_pause_frames", 48)))
15565
+ elif action == "apply_cuts":
15566
+ # Apply a CutList (from propose_cuts) to the timeline. DRY-RUN by default;
15567
+ # applying is destructive (confirm-token gated, a version is archived
15568
+ # first by the destructive hook). Cuts are applied latest-first so ripple
15569
+ # deletes don't invalidate earlier spans.
15570
+ cuts = p.get("cuts")
15571
+ if not isinstance(cuts, list):
15572
+ return _err("apply_cuts requires 'cuts' (a list, e.g. from propose_cuts)")
15573
+ applicable = [
15574
+ c for c in cuts
15575
+ if isinstance(c, dict) and c.get("action") in ("lift", "ripple_delete")
15576
+ and isinstance(c.get("span"), dict)
15577
+ and c["span"].get("start") is not None and c["span"].get("end") is not None
15578
+ ]
15579
+ applicable.sort(key=lambda c: c["span"]["start"], reverse=True)
15580
+ plan = [{"action": c["action"], "span": c["span"], "kind": c.get("kind")} for c in applicable]
15581
+
15582
+ if p.get("dry_run", True):
15583
+ return {
15584
+ "dry_run": True,
15585
+ "would_apply": len(applicable),
15586
+ "plan": plan,
15587
+ "note": "No edits made. Re-run with dry_run=false (and a confirm_token) to apply.",
15588
+ }
15589
+
15590
+ if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required():
15591
+ return _issue_confirm_token(
15592
+ action="timeline.apply_cuts", params=p,
15593
+ preview={
15594
+ "operation": "timeline.apply_cuts",
15595
+ "warning": "Applies lift/ripple deletes to the timeline; ripple closes "
15596
+ "gaps and cannot be selectively undone. A timeline version is "
15597
+ "archived first.",
15598
+ "would_apply": len(applicable),
15599
+ "plan": plan,
15600
+ },
15601
+ )
15602
+ blocked = _consume_confirm_token(action="timeline.apply_cuts", params=p)
15603
+ if blocked:
15604
+ return blocked
15605
+
15606
+ allow_partial = bool(p.get("allow_partial_item_delete", True))
15607
+ results = []
15608
+ for c in applicable:
15609
+ sp = c["span"]
15610
+ res = _timeline_lift_range_impl(tl, {
15611
+ "start_frame": sp["start"],
15612
+ "end_frame": sp["end"],
15613
+ "ripple": c["action"] == "ripple_delete",
15614
+ "allow_partial_item_delete": allow_partial,
15615
+ })
15616
+ results.append({"action": c["action"], "span": sp, "result": res})
15617
+ applied = sum(1 for r in results
15618
+ if isinstance(r["result"], dict) and r["result"].get("success"))
15619
+ return {"success": True, "applied": applied, "total": len(applicable), "results": results}
15506
15620
  elif action == "get_mark_in_out":
15507
15621
  return _ser(tl.GetMarkInOut())
15508
15622
  elif action == "set_mark_in_out":
@@ -15712,7 +15826,7 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
15712
15826
  if mp_err:
15713
15827
  return mp_err
15714
15828
  return _fairlight_boundary_report(proj, mp, tl, p)
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])
15829
+ 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])
15716
15830
 
15717
15831
 
15718
15832
  # ═══════════════════════════════════════════════════════════════════════════════
@@ -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",