davinci-resolve-mcp 2.119.0 → 2.121.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/install.py +1 -1
- package/package.json +1 -1
- package/resolve-advanced/server/author-interchange.mjs +16 -2
- package/resolve-advanced/server/editorial.mjs +26 -1
- package/resolve-advanced/vendor/drp-format/assemble-timeline.js +13 -8
- package/resolve-advanced/vendor/drp-format/cut-media.js +34 -14
- package/resolve-advanced/vendor/drp-format/seq-container-builder.js +9 -3
- package/src/granular/common.py +1 -1
- package/src/server.py +19 -1
package/install.py
CHANGED
|
@@ -37,7 +37,7 @@ from src.utils.update_check import (
|
|
|
37
37
|
|
|
38
38
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
39
39
|
|
|
40
|
-
VERSION = "2.
|
|
40
|
+
VERSION = "2.121.0"
|
|
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
|
@@ -231,9 +231,22 @@ export function eventsToAssembleSpec(events, opts = {}) {
|
|
|
231
231
|
const isAudio = (t) => /^A\d*$/.test(String(t || ''));
|
|
232
232
|
const isMarker = (t) => t === 'MARKER';
|
|
233
233
|
const vids = events.filter((e) => !isAudio(e.track) && !isMarker(e.track) && e.recIn != null && e.recOut != null);
|
|
234
|
-
|
|
234
|
+
// AAF exports one event per audio CHANNEL — a stereo/dual-mono clip arrives
|
|
235
|
+
// as identical A-track legs (measured on a Resolve 19 rich export: every
|
|
236
|
+
// audio event duplicated). Merge exact duplicates (same track/source/range)
|
|
237
|
+
// so they place once instead of refusing as a same-track overlap.
|
|
238
|
+
const audsRaw = events.filter((e) => isAudio(e.track) && e.recIn != null && e.recOut != null);
|
|
239
|
+
const seenAud = new Set();
|
|
240
|
+
const auds = [];
|
|
241
|
+
let audioChannelLegsMerged = 0;
|
|
242
|
+
for (const e of audsRaw) {
|
|
243
|
+
const k = `${e.track}|${e.source}|${e.recIn}|${e.recOut}|${e.srcIn}`;
|
|
244
|
+
if (seenAud.has(k)) { audioChannelLegsMerged += 1; continue; }
|
|
245
|
+
seenAud.add(k);
|
|
246
|
+
auds.push(e);
|
|
247
|
+
}
|
|
235
248
|
const markerEvents = events.filter((e) => isMarker(e.track) && e.recIn != null);
|
|
236
|
-
const audioSkipped = events.length - vids.length -
|
|
249
|
+
const audioSkipped = events.length - vids.length - audsRaw.length - markerEvents.length;
|
|
237
250
|
if (!vids.length) throw new Error('eventsToAssembleSpec: no video events with record ranges');
|
|
238
251
|
|
|
239
252
|
const unmapped = [...new Set([...vids, ...auds].map((e) => e.source).filter((srcName) => !sourceMap[srcName]))];
|
|
@@ -453,6 +466,7 @@ export function eventsToAssembleSpec(events, opts = {}) {
|
|
|
453
466
|
sources: media.length,
|
|
454
467
|
audioEventsSkipped: audioSkipped,
|
|
455
468
|
authoredMarkers: markers.length,
|
|
469
|
+
audioChannelLegsMerged,
|
|
456
470
|
authoredAudioEvents: audioPlacements.length,
|
|
457
471
|
audioRetimesSkipped,
|
|
458
472
|
upperTrackCutsVideoOnly: placements.filter((pl) => pl.track > 1).length,
|
|
@@ -141,6 +141,16 @@ export function parseOTIO(otio, opts = {}) {
|
|
|
141
141
|
// First track of each kind keeps the bare letter (compat); higher tracks
|
|
142
142
|
// are numbered ('V2', 'A2', …).
|
|
143
143
|
const kind = isAudio ? (aNum === 1 ? 'A' : `A${aNum}`) : (vNum === 1 ? 'V' : `V${vNum}`);
|
|
144
|
+
// Track-level markers: marked_range is already in track (record) time.
|
|
145
|
+
for (const mk of track.markers || []) {
|
|
146
|
+
const mrStart = (mk.marked_range && mk.marked_range.start_time && mk.marked_range.start_time.value) || 0;
|
|
147
|
+
const mrRate = (mk.marked_range && mk.marked_range.start_time && mk.marked_range.start_time.rate) || opts.fps || 24;
|
|
148
|
+
events.push(evt({
|
|
149
|
+
index: idx++, track: 'MARKER', source: '',
|
|
150
|
+
recIn: mrStart, recOut: null,
|
|
151
|
+
name: mk.name || undefined, color: mk.color || undefined, fps: mrRate,
|
|
152
|
+
}));
|
|
153
|
+
}
|
|
144
154
|
let rec = 0;
|
|
145
155
|
for (const child of track.children || []) {
|
|
146
156
|
const schema = child.OTIO_SCHEMA || '';
|
|
@@ -429,5 +439,20 @@ export function markerRoundtrip(markers, opts = {}) {
|
|
|
429
439
|
throw new Error(`marker_roundtrip: ${markers.length} in, ${decoded.length} out — round-trip dropped markers`);
|
|
430
440
|
const provenanceOk = decoded.every((m) => typeof m.provenance === 'string' && m.provenance.length);
|
|
431
441
|
if (markers.length && !provenanceOk) throw new Error('marker_roundtrip: a marker lost its provenance tag');
|
|
432
|
-
|
|
442
|
+
// Binary round-trip through the REAL Sm2SequenceLockableBlob codec
|
|
443
|
+
// (byte-exact vs a live export): provenance rides in customData, so an
|
|
444
|
+
// authored .drt carries it. Colors outside the measured 16 refuse there —
|
|
445
|
+
// markerRoundtrip normalizes to 'Blue' above, so this cannot throw for
|
|
446
|
+
// valid input; if it ever does, that IS the failed round-trip.
|
|
447
|
+
let blobRoundTrip = 'skipped (no markers)';
|
|
448
|
+
if (normalized.length) {
|
|
449
|
+
const { encodeTimelineMarkersBlob, decodeTimelineMarkersBlob } = require('../vendor/drp-format/timeline-markers-blob.js');
|
|
450
|
+
const back = decodeTimelineMarkersBlob(encodeTimelineMarkersBlob(normalized.map((m) => ({
|
|
451
|
+
frame: m.frame, color: m.color, name: m.name, note: m.note, customData: m.provenance,
|
|
452
|
+
}))));
|
|
453
|
+
if (back.length !== normalized.length) throw new Error(`marker_roundtrip: blob codec ${normalized.length} in, ${back.length} out`);
|
|
454
|
+
if (!back.every((m) => m.customData && m.customData.length)) throw new Error('marker_roundtrip: provenance lost in the blob codec');
|
|
455
|
+
blobRoundTrip = 'ok';
|
|
456
|
+
}
|
|
457
|
+
return { count: decoded.length, markers: decoded, provenanceOk, roundTrip: 'ok', blobRoundTrip };
|
|
433
458
|
}
|
|
@@ -122,14 +122,19 @@ async function assembleTimeline(spec = {}) {
|
|
|
122
122
|
sources.forEach((src, i) => {
|
|
123
123
|
(src.cuts || []).forEach((cut) => {
|
|
124
124
|
const out = mediaRefs[i] ? { ...cut, mediaRef: mediaRefs[i] } : { ...cut };
|
|
125
|
-
if (
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
125
|
+
if (caches[i] && caches[i].mediaStartTime) out.mediaStartTime = caches[i].mediaStartTime;
|
|
126
|
+
out.srcMeta = {
|
|
127
|
+
name: src.mediaFilePath.split('/').pop(),
|
|
128
|
+
mediaFilePath: src.mediaFilePath,
|
|
129
|
+
fps: Math.round(src.spec.fps || 24),
|
|
130
|
+
frameCount: src.spec.frameCount,
|
|
131
|
+
};
|
|
132
|
+
// Prefer cloning the source's own CAPTURED timeline clip: it carries
|
|
133
|
+
// every native identity field (FieldsBlob, rate, timemap, TC base)
|
|
134
|
+
// at once. Donor-clone + field rewrites is the fallback for caches
|
|
135
|
+
// captured before clip elements were harvested.
|
|
136
|
+
if (caches[i] && caches[i].videoClipElement) out.donorClipVideo = caches[i].videoClipElement;
|
|
137
|
+
if (caches[i] && caches[i].audioClipElement) out.donorClipAudio = caches[i].audioClipElement;
|
|
133
138
|
if (cut.reverse || (cut.speed !== undefined && cut.speed !== 1)) {
|
|
134
139
|
// Constant-speed retime (forward only). The Sm2TimeMap spans the
|
|
135
140
|
// whole source stretched by 1/speed; the clip windows into it with
|
|
@@ -73,8 +73,19 @@ async function cutSourceIntoClips(drpInput, opts = {}) {
|
|
|
73
73
|
let xml = seqXml;
|
|
74
74
|
const clipDbIds = [];
|
|
75
75
|
|
|
76
|
-
const cloneCut = (donor, cut) => {
|
|
77
|
-
|
|
76
|
+
const cloneCut = (donor, cut, kind) => {
|
|
77
|
+
// Prefer the source's own CAPTURED clip of the matching kind — the A1
|
|
78
|
+
// mirror clones AUDIO even for plain video cuts, so the donor must be
|
|
79
|
+
// chosen per track type, never per cut flavor (a video element in an
|
|
80
|
+
// audio track aborts the whole import, measured E31e).
|
|
81
|
+
// NATIVE-DONOR PATH (live verification PENDING — a stuck Resolve modal
|
|
82
|
+
// ended the E31 session before any import of this path could be
|
|
83
|
+
// measured cleanly; only caches that carry videoClipElement /
|
|
84
|
+
// audioClipElement reach it, so ordinary captures keep the proven donor
|
|
85
|
+
// path). Donor clones from splitClipElements come <Element>-wrapped and
|
|
86
|
+
// Items concatenation depends on the wrapper.
|
|
87
|
+
const native = kind === 'audio' ? cut.donorClipAudio : cut.donorClipVideo;
|
|
88
|
+
let c = freshDbIds(native ? native.trim() : donor);
|
|
78
89
|
c = setClipStart(c, cut.startFrame);
|
|
79
90
|
c = setClipDuration(c, cut.durationFrames);
|
|
80
91
|
c = setClipIn(c, cut.srcIn ?? 0);
|
|
@@ -83,13 +94,13 @@ async function cutSourceIntoClips(drpInput, opts = {}) {
|
|
|
83
94
|
// element instead of the donor's.
|
|
84
95
|
c = c.replace(/<MediaRef>[0-9a-f-]{36}<\/MediaRef>/, `<MediaRef>${cut.mediaRef}</MediaRef>`);
|
|
85
96
|
}
|
|
86
|
-
if (cut.
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
97
|
+
if (cut.srcMeta && !native && cut.audioOnly) {
|
|
98
|
+
// Every clone must carry ITS OWN source identity — a donor clone
|
|
99
|
+
// keeping stale Name/MediaFilePath/MediaFrameRate (the template
|
|
100
|
+
// donor's was 29.97!) or the donor's identity-timemap extent imports
|
|
101
|
+
// and reads back fine but fails at render: audio renders SILENT off
|
|
102
|
+
// A1 (measured, E15), and a TC-bearing video source fails the whole
|
|
103
|
+
// render with "Full resolution media not found" (measured, E31).
|
|
93
104
|
const { name, mediaFilePath, fps, frameCount } = cut.srcMeta;
|
|
94
105
|
c = c.replace(/<Name>[\s\S]*?<\/Name>/, `<Name>${name}</Name>`);
|
|
95
106
|
c = c.replace(/<MediaFilePath>[\s\S]*?<\/MediaFilePath>/, `<MediaFilePath>${mediaFilePath}</MediaFilePath>`);
|
|
@@ -100,8 +111,17 @@ async function cutSourceIntoClips(drpInput, opts = {}) {
|
|
|
100
111
|
idm.writeUInt8(0x02, 0);
|
|
101
112
|
idm.writeDoubleBE((frameCount - 1) / fps, 1);
|
|
102
113
|
c = c.replace(/<MediaTimemapBA>[0-9a-fA-F]*<\/MediaTimemapBA>/, `<MediaTimemapBA>${idm.toString('hex')}</MediaTimemapBA>`);
|
|
103
|
-
|
|
104
|
-
|
|
114
|
+
if (cut.audioOnly) {
|
|
115
|
+
// Generic audio-clip FieldsBlob, verbatim from the live A2 harvest.
|
|
116
|
+
// (Video clones keep their blob — repointClipBlobsInXml owns it.)
|
|
117
|
+
c = c.replace(/<FieldsBlob>[0-9a-fA-F]*<\/FieldsBlob>/, '<FieldsBlob>0000000200000005800a022001</FieldsBlob>');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (cut.mediaStartTime !== undefined && cut.mediaStartTime !== null && !native) {
|
|
121
|
+
// Embedded source timecode base, in SECONDS (harvested with the media
|
|
122
|
+
// template). Donor default 0 makes Resolve seek the wrong TC and fail
|
|
123
|
+
// the render with "Full resolution media not found at <TC>".
|
|
124
|
+
c = c.replace(/<MediaStartTime>[\s\S]*?<\/MediaStartTime>/, `<MediaStartTime>${cut.mediaStartTime}</MediaStartTime>`);
|
|
105
125
|
}
|
|
106
126
|
if (cut.timemap) {
|
|
107
127
|
// Constant-speed retime: swap the identity MediaTimemapBA for the
|
|
@@ -140,13 +160,13 @@ async function cutSourceIntoClips(drpInput, opts = {}) {
|
|
|
140
160
|
for (let t = 1; t <= tracks.length; t += 1) {
|
|
141
161
|
const mine = audioCuts.filter((cut) => (cut.track ?? 1) === t);
|
|
142
162
|
if (t > 1 && !mine.length) continue;
|
|
143
|
-
tracks[t - 1] = setItemsInner(tracks[t - 1], mine.map((cut) => cloneCut(donor, cut)).join(''));
|
|
163
|
+
tracks[t - 1] = setItemsInner(tracks[t - 1], mine.map((cut) => cloneCut(donor, cut, 'audio')).join(''));
|
|
144
164
|
}
|
|
145
165
|
} else {
|
|
146
166
|
// A1 mirrors track-1 video cuts only; higher video tracks stay
|
|
147
167
|
// video-only, and so do RETIMED cuts (the audio clone would need its
|
|
148
168
|
// own timemap and pitch handling — video-only is stated, not silent).
|
|
149
|
-
const a1 = cuts.filter((cut) => (cut.track ?? 1) === 1 && !cut.timemap).map((cut) => cloneCut(donor, cut));
|
|
169
|
+
const a1 = cuts.filter((cut) => (cut.track ?? 1) === 1 && !cut.timemap).map((cut) => cloneCut(donor, cut, 'audio'));
|
|
150
170
|
tracks[0] = setItemsInner(tracks[0], a1.join(''));
|
|
151
171
|
}
|
|
152
172
|
xml = replaceTrackVec(xml, trackType, match, tracks);
|
|
@@ -159,7 +179,7 @@ async function cutSourceIntoClips(drpInput, opts = {}) {
|
|
|
159
179
|
for (let t = 1; t <= tracks.length; t += 1) {
|
|
160
180
|
const mine = vCuts.filter((cut) => (cut.track ?? 1) === t);
|
|
161
181
|
if (t > 1 && !mine.length) continue; // grown-empty or untouched track keeps its items
|
|
162
|
-
const clones = mine.map((cut) => cloneCut(donor, cut));
|
|
182
|
+
const clones = mine.map((cut) => cloneCut(donor, cut, 'video'));
|
|
163
183
|
for (const c of clones) clipDbIds.push(clipDbId(c));
|
|
164
184
|
tracks[t - 1] = setItemsInner(tracks[t - 1], clones.join(''));
|
|
165
185
|
}
|
|
@@ -338,9 +338,15 @@ function buildLockableBlobElement(markers, blobId, frameRate) {
|
|
|
338
338
|
]);
|
|
339
339
|
}
|
|
340
340
|
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
|
|
341
|
+
// Real Sm2SequenceLockableBlob encoding (byte-exact vs a live 19.1.3.7
|
|
342
|
+
// export; see timeline-markers-blob.js) — replaces the old simplified
|
|
343
|
+
// marker-encoder blob, whose bytes never matched a real export.
|
|
344
|
+
const { encodeTimelineMarkersBlob } = require('./timeline-markers-blob');
|
|
345
|
+
const fieldsBlobHex = encodeTimelineMarkersBlob(markers.map((m) => ({
|
|
346
|
+
frame: Number(m.frame) || 0,
|
|
347
|
+
color: m.color, name: m.name, note: m.note ?? m.description,
|
|
348
|
+
duration: m.duration, customData: m.customData,
|
|
349
|
+
}))).toString('hex');
|
|
344
350
|
|
|
345
351
|
return buildXmlElement('Sm2SequenceLockableBlob', { DbId: blobId }, [
|
|
346
352
|
buildXmlElement('FieldsBlob', {}, fieldsBlobHex),
|
package/src/granular/common.py
CHANGED
|
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
|
|
|
87
87
|
handlers=[logging.StreamHandler()],
|
|
88
88
|
)
|
|
89
89
|
|
|
90
|
-
VERSION = "2.
|
|
90
|
+
VERSION = "2.121.0"
|
|
91
91
|
logger = logging.getLogger("davinci-resolve-mcp")
|
|
92
92
|
logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
|
|
93
93
|
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 353-tool granular server instead
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
|
-
VERSION = "2.
|
|
14
|
+
VERSION = "2.121.0"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -13879,6 +13879,21 @@ def _capture_media_template(r, pm, p: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
13879
13879
|
media_ref_m = re.search(r"<MediaRef>([0-9a-f-]{36})</MediaRef>", seq_xml)
|
|
13880
13880
|
if end < 0 or not media_ref_m:
|
|
13881
13881
|
return _err("could not isolate the media element / MediaRef from the capture")
|
|
13882
|
+
# Embedded source timecode: Resolve's own timeline clip stores it as
|
|
13883
|
+
# <MediaStartTime> SECONDS. A transplant clone keeping the donor's 0
|
|
13884
|
+
# renders "Full resolution media not found at <TC>" (measured on a
|
|
13885
|
+
# 01:00:00:00-TC source). Carry it so assemble can set it per cut.
|
|
13886
|
+
mst_m = re.search(r"<MediaStartTime>([-0-9.eE]+)</MediaStartTime>", seq_xml)
|
|
13887
|
+
media_start_time = float(mst_m.group(1)) if mst_m else 0.0
|
|
13888
|
+
# Harvest the NATIVE timeline clip elements too: for multi-source
|
|
13889
|
+
# authoring, cloning the template donor leaves the donor's identity
|
|
13890
|
+
# FieldsBlob on other sources' cuts — readback-fine, but the render
|
|
13891
|
+
# fails ("Full resolution media not found") or, once Name/path are
|
|
13892
|
+
# corrected, the whole import aborts on the inconsistency (measured,
|
|
13893
|
+
# E31). Cloning the source's own captured clip carries every native
|
|
13894
|
+
# field at once.
|
|
13895
|
+
vclip_m = re.search(r"<Element>\s*<Sm2TiVideoClip[\s\S]*?</Sm2TiVideoClip>\s*</Element>", seq_xml)
|
|
13896
|
+
aclip_m = re.search(r"<Element>\s*<Sm2TiAudioClip[\s\S]*?</Sm2TiAudioClip>\s*</Element>", seq_xml)
|
|
13882
13897
|
pool_element = mp_xml[start:end]
|
|
13883
13898
|
media_ref = media_ref_m.group(1)
|
|
13884
13899
|
if media_ref not in pool_element:
|
|
@@ -13892,7 +13907,10 @@ def _capture_media_template(r, pm, p: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
13892
13907
|
"mtimeMs": stat.st_mtime * 1000.0,
|
|
13893
13908
|
"sizeBytes": stat.st_size,
|
|
13894
13909
|
"mediaRef": media_ref,
|
|
13910
|
+
"mediaStartTime": media_start_time,
|
|
13895
13911
|
"poolElement": pool_element,
|
|
13912
|
+
"videoClipElement": vclip_m.group(0) if vclip_m else None,
|
|
13913
|
+
"audioClipElement": aclip_m.group(0) if aclip_m else None,
|
|
13896
13914
|
}
|
|
13897
13915
|
with open(cache_path, "w", encoding="utf-8") as fh:
|
|
13898
13916
|
json.dump(payload, fh)
|