davinci-resolve-mcp 4.18.0 → 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 +76 -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 +7 -7
- package/resolve-advanced/server/tools/drx.mjs +1 -1
- package/resolve-advanced/vendor/audio/format-converter.js +1 -1
- package/resolve-advanced/vendor/drp-format/__tests__/literal-replacement.test.js +226 -0
- package/resolve-advanced/vendor/drp-format/assemble-timeline.js +3 -3
- package/resolve-advanced/vendor/drp-format/cut-media.js +8 -7
- package/resolve-advanced/vendor/drp-format/inject-grades.js +1 -1
- package/resolve-advanced/vendor/drp-format/media-template-cache.js +3 -3
- package/resolve-advanced/vendor/drp-format/place-compound.js +18 -18
- package/resolve-advanced/vendor/drp-format/place-fusion-title.js +4 -4
- package/resolve-advanced/vendor/drp-format/place-generator.js +5 -5
- package/resolve-advanced/vendor/drp-format/place-subtitles.js +2 -2
- package/resolve-advanced/vendor/drp-format/place-transition.js +5 -5
- package/resolve-advanced/vendor/drp-format/seq-surgery.js +2 -2
- package/resolve-advanced/vendor/drp-format/splice-clips.js +9 -9
- 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 +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,82 @@
|
|
|
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
|
+
|
|
47
|
+
## What's New in v4.18.1 — Names with `$` or `\` are written as typed
|
|
48
|
+
|
|
49
|
+
### Fixed
|
|
50
|
+
|
|
51
|
+
- **`drt assemble_project` no longer rewrites a merged timeline's name when it
|
|
52
|
+
contains `$`** (#332, thanks @Dev-next-gen). The name was inserted with a
|
|
53
|
+
replacement string, and JavaScript expands `$&`, `$'` and `$$` in one. A
|
|
54
|
+
second timeline named `R2 $& $'` wrote an `MpFolder.xml` that no longer
|
|
55
|
+
parsed, and `Spot $$ 30s` came out as `Spot $ 30s`, so a `folder` move then
|
|
56
|
+
failed to find the timeline.
|
|
57
|
+
- **The same fix in 53 more places in the offline `.drp`/`.drt` writers.**
|
|
58
|
+
Caption text in `placeSubtitles`, generator, title and compound clip names,
|
|
59
|
+
media pool clip names and file paths, and every other value inserted into
|
|
60
|
+
the XML now goes in with a replacer function, so `$`-sequences stay literal.
|
|
61
|
+
A new guard test fails the advanced suite if a `replace()` call passes an
|
|
62
|
+
interpolated template as a string.
|
|
63
|
+
- **The audio-only fallback clone in `cut-media` now XML-escapes the source
|
|
64
|
+
file name and path.** A source path containing `&`, such as
|
|
65
|
+
`Smith & Jones/clip.mov`, wrote invalid XML.
|
|
66
|
+
- **Re-running media analysis on a clip no longer fails when the new analysis
|
|
67
|
+
text contains a backslash.** The managed block in the clip's metadata was
|
|
68
|
+
replaced with `re.sub` using the text as a replacement string, so a Windows
|
|
69
|
+
path raised `bad escape \F` and `\t` turned into a tab. The text is now
|
|
70
|
+
inserted as-is.
|
|
71
|
+
|
|
72
|
+
### Validation
|
|
73
|
+
|
|
74
|
+
- Advanced Node suite on Node 20 with a fresh `npm ci`: 1,224 tests, 0 failed.
|
|
75
|
+
With the fixes reverted, the guard and the caption, generator and title tests
|
|
76
|
+
fail.
|
|
77
|
+
- Python suite: the new metadata test fails with `bad escape \F` without the
|
|
78
|
+
fix and passes with it.
|
|
79
|
+
- Offline file writers only. No Resolve API behaviour changed.
|
|
80
|
+
|
|
5
81
|
## What's New in v4.18.0 — Edit captions with Resolve open
|
|
6
82
|
|
|
7
83
|
### Added
|
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}`));
|
|
@@ -467,7 +467,7 @@ export const drtTool = {
|
|
|
467
467
|
} catch { return whole; }
|
|
468
468
|
});
|
|
469
469
|
// pool clip: uuid remap + keyed SeqRef patch + fresh numeric IDs
|
|
470
|
-
let tlNew = applyBlobs(apply(tlEl)).replace(/<ID>\d+<\/ID>/g, `<ID>${nextPoolId}<\/ID>`.replace('<\/ID>', '</ID>'));
|
|
470
|
+
let tlNew = applyBlobs(apply(tlEl)).replace(/<ID>\d+<\/ID>/g, () => `<ID>${nextPoolId}<\/ID>`.replace('<\/ID>', '</ID>'));
|
|
471
471
|
nextPoolId += 1;
|
|
472
472
|
const fbM = tlNew.match(/(<Sm2Sequence DbId="[^"]+">\s*<FieldsBlob>)([0-9a-fA-F]+)(<\/FieldsBlob>)/);
|
|
473
473
|
if (fbM) {
|
|
@@ -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)) {
|
|
@@ -486,14 +486,14 @@ export const drtTool = {
|
|
|
486
486
|
// Children live INSIDE <MediaVec> — an element appended after its
|
|
487
487
|
// close parses fine and is silently invisible (measured: the whole
|
|
488
488
|
// REEL_02 subtree vanished while every id chain checked out).
|
|
489
|
-
mpXml = mpXml.replace('</MediaVec>', `${tlNew}\n</MediaVec>`);
|
|
489
|
+
mpXml = mpXml.replace('</MediaVec>', () => `${tlNew}\n</MediaVec>`);
|
|
490
490
|
for (const el of mediaEls) {
|
|
491
491
|
const id = el.match(/DbId="([^"]+)"/)[1];
|
|
492
|
-
if (!mpXml.includes(`DbId="${id}"`)) mpXml = mpXml.replace('</MediaVec>', `${el}\n</MediaVec>`);
|
|
492
|
+
if (!mpXml.includes(`DbId="${id}"`)) mpXml = mpXml.replace('</MediaVec>', () => `${el}\n</MediaVec>`);
|
|
493
493
|
}
|
|
494
494
|
const pj2 = await zip.file('project.xml').async('string');
|
|
495
495
|
for (const blob of pj2.match(/<Element>\s*<Sm2(?:Sequence|TiItem)LockableBlob DbId="[^"]+">[\s\S]*?<\/Sm2(?:Sequence|TiItem)LockableBlob>\s*<\/Element>/g) || []) {
|
|
496
|
-
pjXml = pjXml.replace('</LocableBlobSet>', `${apply(blob)}</LocableBlobSet>`);
|
|
496
|
+
pjXml = pjXml.replace('</LocableBlobSet>', () => `${apply(blob)}</LocableBlobSet>`);
|
|
497
497
|
}
|
|
498
498
|
// THE timeline registry (found the hard way — two invisible merges):
|
|
499
499
|
// project.xml's <TimelineHandleVec> lists each pool clip's embedded
|
|
@@ -503,7 +503,7 @@ export const drtTool = {
|
|
|
503
503
|
const vec2 = pj2.match(/<TimelineHandleVec>([\s\S]*?)<\/TimelineHandleVec>/);
|
|
504
504
|
if (vec2) {
|
|
505
505
|
for (const h of vec2[1].match(/<Element>[^<]*<\/Element>/g) || []) {
|
|
506
|
-
pjXml = pjXml.replace('</TimelineHandleVec>', ` ${apply(h)}\n </TimelineHandleVec>`);
|
|
506
|
+
pjXml = pjXml.replace('</TimelineHandleVec>', () => ` ${apply(h)}\n </TimelineHandleVec>`);
|
|
507
507
|
}
|
|
508
508
|
}
|
|
509
509
|
// future merges must not collide with THIS timeline's fresh ids either
|
|
@@ -534,7 +534,7 @@ export const drtTool = {
|
|
|
534
534
|
const hit = mpXml.match(tlRe);
|
|
535
535
|
if (!hit) throw new Error(`assemble_project: could not locate pool clip for timeline ${names[i]} to move into folder ${folder}`);
|
|
536
536
|
mpXml = mpXml.replace(hit[0], '');
|
|
537
|
-
bin.clips.push(hit[0].replace(/<MpFolder>[^<]*<\/MpFolder>/, `<MpFolder>${bin.id}</MpFolder>`));
|
|
537
|
+
bin.clips.push(hit[0].replace(/<MpFolder>[^<]*<\/MpFolder>/, () => `<MpFolder>${bin.id}</MpFolder>`));
|
|
538
538
|
}
|
|
539
539
|
// Register the bins in Master's FieldsBlob — the parent folder blob
|
|
540
540
|
// is the SUBFOLDER registry (measured: with it blanked, a bin's
|
|
@@ -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) {
|
|
@@ -213,7 +213,7 @@ async function convert(inputPath, outputPath, options = {}) {
|
|
|
213
213
|
// Auto-generate output path if not provided
|
|
214
214
|
if (!outputPath) {
|
|
215
215
|
const ext = CONTAINER_EXTENSIONS[preset.container] || '.wav';
|
|
216
|
-
outputPath = inputPath.replace(/\.[^.]+$/, `_${presetName}${ext}`);
|
|
216
|
+
outputPath = inputPath.replace(/\.[^.]+$/, () => `_${presetName}${ext}`);
|
|
217
217
|
}
|
|
218
218
|
|
|
219
219
|
onProgress(10);
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// A string passed as the replacement to String.prototype.replace expands `$&`,
|
|
2
|
+
// `$'`, `` $` `` and `$$`, so a name or caption carrying them is rewritten on its
|
|
3
|
+
// way into the XML (#332: `R2 $& $'` as a timeline name made MpFolder.xml
|
|
4
|
+
// unparseable; `Spot $$ 30s` came out as `Spot $ 30s`). Interpolated
|
|
5
|
+
// replacements are therefore passed as functions, which insert their result
|
|
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.
|
|
9
|
+
|
|
10
|
+
const test = require('node:test');
|
|
11
|
+
const assert = require('node:assert');
|
|
12
|
+
const fs = require('node:fs');
|
|
13
|
+
const path = require('node:path');
|
|
14
|
+
const JSZip = require('jszip');
|
|
15
|
+
const { escapeXml } = require('../xml-builder');
|
|
16
|
+
const { createEmptyProject } = require('../author-project');
|
|
17
|
+
const { placeSubtitles } = require('../place-subtitles');
|
|
18
|
+
const { placeGenerator } = require('../place-generator');
|
|
19
|
+
const { placeFusionTitle } = require('../place-fusion-title');
|
|
20
|
+
const { splitClip, trimClip, trimClipHead } = require('../splice-clips');
|
|
21
|
+
const { placeTransition } = require('../place-transition');
|
|
22
|
+
|
|
23
|
+
const ROOT = path.resolve(__dirname, '..', '..', '..');
|
|
24
|
+
const TRICKY = "Spot $$ 30s $& $' $` end";
|
|
25
|
+
|
|
26
|
+
function sourceFiles(dir) {
|
|
27
|
+
const out = [];
|
|
28
|
+
if (!fs.existsSync(dir)) return out;
|
|
29
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
30
|
+
if (entry.name === 'node_modules' || entry.name === '__tests__') continue;
|
|
31
|
+
const full = path.join(dir, entry.name);
|
|
32
|
+
if (entry.isDirectory()) out.push(...sourceFiles(full));
|
|
33
|
+
else if (/\.(m?js)$/.test(entry.name)) out.push(full);
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
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
|
+
|
|
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
|
+
]);
|
|
118
|
+
|
|
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 = '') {
|
|
124
|
+
const found = [];
|
|
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)})`);
|
|
135
|
+
}
|
|
136
|
+
return found;
|
|
137
|
+
}
|
|
138
|
+
|
|
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);
|
|
142
|
+
assert.ok(files.length > 50, `scanned ${files.length} files`);
|
|
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');
|
|
145
|
+
});
|
|
146
|
+
|
|
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);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
async function sequenceXml(buffer) {
|
|
169
|
+
const zip = await JSZip.loadAsync(buffer);
|
|
170
|
+
const parts = [];
|
|
171
|
+
for (const name of Object.keys(zip.files)) {
|
|
172
|
+
if (/SeqContainer\/[^/]+\.xml$|SeqContainer\d*\.xml$/.test(name)) parts.push(await zip.files[name].async('string'));
|
|
173
|
+
}
|
|
174
|
+
return parts.join('\n');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
test('placeSubtitles keeps $-sequences in caption text', async () => {
|
|
178
|
+
const { buffer, startFrame } = await createEmptyProject({ timelineName: 'Subs' });
|
|
179
|
+
const res = await placeSubtitles(buffer, { subtitles: [{ startFrame, durationFrames: 48, text: TRICKY }] });
|
|
180
|
+
assert.ok((await sequenceXml(res.buffer)).includes(`<Name>${escapeXml(TRICKY)}</Name>`));
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('placeGenerator keeps $-sequences in the generator name', async () => {
|
|
184
|
+
const { buffer, startFrame } = await createEmptyProject({ timelineName: 'Gen' });
|
|
185
|
+
const res = await placeGenerator(buffer, { generatorName: TRICKY, trackIndex: 2, startFrame, durationFrames: 24 });
|
|
186
|
+
const xml = await sequenceXml(res.buffer);
|
|
187
|
+
assert.ok(xml.includes(`<PrettyType>${escapeXml(TRICKY)}</PrettyType>`));
|
|
188
|
+
assert.ok(xml.includes(`<Name>${escapeXml(TRICKY)}</Name>`));
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('placeFusionTitle keeps $-sequences in the clip name', async () => {
|
|
192
|
+
const { buffer, startFrame } = await createEmptyProject({ timelineName: 'Title' });
|
|
193
|
+
const res = await placeFusionTitle(buffer, { trackIndex: 2, startFrame, durationFrames: 24, name: TRICKY });
|
|
194
|
+
assert.ok((await sequenceXml(res.buffer)).includes(`<Name>${escapeXml(TRICKY)}</Name>`));
|
|
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
|
+
});
|
|
@@ -247,7 +247,7 @@ async function assembleTimeline(spec = {}) {
|
|
|
247
247
|
if (!hit) throw new Error(`assembleTimeline: item markers — no ${kind} clip starting at ${cut.startFrame} on track ${cut.track ?? 1}`);
|
|
248
248
|
const blob = encodeTimelineMarkersBlob(cut.markers);
|
|
249
249
|
const el = `<Element>\n <Sm2TiItemLockableBlob DbId="${randomUUID()}">\n <FieldsBlob>${blob.toString('hex')}</FieldsBlob>\n <BlobOwner>${hit[1]}</BlobOwner>\n <DbSavedTime>0</DbSavedTime>\n </Sm2TiItemLockableBlob>\n </Element>\n `;
|
|
250
|
-
pj = pj.replace('</LocableBlobSet>', `${el}</LocableBlobSet>`);
|
|
250
|
+
pj = pj.replace('</LocableBlobSet>', () => `${el}</LocableBlobSet>`);
|
|
251
251
|
}
|
|
252
252
|
zipIM.file('project.xml', pj);
|
|
253
253
|
buffer = await zipIM.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
|
|
@@ -417,7 +417,7 @@ async function assembleTimeline(spec = {}) {
|
|
|
417
417
|
const setM = pjX.match(/<LocableBlobSet>[\s\S]*?<\/LocableBlobSet>/);
|
|
418
418
|
if (!setM) throw new Error('assembleTimeline: project.xml has no LocableBlobSet to hold markers');
|
|
419
419
|
const el = `<Element>\n <Sm2SequenceLockableBlob DbId="${randomUUID()}">\n <FieldsBlob>${blob.toString('hex')}</FieldsBlob>\n <BlobOwner>${seqIdM}</BlobOwner>\n <DbSavedTime>0</DbSavedTime>\n </Sm2SequenceLockableBlob>\n </Element>\n `;
|
|
420
|
-
pjX = pjX.replace('</LocableBlobSet>', `${el}</LocableBlobSet>`);
|
|
420
|
+
pjX = pjX.replace('</LocableBlobSet>', () => `${el}</LocableBlobSet>`);
|
|
421
421
|
zipM.file('project.xml', pjX);
|
|
422
422
|
buffer = await zipM.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
|
|
423
423
|
}
|
|
@@ -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
|
}
|
|
@@ -27,6 +27,7 @@ const {
|
|
|
27
27
|
emptyTrackClone,
|
|
28
28
|
} = require('./seq-surgery');
|
|
29
29
|
const { clipDbId, setClipStart, setClipDuration, setClipIn, setClipInEmpty } = require('./splice-clips');
|
|
30
|
+
const { escapeXml } = require('./xml-builder');
|
|
30
31
|
|
|
31
32
|
/**
|
|
32
33
|
* @param {Buffer|string} drpInput
|
|
@@ -96,7 +97,7 @@ async function cutSourceIntoClips(drpInput, opts = {}) {
|
|
|
96
97
|
if (cut.mediaRef) {
|
|
97
98
|
// Multi-source: point this cut at ITS source's transplanted pool
|
|
98
99
|
// element instead of the donor's.
|
|
99
|
-
c = c.replace(/<MediaRef>[0-9a-f-]{36}<\/MediaRef>/, `<MediaRef>${cut.mediaRef}</MediaRef>`);
|
|
100
|
+
c = c.replace(/<MediaRef>[0-9a-f-]{36}<\/MediaRef>/, () => `<MediaRef>${cut.mediaRef}</MediaRef>`);
|
|
100
101
|
}
|
|
101
102
|
if (cut.srcMeta && !native && cut.audioOnly) {
|
|
102
103
|
// Every clone must carry ITS OWN source identity — a donor clone
|
|
@@ -106,15 +107,15 @@ async function cutSourceIntoClips(drpInput, opts = {}) {
|
|
|
106
107
|
// A1 (measured, E15), and a TC-bearing video source fails the whole
|
|
107
108
|
// render with "Full resolution media not found" (measured, E31).
|
|
108
109
|
const { name, mediaFilePath, fps, frameCount } = cut.srcMeta;
|
|
109
|
-
c = c.replace(/<Name>[\s\S]*?<\/Name>/, `<Name>${name}</Name>`);
|
|
110
|
-
c = c.replace(/<MediaFilePath>[\s\S]*?<\/MediaFilePath>/, `<MediaFilePath>${mediaFilePath}</MediaFilePath>`);
|
|
110
|
+
c = c.replace(/<Name>[\s\S]*?<\/Name>/, () => `<Name>${escapeXml(name)}</Name>`);
|
|
111
|
+
c = c.replace(/<MediaFilePath>[\s\S]*?<\/MediaFilePath>/, () => `<MediaFilePath>${escapeXml(mediaFilePath)}</MediaFilePath>`);
|
|
111
112
|
const rate = Buffer.alloc(16);
|
|
112
113
|
rate.writeDoubleLE(fps, 0);
|
|
113
|
-
c = c.replace(/<MediaFrameRate>[0-9a-fA-F]*<\/MediaFrameRate>/, `<MediaFrameRate>${rate.toString('hex')}</MediaFrameRate>`);
|
|
114
|
+
c = c.replace(/<MediaFrameRate>[0-9a-fA-F]*<\/MediaFrameRate>/, () => `<MediaFrameRate>${rate.toString('hex')}</MediaFrameRate>`);
|
|
114
115
|
const idm = Buffer.alloc(9);
|
|
115
116
|
idm.writeUInt8(0x02, 0);
|
|
116
117
|
idm.writeDoubleBE((frameCount - 1) / fps, 1);
|
|
117
|
-
c = c.replace(/<MediaTimemapBA>[0-9a-fA-F]*<\/MediaTimemapBA>/, `<MediaTimemapBA>${idm.toString('hex')}</MediaTimemapBA>`);
|
|
118
|
+
c = c.replace(/<MediaTimemapBA>[0-9a-fA-F]*<\/MediaTimemapBA>/, () => `<MediaTimemapBA>${idm.toString('hex')}</MediaTimemapBA>`);
|
|
118
119
|
if (cut.audioOnly) {
|
|
119
120
|
// Generic audio-clip FieldsBlob, verbatim from the live A2 harvest.
|
|
120
121
|
// (Video clones keep their blob — repointClipBlobsInXml owns it.)
|
|
@@ -125,13 +126,13 @@ async function cutSourceIntoClips(drpInput, opts = {}) {
|
|
|
125
126
|
// Embedded source timecode base, in SECONDS (harvested with the media
|
|
126
127
|
// template). Donor default 0 makes Resolve seek the wrong TC and fail
|
|
127
128
|
// the render with "Full resolution media not found at <TC>".
|
|
128
|
-
c = c.replace(/<MediaStartTime>[\s\S]*?<\/MediaStartTime>/, `<MediaStartTime>${cut.mediaStartTime}</MediaStartTime>`);
|
|
129
|
+
c = c.replace(/<MediaStartTime>[\s\S]*?<\/MediaStartTime>/, () => `<MediaStartTime>${cut.mediaStartTime}</MediaStartTime>`);
|
|
129
130
|
}
|
|
130
131
|
if (cut.timemap) {
|
|
131
132
|
// Constant-speed retime: swap the identity MediaTimemapBA for the
|
|
132
133
|
// caller-built Sm2TimeMap (r19 keyed form render/readback-verified;
|
|
133
134
|
// the clip's <In>/<Duration> are RECORD-domain, measured live).
|
|
134
|
-
c = c.replace(/<MediaTimemapBA>[0-9a-fA-F]*<\/MediaTimemapBA>/, `<MediaTimemapBA>${cut.timemap}</MediaTimemapBA>`);
|
|
135
|
+
c = c.replace(/<MediaTimemapBA>[0-9a-fA-F]*<\/MediaTimemapBA>/, () => `<MediaTimemapBA>${cut.timemap}</MediaTimemapBA>`);
|
|
135
136
|
}
|
|
136
137
|
return c;
|
|
137
138
|
};
|
|
@@ -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
|
|
@@ -59,11 +59,11 @@ function transplantMediaElement(mpXml, seqXmls, { poolElement, mediaRef }) {
|
|
|
59
59
|
const folderId = (mpXml.slice(start, end).match(/<MpFolder>([0-9a-f-]{36})<\/MpFolder>/) || [])[1];
|
|
60
60
|
let element = poolElement;
|
|
61
61
|
if (folderId) {
|
|
62
|
-
element = element.replace(/<MpFolder>[0-9a-f-]{36}<\/MpFolder>/, `<MpFolder>${folderId}</MpFolder>`);
|
|
62
|
+
element = element.replace(/<MpFolder>[0-9a-f-]{36}<\/MpFolder>/, () => `<MpFolder>${folderId}</MpFolder>`);
|
|
63
63
|
}
|
|
64
64
|
const outMp = mpXml.slice(0, start) + element + mpXml.slice(end);
|
|
65
65
|
const outSeqs = seqXmls.map((seq) =>
|
|
66
|
-
seq.replace(/<MediaRef>[0-9a-f-]{36}<\/MediaRef>/g, `<MediaRef>${mediaRef}</MediaRef>`),
|
|
66
|
+
seq.replace(/<MediaRef>[0-9a-f-]{36}<\/MediaRef>/g, () => `<MediaRef>${mediaRef}</MediaRef>`),
|
|
67
67
|
);
|
|
68
68
|
return { mpXml: outMp, seqXmls: outSeqs };
|
|
69
69
|
}
|
|
@@ -75,7 +75,7 @@ function insertMediaElement(mpXml, poolElement) {
|
|
|
75
75
|
const folderId = (mpXml.match(/<Sm2MpVideoClip[^>]*>[\s\S]*?<MpFolder>([0-9a-f-]{36})<\/MpFolder>/) || [])[1];
|
|
76
76
|
let element = poolElement;
|
|
77
77
|
if (folderId) {
|
|
78
|
-
element = element.replace(/<MpFolder>[0-9a-f-]{36}<\/MpFolder>/, `<MpFolder>${folderId}</MpFolder>`);
|
|
78
|
+
element = element.replace(/<MpFolder>[0-9a-f-]{36}<\/MpFolder>/, () => `<MpFolder>${folderId}</MpFolder>`);
|
|
79
79
|
}
|
|
80
80
|
return mpXml.slice(0, close) + element + mpXml.slice(close);
|
|
81
81
|
}
|
|
@@ -72,22 +72,22 @@ async function placeCompound(drpInput, opts = {}) {
|
|
|
72
72
|
// the cuts machinery, 0-based frames).
|
|
73
73
|
let inner = fs.readFileSync(T('compound-inner-r19.xml'), 'utf8');
|
|
74
74
|
inner = freshDbIds(inner);
|
|
75
|
-
inner = inner.replace(/<Sm2SequenceContainer DbId="[^"]+"/, `<Sm2SequenceContainer DbId="${innerContainerId}"`);
|
|
76
|
-
inner = inner.replace(/<Sequence>[0-9a-f-]{36}<\/Sequence>/g, `<Sequence>${innerSequenceId}</Sequence>`);
|
|
75
|
+
inner = inner.replace(/<Sm2SequenceContainer DbId="[^"]+"/, () => `<Sm2SequenceContainer DbId="${innerContainerId}"`);
|
|
76
|
+
inner = inner.replace(/<Sequence>[0-9a-f-]{36}<\/Sequence>/g, () => `<Sequence>${innerSequenceId}</Sequence>`);
|
|
77
77
|
inner = inner.replace(/<Items>[\s\S]*?<\/Items>/g, '<Items/>');
|
|
78
78
|
zip.file(`SeqContainer/${innerContainerId}.xml`, inner);
|
|
79
79
|
|
|
80
80
|
// 2) Pool compound element: fresh identities + blob SeqRef patch.
|
|
81
81
|
let pool = fs.readFileSync(T('compound-pool-r19.xml'), 'utf8');
|
|
82
|
-
pool = pool.replace(/<Sm2MpCompoundClip DbId="[^"]+"/, `<Sm2MpCompoundClip DbId="${compoundId}"`);
|
|
83
|
-
pool = pool.replace(/<Sm2Sequence DbId="[^"]+"/, `<Sm2Sequence DbId="${innerSequenceId}"`);
|
|
84
|
-
pool = pool.replace(/<UniqueSequenceId>[^<]*<\/UniqueSequenceId>/, `<UniqueSequenceId>${crypto.randomUUID()}</UniqueSequenceId>`);
|
|
85
|
-
pool = pool.replace(/<UniqueMediaPoolItemId>[^<]*<\/UniqueMediaPoolItemId>/, `<UniqueMediaPoolItemId>${crypto.randomUUID()}</UniqueMediaPoolItemId>`);
|
|
86
|
-
pool = pool.replace(/<SmPTZRPreset DbId="[^"]+"/, `<SmPTZRPreset DbId="${crypto.randomUUID()}"`);
|
|
82
|
+
pool = pool.replace(/<Sm2MpCompoundClip DbId="[^"]+"/, () => `<Sm2MpCompoundClip DbId="${compoundId}"`);
|
|
83
|
+
pool = pool.replace(/<Sm2Sequence DbId="[^"]+"/, () => `<Sm2Sequence DbId="${innerSequenceId}"`);
|
|
84
|
+
pool = pool.replace(/<UniqueSequenceId>[^<]*<\/UniqueSequenceId>/, () => `<UniqueSequenceId>${crypto.randomUUID()}</UniqueSequenceId>`);
|
|
85
|
+
pool = pool.replace(/<UniqueMediaPoolItemId>[^<]*<\/UniqueMediaPoolItemId>/, () => `<UniqueMediaPoolItemId>${crypto.randomUUID()}</UniqueMediaPoolItemId>`);
|
|
86
|
+
pool = pool.replace(/<SmPTZRPreset DbId="[^"]+"/, () => `<SmPTZRPreset DbId="${crypto.randomUUID()}"`);
|
|
87
87
|
// The embedded sequence's <Parent> points BACK at the compound's own id —
|
|
88
88
|
// the reference sweep found it as the one un-rewired link (a dangling
|
|
89
89
|
// Parent is presumably as fatal as the dangling MpFolder/SeqRef were).
|
|
90
|
-
pool = pool.replace(/<Parent>[0-9a-f-]{36}<\/Parent>/, `<Parent>${compoundId}</Parent>`);
|
|
90
|
+
pool = pool.replace(/<Parent>[0-9a-f-]{36}<\/Parent>/, () => `<Parent>${compoundId}</Parent>`);
|
|
91
91
|
{
|
|
92
92
|
const fbM = pool.match(/(<Sm2Sequence DbId="[^"]+">\s*<FieldsBlob>)([0-9a-fA-F]+)(<\/FieldsBlob>)/);
|
|
93
93
|
if (!fbM) throw new Error('placeCompound: embedded Sm2Sequence FieldsBlob not found');
|
|
@@ -96,14 +96,14 @@ 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
|
-
pool = pool.replace(/<Name>[^<]*<\/Name>/, `<Name>${escapeXml(name)}</Name>`);
|
|
101
|
+
pool = pool.replace(/<Name>[^<]*<\/Name>/, () => `<Name>${escapeXml(name)}</Name>`);
|
|
102
102
|
// MediaExtents = [startSec, durationSec] LE doubles; inner starts at 0.
|
|
103
103
|
const me = Buffer.alloc(16);
|
|
104
104
|
me.writeDoubleLE(0, 0);
|
|
105
105
|
me.writeDoubleLE(durationFrames / fps, 8);
|
|
106
|
-
pool = pool.replace(/<MediaExtents>[0-9a-fA-F]*<\/MediaExtents>/, `<MediaExtents>${me.toString('hex')}</MediaExtents>`);
|
|
106
|
+
pool = pool.replace(/<MediaExtents>[0-9a-fA-F]*<\/MediaExtents>/, () => `<MediaExtents>${me.toString('hex')}</MediaExtents>`);
|
|
107
107
|
// Attach to the primary MediaPool folder — and point the element's
|
|
108
108
|
// <MpFolder> at THAT folder's DbId (the template's value references the
|
|
109
109
|
// harvest project's folder; a dangling folder ref CRASHES Resolve on
|
|
@@ -112,22 +112,22 @@ async function placeCompound(drpInput, opts = {}) {
|
|
|
112
112
|
let mpXml = await zip.file(mpPath).async('string');
|
|
113
113
|
const folderId = (mpXml.match(/<Sm2MpFolder DbId="([^"]+)"/) || [])[1];
|
|
114
114
|
if (!folderId) throw new Error('placeCompound: target MpFolder has no Sm2MpFolder DbId');
|
|
115
|
-
pool = pool.replace(/<MpFolder>[0-9a-f-]{36}<\/MpFolder>/, `<MpFolder>${folderId}</MpFolder>`);
|
|
116
|
-
mpXml = mpXml.replace('</MediaVec>', `${pool}\n </MediaVec>`);
|
|
115
|
+
pool = pool.replace(/<MpFolder>[0-9a-f-]{36}<\/MpFolder>/, () => `<MpFolder>${folderId}</MpFolder>`);
|
|
116
|
+
mpXml = mpXml.replace('</MediaVec>', () => `${pool}\n </MediaVec>`);
|
|
117
117
|
zip.file(mpPath, mpXml);
|
|
118
118
|
|
|
119
119
|
// 3) Parent item: template clone → fresh id, name, placement, MediaRef,
|
|
120
120
|
// identity timemap over the inner content ((frames − 0.5)/fps, measured).
|
|
121
121
|
let item = fs.readFileSync(T('compound-item-r19.xml'), 'utf8').trim();
|
|
122
122
|
item = freshDbIds(item);
|
|
123
|
-
item = item.replace(/<Name>[^<]*<\/Name>/, `<Name>${escapeXml(name)}</Name>`);
|
|
124
|
-
item = item.replace(/<Start>\d+<\/Start>/, `<Start>${startFrame}</Start>`);
|
|
125
|
-
item = item.replace(/<Duration>\d+<\/Duration>/, `<Duration>${durationFrames}</Duration>`);
|
|
126
|
-
item = item.replace(/<MediaRef>[0-9a-f-]{36}<\/MediaRef>/, `<MediaRef>${compoundId}</MediaRef>`);
|
|
123
|
+
item = item.replace(/<Name>[^<]*<\/Name>/, () => `<Name>${escapeXml(name)}</Name>`);
|
|
124
|
+
item = item.replace(/<Start>\d+<\/Start>/, () => `<Start>${startFrame}</Start>`);
|
|
125
|
+
item = item.replace(/<Duration>\d+<\/Duration>/, () => `<Duration>${durationFrames}</Duration>`);
|
|
126
|
+
item = item.replace(/<MediaRef>[0-9a-f-]{36}<\/MediaRef>/, () => `<MediaRef>${compoundId}</MediaRef>`);
|
|
127
127
|
const tm = Buffer.alloc(9);
|
|
128
128
|
tm.writeUInt8(0x02, 0);
|
|
129
129
|
tm.writeDoubleBE((durationFrames - 0.5) / fps, 1);
|
|
130
|
-
item = item.replace(/<MediaTimemapBA>[0-9a-fA-F]*<\/MediaTimemapBA>/, `<MediaTimemapBA>${tm.toString('hex')}</MediaTimemapBA>`);
|
|
130
|
+
item = item.replace(/<MediaTimemapBA>[0-9a-fA-F]*<\/MediaTimemapBA>/, () => `<MediaTimemapBA>${tm.toString('hex')}</MediaTimemapBA>`);
|
|
131
131
|
|
|
132
132
|
const { match: vec, tracks } = getTrackVec(parentXml, 'video');
|
|
133
133
|
if (track > tracks.length) throw new Error(`placeCompound: parent video track ${track} does not exist (${tracks.length})`);
|
|
@@ -96,14 +96,14 @@ async function placeFusionTitle(drpInput, opts = {}) {
|
|
|
96
96
|
throw new Error('placeFusionTitle: template is not a Fusion Title clip');
|
|
97
97
|
}
|
|
98
98
|
clip = freshDbIds(clip);
|
|
99
|
-
clip = clip.replace(/<Start>\d+<\/Start>/, `<Start>${startFrame}</Start>`);
|
|
100
|
-
clip = clip.replace(/<Duration>\d+<\/Duration>/, `<Duration>${durationFrames}</Duration>`);
|
|
101
|
-
if (name) clip = clip.replace(/<Name>[\s\S]*?<\/Name>/, `<Name>${escapeXml(name)}</Name>`);
|
|
99
|
+
clip = clip.replace(/<Start>\d+<\/Start>/, () => `<Start>${startFrame}</Start>`);
|
|
100
|
+
clip = clip.replace(/<Duration>\d+<\/Duration>/, () => `<Duration>${durationFrames}</Duration>`);
|
|
101
|
+
if (name) clip = clip.replace(/<Name>[\s\S]*?<\/Name>/, () => `<Name>${escapeXml(name)}</Name>`);
|
|
102
102
|
const titleInputs = { text, font, style, size, vJustify, hJustify, color };
|
|
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.
|
|
@@ -75,17 +75,17 @@ async function placeGenerator(drpInput, opts = {}) {
|
|
|
75
75
|
|
|
76
76
|
let gen = fs.readFileSync(snippetPathFor(templateVersion), 'utf8').trim();
|
|
77
77
|
gen = freshDbIds(gen);
|
|
78
|
-
gen = gen.replace(/<PrettyType>[\s\S]*?<\/PrettyType>/, `<PrettyType>${escapeXml(generatorName)}</PrettyType>`);
|
|
79
|
-
gen = gen.replace(/<Name>[\s\S]*?<\/Name>/, `<Name>${escapeXml(generatorName)}</Name>`);
|
|
80
|
-
gen = gen.replace(/<Start>\d+<\/Start>/, `<Start>${startFrame}</Start>`);
|
|
81
|
-
gen = gen.replace(/<Duration>\d+<\/Duration>/, `<Duration>${durationFrames}</Duration>`);
|
|
78
|
+
gen = gen.replace(/<PrettyType>[\s\S]*?<\/PrettyType>/, () => `<PrettyType>${escapeXml(generatorName)}</PrettyType>`);
|
|
79
|
+
gen = gen.replace(/<Name>[\s\S]*?<\/Name>/, () => `<Name>${escapeXml(generatorName)}</Name>`);
|
|
80
|
+
gen = gen.replace(/<Start>\d+<\/Start>/, () => `<Start>${startFrame}</Start>`);
|
|
81
|
+
gen = gen.replace(/<Duration>\d+<\/Duration>/, () => `<Duration>${durationFrames}</Duration>`);
|
|
82
82
|
const colorNorm = color != null ? normalizeColor(color) : null;
|
|
83
83
|
if (colorNorm) {
|
|
84
84
|
const blob = solidColorEffectBlob(colorNorm);
|
|
85
85
|
if (!/<EffectFiltersBA\s*\/>|<EffectFiltersBA>\s*<\/EffectFiltersBA>/.test(gen)) {
|
|
86
86
|
throw new Error('placeGenerator: template carries no empty <EffectFiltersBA/> to populate with the colour');
|
|
87
87
|
}
|
|
88
|
-
gen = gen.replace(/<EffectFiltersBA\s*\/>|<EffectFiltersBA>\s*<\/EffectFiltersBA>/, `<EffectFiltersBA>${blob}</EffectFiltersBA>`);
|
|
88
|
+
gen = gen.replace(/<EffectFiltersBA\s*\/>|<EffectFiltersBA>\s*<\/EffectFiltersBA>/, () => `<EffectFiltersBA>${blob}</EffectFiltersBA>`);
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
const { match: vtv, tracks } = getTrackVec(seqXml, 'video');
|
|
@@ -94,9 +94,9 @@ async function placeSubtitles(drpInput, opts = {}) {
|
|
|
94
94
|
' <UserDefinedName/>\n <LayersVec/>\n </Sm2TiTrack>\n </Element>\n ';
|
|
95
95
|
let xml;
|
|
96
96
|
if (/<SubtitleTrackVec\/>/.test(seqXml)) {
|
|
97
|
-
xml = seqXml.replace('<SubtitleTrackVec/>', `<SubtitleTrackVec>${track}</SubtitleTrackVec>`);
|
|
97
|
+
xml = seqXml.replace('<SubtitleTrackVec/>', () => `<SubtitleTrackVec>${track}</SubtitleTrackVec>`);
|
|
98
98
|
} else if (/<SubtitleTrackVec>/.test(seqXml)) {
|
|
99
|
-
xml = seqXml.replace('</SubtitleTrackVec>', `${track}</SubtitleTrackVec>`);
|
|
99
|
+
xml = seqXml.replace('</SubtitleTrackVec>', () => `${track}</SubtitleTrackVec>`);
|
|
100
100
|
} else {
|
|
101
101
|
throw new Error('placeSubtitles: timeline has no SubtitleTrackVec');
|
|
102
102
|
}
|
|
@@ -115,9 +115,9 @@ async function placeTransition(drpInput, opts = {}) {
|
|
|
115
115
|
let trans = fs.readFileSync(trackType === 'audio' ? AUDIO_TEMPLATE_PATH : TEMPLATE_PATH, 'utf8').trim();
|
|
116
116
|
trans = freshDbIds(trans);
|
|
117
117
|
if (type === 'wipe') {
|
|
118
|
-
trans = trans.replace(/<FieldsBlob>[0-9a-fA-F]*<\/FieldsBlob>/, `<FieldsBlob>${WIPE_FIELDS_BLOB}</FieldsBlob>`);
|
|
118
|
+
trans = trans.replace(/<FieldsBlob>[0-9a-fA-F]*<\/FieldsBlob>/, () => `<FieldsBlob>${WIPE_FIELDS_BLOB}</FieldsBlob>`);
|
|
119
119
|
} else if (STYLE_PRETTY_TYPES[type]) {
|
|
120
|
-
trans = trans.replace(/<PrettyType>Cross Dissolve<\/PrettyType>/, `<PrettyType>${STYLE_PRETTY_TYPES[type]}</PrettyType>`);
|
|
120
|
+
trans = trans.replace(/<PrettyType>Cross Dissolve<\/PrettyType>/, () => `<PrettyType>${STYLE_PRETTY_TYPES[type]}</PrettyType>`);
|
|
121
121
|
}
|
|
122
122
|
// Span geometry follows <Start>/<Duration>, NOT AlignmentType (the E61
|
|
123
123
|
// harvest of Resolve's own EDL import carries AlignmentType 2 with the
|
|
@@ -132,12 +132,12 @@ async function placeTransition(drpInput, opts = {}) {
|
|
|
132
132
|
}
|
|
133
133
|
}
|
|
134
134
|
const start = spanStart !== undefined ? spanStart : atFrame - Math.floor(durationFrames / 2);
|
|
135
|
-
trans = trans.replace(/<Start>\d+<\/Start>/, `<Start>${start}</Start>`);
|
|
136
|
-
trans = trans.replace(/<Duration>\d+<\/Duration>/, `<Duration>${durationFrames}</Duration>`);
|
|
135
|
+
trans = trans.replace(/<Start>\d+<\/Start>/, () => `<Start>${start}</Start>`);
|
|
136
|
+
trans = trans.replace(/<Duration>\d+<\/Duration>/, () => `<Duration>${durationFrames}</Duration>`);
|
|
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);
|
|
@@ -89,9 +89,9 @@ function getItemsInner(trackElement) {
|
|
|
89
89
|
function setItemsInner(trackElement, inner) {
|
|
90
90
|
const body = inner && inner.trim() ? inner : null;
|
|
91
91
|
if (/<Items\s*\/>/.test(trackElement)) {
|
|
92
|
-
return body ? trackElement.replace(/<Items\s*\/>/, `<Items>${body}</Items>`) : trackElement;
|
|
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) {
|
|
@@ -37,10 +37,10 @@ function clipDuration(clipXml) {
|
|
|
37
37
|
return m ? parseInt(m[1], 10) : null;
|
|
38
38
|
}
|
|
39
39
|
function setClipStart(clipXml, v) {
|
|
40
|
-
return clipXml.replace(/<Start>\d+<\/Start>/, `<Start>${v}</Start>`);
|
|
40
|
+
return clipXml.replace(/<Start>\d+<\/Start>/, () => `<Start>${v}</Start>`);
|
|
41
41
|
}
|
|
42
42
|
function setClipDuration(clipXml, v) {
|
|
43
|
-
return clipXml.replace(/<Duration>\d+<\/Duration>/, `<Duration>${v}</Duration>`);
|
|
43
|
+
return clipXml.replace(/<Duration>\d+<\/Duration>/, () => `<Duration>${v}</Duration>`);
|
|
44
44
|
}
|
|
45
45
|
// The source IN-point lives in <In> as `<framePos>|<LE-double of framePos*0.001>`, where framePos
|
|
46
46
|
// is in TIMELINE frames (verified live: Resolve appended a clip with source-in 600 media-frames @29.97
|
|
@@ -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
|
}
|
|
@@ -143,7 +143,7 @@ async function moveClip(drpInput, opts = {}) {
|
|
|
143
143
|
// Optionally retime.
|
|
144
144
|
if (toStart !== undefined && toStart !== null) {
|
|
145
145
|
if (!Number.isInteger(toStart)) throw new TypeError('moveClip: toStart must be an integer');
|
|
146
|
-
clip = clip.replace(/<Start>\d+<\/Start>/, `<Start>${toStart}</Start>`);
|
|
146
|
+
clip = clip.replace(/<Start>\d+<\/Start>/, () => `<Start>${toStart}</Start>`);
|
|
147
147
|
}
|
|
148
148
|
|
|
149
149
|
// Grow to the destination track with empty clones, then insert.
|
|
@@ -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()}")
|
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.
|
|
14
|
+
VERSION = "4.18.2"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -13357,7 +13357,9 @@ def _media_analysis_replace_owned_block(existing: Any, body: Any) -> str:
|
|
|
13357
13357
|
+ re.escape(_MCP_METADATA_BLOCK_END)
|
|
13358
13358
|
)
|
|
13359
13359
|
if re.search(pattern, existing_text, flags=re.DOTALL):
|
|
13360
|
-
|
|
13360
|
+
# A function replacement inserts the block literally: as a string, a
|
|
13361
|
+
# backslash in the analysis text (a Windows path) is read as an escape.
|
|
13362
|
+
return re.sub(pattern, lambda _match: block, existing_text, flags=re.DOTALL).strip()
|
|
13361
13363
|
return f"{existing_text}\n\n{block}".strip() if existing_text else block
|
|
13362
13364
|
|
|
13363
13365
|
|