davinci-resolve-mcp 2.119.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/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "2.119.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.119.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>",
@@ -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
- const auds = events.filter((e) => isAudio(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
+ }
235
248
  const markerEvents = events.filter((e) => isMarker(e.track) && e.recIn != null);
236
- const audioSkipped = events.length - vids.length - auds.length - markerEvents.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,
@@ -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.119.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.119.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)