davinci-resolve-mcp 4.10.7 → 4.11.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,64 @@
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.11.1 — `.cube` input ranges, `"false"` import flags and the caption character cap
6
+
7
+ Three fixes from @Dev-next-gen (#286, #287, #288), each with a reproduction and a
8
+ regression test.
9
+
10
+ ### Fixes
11
+
12
+ - **A 3D `.cube` carrying `LUT_3D_INPUT_RANGE` could not be read** (#288).
13
+ `read_cube` knew `DOMAIN_MIN`/`DOMAIN_MAX` but not Resolve's own header form,
14
+ so the line fell through to the data-row branch and the whole file was refused
15
+ as a "non-numeric entry". `read_lut_summary` and `attenuate_lut_file` both
16
+ failed on it. The keyword is now read as one domain for all three channels;
17
+ attenuation still refuses a non-unit range, exactly as it does for
18
+ `DOMAIN_MIN`/`DOMAIN_MAX`.
19
+ - **On landing:** a short or non-numeric `LUT_3D_INPUT_RANGE` escaped as a bare
20
+ `ValueError`, and a short `DOMAIN_MIN`/`DOMAIN_MAX` produced a silently short
21
+ domain list. All three headers now go through one reader that raises
22
+ `CubeLutError` naming the file, line and keyword.
23
+ - **`timeline(action="import_timeline_checked")` read `"false"` as true** (#287).
24
+ `sanitize_media` / `relink_media` and `import_source_clips` were read with bare
25
+ `bool()`, so `sanitize_media="false"` still handed Resolve the sanitized copy
26
+ instead of the file passed, and `import_source_clips="false"` reached
27
+ `ImportTimelineFromFile` as `importSourceClips: True`. Both now go through
28
+ `_coerce_bool`, as the rest of `server.py` has since v4.8.20.
29
+ - **Caption orphan rebalancing could break `max_chars_per_line`** (#286).
30
+ Moving the last word of the penultimate line onto a one-word final line never
31
+ re-checked the cap, so "we had a wonderful conversation" at 20 characters came
32
+ out with a 22-character second line in the SRT/WebVTT. The move now happens
33
+ only when the merged line fits; a lone last word is the lesser fault.
34
+
35
+ ## What's New in v4.11.0 — `rename_plan` collides names a case-insensitive volume treats as one
36
+
37
+ ### Fixes
38
+
39
+ - **`media(action="rename_plan")` missed collisions that only differ in case
40
+ or Unicode form.** macOS (default APFS) and Windows volumes are
41
+ case-insensitive, and APFS also ignores Unicode normalization, so renaming
42
+ `take_A.mov` to `A.mov` overwrites an existing `a.mov`. The plan compared
43
+ names byte-for-byte and reported that rename as free (`collisions: 0`).
44
+ - Targets are now compared after NFC normalization and lowercasing, so
45
+ `A.mov` / `a.mov` and `café` spelled precomposed or with a combining
46
+ accent each count as one file.
47
+ - A case-only rename of a single file (`clip.MOV` → `clip.mov`) is still a
48
+ `rename`, not a collision.
49
+
50
+ ### New parameter
51
+
52
+ - `rename_plan` takes `caseSensitive` (default `false`). Pass `true` only for
53
+ a case-sensitive volume to restore exact comparison. The result reports the
54
+ mode used as `caseSensitive`.
55
+
56
+ ### Validation
57
+
58
+ - Offline planning only; nothing calls Resolve or touches files. New
59
+ `media-ops` test fails on the old code and passes now; `group-grade-read`
60
+ gains a case for a group name with `"`, `<` and `>`, as Studio 19.1.3.7
61
+ escapes them.
62
+
5
63
  ## What's New in v4.10.7 — `retargetDRT` moves the `<ProjectVersion>` import gate, not only the comment stamp
6
64
 
7
65
  ### Fixes
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.10.7-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.11.1-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(389%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.10.7-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.11.1-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(389%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.10.7 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v4.11.1 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "4.10.7"
40
+ VERSION = "4.11.1"
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.10.7",
3
+ "version": "4.11.1",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -121,7 +121,9 @@ const CAMERA_ORIGINAL_RE = /^([A-Z]\d{3}[_ ]?[CR]\d{3,4}|[A-Z]\d{3}_\d{8}_C\d+|M
121
121
  /**
122
122
  * Dry-run rename plan (regex find/replace). REFUSES camera originals (sidecar/DB only).
123
123
  * @param {string[]} names basenames to rename
124
- * @param {{find:string, replace:string, allowCameraOriginals?:boolean, cameraOriginalRe?:RegExp}} rule
124
+ * @param {{find:string, replace:string, allowCameraOriginals?:boolean, cameraOriginalRe?:RegExp, caseSensitive?:boolean}} rule
125
+ * caseSensitive defaults to false: macOS (APFS) and Windows volumes treat names that
126
+ * differ only in case or Unicode normalization as one file, so those collide.
125
127
  */
126
128
  export function renamePlan(names, rule = {}) {
127
129
  let re;
@@ -133,27 +135,30 @@ export function renamePlan(names, rule = {}) {
133
135
  const camRe = rule.cameraOriginalRe || CAMERA_ORIGINAL_RE;
134
136
  const plan = [];
135
137
  const targets = new Map();
138
+ const caseSensitive = rule.caseSensitive === true;
139
+ const key = (n) => (caseSensitive ? n : n.normalize('NFC').toLowerCase());
136
140
  // A name the plan leaves in place still occupies it — a refused camera original
137
141
  // above all — so it counts as a target too, or a rename onto it reads as free.
138
142
  for (const name of names) {
139
143
  const isCam = camRe.test(name);
140
144
  if (isCam && !rule.allowCameraOriginals) {
141
145
  plan.push({ from: name, action: 'refuse-camera-original', note: 'camera original — rename via sidecar/DB only, never the file' });
142
- targets.set(name, (targets.get(name) || 0) + 1);
146
+ targets.set(key(name), (targets.get(key(name)) || 0) + 1);
143
147
  continue;
144
148
  }
145
149
  const to = name.replace(re, rule.replace ?? '');
146
- targets.set(to, (targets.get(to) || 0) + 1);
150
+ targets.set(key(to), (targets.get(key(to)) || 0) + 1);
147
151
  plan.push({ from: name, to, action: to === name ? 'noop' : 'rename' });
148
152
  }
149
153
  // Flag collisions (two sources → one target).
150
- for (const p of plan) if (p.action === 'rename' && targets.get(p.to) > 1) p.action = 'collision';
154
+ for (const p of plan) if (p.action === 'rename' && targets.get(key(p.to)) > 1) p.action = 'collision';
151
155
  const collisions = plan.filter((p) => p.action === 'collision');
152
156
  return {
153
157
  plan,
154
158
  renameCount: plan.filter((p) => p.action === 'rename').length,
155
159
  refused: plan.filter((p) => p.action === 'refuse-camera-original').length,
156
160
  collisions: collisions.length,
161
+ caseSensitive,
157
162
  dryRun: true,
158
163
  };
159
164
  }
@@ -47,6 +47,10 @@ const renameSchema = z.object({
47
47
  find: z.string(),
48
48
  replace: z.string().optional(),
49
49
  allowCameraOriginals: z.boolean().optional(),
50
+ caseSensitive: z
51
+ .boolean()
52
+ .optional()
53
+ .describe('Default false: names differing only in case/Unicode form collide, as on macOS and Windows volumes. Pass true only for a case-sensitive volume.'),
50
54
  });
51
55
 
52
56
  const reelSchema = z.object({ reels: z.array(z.string()), pad: z.number().optional() });
@@ -83,7 +87,7 @@ export const mediaTool = {
83
87
  }
84
88
  if (action === 'rename_plan') {
85
89
  const p = renameSchema.parse(args);
86
- return renamePlan(p.names, { find: p.find, replace: p.replace, allowCameraOriginals: p.allowCameraOriginals });
90
+ return renamePlan(p.names, { find: p.find, replace: p.replace, allowCameraOriginals: p.allowCameraOriginals, caseSensitive: p.caseSensitive });
87
91
  }
88
92
  if (action === 'reel_normalize') {
89
93
  const p = reelSchema.parse(args);
@@ -91,7 +91,7 @@ if not logging.getLogger().handlers:
91
91
  handlers=[logging.StreamHandler()],
92
92
  )
93
93
 
94
- VERSION = "4.10.7"
94
+ VERSION = "4.11.1"
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.10.7"
14
+ VERSION = "4.11.1"
15
15
 
16
16
  import base64
17
17
  import os
@@ -7699,7 +7699,7 @@ def _import_timeline_checked(proj, mp, p: Dict[str, Any]):
7699
7699
  # abort Resolve's scripting-API import and leave the timeline fully offline.
7700
7700
  # The original is only read; the sanitized copy lands in a temp dir, so the
7701
7701
  # temp-path guard never applies to it.
7702
- sanitize_requested = bool(p.get("sanitize_media") or p.get("relink_media"))
7702
+ sanitize_requested = _coerce_bool(p.get("sanitize_media")) or _coerce_bool(p.get("relink_media"))
7703
7703
  binary_relink_note = None
7704
7704
  _binary_has_roots = bool(p.get("relink_search_roots") or p.get("search_roots"))
7705
7705
  if is_binary and _binary_has_roots:
@@ -7730,7 +7730,7 @@ def _import_timeline_checked(proj, mp, p: Dict[str, Any]):
7730
7730
  if p.get("timeline_name") and "timelineName" not in options:
7731
7731
  options["timelineName"] = p["timeline_name"]
7732
7732
  if "import_source_clips" in p and "importSourceClips" not in options:
7733
- options["importSourceClips"] = bool(p["import_source_clips"])
7733
+ options["importSourceClips"] = _coerce_bool(p["import_source_clips"])
7734
7734
 
7735
7735
  # Optional fuzzy relink: when search roots are given, missing-at-original-path
7736
7736
  # clips are matched against on-disk media (exact/ext-agnostic/normalized/reel)
@@ -83,13 +83,19 @@ def _wrap(text: str, max_chars: int, max_lines: int) -> Optional[List[str]]:
83
83
  return lines if len(lines) <= max_lines else None
84
84
 
85
85
 
86
- def _no_orphan(lines: List[str]) -> List[str]:
87
- """Rebalance so no line is a lone word when a neighbour could give one up."""
86
+ def _no_orphan(lines: List[str], max_chars: int) -> List[str]:
87
+ """Rebalance so no line is a lone word when a neighbour could give one up.
88
+
89
+ It cannot when the merged line would break the character cap `_wrap` has
90
+ just enforced; an orphan is the lesser of the two faults.
91
+ """
88
92
  if len(lines) < 2:
89
93
  return lines
90
94
  if len(lines[-1].split()) == 1 and len(lines[-2].split()) > 2:
91
95
  head = lines[-2].split()
92
- lines = lines[:-2] + [" ".join(head[:-1]), f"{head[-1]} {lines[-1]}"]
96
+ merged = f"{head[-1]} {lines[-1]}"
97
+ if len(merged) <= max_chars:
98
+ lines = lines[:-2] + [" ".join(head[:-1]), merged]
93
99
  return lines
94
100
 
95
101
 
@@ -147,7 +153,7 @@ def build_blocks(
147
153
  _word_time(current[-1], "end_seconds", "end")
148
154
  or _word_time(current[-1], "start_seconds", "start")
149
155
  ),
150
- "lines": _no_orphan(lines),
156
+ "lines": _no_orphan(lines, max_chars_per_line),
151
157
  "word_count": len(current),
152
158
  })
153
159
  current.clear()
@@ -28,7 +28,7 @@ from __future__ import annotations
28
28
 
29
29
  import os
30
30
  import re
31
- from typing import Any, Dict, Optional, Tuple
31
+ from typing import Any, Dict, List, Optional, Tuple
32
32
 
33
33
  try:
34
34
  import numpy as _np
@@ -56,6 +56,19 @@ def _require_numpy() -> None:
56
56
  raise CubeLutError("numpy is required for LUT arithmetic (pip install numpy)")
57
57
 
58
58
 
59
+ def _header_floats(path: str, line_number: int, line: str, count: int) -> List[float]:
60
+ """The `count` numbers after a header keyword, or a CubeLutError naming the line."""
61
+ values = line.split()[1:count + 1]
62
+ try:
63
+ parsed = [float(value) for value in values]
64
+ except ValueError:
65
+ parsed = []
66
+ if len(parsed) != count:
67
+ keyword = line.split()[0]
68
+ raise CubeLutError(f"{path}:{line_number}: malformed {keyword} (expected {count} numbers)")
69
+ return parsed
70
+
71
+
59
72
  def read_cube(path: str) -> Dict[str, Any]:
60
73
  """Parse a 3D `.cube` file into `{size, table, domain_min, domain_max, title}`.
61
74
 
@@ -100,10 +113,15 @@ def read_cube(path: str) -> Dict[str, Any]:
100
113
  title = match.group(1) if match else ""
101
114
  continue
102
115
  if upper.startswith("DOMAIN_MIN"):
103
- domain_min = [float(value) for value in line.split()[1:4]]
116
+ domain_min = _header_floats(path, line_number, line, 3)
104
117
  continue
105
118
  if upper.startswith("DOMAIN_MAX"):
106
- domain_max = [float(value) for value in line.split()[1:4]]
119
+ domain_max = _header_floats(path, line_number, line, 3)
120
+ continue
121
+ if upper.startswith("LUT_3D_INPUT_RANGE"):
122
+ # Resolve's form of DOMAIN_MIN/MAX: one min and one max for all channels.
123
+ low, high = _header_floats(path, line_number, line, 2)
124
+ domain_min, domain_max = [low] * 3, [high] * 3
107
125
  continue
108
126
  parts = line.split()
109
127
  if len(parts) != 3: