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.
@@ -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.118.0"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.118.0",
3
+ "version": "2.120.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -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 vids = events.filter((e) => !isAudio(e.track) && e.recIn != null && e.recOut != null);
233
- const auds = events.filter((e) => isAudio(e.track) && e.recIn != null && e.recOut != null);
234
- const audioSkipped = events.length - vids.length - auds.length;
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 (cut.audioOnly) {
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
- }
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
- let c = freshDbIds(donor);
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.audioOnly && cut.srcMeta) {
87
- // An audio-only clone must carry ITS OWN source identity — a live
88
- // A2-placed clip (harvested via AppendToTimeline mediaType:2) differs
89
- // from a donor clone in Name, MediaFilePath, MediaFrameRate (the
90
- // template donor's was 29.97!), the identity MediaTimemapBA extent,
91
- // and FieldsBlob. A clone keeping donor values imports and reads back
92
- // fine but renders SILENT off A1.
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
- // Generic audio-clip FieldsBlob, verbatim from the live A2 harvest.
104
- c = c.replace(/<FieldsBlob>[0-9a-fA-F]*<\/FieldsBlob>/, '<FieldsBlob>0000000200000005800a022001</FieldsBlob>');
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
  }
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "2.118.0"
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.118.0"
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)