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 +42 -0
- package/README.md +1 -1
- package/README.zh-CN.md +2 -2
- package/install.py +1 -1
- package/package.json +1 -1
- package/resolve-advanced/server/lut-apply.mjs +1 -1
- package/resolve-advanced/server/media-ops.mjs +1 -1
- package/resolve-advanced/server/subtitle-timeline.mjs +5 -5
- package/resolve-advanced/server/tools/drt.mjs +1 -1
- package/resolve-advanced/server/tools/drx.mjs +1 -1
- package/resolve-advanced/vendor/drp-format/__tests__/literal-replacement.test.js +155 -24
- package/resolve-advanced/vendor/drp-format/assemble-timeline.js +1 -1
- package/resolve-advanced/vendor/drp-format/inject-grades.js +1 -1
- package/resolve-advanced/vendor/drp-format/place-compound.js +1 -1
- package/resolve-advanced/vendor/drp-format/place-fusion-title.js +1 -1
- package/resolve-advanced/vendor/drp-format/place-transition.js +1 -1
- package/resolve-advanced/vendor/drp-format/seq-surgery.js +1 -1
- package/resolve-advanced/vendor/drp-format/splice-clips.js +6 -6
- package/resolve-advanced/vendor/drt-format/resolve-versions.js +1 -1
- package/resolve-advanced/vendor/drx-codec/color-nlp-parser.js +1 -1
- package/src/granular/common.py +1 -1
- package/src/server.py +1 -1
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
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
8
|
[-blue.svg)](#server-modes)
|
package/README.zh-CN.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.md) | 简体中文
|
|
4
4
|
|
|
5
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
8
|
[-blue.svg)](#服务器模式)
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
[](https://www.python.org/downloads/)
|
|
13
13
|
[](https://opensource.org/licenses/MIT)
|
|
14
14
|
|
|
15
|
-
> 本翻译对应 v4.18.
|
|
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.
|
|
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
|
@@ -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
|
|
7
|
-
//
|
|
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
|
-
//
|
|
35
|
-
//
|
|
36
|
-
const
|
|
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
|
-
|
|
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(
|
|
42
|
-
const
|
|
43
|
-
if (
|
|
44
|
-
|
|
45
|
-
|
|
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('
|
|
51
|
-
const
|
|
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
|
-
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
}
|
package/src/granular/common.py
CHANGED
|
@@ -92,7 +92,7 @@ if not logging.getLogger().handlers:
|
|
|
92
92
|
handlers=[logging.StreamHandler()],
|
|
93
93
|
)
|
|
94
94
|
|
|
95
|
-
VERSION = "4.18.
|
|
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()}")
|