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 +22 -0
- package/README.md +1 -1
- package/docs/SKILL.md +3 -0
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/granular/common.py +1 -1
- package/src/server.py +117 -3
- package/src/utils/destructive_hook.py +1 -0
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
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
4
4
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
5
5
|
[](docs/reference/api-coverage.md)
|
|
6
6
|
[-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.
|
|
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
package/src/granular/common.py
CHANGED
|
@@ -80,7 +80,7 @@ if not logging.getLogger().handlers:
|
|
|
80
80
|
handlers=[logging.StreamHandler()],
|
|
81
81
|
)
|
|
82
82
|
|
|
83
|
-
VERSION = "2.
|
|
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.
|
|
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
|
# ═══════════════════════════════════════════════════════════════════════════════
|