davinci-resolve-mcp 4.10.4 → 4.10.6

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,76 @@
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.10.6 — names that need xml escaping reach their bin and their color group
6
+
7
+ ### Fixes
8
+
9
+ - **`drt(action="assemble_project")` failed outright when a timeline placed
10
+ in a bin had `&`, `"` or `'` in its name.** The pool clip stores the name
11
+ escaped (`Reel 1 & 2`), but the step that moves it into its bin searched
12
+ for the raw `Reel 1 & 2`, found nothing, and threw
13
+ `could not locate pool clip for timeline Reel 1 & 2 to move into folder Reels`.
14
+ No archive was written. The lookup now matches the escaped name.
15
+ - **The bin's own name was written unescaped**, so `folder: "R&D"` produced
16
+ a `MpFolder.xml` that does not parse. It is escaped now.
17
+ - A name without those characters escapes to itself, so existing archives
18
+ come out byte-for-byte unchanged.
19
+ - Lands #283 from @Dev-next-gen, as submitted, with two tests that fail on
20
+ the old code.
21
+ - **The same mistake in reverse affected color-group grade reads.** The
22
+ helpers behind `drp(action="extract_group_grades")` and
23
+ `drx(action="grade_transfer")` found a group by searching `project.xml` for
24
+ `<Name>` plus the raw name:
25
+ - asking for a group called `Night & Day` returned `group not found`, because
26
+ the file stores `Night &amp; Day`;
27
+ - the unfiltered listing reported the stored form, `Night &amp; Day`, as the
28
+ group's name;
29
+ - a clip or other element with the same name as a group, placed before it in
30
+ the file, made the lookup return a segment that spanned two groups and
31
+ both of their grades.
32
+
33
+ Group names are now decoded when read and compared as plain text. An
34
+ apostrophe resolves whether the file spells it `'` or `&apos;`. The lookup
35
+ only looks inside `<Sm2Group>` elements, and the label passed to the
36
+ decoder is escaped. A caller that passed the escaped form back
37
+ (`Night &amp; Day`) must now pass the plain name.
38
+
39
+ ### Validation
40
+
41
+ - Offline file handling only; nothing calls Resolve. Advanced suite green,
42
+ with two new group-name tests that fail on the old code; Python suite green.
43
+ - `assemble_project` changes the offline `.drp` authoring tier. The
44
+ round-trip harness was not run: it needs a Resolve 21.x Studio host, and
45
+ this machine runs 19.1.3.7. For a name without `&`, `"` or `'`, the archive
46
+ is byte-identical to before.
47
+
48
+ ## What's New in v4.10.5 — `rename_plan` sees a name the plan keeps
49
+
50
+ ### Fixes
51
+
52
+ - **`media(action="rename_plan")` missed a rename onto a name that stays in
53
+ place.** It counted collisions only among the names it renames *to*, so a
54
+ name it leaves alone was invisible to the check. With
55
+ `names: ["A001_C001.mov", "dailies_A001_C001.mov"]`, `find: "dailies_"`,
56
+ `replace: ""`, the camera original was correctly refused, and the plan then
57
+ proposed renaming `dailies_A001_C001.mov` onto it with `collisions: 0`.
58
+ Applying that plan overwrites the file the refusal was protecting.
59
+ - A name the plan keeps now occupies its target: a refused camera original,
60
+ and a name `find` leaves unchanged. The same call returns
61
+ `action: "collision"`, `collisions: 1`, `renameCount: 0`.
62
+ - **`reel_normalize` had the same gap**: a reel already at its normalized
63
+ name was not counted, so `["card1", "CARD001"]` both landed on `CARD001`
64
+ with no collision. It is counted now.
65
+ - A swap (`b → x` while `x → y`) is still not a collision, since `x` is
66
+ leaving. Noops stay noops and refusals stay refusals.
67
+ - Lands #284 from @Dev-next-gen, as submitted, with two tests that fail on
68
+ the old code.
69
+
70
+ ### Validation
71
+
72
+ - Planning code only; nothing touches Resolve or media. Advanced suite
73
+ green; Python suite green.
74
+
5
75
  ## What's New in v4.10.4 — enrolled in Anthropic's OSS Scanner
6
76
 
7
77
  ### Security
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.4-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.10.6-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.4-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.10.6-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.4 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v4.10.6 版 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.4"
40
+ VERSION = "4.10.6"
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.4",
3
+ "version": "4.10.6",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -54,26 +54,36 @@ export function readProjectXml(drpPath) {
54
54
  return xml;
55
55
  }
56
56
 
57
+ // project.xml stores a name as xml text (`Night &amp; Day`); callers name a group as
58
+ // plain text. Read and compare the decoded form so both spellings agree.
59
+ const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
60
+ const unescapeXml = (s) =>
61
+ s.replace(/&(?:#(\d+)|#x([0-9a-fA-F]+)|(amp|lt|gt|quot|apos));/g, (m, dec, hex, name) =>
62
+ dec ? String.fromCodePoint(+dec) : hex ? String.fromCodePoint(parseInt(hex, 16)) : ENTITIES[name]);
63
+ const xmlEscape = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
64
+
65
+ /** Each <Sm2Group> element with its (decoded) name. */
66
+ function* groups(xml) {
67
+ for (const m of xml.matchAll(/<Sm2Group\b[\s\S]*?<\/Sm2Group>/g)) {
68
+ const n = m[0].match(/<Name>([^<]*)<\/Name>/);
69
+ if (n) yield { name: unescapeXml(n[1]), seg: m[0] };
70
+ }
71
+ }
72
+
57
73
  /** All color-group names present in the project (from <Sm2Group><Name>…). */
58
74
  export function listGroupNames(xml) {
59
- const names = [];
60
- for (const m of xml.matchAll(/<Sm2Group\b[\s\S]*?<Name>([^<]+)<\/Name>/g)) names.push(m[1]);
61
- return [...new Set(names)];
75
+ return [...new Set([...groups(xml)].map((g) => g.name))];
62
76
  }
63
77
 
64
78
  export function groupSegment(xml, name) {
65
- const j = xml.indexOf(`<Name>${name}</Name>`);
66
- if (j < 0) return null;
67
- const start = xml.lastIndexOf('<Sm2Group', j);
68
- const end = xml.indexOf('</Sm2Group>', j);
69
- if (start < 0 || end < 0) return null;
70
- return xml.slice(start, end + '</Sm2Group>'.length);
79
+ for (const g of groups(xml)) if (g.name === name) return g.seg;
80
+ return null;
71
81
  }
72
82
 
73
83
  export const groupBodies = (seg) => [...seg.matchAll(/<Body>([0-9a-fA-F]+)<\/Body>/g)].map((m) => m[1]);
74
84
 
75
85
  async function decodeBody(bodyHex, label) {
76
- const content = `<?xml version="1.0" encoding="UTF-8"?>\n<Resolve_Color_Exchange><Label>${label}</Label><Width>1920</Width><Height>1080</Height><Body>${bodyHex}</Body></Resolve_Color_Exchange>`;
86
+ const content = `<?xml version="1.0" encoding="UTF-8"?>\n<Resolve_Color_Exchange><Label>${xmlEscape(label)}</Label><Width>1920</Width><Height>1080</Height><Body>${bodyHex}</Body></Resolve_Color_Exchange>`;
77
87
  let r;
78
88
  try {
79
89
  r = await drxTool.handler({ action: 'parse', args: { content } });
@@ -133,19 +133,18 @@ export function renamePlan(names, rule = {}) {
133
133
  const camRe = rule.cameraOriginalRe || CAMERA_ORIGINAL_RE;
134
134
  const plan = [];
135
135
  const targets = new Map();
136
+ // A name the plan leaves in place still occupies it — a refused camera original
137
+ // above all — so it counts as a target too, or a rename onto it reads as free.
136
138
  for (const name of names) {
137
139
  const isCam = camRe.test(name);
138
140
  if (isCam && !rule.allowCameraOriginals) {
139
141
  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);
140
143
  continue;
141
144
  }
142
145
  const to = name.replace(re, rule.replace ?? '');
143
- if (to === name) {
144
- plan.push({ from: name, to, action: 'noop' });
145
- continue;
146
- }
147
146
  targets.set(to, (targets.get(to) || 0) + 1);
148
- plan.push({ from: name, to, action: 'rename' });
147
+ plan.push({ from: name, to, action: to === name ? 'noop' : 'rename' });
149
148
  }
150
149
  // Flag collisions (two sources → one target).
151
150
  for (const p of plan) if (p.action === 'rename' && targets.get(p.to) > 1) p.action = 'collision';
@@ -168,7 +167,7 @@ export function reelNormalize(reels, opts = {}) {
168
167
  const to = String(r)
169
168
  .toUpperCase()
170
169
  .replace(/(\d+)\s*$/, (m) => m.padStart(pad, '0'));
171
- if (to !== r) targets.set(to, (targets.get(to) || 0) + 1);
170
+ targets.set(to, (targets.get(to) || 0) + 1);
172
171
  plan.push({ from: r, to, action: to === r ? 'noop' : 'normalize' });
173
172
  }
174
173
  for (const p of plan) if (p.action === 'normalize' && targets.get(p.to) > 1) p.action = 'collision';
@@ -316,7 +316,7 @@ export const drtTool = {
316
316
  if (!f || /[\/\\]/.test(f)) throw new Error(`assemble_project: folder must be a plain bin name (no path separators): ${JSON.stringify(t.folder)}`);
317
317
  return f;
318
318
  });
319
- const { assembleTimeline } = drp();
319
+ const { assembleTimeline, escapeXml } = drp();
320
320
  const buffers = [];
321
321
  for (const [i, spec] of p.timelines.entries()) {
322
322
  const s = { ...spec, timelineName: names[i] };
@@ -459,7 +459,10 @@ export const drtTool = {
459
459
  bins.set(folder, bin);
460
460
  }
461
461
  const bin = bins.get(folder);
462
- const tlRe = new RegExp(`<Element>\\s*<Sm2MpTimelineClip DbId="[^"]+">(?:(?!<\\/Element>\\s*<Element>)[\\s\\S])*?<Name>${names[i].replace(/[.*+?^$()|[\]{}]/g, '\\$&')}<\\/Name>[\\s\\S]*?<\\/Sm2MpTimelineClip>\\s*<\\/Element>`);
462
+ // The name reached the pool clip through escapeXml: `Reel 1 & 2` is stored
463
+ // `Reel 1 &amp; 2`, and the raw name matched nothing.
464
+ const nameXml = escapeXml(names[i]).replace(/[.*+?^$()|[\]{}]/g, '\\$&');
465
+ const tlRe = new RegExp(`<Element>\\s*<Sm2MpTimelineClip DbId="[^"]+">(?:(?!<\\/Element>\\s*<Element>)[\\s\\S])*?<Name>${nameXml}<\\/Name>[\\s\\S]*?<\\/Sm2MpTimelineClip>\\s*<\\/Element>`);
463
466
  const hit = mpXml.match(tlRe);
464
467
  if (!hit) throw new Error(`assemble_project: could not locate pool clip for timeline ${names[i]} to move into folder ${folder}`);
465
468
  mpXml = mpXml.replace(hit[0], '');
@@ -495,7 +498,7 @@ export const drtTool = {
495
498
  for (const [folder, bin] of bins) {
496
499
  base.file(bin.entry,
497
500
  `<?xml version="1.0" encoding="UTF-8"?>\n` +
498
- `<Sm2MpFolder DbId="${bin.id}">\n <FieldsBlob/>\n <Name>${folder}</Name>\n` +
501
+ `<Sm2MpFolder DbId="${bin.id}">\n <FieldsBlob/>\n <Name>${escapeXml(folder)}</Name>\n` +
499
502
  ` <MpFolder>${masterFolderId}</MpFolder>\n <UniqueMediaPoolItemId>${randomUUID()}</UniqueMediaPoolItemId>\n` +
500
503
  ` <MediaVec>\n${bin.clips.join('\n')}\n </MediaVec>\n` +
501
504
  ` <MediaPool>${poolId}</MediaPool>\n <Folded>false</Folded>\n <ColorTag>FOLDER_COLOR_NONE</ColorTag>\n` +
@@ -91,7 +91,7 @@ if not logging.getLogger().handlers:
91
91
  handlers=[logging.StreamHandler()],
92
92
  )
93
93
 
94
- VERSION = "4.10.4"
94
+ VERSION = "4.10.6"
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.4"
14
+ VERSION = "4.10.6"
15
15
 
16
16
  import base64
17
17
  import os