davinci-resolve-mcp 2.59.0 → 2.60.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,55 @@
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.60.0
6
+
7
+ A community bug-fix bundle — three external contributions plus the three issues
8
+ they filed, integrated with the contributors credited as co-authors.
9
+
10
+ ### Fixed
11
+
12
+ - **Frame accuracy (`edit_engine`)** — `MediaPool.AppendToTimeline` clipInfo
13
+ `endFrame` is an *exclusive* bound (duration = `endFrame - startFrame`), but
14
+ three plan builders wrote an inclusive end (`round(t*fps) - 1`) and
15
+ `execute_selects` advanced its record cursor by `end - start + 1`. Result:
16
+ `plan_tighten` kept ranges were one frame short per segment (~4.3s across 130
17
+ segments), `plan_selects`/`plan_swap` source ranges were one frame short, and
18
+ `execute_selects` left a 1-frame gap between selects. Now half-open everywhere.
19
+ (#82, thanks @chenyuxiaojin)
20
+ - **Timeline rename no longer archives (`destructive_hook`)** — `timeline.set_name`
21
+ was version-on-mutate, so renames spawned redundant `_archived` timelines
22
+ (and renaming an archive archived the archive). A rename is content-preserving,
23
+ so it's out of the destructive registry. (#83, thanks @chenyuxiaojin)
24
+ - **Windows startup (`server`)** — initialize the Resolve scripting env
25
+ (PYTHONHOME, PATH, `os.add_dll_directory`) before importing the fusionscript
26
+ bridge, avoiding a native access violation that crashed network transports
27
+ before bind. No-op off Windows. (#78, thanks @POLEPALLIANVESH)
28
+ - Fixed a stale offline test that patched a refactored-away seam
29
+ (`project_manager` delete routing), restoring a fully green baseline.
30
+
31
+ ### Added
32
+
33
+ - **`execute_tighten(..., include_details?)`** — the readback `structural_diff`
34
+ is compact by default (counts + a small head/tail sample) instead of embedding
35
+ every before/after item id (226 KB for a 130-segment tighten). The full
36
+ per-item diff is persisted in the plan record (`get_plan` →
37
+ `execution_summary.structural_diff`) and returned inline with
38
+ `include_details=true`. (#84, thanks @chenyuxiaojin)
39
+ - **`plan_tighten` skip dedup** — identical `(item, reason)` skip rows collapse
40
+ into one entry with a `count` (an unanalyzed layer no longer repeats the same
41
+ row once per segment). (#81, thanks @chenyuxiaojin)
42
+
43
+ ### Documentation
44
+
45
+ - Recorded the `AppendToTimeline` `endFrame` exclusive-bound semantics in the
46
+ `api_truth` ledger (internal quirk entry). (#80, thanks @chenyuxiaojin)
47
+
48
+ ### Validation
49
+
50
+ - Full offline suite green (1343 tests). Static/drift guards pass.
51
+ - Live Resolve validation of the #82 frame-accuracy fix (selects butt-join +
52
+ tighten frame-exact keep ranges) on a disposable project.
53
+
5
54
  ## What's New in v2.59.0
6
55
 
7
56
  First-class conform ingest for **AAF** and **DRP**, an offline **Premiere `.prproj`** reader with
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.59.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.60.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-34%20(341%20full)-blue.svg)](#server-modes)
package/docs/SKILL.md CHANGED
@@ -794,8 +794,10 @@ clip-count readback plus `brain_edits` rationale rows.
794
794
  media-id fallback), and `readback` carries per-track-type
795
795
  `track_counts` plus an `audio_accounting` block so swap symmetry is
796
796
  verifiable. `execute_tighten` readback gains `structural_diff` (source
797
- vs variant, via the same engine as `diff_timelines`); `execute_selects`
798
- readback gains a `usage_summary`.
797
+ vs variant, via the same engine as `diff_timelines`) — compact by
798
+ default (counts + a small sample; full per-item diff persisted in the
799
+ plan record via `get_plan`, or inline with `include_details=true`);
800
+ `execute_selects` readback gains a `usage_summary`.
799
801
  - `list_plans(limit?)` / `get_plan(plan_id)`.
800
802
 
801
803
  The engine needs the analysis substrate: analyzed clips in the DB (run
package/install.py CHANGED
@@ -35,7 +35,7 @@ from src.utils.update_check import (
35
35
 
36
36
  # ─── Version ──────────────────────────────────────────────────────────────────
37
37
 
38
- VERSION = "2.59.0"
38
+ VERSION = "2.60.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.59.0",
3
+ "version": "2.60.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.59.0"
83
+ VERSION = "2.60.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.59.0"
14
+ VERSION = "2.60.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -745,6 +745,32 @@ resolve = None
745
745
  dvr_script = None
746
746
  _resolve_lock = threading.RLock()
747
747
 
748
+ # On Windows the fusionscript native bridge DLL must be locatable before the
749
+ # Python import machinery attempts to load it. Setting PYTHONHOME, prepending
750
+ # the Resolve install directory to PATH, and registering it with
751
+ # os.add_dll_directory() ensures the dynamic loader can find fusionscript.dll
752
+ # and its dependencies even when the server is launched from a virtual-env or
753
+ # a working directory that is not the Resolve install directory. These steps
754
+ # MUST happen before `import DaVinciResolveScript` or the import triggers a
755
+ # native access-violation on Windows (crash before port bind in network mode).
756
+ if sys.platform.startswith("win") and RESOLVE_LIB_PATH:
757
+ _resolve_install_dir = os.path.dirname(RESOLVE_LIB_PATH)
758
+ if os.path.isdir(_resolve_install_dir):
759
+ # Ensure Python's own runtime DLLs are discoverable by the bridge.
760
+ if not os.environ.get("PYTHONHOME"):
761
+ os.environ["PYTHONHOME"] = sys.base_prefix
762
+ # Prepend Resolve's install dir to PATH so the loader finds
763
+ # fusionscript.dll and sibling DLLs even without a system-wide install.
764
+ _cur_path = os.environ.get("PATH", "")
765
+ if _resolve_install_dir.lower() not in _cur_path.lower():
766
+ os.environ["PATH"] = _resolve_install_dir + os.pathsep + _cur_path
767
+ # os.add_dll_directory is available on Python 3.8+ (Windows only).
768
+ if hasattr(os, "add_dll_directory"):
769
+ try:
770
+ os.add_dll_directory(_resolve_install_dir)
771
+ except OSError:
772
+ pass
773
+
748
774
  try:
749
775
  import DaVinciResolveScript as dvr_script
750
776
  logger.info("DaVinciResolveScript module loaded")
@@ -17880,6 +17906,47 @@ def _edit_engine_capture(tl) -> Dict[str, Any]:
17880
17906
  return out
17881
17907
 
17882
17908
 
17909
+ def _compact_structural_diff(diff: Any, *, sample_n: int = 3) -> Any:
17910
+ """Trim a compare_usage_snapshots result for the default execute response.
17911
+
17912
+ The full added/removed/moved/trimmed rows (one per timeline item, each with
17913
+ media_pool_item_id + track + in/out frames) dominate large tighten
17914
+ responses — 226 KB for a 130-segment tighten (issue #84). The counts already
17915
+ live in `summary`; keep those plus a small head/tail sample per change kind.
17916
+ The full diff is persisted in the executed plan record (edit_engine
17917
+ get_plan(plan_id) → plan.execution_summary.structural_diff) and returned
17918
+ inline when the caller passes include_details=true.
17919
+ """
17920
+ if not isinstance(diff, dict):
17921
+ return diff
17922
+ # An error stand-in (diff failed) or an already-compact payload: pass through.
17923
+ if "summary" not in diff:
17924
+ return diff
17925
+ sample: Dict[str, Any] = {}
17926
+ omitted = 0
17927
+ for kind in ("added", "removed", "moved", "trimmed"):
17928
+ rows = diff.get(kind)
17929
+ if not isinstance(rows, list) or not rows:
17930
+ continue
17931
+ if len(rows) <= sample_n:
17932
+ sample[kind] = list(rows)
17933
+ continue
17934
+ # Head sample + final row so the caller sees both ends of the change set.
17935
+ sample[kind] = list(rows[: sample_n - 1]) + [rows[-1]]
17936
+ omitted += len(rows) - sample_n
17937
+ return {
17938
+ "summary": diff.get("summary"),
17939
+ "sample": sample,
17940
+ "truncated": True,
17941
+ "omitted_rows": omitted,
17942
+ "detail_hint": (
17943
+ "Full per-item diff persisted in the plan record — retrieve via "
17944
+ "edit_engine get_plan(plan_id) → plan.execution_summary.structural_diff, "
17945
+ "or re-run with include_details=true to inline it."
17946
+ ),
17947
+ }
17948
+
17949
+
17883
17950
  def _edit_engine_track_counts(tl) -> Dict[str, int]:
17884
17951
  """Per-track-type item counts — the swap symmetry signal in readback."""
17885
17952
  counts: Dict[str, int] = {}
@@ -18001,9 +18068,12 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
18001
18068
  evidence for the current (or named) timeline. Kept ranges mirror onto the
18002
18069
  items' linked audio tracks by default so the variant is audible; pass
18003
18070
  include_audio=false for a video-only (silent) assembly.
18004
- - execute_tighten(plan_id, confirm_token?) — assembles a tightened VARIANT
18005
- timeline from the plan's keep ranges (video + mirrored audio), never
18006
- mutating the original. Readback includes an audio_accounting block.
18071
+ - execute_tighten(plan_id, confirm_token?, include_details?) — assembles a
18072
+ tightened VARIANT timeline from the plan's keep ranges (video + mirrored
18073
+ audio), never mutating the original. Readback includes an audio_accounting
18074
+ block and a compact structural_diff (counts + a small sample); the full
18075
+ per-item diff is persisted in the plan record (get_plan → execution_summary)
18076
+ and returned inline only when include_details=true.
18007
18077
  - plan_swap(track_index?, timeline_start_frame | item_name, kind?, limit?)
18008
18078
  — alternates for one timeline item via the similarity index.
18009
18079
  - execute_swap(plan_id, alternate_index, confirm_token?) — replaces the
@@ -18161,7 +18231,10 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
18161
18231
  build_errors.append({"index": i, "error": row_err.get("error")})
18162
18232
  continue
18163
18233
  built.append(row)
18164
- record_cursor += int(append_ci.get("end_frame", 0)) - int(append_ci.get("start_frame", 0)) + 1
18234
+ # AppendToTimeline endFrame is exclusive: the item occupies
18235
+ # (end - start) frames. Advancing by +1 more leaves a 1-frame gap
18236
+ # (black flash) between consecutive selects. See api_truth endFrame entry.
18237
+ record_cursor += int(append_ci.get("end_frame", 0)) - int(append_ci.get("start_frame", 0))
18165
18238
  appended = mp.AppendToTimeline(built) if built else None
18166
18239
  readback = _edit_engine_capture(tl)
18167
18240
  # A diff against a source timeline is meaningless for a fresh assembly;
@@ -18291,12 +18364,20 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
18291
18364
  )
18292
18365
  except Exception:
18293
18366
  pass
18367
+ # The full per-item structural diff is persisted in the plan record so it
18368
+ # stays retrievable (edit_engine get_plan) without bloating every
18369
+ # response — see issue #84. The MCP response carries a compact form by
18370
+ # default and the full diff only when include_details=true.
18371
+ include_details = _media_analysis_bool(
18372
+ p.get("include_details", p.get("includeDetails")), False
18373
+ )
18294
18374
  _edit_engine_mod.mark_plan_executed(project_root, plan["plan_id"], {
18295
18375
  "variant_timeline": variant.get("name") or variant_name,
18296
18376
  "keep_ranges": len(keep_ranges),
18297
18377
  "lifts": len(lifts),
18298
18378
  "before": before,
18299
18379
  "after": after,
18380
+ "structural_diff": structural_diff,
18300
18381
  })
18301
18382
  return {
18302
18383
  "success": True,
@@ -18316,7 +18397,10 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
18316
18397
  if before.get("duration_seconds") is not None and after.get("duration_seconds") is not None
18317
18398
  else None
18318
18399
  ),
18319
- "structural_diff": structural_diff,
18400
+ "structural_diff": (
18401
+ structural_diff if include_details
18402
+ else _compact_structural_diff(structural_diff)
18403
+ ),
18320
18404
  "audio_accounting": {
18321
18405
  "planned_audio_ranges": audio_keep_ranges,
18322
18406
  "planned_video_ranges": video_keep_ranges,
@@ -508,6 +508,30 @@ API_TRUTH: List[Dict[str, Any]] = [
508
508
  "pass-through.",
509
509
  "tags": ["silent-failure", "color", "node-graph", "layout", "drx"],
510
510
  },
511
+ {
512
+ "symbol": "MediaPool.AppendToTimeline clipInfo endFrame (exclusive bound)",
513
+ "object": "MediaPool",
514
+ "signature": "([{mediaPoolItem, startFrame, endFrame, recordFrame, "
515
+ "trackIndex, mediaType}]) -> [TimelineItem]",
516
+ "reality": "clipInfo endFrame is an EXCLUSIVE bound: the appended item's "
517
+ "duration is endFrame - startFrame frames, not "
518
+ "endFrame - startFrame + 1. Verified by readback probe on "
519
+ "Resolve Studio 21.0 — appending {startFrame: 6254, "
520
+ "endFrame: 6348} yields a 94-frame item, and 130 such "
521
+ "appends whose absolute recordFrames advance by exactly "
522
+ "(endFrame - startFrame) assemble a gap-free track whose "
523
+ "total length equals the sum of (end - start). Code that "
524
+ "assumes an inclusive bound drifts one frame per clip: "
525
+ "advancing a record cursor by (end - start + 1) leaves a "
526
+ "1-frame hole between consecutive clips, and converting a "
527
+ "half-open range with (end - 1) shaves the range's last "
528
+ "frame.",
529
+ "recommended": "Treat [startFrame, endFrame) as half-open everywhere: "
530
+ "duration = endFrame - startFrame; next recordFrame = "
531
+ "previous recordFrame + duration; no ±1 corrections "
532
+ "when mirroring keep-ranges into clipInfos.",
533
+ "tags": ["timeline", "edit", "off-by-one", "readback"],
534
+ },
511
535
  ]
512
536
 
513
537
 
@@ -99,7 +99,10 @@ DESTRUCTIVE_ACTIONS_BY_TOOL: Dict[str, FrozenSet[str]] = {
99
99
  "set_track_enable",
100
100
  "set_track_name",
101
101
  "set_voice_isolation_state",
102
- "set_name",
102
+ # NOTE: timeline.set_name (rename) is intentionally NOT here. A rename is
103
+ # content-preserving and trivially reversible, so version-on-mutate added
104
+ # no recovery value — it only spawned redundant `_archived` copies (one per
105
+ # rename, and renaming an archive archived the archive). Issue #83.
103
106
  "set_start_timecode",
104
107
  "set_setting",
105
108
  "set_mark_in_out",
@@ -262,7 +262,10 @@ def plan_selects(
262
262
  if isinstance(clip_duration, (int, float)) and clip_duration:
263
263
  src_end = min(src_end, float(clip_duration))
264
264
  start_frame = int(round(src_start * fps))
265
- end_frame = max(start_frame + 1, int(round(src_end * fps)) - 1)
265
+ # AppendToTimeline clipInfo endFrame is a half-open (exclusive) bound
266
+ # duration = endFrame - startFrame. See api_truth "AppendToTimeline
267
+ # clipInfo endFrame". No -1: that would shave the last frame of every select.
268
+ end_frame = max(start_frame + 1, int(round(src_end * fps)))
266
269
  decision = {k: v for k, v in candidate.items() if not k.startswith("_")}
267
270
  decision["source_frame_range"] = [start_frame, end_frame]
268
271
  decisions.append(decision)
@@ -339,6 +342,29 @@ def _gaps_in_range(
339
342
  return gaps
340
343
 
341
344
 
345
+ def _dedupe_skipped(skipped: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
346
+ """Collapse identical (item, reason) skip rows into one entry with a count.
347
+
348
+ A timeline layer cut into many segments from one unanalyzed source clip
349
+ otherwise reports the same skip once per segment (87 identical rows in a
350
+ real two-layer session), which bloats the plan payload without adding
351
+ signal. First occurrence order is preserved.
352
+ """
353
+ out: List[Dict[str, Any]] = []
354
+ index: Dict[Tuple[Any, Any], Dict[str, Any]] = {}
355
+ for row in skipped:
356
+ key = (row.get("item"), row.get("reason"))
357
+ hit = index.get(key)
358
+ if hit is None:
359
+ hit = dict(row)
360
+ hit["count"] = 1
361
+ index[key] = hit
362
+ out.append(hit)
363
+ else:
364
+ hit["count"] += 1
365
+ return out
366
+
367
+
342
368
  def plan_tighten(
343
369
  project_root: str,
344
370
  *,
@@ -440,6 +466,7 @@ def plan_tighten(
440
466
  },
441
467
  })
442
468
 
469
+ skipped = _dedupe_skipped(skipped)
443
470
  if not lifts:
444
471
  return {
445
472
  "success": False,
@@ -492,7 +519,9 @@ def plan_tighten(
492
519
  audio_indices = [1]
493
520
  for seg_start, seg_end in segments:
494
521
  start_frame = int(round(seg_start * clip_fps))
495
- end_frame = max(start_frame + 1, int(round(seg_end * clip_fps)) - 1)
522
+ # Half-open (exclusive) endFrame duration = end - start. No -1, else
523
+ # every kept segment loses its last frame (see api_truth endFrame entry).
524
+ end_frame = max(start_frame + 1, int(round(seg_end * clip_fps)))
496
525
  keep_ranges.append({
497
526
  "clip_id": spec["resolve_clip_id"],
498
527
  "start_frame": start_frame,
@@ -642,7 +671,9 @@ def plan_swap(
642
671
  alt = dict(alt_clip)
643
672
  alt_fps = _clip_fps(alt)
644
673
  start_frame = int(round(float(alt_start) * alt_fps))
645
- end_frame = start_frame + int(round(needed_seconds * alt_fps)) - 1
674
+ # Half-open (exclusive) endFrame duration = end - start fills the slot
675
+ # exactly. No -1, else the replacement lands one frame short of the slot.
676
+ end_frame = start_frame + int(round(needed_seconds * alt_fps))
646
677
  return {
647
678
  "score": score,
648
679
  "clip_uuid": clip_uuid,