davinci-resolve-mcp 2.73.1 → 2.74.0
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 +53 -0
- package/README.md +1 -1
- package/install.py +1 -1
- package/package.json +1 -1
- package/resolve-advanced/vendor/drp-format/__tests__/set-framerate.test.js +62 -0
- package/resolve-advanced/vendor/drp-format/index.js +4 -0
- package/resolve-advanced/vendor/drp-format/set-framerate.js +97 -0
- package/resolve-advanced/vendor/drx-parameters/index.js +1 -1
- package/src/granular/common.py +1 -1
- package/src/server.py +45 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,59 @@
|
|
|
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 v2.74.0
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`drp-format/set-framerate` — relabel a `.drp` timeline frame rate in place.**
|
|
10
|
+
`setTimelineFrameRate(drpInput, targetFps)` rewrites the timeline
|
|
11
|
+
`<FrameRate>` blob(s) to a new fps while leaving every clip's integer
|
|
12
|
+
Start/Duration/In/Out and every clip-level `<MediaFrameRate>` untouched — a
|
|
13
|
+
relabel, not a retime. Use it to fix a contaminated rate tag (e.g. an export
|
|
14
|
+
step that stamped 23.976 onto a 24.000 timeline whose frames are correct).
|
|
15
|
+
Offline-only by necessity: Resolve locks a timeline's frame rate once the
|
|
16
|
+
timeline exists, so an imported `.drp` can never be relabelled through
|
|
17
|
+
Resolve itself. `readTimelineFrameRates(drpInput)` reports the current
|
|
18
|
+
rate(s) without modifying anything. Both are exported from the `drp-format`
|
|
19
|
+
index; five node:test cases cover relabel, `MediaFrameRate` isolation,
|
|
20
|
+
idempotence, and input validation.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- Corrected a garbled doc comment in `drx-parameters/index.js`.
|
|
25
|
+
|
|
26
|
+
## What's New in v2.73.2
|
|
27
|
+
|
|
28
|
+
Two honesty fixes in the conform path, both found by running a real 83-minute
|
|
29
|
+
Avid AAF turnover end to end. Neither adds tool surface; `detect_missing_media`
|
|
30
|
+
gains two additive response fields.
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- **`detect_missing_media` counted an unknown item as a present one.** A timeline
|
|
35
|
+
item with no media pool item has no file path and no offline marker, so it fell
|
|
36
|
+
through to `present` — absence of information reported as presence. An AAF
|
|
37
|
+
imported with `importSourceClips=false` yields 882 such items, and the probe
|
|
38
|
+
answered `present_count: 882, missing_count: 0` while every one of them returned
|
|
39
|
+
`None` from `GetMediaPoolItem()`. The payload contradicted itself: the diagnosis
|
|
40
|
+
already said `unique_media_pool_item_count: 0`.
|
|
41
|
+
|
|
42
|
+
Those items now report under `unlinked` / `unlinked_count` and never inflate
|
|
43
|
+
`present_count`. They are deliberately **not** folded into `missing`: those rows
|
|
44
|
+
drive relink plans keyed on `media_pool_item_id`, and there is no pool item here
|
|
45
|
+
to relink. When a timeline is entirely unlinked the diagnosis says so, instead of
|
|
46
|
+
"No offline media detected" — technically true and completely misleading.
|
|
47
|
+
|
|
48
|
+
- **The "Resolve created no timeline" remediation named the wrong fix.** It advised
|
|
49
|
+
converting the file to FCP7 XML / FCPXML. The far more common cause is that
|
|
50
|
+
`importSourceClips` defaults to `True`, so Resolve tries to pull in the sequence's
|
|
51
|
+
source clips and fails the entire import when those paths do not resolve — the
|
|
52
|
+
normal state of a turnover, whose paths belong to the offline editor.
|
|
53
|
+
`import_source_clips=false` lands the timeline offline and now leads the
|
|
54
|
+
remediation. Note `sourceClipsPath` does not rescue it: Resolve matches source
|
|
55
|
+
clips by the filenames recorded in the sequence, so an AAF referencing Avid MXF
|
|
56
|
+
finds nothing in a folder of differently-named finishing media.
|
|
57
|
+
|
|
5
58
|
## What's New in v2.73.1
|
|
6
59
|
|
|
7
60
|
Packaging fix. The npm package shipped the AAF reader's Node half without its
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# DaVinci Resolve MCP Server
|
|
2
2
|
|
|
3
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
4
4
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
5
5
|
[](docs/reference/api-coverage.md)
|
|
6
6
|
[-blue.svg)](#server-modes)
|
package/install.py
CHANGED
|
@@ -36,7 +36,7 @@ from src.utils.update_check import (
|
|
|
36
36
|
|
|
37
37
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
38
38
|
|
|
39
|
-
VERSION = "2.
|
|
39
|
+
VERSION = "2.74.0"
|
|
40
40
|
# Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
|
|
41
41
|
# Resolve's scripting bridge loads into newer interpreters on recent builds
|
|
42
42
|
# (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
|
package/package.json
CHANGED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
const test = require('node:test');
|
|
2
|
+
const assert = require('node:assert');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const JSZip = require('jszip');
|
|
6
|
+
const { setTimelineFrameRate, readTimelineFrameRates } = require('../set-framerate');
|
|
7
|
+
|
|
8
|
+
// A real Resolve export with one media clip — carries a timeline <FrameRate> blob
|
|
9
|
+
// and a clip-level <MediaFrameRate> that a relabel must NOT touch.
|
|
10
|
+
const TEMPLATE = path.join(__dirname, '..', 'templates', 'media-clip-h264.drp');
|
|
11
|
+
|
|
12
|
+
async function countTag(buf, tag) {
|
|
13
|
+
const zip = await JSZip.loadAsync(buf);
|
|
14
|
+
let n = 0;
|
|
15
|
+
for (const name of Object.keys(zip.files)) {
|
|
16
|
+
if (!name.endsWith('.xml')) continue;
|
|
17
|
+
const xml = await zip.files[name].async('string');
|
|
18
|
+
n += (xml.match(new RegExp(`<${tag}>[0-9a-fA-F]{32}</${tag}>`, 'g')) || []).length;
|
|
19
|
+
}
|
|
20
|
+
return n;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test('readTimelineFrameRates reports the template timeline rate', async () => {
|
|
24
|
+
const rates = await readTimelineFrameRates(TEMPLATE);
|
|
25
|
+
assert.ok(rates.length > 0, 'expected at least one timeline FrameRate blob');
|
|
26
|
+
for (const r of rates) {
|
|
27
|
+
assert.ok(Number.isFinite(r.fps) && r.fps > 0, `decoded fps should be positive, got ${r.fps}`);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('setTimelineFrameRate relabels every timeline FrameRate blob', async () => {
|
|
32
|
+
const before = await readTimelineFrameRates(TEMPLATE);
|
|
33
|
+
const target = Math.abs(before[0].fps - 25) < 1e-6 ? 24 : 25;
|
|
34
|
+
|
|
35
|
+
const { buffer, changes, timelineFrameRates } = await setTimelineFrameRate(TEMPLATE, target);
|
|
36
|
+
assert.ok(changes.length > 0, 'a real rate change must be recorded');
|
|
37
|
+
assert.deepStrictEqual(timelineFrameRates, before.map(r => r.fps));
|
|
38
|
+
|
|
39
|
+
const after = await readTimelineFrameRates(buffer);
|
|
40
|
+
assert.strictEqual(after.length, before.length, 'blob count must not change');
|
|
41
|
+
for (const r of after) assert.ok(Math.abs(r.fps - target) < 1e-6, `expected ${target}, got ${r.fps}`);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('relabel leaves clip-level MediaFrameRate blobs untouched', async () => {
|
|
45
|
+
const original = fs.readFileSync(TEMPLATE);
|
|
46
|
+
const mediaBefore = await countTag(original, 'MediaFrameRate');
|
|
47
|
+
const { buffer } = await setTimelineFrameRate(original, 30);
|
|
48
|
+
const mediaAfter = await countTag(buffer, 'MediaFrameRate');
|
|
49
|
+
assert.strictEqual(mediaAfter, mediaBefore, 'MediaFrameRate must be left alone');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('relabel is idempotent: same target twice yields no recorded change the second time', async () => {
|
|
53
|
+
const first = await setTimelineFrameRate(TEMPLATE, 25);
|
|
54
|
+
const second = await setTimelineFrameRate(first.buffer, 25);
|
|
55
|
+
assert.strictEqual(second.changes.length, 0, 'second pass at same fps records no change');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('rejects invalid targets', async () => {
|
|
59
|
+
await assert.rejects(() => setTimelineFrameRate(TEMPLATE, 0), /positive finite number/);
|
|
60
|
+
await assert.rejects(() => setTimelineFrameRate(TEMPLATE, NaN), /positive finite number/);
|
|
61
|
+
await assert.rejects(() => setTimelineFrameRate(TEMPLATE, 'fast'), /positive finite number/);
|
|
62
|
+
});
|
|
@@ -153,6 +153,10 @@ module.exports = {
|
|
|
153
153
|
// Author a project with one media clip referencing an arbitrary h264 file, from scratch [P8].
|
|
154
154
|
addMediaClip: require('./author-project').addMediaClip,
|
|
155
155
|
|
|
156
|
+
// Relabel the timeline frame rate tag in place (not a retime — frame counts untouched).
|
|
157
|
+
setTimelineFrameRate: require('./set-framerate').setTimelineFrameRate,
|
|
158
|
+
readTimelineFrameRates: require('./set-framerate').readTimelineFrameRates,
|
|
159
|
+
|
|
156
160
|
// Offline media relink — repoint media to new paths in the Media Pool blobs (no Resolve).
|
|
157
161
|
relinkMedia: require('./relink-media').relinkMedia,
|
|
158
162
|
// Relink + fix cached specs (resolution/frames/fps) for a differently-formatted file [P8].
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* set-framerate — **relabel** a Resolve `.drp` timeline frame rate in place.
|
|
3
|
+
*
|
|
4
|
+
* This is a *relabel*, not a retime: it rewrites the timeline `FrameRate` blob(s) to a new fps
|
|
5
|
+
* while leaving every clip's integer Start/Duration/In/Out and every `MediaFrameRate` (clip
|
|
6
|
+
* *source* rate) untouched. The frame count is identical; only the rate the timeline is
|
|
7
|
+
* interpreted at changes. Use this to fix a contaminated tag — e.g. a caption/export step that
|
|
8
|
+
* stamped 23.976 onto a 24.000 timeline whose frames are actually correct.
|
|
9
|
+
*
|
|
10
|
+
* Why offline: Resolve locks a timeline's frame rate once the timeline exists (writable only when
|
|
11
|
+
* a project has 0 timelines), so an imported `.drp` can never be relabelled *through* Resolve —
|
|
12
|
+
* only retimed.
|
|
13
|
+
*
|
|
14
|
+
* Where fps lives in a real DRP (verified against templates/media-clip-h264.drp, a Resolve export):
|
|
15
|
+
* - timeline rate → `<FrameRate>[double fps][double 0]</FrameRate>` in MediaPool/<folder>/MpFolder.xml
|
|
16
|
+
* - clip source → `<MediaFrameRate>…</MediaFrameRate>` (LEFT ALONE — different tag)
|
|
17
|
+
* - project.xml / Gallery.xml carry no fps.
|
|
18
|
+
* The exact tag `<FrameRate>` never substring-matches `<MediaFrameRate>` (char before "FrameRate"
|
|
19
|
+
* is "a", not "<"), so a `<FrameRate>`-scoped rewrite is inherently clip-safe.
|
|
20
|
+
*
|
|
21
|
+
* @module drp-format/set-framerate
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const fs = require('node:fs');
|
|
25
|
+
const JSZip = require('jszip');
|
|
26
|
+
const { decodeRateBlob, encodeRateBlob } = require('./media-blobs');
|
|
27
|
+
|
|
28
|
+
// Exact timeline FrameRate blob element. `[^]` (not `.`) so a stray newline can't break the match.
|
|
29
|
+
const FRAMERATE_BLOB_RE = /<FrameRate>([0-9a-fA-F]{32})<\/FrameRate>/g;
|
|
30
|
+
|
|
31
|
+
async function loadZip(drpInput) {
|
|
32
|
+
const buf = Buffer.isBuffer(drpInput) ? drpInput : await fs.promises.readFile(drpInput);
|
|
33
|
+
return JSZip.loadAsync(buf);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Relabel every timeline FrameRate blob in a `.drp` to `targetFps`.
|
|
38
|
+
*
|
|
39
|
+
* @param {string|Buffer} drpInput Path to a `.drp` (or its Buffer).
|
|
40
|
+
* @param {number} targetFps New timeline fps (e.g. 24, 23.976, 25).
|
|
41
|
+
* @returns {Promise<{ buffer: Buffer, changes: Array<{entry:string, from:number, to:number}>, timelineFrameRates:number[] }>}
|
|
42
|
+
* @throws if the target is not a finite positive number or no timeline FrameRate blob is found.
|
|
43
|
+
*/
|
|
44
|
+
async function setTimelineFrameRate(drpInput, targetFps) {
|
|
45
|
+
if (typeof targetFps !== 'number' || !Number.isFinite(targetFps) || targetFps <= 0) {
|
|
46
|
+
throw new Error(`set-framerate: targetFps must be a positive finite number, got ${targetFps}`);
|
|
47
|
+
}
|
|
48
|
+
const zip = await loadZip(drpInput);
|
|
49
|
+
const newHex = encodeRateBlob(targetFps); // [double fps][double 0], 32 hex chars
|
|
50
|
+
|
|
51
|
+
const entries = [];
|
|
52
|
+
zip.forEach((p, e) => { if (!e.dir && /\.xml$/i.test(p)) entries.push(p); });
|
|
53
|
+
|
|
54
|
+
const changes = [];
|
|
55
|
+
const seenRates = [];
|
|
56
|
+
for (const entry of entries) {
|
|
57
|
+
const xml = await zip.file(entry).async('string');
|
|
58
|
+
if (!FRAMERATE_BLOB_RE.test(xml)) continue;
|
|
59
|
+
FRAMERATE_BLOB_RE.lastIndex = 0;
|
|
60
|
+
const next = xml.replace(FRAMERATE_BLOB_RE, (_m, hex) => {
|
|
61
|
+
const from = decodeRateBlob(hex);
|
|
62
|
+
if (from != null) seenRates.push(from);
|
|
63
|
+
// Round-trip guard: only record a real change; still rewrite so the blob's
|
|
64
|
+
// trailing 8 bytes are canonicalised to zero.
|
|
65
|
+
if (from == null || Math.abs(from - targetFps) > 1e-6) {
|
|
66
|
+
changes.push({ entry, from: from ?? NaN, to: targetFps });
|
|
67
|
+
}
|
|
68
|
+
return `<FrameRate>${newHex}</FrameRate>`;
|
|
69
|
+
});
|
|
70
|
+
zip.file(entry, next);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (seenRates.length === 0) {
|
|
74
|
+
throw new Error('set-framerate: no timeline <FrameRate> blob found — not a .drp with a timeline?');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const buffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
|
|
78
|
+
return { buffer, changes, timelineFrameRates: seenRates };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Read-only: report the timeline frame rate(s) in a `.drp` without modifying it. */
|
|
82
|
+
async function readTimelineFrameRates(drpInput) {
|
|
83
|
+
const zip = await loadZip(drpInput);
|
|
84
|
+
const rates = [];
|
|
85
|
+
const entries = [];
|
|
86
|
+
zip.forEach((p, e) => { if (!e.dir && /\.xml$/i.test(p)) entries.push(p); });
|
|
87
|
+
for (const entry of entries) {
|
|
88
|
+
const xml = await zip.file(entry).async('string');
|
|
89
|
+
for (const m of xml.matchAll(FRAMERATE_BLOB_RE)) {
|
|
90
|
+
const fps = decodeRateBlob(m[1]);
|
|
91
|
+
if (fps != null) rates.push({ entry, fps });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return rates;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = { setTimelineFrameRate, readTimelineFrameRates };
|
package/src/granular/common.py
CHANGED
|
@@ -85,7 +85,7 @@ if not logging.getLogger().handlers:
|
|
|
85
85
|
handlers=[logging.StreamHandler()],
|
|
86
86
|
)
|
|
87
87
|
|
|
88
|
-
VERSION = "2.
|
|
88
|
+
VERSION = "2.74.0"
|
|
89
89
|
logger = logging.getLogger("davinci-resolve-mcp")
|
|
90
90
|
logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
|
|
91
91
|
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 341-tool granular server instead
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
|
-
VERSION = "2.
|
|
14
|
+
VERSION = "2.74.0"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -6048,9 +6048,23 @@ def _import_timeline_checked(proj, mp, p: Dict[str, Any]):
|
|
|
6048
6048
|
imported = t
|
|
6049
6049
|
if not imported:
|
|
6050
6050
|
if is_binary:
|
|
6051
|
+
# importSourceClips defaults to True, so Resolve tries to pull in the
|
|
6052
|
+
# sequence's source clips during import — and when those paths do not
|
|
6053
|
+
# resolve it fails the whole import rather than creating an offline
|
|
6054
|
+
# timeline. That is the normal case for a turnover (the paths belong
|
|
6055
|
+
# to the offline editor), so it is by far the most common reason a
|
|
6056
|
+
# valid AAF "creates no timeline", and the flag fixes it. Naming a
|
|
6057
|
+
# format conversion first sent people down a much longer road than
|
|
6058
|
+
# the one-flag retry that actually works. Note sourceClipsPath does
|
|
6059
|
+
# NOT rescue it: Resolve matches source clips by the filenames
|
|
6060
|
+
# recorded in the sequence, so an AAF referencing Avid MXF finds
|
|
6061
|
+
# nothing in a folder of differently-named finishing media.
|
|
6051
6062
|
remediation = (
|
|
6052
|
-
f"Resolve created no timeline from this {ext}.
|
|
6053
|
-
"
|
|
6063
|
+
f"Resolve created no timeline from this {ext}. Most often the source clips "
|
|
6064
|
+
"could not be resolved: retry with import_source_clips=false to land the "
|
|
6065
|
+
"timeline offline, then add the media to the media pool and relink. "
|
|
6066
|
+
"Otherwise verify it exports/opens in Resolve directly, or convert to "
|
|
6067
|
+
"FCP7 XML / FCPXML upstream and import that."
|
|
6054
6068
|
)
|
|
6055
6069
|
elif sanitize:
|
|
6056
6070
|
remediation = None
|
|
@@ -6583,6 +6597,7 @@ def _missing_media_diagnosis(missing_rows: List[Dict[str, Any]]) -> Dict[str, An
|
|
|
6583
6597
|
def _detect_missing_media_from_snapshot(snapshot: Dict[str, Any]):
|
|
6584
6598
|
missing = []
|
|
6585
6599
|
present = []
|
|
6600
|
+
unlinked = []
|
|
6586
6601
|
for track_type, type_payload in (snapshot.get("tracks") or {}).items():
|
|
6587
6602
|
for track in type_payload.get("tracks", []):
|
|
6588
6603
|
for item in track.get("items", []):
|
|
@@ -6603,13 +6618,40 @@ def _detect_missing_media_from_snapshot(snapshot: Dict[str, Any]):
|
|
|
6603
6618
|
}
|
|
6604
6619
|
if is_missing:
|
|
6605
6620
|
missing.append(row)
|
|
6621
|
+
elif not file_path and not item.get("media_pool_item_id"):
|
|
6622
|
+
# No path AND no media pool item: the timeline item has nothing
|
|
6623
|
+
# behind it at all. It is not "present" — we simply know nothing
|
|
6624
|
+
# about it — and counting it as present is how an entirely
|
|
6625
|
+
# offline timeline reported full coverage (an AAF imported with
|
|
6626
|
+
# importSourceClips=false yields 882 such items and used to
|
|
6627
|
+
# report present_count 882 / missing_count 0, while every one of
|
|
6628
|
+
# them returned None from GetMediaPoolItem()).
|
|
6629
|
+
#
|
|
6630
|
+
# Kept out of `missing` deliberately: those rows drive relink
|
|
6631
|
+
# plans keyed on media_pool_item_id, and there is no pool item
|
|
6632
|
+
# here to relink. This is a third state, so it gets its own.
|
|
6633
|
+
unlinked.append(row)
|
|
6606
6634
|
else:
|
|
6607
6635
|
present.append(row)
|
|
6608
6636
|
diagnosis = _missing_media_diagnosis(missing)
|
|
6637
|
+
if unlinked and not missing:
|
|
6638
|
+
# Nothing to relink, so the generic "no offline media detected" advice
|
|
6639
|
+
# would send the caller away satisfied from a timeline with no media.
|
|
6640
|
+
diagnosis = dict(diagnosis)
|
|
6641
|
+
diagnosis["primary_cause"] = "no_media_pool_items"
|
|
6642
|
+
diagnosis["recommended_next_step"] = (
|
|
6643
|
+
f"{len(unlinked)} timeline items have no media pool item at all — the timeline was "
|
|
6644
|
+
"imported without its source clips. Add the media to the media pool, then relink; "
|
|
6645
|
+
"there is nothing here for a path-based relink to act on."
|
|
6646
|
+
)
|
|
6609
6647
|
return {
|
|
6610
6648
|
"missing": missing,
|
|
6611
6649
|
"present_count": len(present),
|
|
6612
6650
|
"missing_count": len(missing),
|
|
6651
|
+
# Timeline items with neither a file path nor a media pool item. Never
|
|
6652
|
+
# folded into present_count — see above.
|
|
6653
|
+
"unlinked": unlinked,
|
|
6654
|
+
"unlinked_count": len(unlinked),
|
|
6613
6655
|
"diagnosis": diagnosis,
|
|
6614
6656
|
}
|
|
6615
6657
|
|