davinci-resolve-mcp 4.18.1 → 4.18.2

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,48 @@
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.18.2 — The rest of the `$` replacement bugs
6
+
7
+ v4.18.1 fixed `replace()` calls that put user text in with an interpolated
8
+ template string. Its guard only looked for that one form, so calls whose
9
+ replacement was a variable, a function call or a conditional were missed.
10
+
11
+ ### Fixed
12
+
13
+ - **Splitting, trimming, adding a transition, or adding a clip to a track that
14
+ already has clips no longer corrupts the track when a clip name contains
15
+ `$`** (#333, thanks @Dev-next-gen). The rewritten clip went into the track
16
+ as a replacement string, so `$&` or `` $` `` in a name copied other clips into
17
+ the track and duplicated them.
18
+ - **`media_ops` `relink_manifest` keeps a `$` in a target path prefix as
19
+ typed.** Mapping `/old/vol` to `/new/Spot $$ 30s $&` produced
20
+ `/new/Spot $ 30s /old/vol/...`, a path that does not exist.
21
+ - The same form, now fixed, was also in `assemble_timeline` (the timeline's
22
+ pool block, which holds its name, when the media extents are rewritten),
23
+ in the subtitle preset writer (the preset section XML), and in the agent-rule
24
+ generator (generated doc sections). The other changed calls only insert hex,
25
+ IDs or numbers; they now use replacer functions too, so a single rule holds
26
+ everywhere.
27
+
28
+ ### Changed
29
+
30
+ - The guard test now splits every `replace()` / `replaceAll()` call in
31
+ `resolve-advanced` and `scripts/` into its arguments, and fails unless the
32
+ replacement is a function or a literal with no `$` in it. Literals whose
33
+ `$`-sequences are backreferences (`$1`, `'\\$&'`) are allowed. The
34
+ `editorial` source-alias rules and the `media_ops` rename rules, where `$1`
35
+ is part of the feature, are listed by name. Run against the v4.18.1 sources,
36
+ it reports 25 call sites, including all six that #333 fixed.
37
+
38
+ ### Validation
39
+
40
+ - Advanced Node suite on Node 20 with a fresh `npm ci`: 1,227 tests, 0 failed.
41
+ Python suite: 4,295 tests; the only failures are the 4 `source_media_guard`
42
+ path checks that always fail in a scratch worktree. With #333's fix
43
+ reverted, its two tests fail. The new relink test
44
+ fails on the v4.18.1 code with the wrong path shown above.
45
+ - Offline file writers only. No Resolve API behaviour changed.
46
+
5
47
  ## What's New in v4.18.1 — Names with `$` or `\` are written as typed
6
48
 
7
49
  ### 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.18.1-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.18.2-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.18.1-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-4.18.2-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.18.1 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v4.18.2 版 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.18.1"
40
+ VERSION = "4.18.2"
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.18.1",
3
+ "version": "4.18.2",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -42,7 +42,7 @@ export async function applyLut(ref, opts = {}) {
42
42
  const xml = ref.content != null ? ref.content : await fs.readFile(ref.drxPath, 'utf8');
43
43
  const bodyHex = extractBodyHex(xml);
44
44
  const newBodyHex = await injectNodeLut(bodyHex, nodeIndex, { lutPath: opts.lutPath, slotMeta });
45
- const newXml = xml.replace(BODY_RE, `<Body>${newBodyHex}</Body>`);
45
+ const newXml = xml.replace(BODY_RE, () => `<Body>${newBodyHex}</Body>`);
46
46
 
47
47
  // ROUND-TRIP ASSERT: decode the patched grade and confirm the LUT ref survives.
48
48
  const parsed = await drxParser().parseDRXContent(newXml);
@@ -107,7 +107,7 @@ export function relinkManifest(offlinePaths, pathMap = [], opts = {}) {
107
107
  continue;
108
108
  }
109
109
  const m = matches[0];
110
- const target = src.replace(m.from, m.to);
110
+ const target = src.replace(m.from, () => m.to);
111
111
  if (opts.checkExists === false || fs.existsSync(target)) relinkable.push({ from: src, to: target, verified: opts.checkExists !== false });
112
112
  else stillOffline.push({ from: src, to: target, reason: 'target does not exist' });
113
113
  }
@@ -61,15 +61,15 @@ export async function editSubtitleTimeline({ source, output, track = 1, inputs =
61
61
  .filter(m => m.decoded.templateId === template_id);
62
62
  if (matches.length !== 1) throw new Error('Native title reference must contain exactly one matching composition');
63
63
  const scaffold = before ?? holder(await fs.readFile(new URL('../assets/lollipop-track.xml', import.meta.url), 'utf8'));
64
- chosen = { ...matches[0], text: scaffold.text.replace(scaffold.hex, matches[0].hex) };
64
+ chosen = { ...matches[0], text: scaffold.text.replace(scaffold.hex, () => matches[0].hex) };
65
65
  }
66
66
  if (!chosen) throw new Error('No animation preset: supply a native preset_reference DRT or apply Word Highlight through the live Effects UI');
67
67
  const patched = Object.keys(parsed.inputs).length
68
68
  ? setPresetInputs(Buffer.from(chosen.hex, 'hex'), parsed.inputs) : Buffer.from(chosen.hex, 'hex');
69
- const section = freshIds(chosen.text.replace(chosen.hex, patched.toString('hex')));
69
+ const section = freshIds(chosen.text.replace(chosen.hex, () => patched.toString('hex')));
70
70
  let text;
71
- if (before) text = target.text.replace(before.text, section);
72
- else if (/<FusionCompHolderItems\s*\/>/.test(target.text)) text = target.text.replace(/<FusionCompHolderItems\s*\/>/, section);
71
+ if (before) text = target.text.replace(before.text, () => section);
72
+ else if (/<FusionCompHolderItems\s*\/>/.test(target.text)) text = target.text.replace(/<FusionCompHolderItems\s*\/>/, () => section);
73
73
  else throw new Error('Unsupported track: missing FusionCompHolderItems field');
74
74
  const xml = exported.xml.slice(0, target.offset) + text + exported.xml.slice(target.offset + target.text.length);
75
75
  // Resolve remaps timeline identity on import. Keep cross-entry sequence
@@ -142,7 +142,7 @@ export async function editCaptionTimeline({ source, output, track = 1, add = [],
142
142
  const elements = final.map((c) => {
143
143
  const s = stagedById.get(c.id);
144
144
  if (!s) return byId.get(c.id).text;
145
- let text = s.fresh ? template.text.replace(template.dbId, randomUUID()) : s.base.text;
145
+ let text = s.fresh ? template.text.replace(template.dbId, () => randomUUID()) : s.base.text;
146
146
  if (s.fresh) text = text.replace(/<(MarkersBA|RenderCacheBA|ImportExportMetadataBA)>[\s\S]*?<\/\1>/g, '<$1/>');
147
147
  text = setTag(text, 'FieldsBlob', s.blob.toString('hex'));
148
148
  text = setTag(text, 'Name', xmlEscape(` ${s.caption.text}`));
@@ -475,7 +475,7 @@ export const drtTool = {
475
475
  const seqRef = dict.entries.find((e) => e.key === 'SeqRef');
476
476
  if (seqRef && remap.has(String(seqRef.value))) {
477
477
  seqRef.value = remap.get(String(seqRef.value));
478
- tlNew = tlNew.replace(fbM[0], `${fbM[1]}${encodeKeyedDict({ hdr: dict.hdr, entries: dict.entries }).toString('hex')}${fbM[3]}`);
478
+ tlNew = tlNew.replace(fbM[0], () => `${fbM[1]}${encodeKeyedDict({ hdr: dict.hdr, entries: dict.entries }).toString('hex')}${fbM[3]}`);
479
479
  }
480
480
  }
481
481
  for (const [n, xml] of Object.entries(containers)) {
@@ -844,7 +844,7 @@ export const drxTool = {
844
844
  });
845
845
  // Splice the new Body into the ORIGINAL envelope (byte-lossless outside the
846
846
  // position varints — labels, keyframes, OFX, still metadata all pass through).
847
- const out = xml.replace(m[0], `<Body>${r.bodyHex}</Body>`);
847
+ const out = xml.replace(m[0], () => `<Body>${r.bodyHex}</Body>`);
848
848
  // Guard: the rewrite must not change the node count (silent-corruption check).
849
849
  const reparsed = await drxParser().parseDRXContent(out);
850
850
  if ((reparsed.nodes || []).length !== r.nodeCount) {
@@ -3,8 +3,9 @@
3
3
  // way into the XML (#332: `R2 $& $'` as a timeline name made MpFolder.xml
4
4
  // unparseable; `Spot $$ 30s` came out as `Spot $ 30s`). Interpolated
5
5
  // replacements are therefore passed as functions, which insert their result
6
- // literally. This file guards that rule and checks the writers that carry user
7
- // text.
6
+ // literally. This file guards that rule for every replace() call (#333 found
7
+ // forms the first guard missed: a variable, a call or a conditional as the
8
+ // replacement) and checks the writers that carry user text.
8
9
 
9
10
  const test = require('node:test');
10
11
  const assert = require('node:assert');
@@ -16,12 +17,15 @@ const { createEmptyProject } = require('../author-project');
16
17
  const { placeSubtitles } = require('../place-subtitles');
17
18
  const { placeGenerator } = require('../place-generator');
18
19
  const { placeFusionTitle } = require('../place-fusion-title');
20
+ const { splitClip, trimClip, trimClipHead } = require('../splice-clips');
21
+ const { placeTransition } = require('../place-transition');
19
22
 
20
23
  const ROOT = path.resolve(__dirname, '..', '..', '..');
21
24
  const TRICKY = "Spot $$ 30s $& $' $` end";
22
25
 
23
26
  function sourceFiles(dir) {
24
27
  const out = [];
28
+ if (!fs.existsSync(dir)) return out;
25
29
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
26
30
  if (entry.name === 'node_modules' || entry.name === '__tests__') continue;
27
31
  const full = path.join(dir, entry.name);
@@ -31,38 +35,134 @@ function sourceFiles(dir) {
31
35
  return out;
32
36
  }
33
37
 
34
- // `.replace(<string or regex literal>, `...${...}...`)` on one line. A template
35
- // that uses a $-backreference ($1, $&, $<name>) needs the string form and is allowed.
36
- const STRING_REPLACEMENT = /\.replace\(\s*(?:'(?:[^'\\\n]|\\.)*'|"(?:[^"\\\n]|\\.)*"|\/(?:[^/\\\n]|\\.)+\/[a-z]*)\s*,\s*`((?:[^`\\]|\\.)*)`/g;
38
+ // A small scanner, enough to split each replace() call into its arguments: it
39
+ // skips strings, templates, regex literals and comments, and matches brackets.
40
+ const REGEX_CAN_FOLLOW = '(,=:[!&|?{};+-*%<>~^';
41
+ function prevSignificant(s, i) {
42
+ let j = i - 1;
43
+ while (j >= 0 && /\s/.test(s[j])) j -= 1;
44
+ return j >= 0 ? s[j] : '';
45
+ }
46
+ function skipString(s, i) {
47
+ const q = s[i];
48
+ i += 1;
49
+ while (s[i] !== q) i += s[i] === '\\' ? 2 : 1;
50
+ return i + 1;
51
+ }
52
+ function skipTemplate(s, i) {
53
+ i += 1;
54
+ while (s[i] !== '`') {
55
+ if (s[i] === '\\') i += 2;
56
+ else if (s.startsWith('${', i)) i = skipBracketed(s, i + 1);
57
+ else i += 1;
58
+ }
59
+ return i + 1;
60
+ }
61
+ function skipRegex(s, i) {
62
+ let inClass = false;
63
+ for (i += 1; ; i += 1) {
64
+ const c = s[i];
65
+ if (c === '\\') { i += 1; continue; }
66
+ if (c === '\n' || c === undefined) return -1;
67
+ if (c === '[') inClass = true;
68
+ else if (c === ']') inClass = false;
69
+ else if (c === '/' && !inClass) break;
70
+ }
71
+ i += 1;
72
+ while (/[a-z]/.test(s[i] || '')) i += 1;
73
+ return i;
74
+ }
75
+ // Skip a string, template, comment or regex literal at i; return the index after
76
+ // it, or -1 when none starts there.
77
+ function skipAtom(s, i) {
78
+ const c = s[i];
79
+ if (c === "'" || c === '"') return skipString(s, i);
80
+ if (c === '`') return skipTemplate(s, i);
81
+ if (c === '/' && s[i + 1] === '/') return s.indexOf('\n', i);
82
+ if (c === '/' && s[i + 1] === '*') return s.indexOf('*/', i) + 2;
83
+ if (c === '/' && REGEX_CAN_FOLLOW.includes(prevSignificant(s, i))) return skipRegex(s, i);
84
+ return -1;
85
+ }
86
+ function skipBracketed(s, i) {
87
+ let depth = 0;
88
+ while (i < s.length) {
89
+ const next = skipAtom(s, i);
90
+ if (next > i) { i = next; continue; }
91
+ if ('([{'.includes(s[i])) depth += 1;
92
+ else if (')]}'.includes(s[i]) && --depth === 0) return i + 1;
93
+ i += 1;
94
+ }
95
+ return s.length;
96
+ }
97
+ function callArguments(s, open) {
98
+ const close = skipBracketed(s, open) - 1;
99
+ const args = [];
100
+ let start = open + 1;
101
+ for (let i = start; i < close; ) {
102
+ const next = skipAtom(s, i);
103
+ if (next > i) { i = next; continue; }
104
+ if ('([{'.includes(s[i])) { i = skipBracketed(s, i); continue; }
105
+ if (s[i] === ',') { args.push(s.slice(start, i).trim()); start = i + 1; }
106
+ i += 1;
107
+ }
108
+ args.push(s.slice(start, close).trim());
109
+ return args;
110
+ }
111
+
37
112
  const BACKREF = /\$(?:[0-9&'`]|<[A-Za-z_]\w*>)/;
113
+ // Replacements that are rules a caller writes, where $1 and $& are the feature.
114
+ const INTENDED = new Set([
115
+ 'server/editorial.mjs: a.replace',
116
+ "server/media-ops.mjs: rule.replace ?? ''",
117
+ ]);
38
118
 
39
- function offenders(text) {
119
+ // The replacement of every .replace()/.replaceAll() call must be a function, or a
120
+ // literal with no $ in it. A literal whose $-sequences are backreferences ($1,
121
+ // $&) is deliberate and allowed; anything else (a variable, a call, an
122
+ // interpolated template) may carry a $ and is reported.
123
+ function offenders(text, file = '') {
40
124
  const found = [];
41
- for (const m of text.matchAll(STRING_REPLACEMENT)) {
42
- const template = m[1];
43
- if (!template.includes('${')) continue;
44
- if (BACKREF.test(template.replace(/\$\{[^}]*\}/g, ''))) continue;
45
- found.push(m[0].slice(0, 120));
125
+ for (const m of text.matchAll(/\.(?:replace|replaceAll)\(/g)) {
126
+ const args = callArguments(text, m.index + m[0].length - 1);
127
+ if (args.length < 2) continue;
128
+ const rep = args[1];
129
+ if (/^(async\s+)?(\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>|^function\b/.test(rep)) continue;
130
+ const literal = /^(['"`])[\s\S]*\1$/.test(rep);
131
+ if (literal && !rep.includes('$')) continue;
132
+ if (literal && BACKREF.test(rep.replace(/\$\{[^}]*\}/g, ''))) continue;
133
+ if (INTENDED.has(`${file}: ${rep}`)) continue;
134
+ found.push(`${file}: .replace(${args[0].slice(0, 40)}, ${rep.slice(0, 60)})`);
46
135
  }
47
136
  return found;
48
137
  }
49
138
 
50
- test('no interpolated replace() replacement is passed as a string', () => {
51
- const files = [...sourceFiles(path.join(ROOT, 'server')), ...sourceFiles(path.join(ROOT, 'vendor'))];
139
+ test('every replace() replacement is a function or a $-free literal', () => {
140
+ const dirs = ['server', 'vendor', 'bin', '../scripts', '../bin'].map((d) => path.join(ROOT, d));
141
+ const files = dirs.flatMap(sourceFiles);
52
142
  assert.ok(files.length > 50, `scanned ${files.length} files`);
53
- const bad = [];
54
- for (const file of files) {
55
- for (const hit of offenders(fs.readFileSync(file, 'utf8'))) bad.push(`${path.relative(ROOT, file)}: ${hit}`);
56
- }
57
- assert.deepStrictEqual(bad, [], 'pass the replacement as () => `...` so $-sequences stay literal');
143
+ const bad = files.flatMap((file) => offenders(fs.readFileSync(file, 'utf8'), path.relative(ROOT, file).split(path.sep).join('/')));
144
+ assert.deepStrictEqual(bad, [], 'pass the replacement as () => ... so $-sequences in it stay literal');
58
145
  });
59
146
 
60
- test('the guard catches the #332 form and allows the safe ones', () => {
61
- assert.strictEqual(offenders("x.replace('</MediaVec>', `${el}\\n</MediaVec>`)").length, 1);
62
- assert.strictEqual(offenders('x.replace(/<Name>[^<]*<\\/Name>/, `<Name>${n}</Name>`)').length, 1);
63
- assert.strictEqual(offenders("x.replace('</MediaVec>', () => `${el}\\n</MediaVec>`)").length, 0);
64
- assert.strictEqual(offenders('x.replace(/(<a>)<b\\/>/, `$1<b>${hex}</b>`)').length, 0);
65
- assert.strictEqual(offenders("x.replace('a', 'b')").length, 0);
147
+ test('the guard catches each string form and allows the safe ones', () => {
148
+ const caught = [
149
+ "x.replace('</MediaVec>', `${el}\\n</MediaVec>`)", // #332
150
+ 'x.replace(/<Name>[^<]*<\\/Name>/, `<Name>${n}</Name>`)',
151
+ 'out.replace(c, nc)', // #333
152
+ 'items.replace(target, setClipDuration(target, d))',
153
+ 'x.replace(/<Items>[\\s\\S]*?<\\/Items>/, body ? `<Items>${body}</Items>` : "<Items/>")',
154
+ 'x.replaceAll(a, b)',
155
+ ];
156
+ for (const code of caught) assert.strictEqual(offenders(code).length, 1, code);
157
+ const allowed = [
158
+ "x.replace('</MediaVec>', () => `${el}\\n</MediaVec>`)",
159
+ 'x.replace(/(<a>)<b\\/>/, `$1<b>${hex}</b>`)',
160
+ "x.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')",
161
+ "x.replace('a', 'b')",
162
+ 'x.replace(re, (m, g) => g.toUpperCase())',
163
+ "x.replace(re, function (m) { return m + ', '; })",
164
+ ];
165
+ for (const code of allowed) assert.strictEqual(offenders(code).length, 0, code);
66
166
  });
67
167
 
68
168
  async function sequenceXml(buffer) {
@@ -93,3 +193,34 @@ test('placeFusionTitle keeps $-sequences in the clip name', async () => {
93
193
  const res = await placeFusionTitle(buffer, { trackIndex: 2, startFrame, durationFrames: 24, name: TRICKY });
94
194
  assert.ok((await sequenceXml(res.buffer)).includes(`<Name>${escapeXml(TRICKY)}</Name>`));
95
195
  });
196
+
197
+ // A second clip on a track goes through the populated-track branch of
198
+ // setItemsInner, and splitClip/placeTransition rewrite the clip in place. A
199
+ // string replacement there copies the matched <Items> block ($&) and the text
200
+ // around it ($`, $') into the track, duplicating clips.
201
+ const nameCount = (xml) => xml.split(`<Name>${escapeXml(TRICKY)}</Name>`).length - 1;
202
+
203
+ async function titledTrack() {
204
+ const { buffer, startFrame } = await createEmptyProject({ timelineName: 'Track' });
205
+ const first = await placeFusionTitle(buffer, { trackIndex: 2, startFrame, durationFrames: 24, name: TRICKY });
206
+ const second = await placeFusionTitle(first.buffer, { trackIndex: 2, startFrame: startFrame + 24, durationFrames: 24, name: 'Next' });
207
+ return { buffer: second.buffer, startFrame };
208
+ }
209
+
210
+ test('a clip added to a populated track leaves the clip already there intact', async () => {
211
+ const xml = await sequenceXml((await titledTrack()).buffer);
212
+ assert.strictEqual(nameCount(xml), 1);
213
+ assert.strictEqual(xml.split('<Name>Next</Name>').length - 1, 1);
214
+ });
215
+
216
+ test('splitClip, trimClip, trimClipHead and placeTransition keep $-sequences in the clip name', async () => {
217
+ const { buffer, startFrame } = await titledTrack();
218
+ for (const trim of [trimClip, trimClipHead]) {
219
+ const res = await trim(buffer, { track: 2, clipIndex: 0, newDuration: 12, frames: 6, ripple: true });
220
+ assert.strictEqual(nameCount(await sequenceXml(res.buffer)), 1);
221
+ }
222
+ const split = await splitClip(buffer, { track: 2, at: startFrame + 12 });
223
+ assert.strictEqual(nameCount(await sequenceXml(split.buffer)), 2);
224
+ const res = await placeTransition(buffer, { track: 2, atFrame: startFrame + 24 });
225
+ assert.strictEqual(nameCount(await sequenceXml(res.buffer)), 1);
226
+ });
@@ -438,7 +438,7 @@ async function assembleTimeline(spec = {}) {
438
438
  const me = Buffer.alloc(16);
439
439
  me.writeDoubleLE(originFrame / 24, 0);
440
440
  me.writeDoubleLE((maxEnd - originFrame) / 24, 8);
441
- mpF = mpF.replace(tlBlock, tlBlock.replace(meM[0], `<MediaExtents>${me.toString('hex')}</MediaExtents>`));
441
+ mpF = mpF.replace(tlBlock, () => tlBlock.replace(meM[0], () => `<MediaExtents>${me.toString('hex')}</MediaExtents>`));
442
442
  zipF.file(mpP, mpF);
443
443
  buffer = await zipF.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
444
444
  }
@@ -105,7 +105,7 @@ function replaceBodyForClip(seqXml, targetDbId, newBodyHex) {
105
105
  if (bodyAt < 0) return null;
106
106
  let newClipRange = clipRange.replace(
107
107
  bodyRe,
108
- `<Body>${newBodyHex}</Body>`,
108
+ () => `<Body>${newBodyHex}</Body>`,
109
109
  );
110
110
 
111
111
  // 4. Mark the owning version as corrected. Resolve keeps a per-version
@@ -96,7 +96,7 @@ async function placeCompound(drpInput, opts = {}) {
96
96
  if (!seqRef) throw new Error('placeCompound: embedded blob has no SeqRef entry — schema drift, refusing to author a crasher');
97
97
  seqRef.value = innerContainerId;
98
98
  const patched = encodeKeyedDict({ hdr: dict.hdr, entries: dict.entries }).toString('hex');
99
- pool = pool.replace(fbM[0], `${fbM[1]}${patched}${fbM[3]}`);
99
+ pool = pool.replace(fbM[0], () => `${fbM[1]}${patched}${fbM[3]}`);
100
100
  }
101
101
  pool = pool.replace(/<Name>[^<]*<\/Name>/, () => `<Name>${escapeXml(name)}</Name>`);
102
102
  // MediaExtents = [startSec, durationSec] LE doubles; inner starts at 0.
@@ -103,7 +103,7 @@ async function placeFusionTitle(drpInput, opts = {}) {
103
103
  if (Object.values(titleInputs).some((v) => v !== undefined && v !== null)) {
104
104
  const m = clip.match(/<CompositionBA>([0-9a-fA-F]*)<\/CompositionBA>/);
105
105
  if (!m || !m[1]) throw new Error('placeFusionTitle: template has no CompositionBA to style');
106
- clip = clip.replace(m[0], `<CompositionBA>${setTitleInputs(m[1], titleInputs)}</CompositionBA>`);
106
+ clip = clip.replace(m[0], () => `<CompositionBA>${setTitleInputs(m[1], titleInputs)}</CompositionBA>`);
107
107
  }
108
108
 
109
109
  // Locate VideoTrackVec and split into track elements.
@@ -137,7 +137,7 @@ async function placeTransition(drpInput, opts = {}) {
137
137
  const transitionDbId = (trans.match(/<Sm2TiTransition DbId="([^"]+)"/) || [])[1] || null;
138
138
 
139
139
  // Insert the transition <Element> immediately after the left clip's <Element> in <Items>.
140
- const newItems = items.replace(clips[leftIdx], `${clips[leftIdx]}${trans}`);
140
+ const newItems = items.replace(clips[leftIdx], () => `${clips[leftIdx]}${trans}`);
141
141
  tracks[track - 1] = setItemsInner(tracks[track - 1], newItems);
142
142
 
143
143
  const xml = replaceTrackVec(seqXml, trackType, vec, tracks);
@@ -91,7 +91,7 @@ function setItemsInner(trackElement, inner) {
91
91
  if (/<Items\s*\/>/.test(trackElement)) {
92
92
  return body ? trackElement.replace(/<Items\s*\/>/, () => `<Items>${body}</Items>`) : trackElement;
93
93
  }
94
- return trackElement.replace(/<Items>[\s\S]*?<\/Items>/, body ? `<Items>${body}</Items>` : '<Items/>');
94
+ return trackElement.replace(/<Items>[\s\S]*?<\/Items>/, () => (body ? `<Items>${body}</Items>` : '<Items/>'));
95
95
  }
96
96
 
97
97
  function insertClipIntoTrack(trackElement, clipElement) {
@@ -57,8 +57,8 @@ function clipIn(clipXml) {
57
57
  }
58
58
  function setClipIn(clipXml, framePos) {
59
59
  const enc = `<In>${encodeSourceIn(framePos)}</In>`;
60
- if (/<In\s*\/>/.test(clipXml)) return clipXml.replace(/<In\s*\/>/, enc);
61
- if (/<In>[^<]*<\/In>/.test(clipXml)) return clipXml.replace(/<In>[^<]*<\/In>/, enc);
60
+ if (/<In\s*\/>/.test(clipXml)) return clipXml.replace(/<In\s*\/>/, () => enc);
61
+ if (/<In>[^<]*<\/In>/.test(clipXml)) return clipXml.replace(/<In>[^<]*<\/In>/, () => enc);
62
62
  return clipXml; // no In element to set
63
63
  }
64
64
  // A FROZEN clip carries an EMPTY <In/> (harvested live from an EDL M2 000.0
@@ -77,7 +77,7 @@ function mapClipsOnTrack(trackElement, fn) {
77
77
  let out = inner;
78
78
  for (const c of splitClipElements(inner)) {
79
79
  const nc = fn(c);
80
- if (nc !== c) out = out.replace(c, nc);
80
+ if (nc !== c) out = out.replace(c, () => nc);
81
81
  }
82
82
  return setItemsInner(trackElement, out);
83
83
  }
@@ -263,7 +263,7 @@ async function trimClip(drpInput, opts = {}) {
263
263
  const tStart = clipStart(target);
264
264
  const trimmedClipDbId = clipDbId(target);
265
265
 
266
- let track = setItemsInner(tracks[trackIdx - 1], items.replace(target, setClipDuration(target, newDuration)));
266
+ let track = setItemsInner(tracks[trackIdx - 1], items.replace(target, () => setClipDuration(target, newDuration)));
267
267
  if (ripple && oldDuration !== null && tStart !== null) {
268
268
  const delta = newDuration - oldDuration;
269
269
  const tEnd = tStart + oldDuration;
@@ -335,7 +335,7 @@ async function trimClipHead(drpInput, opts = {}) {
335
335
  edited = setClipDuration(edited, newDur);
336
336
  edited = setClipStart(edited, newStart);
337
337
 
338
- let track = setItemsInner(tracks[trackIdx - 1], items.replace(target, edited));
338
+ let track = setItemsInner(tracks[trackIdx - 1], items.replace(target, () => edited));
339
339
  if (ripple) {
340
340
  track = mapClipsOnTrack(track, (c) => {
341
341
  const s = clipStart(c);
@@ -399,7 +399,7 @@ async function splitClip(drpInput, opts = {}) {
399
399
  right = setClipDuration(right, rightDur);
400
400
  right = setClipIn(right, leftIn + leftDur); // source continues where left ended
401
401
 
402
- const track = setItemsInner(tracks[trackIdx - 1], items.replace(target, `${left}${right}`));
402
+ const track = setItemsInner(tracks[trackIdx - 1], items.replace(target, () => `${left}${right}`));
403
403
  tracks[trackIdx - 1] = track;
404
404
 
405
405
  const xml = replaceTrackVec(seqXml, trackType, vtv, tracks);
@@ -70,7 +70,7 @@ function restampVersions(xmlStr, src, target) {
70
70
  return xmlStr
71
71
  .split(`DbAppVer="${src.dbAppVer}"`).join(`DbAppVer="${target.dbAppVer}"`)
72
72
  .split(`DbPrjVer="${src.dbPrjVer}"`).join(`DbPrjVer="${target.dbPrjVer}"`)
73
- .replace(PROJECT_VERSION_RE, `<ProjectVersion>${target.dbPrjVer}</ProjectVersion>`);
73
+ .replace(PROJECT_VERSION_RE, () => `<ProjectVersion>${target.dbPrjVer}</ProjectVersion>`);
74
74
  }
75
75
 
76
76
  /**
@@ -88,7 +88,7 @@ function expandSynonyms(text, synonyms) {
88
88
  const regex = new RegExp(`\\b${alt}\\b`, 'gi');
89
89
  if (regex.test(expanded)) {
90
90
  // Don't replace, just note the canonical form
91
- expanded = expanded.replace(regex, canonical);
91
+ expanded = expanded.replace(regex, () => canonical);
92
92
  }
93
93
  }
94
94
  }
@@ -92,7 +92,7 @@ if not logging.getLogger().handlers:
92
92
  handlers=[logging.StreamHandler()],
93
93
  )
94
94
 
95
- VERSION = "4.18.1"
95
+ VERSION = "4.18.2"
96
96
  logger = logging.getLogger("davinci-resolve-mcp")
97
97
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
98
98
  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.18.1"
14
+ VERSION = "4.18.2"
15
15
 
16
16
  import base64
17
17
  import os