davinci-resolve-mcp 4.13.6 → 4.13.7

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,45 @@
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 v4.13.7 — Missing-media relink works for paths written on another OS
6
+
7
+ Lands #294, the follow-up to #293 for the Windows/macOS path work tracked in
8
+ #278. A project moved between macOS and Windows keeps the paths the other OS
9
+ wrote, so the missing-media diagnosis and relink plan now go by the path's own
10
+ form.
11
+
12
+ ### Fixed
13
+
14
+ - **Wanted basename (`src/server.py`, `_missing_media_diagnosis` and
15
+ `_build_relink_plan`)**: both used `os.path.basename`, which splits on the
16
+ running OS's separator only. On macOS the basename of `E:\DCIM\P1047043.MOV`
17
+ was the whole string, so a bounded search could never match. Both now split
18
+ on `/` and `\`.
19
+ - **Volumes from another OS**: after #293, `E:\` on macOS (or `/Volumes/X` on
20
+ Windows) counted as an unmounted volume. That skipped the relink search and
21
+ advised mounting a volume this OS can never mount. Such roots are now flagged
22
+ `foreign_os_path` on volume, folder, item and candidate rows, and are never
23
+ probed with `isdir`. They report the new
24
+ `primary_cause="foreign_os_path"`, which advises searching the approved roots,
25
+ and they no longer trigger `skip_search_when_volume_missing`. A same-OS
26
+ unmounted volume behaves as before. When both kinds are present, the advice
27
+ names both.
28
+ - **Sanitized relink plans**: with `sanitized=true`, `file_path` is dropped, so
29
+ the plan searched by the Resolve clip name. It now takes the basename from
30
+ `file_path_sanitized`, which keeps the filename.
31
+
32
+ ### Documentation
33
+
34
+ - `docs/SKILL.md` documents `foreign_os_path`.
35
+
36
+ ### Validation
37
+
38
+ - New `ForeignOsMediaPathTests` and basename/form tables pin the host OS, so
39
+ Linux, macOS and Windows CI all run both directions. Linux CI green on the PR.
40
+ - Full offline suite and release static checks pass on macOS.
41
+ - Diagnosis only, with no Resolve API call changed, so a live test is not
42
+ required.
43
+
5
44
  ## What's New in v4.13.6 — `drt` downgrade stamps the real Resolve build
6
45
 
7
46
  ### Fixed
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  English | [简体中文](README.zh-CN.md)
4
4
 
5
- [![Version](https://img.shields.io/badge/version-4.13.6-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.13.7-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-37%20(390%20full)-blue.svg)](#server-modes)
package/README.zh-CN.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [English](README.md) | 简体中文
4
4
 
5
- [![Version](https://img.shields.io/badge/version-4.13.6-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.13.7-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-37%20(390%20full)-blue.svg)](#服务器模式)
@@ -12,7 +12,7 @@
12
12
  [![Python](https://img.shields.io/badge/python-3.10+-green.svg)](https://www.python.org/downloads/)
13
13
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT)
14
14
 
15
- > 本翻译对应 v4.13.6 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v4.13.7 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
package/docs/SKILL.md CHANGED
@@ -1806,7 +1806,11 @@ missing volume roots, sample basenames, and a recommended next step. If a source
1806
1806
  volume such as a camera card is not mounted, `build_relink_plan` skips broad
1807
1807
  search by default and reports `skip_reason="missing_source_volume_not_mounted"`;
1808
1808
  mount the volume or pass `skip_search_when_volume_missing=false` only when a
1809
- bounded scan of approved roots is intentional.
1809
+ bounded scan of approved roots is intentional. Paths written on another OS
1810
+ (`E:\...` on macOS, `/Volumes/...` on Windows, after a project moves between
1811
+ machines) are flagged `foreign_os_path`: their volume can never be mounted
1812
+ here, so they report `primary_cause="foreign_os_path"`, do not hold back the
1813
+ search, and are matched by their own basename under the approved roots.
1810
1814
 
1811
1815
  Audio / Fairlight kernel actions (v2.14.0+) add live-tested audio state,
1812
1816
  mapping, voice-isolation, sync, transcription, subtitle, and Fairlight boundary
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "4.13.6"
40
+ VERSION = "4.13.7"
41
41
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
42
42
  # Resolve's scripting bridge loads into newer interpreters on recent builds
43
43
  # (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": "4.13.6",
3
+ "version": "4.13.7",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -91,7 +91,7 @@ if not logging.getLogger().handlers:
91
91
  handlers=[logging.StreamHandler()],
92
92
  )
93
93
 
94
- VERSION = "4.13.6"
94
+ VERSION = "4.13.7"
95
95
  logger = logging.getLogger("davinci-resolve-mcp")
96
96
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
97
97
  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 377-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "4.13.6"
14
+ VERSION = "4.13.7"
15
15
 
16
16
  import base64
17
17
  import os
@@ -21,6 +21,7 @@ import inspect
21
21
  import json
22
22
  import logging
23
23
  import math
24
+ import ntpath
24
25
  import platform
25
26
  import re
26
27
  import shutil
@@ -8465,6 +8466,43 @@ def _media_path_parts(text: str) -> List[str]:
8465
8466
  return [part for part in re.split(r"[\\/]", text) if part]
8466
8467
 
8467
8468
 
8469
+ def _media_path_basename(path: Any) -> str:
8470
+ # `os.path.basename` splits on the running OS's separator only, so on macOS
8471
+ # the basename of `E:\DCIM\P1047043.MOV` is the whole string and a search
8472
+ # for it can never match. Go by the path's own form instead.
8473
+ parts = _media_path_parts(str(path or ""))
8474
+ return parts[-1] if parts else ""
8475
+
8476
+
8477
+ def _media_path_form(path: Any) -> Optional[str]:
8478
+ # "windows" for a drive letter or UNC share, "posix" for a /-rooted path,
8479
+ # None for a relative path, which says nothing about the OS that wrote it.
8480
+ text = str(path or "")
8481
+ if text.startswith("\\\\") or _WINDOWS_DRIVE_PATH.match(text):
8482
+ return "windows"
8483
+ if text.startswith("/"):
8484
+ return "posix"
8485
+ return None
8486
+
8487
+
8488
+ def _host_media_path_form() -> str:
8489
+ return "windows" if os.name == "nt" else "posix"
8490
+
8491
+
8492
+ def _media_path_is_foreign(path: Any) -> bool:
8493
+ # A path another OS wrote: `E:\` on macOS, `/Volumes/X` on Windows. Its
8494
+ # volume can never be mounted on this machine, so "mount it" is the wrong
8495
+ # advice; the media has to be found under a root this OS can see.
8496
+ form = _media_path_form(path)
8497
+ return form is not None and form != _host_media_path_form()
8498
+
8499
+
8500
+ def _media_path_dirname(text: str) -> str:
8501
+ # ntpath understands drive letters, UNC shares and both separators on any
8502
+ # OS; posix-form paths keep the running OS's own dirname.
8503
+ return ntpath.dirname(text) if _media_path_form(text) == "windows" else os.path.dirname(text)
8504
+
8505
+
8468
8506
  def _sanitize_media_path(path: Any, *, keep_filename: bool = True) -> Optional[str]:
8469
8507
  if not path:
8470
8508
  return None
@@ -8508,7 +8546,8 @@ def _missing_media_diagnosis(missing_rows: List[Dict[str, Any]]) -> Dict[str, An
8508
8546
  for row in missing_rows:
8509
8547
  file_path = row.get("file_path")
8510
8548
  media_pool_item_id = row.get("media_pool_item_id") or file_path or row.get("name")
8511
- basename = os.path.basename(str(file_path or row.get("media_pool_item_name") or row.get("name") or ""))
8549
+ basename = _media_path_basename(file_path or row.get("media_pool_item_name") or row.get("name"))
8550
+ foreign = _media_path_is_foreign(file_path)
8512
8551
  item = by_media_pool_item.setdefault(
8513
8552
  str(media_pool_item_id),
8514
8553
  {
@@ -8517,6 +8556,7 @@ def _missing_media_diagnosis(missing_rows: List[Dict[str, Any]]) -> Dict[str, An
8517
8556
  "wanted_basename": basename,
8518
8557
  "file_path_sanitized": _sanitize_media_path(file_path),
8519
8558
  "volume_root_sanitized": _sanitize_media_path(_media_path_volume_root(file_path), keep_filename=False),
8559
+ "foreign_os_path": foreign,
8520
8560
  "timeline_occurrence_count": 0,
8521
8561
  "track_refs": [],
8522
8562
  },
@@ -8536,7 +8576,11 @@ def _missing_media_diagnosis(missing_rows: List[Dict[str, Any]]) -> Dict[str, An
8536
8576
  volume_root,
8537
8577
  {
8538
8578
  "volume_root_sanitized": _sanitize_media_path(volume_root, keep_filename=False),
8539
- "mounted": os.path.isdir(volume_root),
8579
+ # Never probed: this OS reads the name as something else
8580
+ # (`/Volumes/X` is a folder on the current Windows drive,
8581
+ # `E:\` a relative name on macOS).
8582
+ "mounted": False if foreign else os.path.isdir(volume_root),
8583
+ "foreign_os_path": foreign,
8540
8584
  "clip_count": 0,
8541
8585
  "sample_basenames": [],
8542
8586
  },
@@ -8545,13 +8589,14 @@ def _missing_media_diagnosis(missing_rows: List[Dict[str, Any]]) -> Dict[str, An
8545
8589
  if basename and basename not in volume_row["sample_basenames"] and len(volume_row["sample_basenames"]) < 8:
8546
8590
  volume_row["sample_basenames"].append(basename)
8547
8591
 
8548
- folder = os.path.dirname(str(file_path)) if file_path else ""
8592
+ folder = _media_path_dirname(str(file_path)) if file_path else ""
8549
8593
  if folder:
8550
8594
  folder_row = missing_folders.setdefault(
8551
8595
  folder,
8552
8596
  {
8553
8597
  "folder_sanitized": _sanitize_media_path(folder, keep_filename=False),
8554
- "exists": os.path.isdir(folder),
8598
+ "exists": False if foreign else os.path.isdir(folder),
8599
+ "foreign_os_path": foreign,
8555
8600
  "clip_count": 0,
8556
8601
  "sample_basenames": [],
8557
8602
  },
@@ -8563,14 +8608,28 @@ def _missing_media_diagnosis(missing_rows: List[Dict[str, Any]]) -> Dict[str, An
8563
8608
  unique_items = list(by_media_pool_item.values())
8564
8609
  missing_volume_rows = list(missing_volumes.values())
8565
8610
  missing_folder_rows = list(missing_folders.values())
8566
- absent_volumes = [row for row in missing_volume_rows if not row.get("mounted")]
8611
+ foreign_volumes = [row for row in missing_volume_rows if row.get("foreign_os_path")]
8612
+ absent_volumes = [row for row in missing_volume_rows if not row.get("mounted") and not row.get("foreign_os_path")]
8567
8613
  absent_folders = [row for row in missing_folder_rows if not row.get("exists")]
8614
+ foreign_labels = ", ".join(row["volume_root_sanitized"] for row in foreign_volumes[:3])
8568
8615
  primary_cause = "none"
8569
8616
  recommended_next_step = "No offline media detected."
8570
8617
  if absent_volumes:
8571
8618
  primary_cause = "volume_not_mounted"
8572
8619
  labels = ", ".join(row["volume_root_sanitized"] for row in absent_volumes[:3])
8573
8620
  recommended_next_step = f"Mount the missing volume(s): {labels}."
8621
+ if foreign_volumes:
8622
+ recommended_next_step += (
8623
+ f" Paths written on another operating system ({foreign_labels}) cannot be mounted here;"
8624
+ " relink those from approved media roots by basename."
8625
+ )
8626
+ elif foreign_volumes:
8627
+ primary_cause = "foreign_os_path"
8628
+ recommended_next_step = (
8629
+ f"These media paths were written on another operating system, so their volume(s)"
8630
+ f" ({foreign_labels}) cannot be mounted here. Search approved media roots for the wanted basenames"
8631
+ " (build_relink_plan), then relink with media_pool.safe_relink."
8632
+ )
8574
8633
  elif absent_folders:
8575
8634
  primary_cause = "folder_not_found"
8576
8635
  labels = ", ".join(row["folder_sanitized"] for row in absent_folders[:3])
@@ -8731,8 +8790,10 @@ def _build_relink_plan(tl, p: Dict[str, Any]):
8731
8790
  return _err(f"search_roots must be existing directories: {invalid}")
8732
8791
  missing_report = _detect_missing_media(tl, p)
8733
8792
  diagnosis = missing_report.get("diagnosis") or _missing_media_diagnosis(missing_report.get("missing", []))
8793
+ # A volume another OS wrote can never be mounted here, so it never holds the
8794
+ # search back: approved roots are the only place its media can turn up.
8734
8795
  if _coerce_bool(p.get("skip_search_when_volume_missing"), True) and any(
8735
- not row.get("mounted") for row in diagnosis.get("missing_volumes", [])
8796
+ not row.get("mounted") and not row.get("foreign_os_path") for row in diagnosis.get("missing_volumes", [])
8736
8797
  ):
8737
8798
  return {
8738
8799
  "success": True,
@@ -8749,7 +8810,10 @@ def _build_relink_plan(tl, p: Dict[str, Any]):
8749
8810
  candidates = []
8750
8811
  rows_by_basename: Dict[str, List[Dict[str, Any]]] = {}
8751
8812
  for row in missing_report.get("missing", []):
8752
- wanted = os.path.basename(str(row.get("file_path") or row.get("media_pool_item_name") or row.get("name") or ""))
8813
+ # A sanitized report drops `file_path`; its sanitized form keeps the
8814
+ # filename and the root's form, which is all the search needs.
8815
+ path = row.get("file_path") or row.get("file_path_sanitized")
8816
+ wanted = _media_path_basename(path or row.get("media_pool_item_name") or row.get("name"))
8753
8817
  if wanted:
8754
8818
  rows_by_basename.setdefault(wanted, []).append(row)
8755
8819
  for wanted, rows in rows_by_basename.items():
@@ -8763,6 +8827,7 @@ def _build_relink_plan(tl, p: Dict[str, Any]):
8763
8827
  {
8764
8828
  **row,
8765
8829
  "wanted_basename": wanted,
8830
+ "foreign_os_path": _media_path_is_foreign(row.get("file_path") or row.get("file_path_sanitized")),
8766
8831
  "timeline_occurrence_count": len(rows),
8767
8832
  "candidate_paths": [] if _coerce_bool(p.get("sanitize_paths")) or _coerce_bool(p.get("sanitized")) else matches,
8768
8833
  "candidate_paths_sanitized": sanitized_matches,