davinci-resolve-mcp 2.118.0 → 2.120.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/docs/guides/native-drt-authoring.md +1 -0
- package/install.py +1 -1
- package/package.json +1 -1
- package/resolve-advanced/server/author-interchange.mjs +39 -4
- package/resolve-advanced/server/editorial.mjs +23 -0
- package/resolve-advanced/vendor/drp-format/assemble-timeline.js +13 -8
- package/resolve-advanced/vendor/drp-format/cut-media.js +34 -14
- package/src/granular/common.py +1 -1
- package/src/server.py +19 -1
|
@@ -49,6 +49,7 @@ window. `render.verify_output` covers the container-level checks.
|
|
|
49
49
|
| Built-in generators | `elements: [{type:'generator', generatorName}]` | v2.110 |
|
|
50
50
|
| Custom start timecode | `spec.startFrame` / `preserveStartTimecode` | v2.117 |
|
|
51
51
|
| Timeline markers | `spec.markers` (16 colors, notes, durations, customData) | v2.118 |
|
|
52
|
+
| Turnover markers | EDL `* LOC:` locators + OTIO markers → authored | v2.119 |
|
|
52
53
|
| Fusion titles | `elements: [{type:'title', text}]` — **21-gen hosts only** | v2.108 |
|
|
53
54
|
|
|
54
55
|
`assemble_from_interchange` drives the same engine from an EDL / OTIO /
|
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.120.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
|
@@ -229,9 +229,24 @@ export function eventsToAssembleSpec(events, opts = {}) {
|
|
|
229
229
|
}
|
|
230
230
|
const DEFAULT_ORIGIN = 86400;
|
|
231
231
|
const isAudio = (t) => /^A\d*$/.test(String(t || ''));
|
|
232
|
-
const
|
|
233
|
-
const
|
|
234
|
-
|
|
232
|
+
const isMarker = (t) => t === 'MARKER';
|
|
233
|
+
const vids = events.filter((e) => !isAudio(e.track) && !isMarker(e.track) && e.recIn != null && e.recOut != null);
|
|
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
|
+
}
|
|
248
|
+
const markerEvents = events.filter((e) => isMarker(e.track) && e.recIn != null);
|
|
249
|
+
const audioSkipped = events.length - vids.length - audsRaw.length - markerEvents.length;
|
|
235
250
|
if (!vids.length) throw new Error('eventsToAssembleSpec: no video events with record ranges');
|
|
236
251
|
|
|
237
252
|
const unmapped = [...new Set([...vids, ...auds].map((e) => e.source).filter((srcName) => !sourceMap[srcName]))];
|
|
@@ -426,12 +441,32 @@ export function eventsToAssembleSpec(events, opts = {}) {
|
|
|
426
441
|
transitions.push({ track: c.track, atFrame: c.atFrame, durationFrames: c.durationFrames, trackType: 'audio' });
|
|
427
442
|
}
|
|
428
443
|
|
|
444
|
+
// Turnover markers (EDL * LOC: locators, OTIO Marker objects) → authored
|
|
445
|
+
// timeline markers. Interchange colors map to the measured Resolve names;
|
|
446
|
+
// unknown colors fall back to Blue. Frames are timeline-absolute like cuts.
|
|
447
|
+
const COLOR_MAP = {
|
|
448
|
+
blue: 'Blue', cyan: 'Cyan', green: 'Green', yellow: 'Yellow', red: 'Red',
|
|
449
|
+
pink: 'Pink', purple: 'Purple', magenta: 'Fuchsia', fuchsia: 'Fuchsia',
|
|
450
|
+
rose: 'Rose', lavender: 'Lavender', sky: 'Sky', mint: 'Mint',
|
|
451
|
+
lemon: 'Lemon', sand: 'Sand', cocoa: 'Cocoa', cream: 'Cream',
|
|
452
|
+
orange: 'Sand', white: 'Cream', black: 'Cocoa',
|
|
453
|
+
};
|
|
454
|
+
const markers = markerEvents
|
|
455
|
+
.map((e) => ({
|
|
456
|
+
frame: ORIGIN + (toTl(e.recIn, e.fps) - minRec),
|
|
457
|
+
color: COLOR_MAP[String(e.color || '').toLowerCase()] || 'Blue',
|
|
458
|
+
...(e.name ? { name: e.name } : {}),
|
|
459
|
+
}))
|
|
460
|
+
.filter((m) => m.frame >= (preserveStartTimecode ? startFrame : DEFAULT_ORIGIN));
|
|
461
|
+
|
|
429
462
|
return {
|
|
430
|
-
spec: { timelineName, media, ...(preserveStartTimecode ? { startFrame } : {}), ...(transitions.length ? { transitions } : {}) },
|
|
463
|
+
spec: { timelineName, media, ...(preserveStartTimecode ? { startFrame } : {}), ...(markers.length ? { markers } : {}), ...(transitions.length ? { transitions } : {}) },
|
|
431
464
|
report: {
|
|
432
465
|
videoEvents: vids.length,
|
|
433
466
|
sources: media.length,
|
|
434
467
|
audioEventsSkipped: audioSkipped,
|
|
468
|
+
authoredMarkers: markers.length,
|
|
469
|
+
audioChannelLegsMerged,
|
|
435
470
|
authoredAudioEvents: audioPlacements.length,
|
|
436
471
|
audioRetimesSkipped,
|
|
437
472
|
upperTrackCutsVideoOnly: placements.filter((pl) => pl.track > 1).length,
|
|
@@ -49,6 +49,8 @@ function evt(o) {
|
|
|
49
49
|
return {
|
|
50
50
|
index: o.index ?? null,
|
|
51
51
|
track: o.track || 'V',
|
|
52
|
+
...(o.name !== undefined ? { name: o.name } : {}),
|
|
53
|
+
...(o.color !== undefined ? { color: o.color } : {}),
|
|
52
54
|
source: o.source || 'UNKNOWN',
|
|
53
55
|
srcIn: o.srcIn ?? null,
|
|
54
56
|
srcOut: o.srcOut ?? null,
|
|
@@ -83,6 +85,19 @@ export function parseEDL(text, opts = {}) {
|
|
|
83
85
|
}
|
|
84
86
|
continue;
|
|
85
87
|
}
|
|
88
|
+
// Avid-style locators: `* LOC: 01:00:01:12 BLUE marker text` — a marker
|
|
89
|
+
// at an absolute record timecode. Emitted as track 'MARKER' pseudo-events
|
|
90
|
+
// (recIn only) so the assemble bridge can author them; consumers that
|
|
91
|
+
// filter video/audio by track shape ignore them.
|
|
92
|
+
const loc = /^\*\s*LOC:\s*(\d{2}:\d{2}:\d{2}[:;]\d{2})\s+(\S+)\s*(.*)$/i.exec(line);
|
|
93
|
+
if (loc) {
|
|
94
|
+
events.push(evt({
|
|
95
|
+
index: events.length + 1, track: 'MARKER', source: '',
|
|
96
|
+
recIn: tcToFrames(loc[1], fps), recOut: null,
|
|
97
|
+
name: loc[3].trim() || undefined, color: loc[2], fps,
|
|
98
|
+
}));
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
86
101
|
const tokens = line.split(/\s+/);
|
|
87
102
|
if (!/^\d+$/.test(tokens[0])) continue; // not an event line
|
|
88
103
|
const tcs = tokens.filter(isTc);
|
|
@@ -147,6 +162,14 @@ export function parseOTIO(otio, opts = {}) {
|
|
|
147
162
|
}
|
|
148
163
|
}
|
|
149
164
|
const src = (child.media_reference && (child.media_reference.target_url || child.media_reference.name)) || child.name || 'UNKNOWN';
|
|
165
|
+
for (const mk of child.markers || []) {
|
|
166
|
+
const mrStart = (mk.marked_range && mk.marked_range.start_time && mk.marked_range.start_time.value) || 0;
|
|
167
|
+
events.push(evt({
|
|
168
|
+
index: idx++, track: 'MARKER', source: '',
|
|
169
|
+
recIn: rec + (mrStart - startVal), recOut: null,
|
|
170
|
+
name: mk.name || undefined, color: mk.color || undefined, fps: rate,
|
|
171
|
+
}));
|
|
172
|
+
}
|
|
150
173
|
events.push(
|
|
151
174
|
evt({
|
|
152
175
|
index: idx++,
|
|
@@ -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
|
}
|
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.120.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.120.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)
|