davinci-resolve-mcp 2.110.0 → 2.112.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.110.0"
40
+ VERSION = "2.112.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.110.0",
3
+ "version": "2.112.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -202,9 +202,11 @@ export function eventsToOTIO(events, opts = {}) {
202
202
  * timeline runs 24fps with origin 86400, so rec/src frames convert as
203
203
  * round(frames × 24 / nominalFps). Placement anchors the EARLIEST video
204
204
  * event at the origin. Honesty ledger in the returned report: flattened
205
- * retimes (the template clip schema has no per-clip speed), dropped
206
- * transitions (treated as cuts at their boundary), and skipped audio events
207
- * (cuts carry linked A1 audio from their own source already).
205
+ * retimes (the template clip schema has no per-clip speed), authored vs
206
+ * dropped transitions (cross-dissolves are AUTHORED when the predecessor
207
+ * abuts the cut and both sides have handle media — render-verified on
208
+ * 19.1.3.7; otherwise dropped with the reason, as a cut at the boundary),
209
+ * and skipped audio events (cuts carry linked A1 audio already).
208
210
  *
209
211
  * @param {Array} events - normalized events (parseInterchange shape)
210
212
  * @param {object} opts
@@ -236,8 +238,11 @@ export function eventsToAssembleSpec(events, opts = {}) {
236
238
  }
237
239
 
238
240
  const toTl = (frames, fps) => Math.round((frames * 24) / Math.round(fps || 24));
241
+ // 'V'/'V1' → 1, 'V2' → 2, … (parsers number video tracks; EDL is single-V).
242
+ const trackNum = (t) => { const m = /^V(\d+)?$/.exec(String(t || 'V')); return m ? (m[1] ? parseInt(m[1], 10) : 1) : 1; };
239
243
  const flattenedRetimes = [];
240
244
  const droppedTransitions = [];
245
+ const transitionCandidates = [];
241
246
  const perSource = new Map();
242
247
  const placements = [];
243
248
 
@@ -250,23 +255,42 @@ export function eventsToAssembleSpec(events, opts = {}) {
250
255
  if ((e.speed ?? 100) !== 100 || e.reverse) {
251
256
  flattenedRetimes.push({ index: e.index, source: e.source, speed: e.speed, reverse: !!e.reverse });
252
257
  }
258
+ const vTrack = trackNum(e.track);
259
+ const cut = { startFrame: recIn, durationFrames, srcIn: toTl(e.srcIn ?? 0, e.fps), ...(vTrack > 1 ? { track: vTrack } : {}) };
253
260
  if (e.transition) {
254
- droppedTransitions.push({ index: e.index, type: e.transition.type, duration: e.transition.duration });
261
+ // A dissolve INTO this event, at its record-in boundary. Whether it can
262
+ // be authored (abutting predecessor + handles both sides) is decided
263
+ // after all placements are known.
264
+ let d = Math.max(2, toTl(e.transition.duration || 0, e.fps) || 2);
265
+ d += d % 2; // placeTransition centers on the cut; keep it even
266
+ transitionCandidates.push({
267
+ atFrame: recIn, durationFrames: d, track: vTrack,
268
+ index: e.index, type: e.transition.type, rawDuration: e.transition.duration,
269
+ source: e.source, srcIn: cut.srcIn,
270
+ });
255
271
  }
256
- const cut = { startFrame: recIn, durationFrames, srcIn: toTl(e.srcIn ?? 0, e.fps) };
257
- placements.push({ start: recIn, end: recOut, index: e.index });
272
+ placements.push({ start: recIn, end: recOut, index: e.index, source: e.source, srcIn: cut.srcIn, durationFrames, track: vTrack });
258
273
  if (!perSource.has(e.source)) perSource.set(e.source, []);
259
274
  perSource.get(e.source).push(cut);
260
275
  }
261
276
 
277
+ // Overlap is judged PER VIDEO TRACK — V2 stacking over V1 is legitimate
278
+ // conform geometry (render-verified: an upper-track clip covers the lower).
262
279
  placements.sort((a, b) => a.start - b.start);
263
- for (let i = 1; i < placements.length; i += 1) {
264
- if (placements[i].start < placements[i - 1].end) {
265
- throw new Error(
266
- `eventsToAssembleSpec: events ${placements[i - 1].index} and ${placements[i].index} ` +
267
- 'overlap on the record track after frame conversion — a single V1 cannot hold both. ' +
268
- 'Resolve the overlap upstream (transitions count as cuts at their boundary here).',
269
- );
280
+ const byTrack = new Map();
281
+ for (const pl of placements) {
282
+ if (!byTrack.has(pl.track)) byTrack.set(pl.track, []);
283
+ byTrack.get(pl.track).push(pl);
284
+ }
285
+ for (const [trk, pls] of byTrack) {
286
+ for (let i = 1; i < pls.length; i += 1) {
287
+ if (pls[i].start < pls[i - 1].end) {
288
+ throw new Error(
289
+ `eventsToAssembleSpec: events ${pls[i - 1].index} and ${pls[i].index} ` +
290
+ `overlap on video track ${trk} after frame conversion — one track cannot hold both. ` +
291
+ 'Resolve the overlap upstream (transitions count as cuts at their boundary here).',
292
+ );
293
+ }
270
294
  }
271
295
  }
272
296
 
@@ -276,13 +300,45 @@ export function eventsToAssembleSpec(events, opts = {}) {
276
300
  cuts,
277
301
  }));
278
302
 
303
+ // Author cross-dissolves where the geometry allows it (render-verified on
304
+ // 19.1.3.7: an offline Sm2TiTransition over transplanted cross-source media
305
+ // blends 124→181.6→234 at the cut — transitions carry no Fusion comp, so
306
+ // the byte-keyed comp-cache law does not apply). A candidate is authorable
307
+ // when a predecessor ends EXACTLY at its cut and both sides have handle
308
+ // media for the centered span; anything else stays in droppedTransitions
309
+ // with the reason.
310
+ const transitions = [];
311
+ for (const c of transitionCandidates) {
312
+ const prev = placements.find((pl) => pl.track === c.track && pl.end === c.atFrame);
313
+ if (!prev) {
314
+ droppedTransitions.push({ index: c.index, type: c.type, duration: c.rawDuration, reason: 'no abutting predecessor at the cut' });
315
+ continue;
316
+ }
317
+ const half = c.durationFrames / 2;
318
+ const bHandle = c.srcIn >= half;
319
+ const aSpec = sourceMap[prev.source] && sourceMap[prev.source].spec;
320
+ const aFrames = aSpec && Number(aSpec.frameCount);
321
+ const aHandle = Number.isFinite(aFrames) ? prev.srcIn + prev.durationFrames + half <= aFrames : false;
322
+ if (!bHandle || !aHandle) {
323
+ droppedTransitions.push({
324
+ index: c.index, type: c.type, duration: c.rawDuration,
325
+ reason: `insufficient handles for a centered ${c.durationFrames}f dissolve` +
326
+ `${bHandle ? '' : ' (incoming srcIn < half)'}${aHandle ? '' : ' (outgoing tail media < half)'}`,
327
+ });
328
+ continue;
329
+ }
330
+ transitions.push({ track: c.track, atFrame: c.atFrame, durationFrames: c.durationFrames });
331
+ }
332
+
279
333
  return {
280
- spec: { timelineName, media },
334
+ spec: { timelineName, media, ...(transitions.length ? { transitions } : {}) },
281
335
  report: {
282
336
  videoEvents: vids.length,
283
337
  sources: media.length,
284
338
  audioEventsSkipped: audioSkipped,
339
+ upperTrackCutsVideoOnly: placements.filter((pl) => pl.track > 1).length,
285
340
  flattenedRetimes,
341
+ authoredTransitions: transitions,
286
342
  droppedTransitions,
287
343
  origin: ORIGIN,
288
344
  },
@@ -116,8 +116,12 @@ export function parseOTIO(otio, opts = {}) {
116
116
  const tracks = (doc.tracks && doc.tracks.children) || [];
117
117
  const events = [];
118
118
  let idx = 1;
119
+ let vNum = 0;
119
120
  for (const track of tracks) {
120
- const kind = track.kind === 'Audio' ? 'A' : 'V';
121
+ const isAudio = track.kind === 'Audio';
122
+ if (!isAudio) vNum += 1;
123
+ // First video track stays 'V' (compat); higher tracks are 'V2', 'V3', …
124
+ const kind = isAudio ? 'A' : (vNum === 1 ? 'V' : `V${vNum}`);
121
125
  let rec = 0;
122
126
  for (const child of track.children || []) {
123
127
  const schema = child.OTIO_SCHEMA || '';
@@ -199,7 +203,7 @@ export function parseXMEMLEvents(xml, opts = {}) {
199
203
  const media = seq && seq.media;
200
204
  if (media) {
201
205
  const vtracks = media.video && media.video.track ? (Array.isArray(media.video.track) ? media.video.track : [media.video.track]) : [];
202
- for (const t of vtracks) if (t.clipitem) walk(t.clipitem, 'V');
206
+ vtracks.forEach((t, vi) => { if (t.clipitem) walk(t.clipitem, vi === 0 ? 'V' : `V${vi + 1}`); });
203
207
  const atracks = media.audio && media.audio.track ? (Array.isArray(media.audio.track) ? media.audio.track : [media.audio.track]) : [];
204
208
  for (const t of atracks) if (t.clipitem) walk(t.clipitem, 'A');
205
209
  }
@@ -320,7 +324,7 @@ export function timingGuards(oldEvents, newEvents) {
320
324
  const ne = matches[0];
321
325
  if (!ne) {
322
326
  // A dropped audio event where its video sibling survives → dropped J/L-cut audio.
323
- if (oe.track === 'A' && newEvents.some((x) => x.track === 'V' && x.source === oe.source))
327
+ if (oe.track === 'A' && newEvents.some((x) => x.track !== 'A' && x.source === oe.source))
324
328
  flags.push({ kind: 'dropped_split_audio', source: oe.source, detail: 'audio event gone but video sibling present (J/L-cut lost)' });
325
329
  continue;
326
330
  }
@@ -46,7 +46,7 @@ const assembleSchema = z.object({
46
46
  spec: z
47
47
  .object({})
48
48
  .passthrough()
49
- .describe("assembleTimeline spec: { timelineName?, media?: {mediaFilePath, spec:{width,height,frameCount,fps}, cuts:[{startFrame,durationFrames,srcIn?}]} | [same, ...] (multi-source needs media_pool.capture_media_template run once per file), elements?: [{type:'title'|'generator', track, startFrame, durationFrames?, text?, generatorName? ('Solid Color'|'SMPTE Color Bar'|'Grey Scale' render-verified on 19), ...}], transitions? }. startFrame is timeline-absolute (origin 86400)."),
49
+ .describe("assembleTimeline spec: { timelineName?, media?: {mediaFilePath, spec:{width,height,frameCount,fps}, cuts:[{startFrame,durationFrames,srcIn?,track? (1-based video track; >1 = video-only, render-verified stacking)}]} | [same, ...] (multi-source needs media_pool.capture_media_template run once per file), elements?: [{type:'title'|'generator', track, startFrame, durationFrames?, text?, generatorName? ('Solid Color'|'SMPTE Color Bar'|'Grey Scale' render-verified on 19), ...}], transitions? }. startFrame is timeline-absolute (origin 86400)."),
50
50
  outputPath: z.string().describe('Absolute path where the importable .drt will be written'),
51
51
  targetAppVersion: z
52
52
  .union([z.string(), z.number()])
@@ -148,7 +148,7 @@ function requirePathArg(args, key, action) {
148
148
  export const drtTool = {
149
149
  name: 'drt',
150
150
  description:
151
- 'DaVinci Resolve Timeline (.drt) operations — offline, no Resolve required. Actions: assemble_from_interchange (EDL/OTIO/XML/AAF + sourceMap → IMPORTABLE RENDERING native .drt in one call; retimes flatten, transitions become cuts, ledger in `conform`), assemble (spec → IMPORTABLE native-schema .drt via template-spliced real structures; pass targetAppVersion e.g. \'19.1\' for pre-21 hosts), parse, list_sequences (enumerate the timelines inside a .drp/.drt → [{id,name,eventCount,index}] to drive a "which sequence?" picker), author, validate, inject_into_drp, extract_from_drp (pull one SeqContainer out as a .drt — feed the .drt to the Python davinci-resolve MCP timeline.import_timeline_checked, or use timeline.import_from_drp to do both), downgrade (stamp <ProjectVersion> down so an OLDER Resolve will import a .drt/.drp from a newer one — pass targetAppVersion like "19.1.3" or targetProjectVersion).',
151
+ 'DaVinci Resolve Timeline (.drt) operations — offline, no Resolve required. Actions: assemble_from_interchange (EDL/OTIO/XML/AAF + sourceMap → IMPORTABLE RENDERING native .drt in one call; retimes flatten; cross-dissolves are AUTHORED when the cut abuts with handles both sides (render-verified on 19), else dropped with reason; ledger in `conform`), assemble (spec → IMPORTABLE native-schema .drt via template-spliced real structures; pass targetAppVersion e.g. \'19.1\' for pre-21 hosts), parse, list_sequences (enumerate the timelines inside a .drp/.drt → [{id,name,eventCount,index}] to drive a "which sequence?" picker), author, validate, inject_into_drp, extract_from_drp (pull one SeqContainer out as a .drt — feed the .drt to the Python davinci-resolve MCP timeline.import_timeline_checked, or use timeline.import_from_drp to do both), downgrade (stamp <ProjectVersion> down so an OLDER Resolve will import a .drt/.drp from a newer one — pass targetAppVersion like "19.1.3" or targetProjectVersion).',
152
152
  async handler({ action, args }) {
153
153
  if (action === 'parse') {
154
154
  const p = parseSchema.parse(requirePathArg(args, 'drtPath', 'parse'));
@@ -24,16 +24,20 @@ const {
24
24
  freshDbIds,
25
25
  getTrackVec,
26
26
  replaceTrackVec,
27
+ emptyTrackClone,
27
28
  } = require('./seq-surgery');
28
29
  const { clipDbId, setClipStart, setClipDuration, setClipIn } = require('./splice-clips');
29
30
 
30
31
  /**
31
32
  * @param {Buffer|string} drpInput
32
33
  * @param {object} opts
33
- * @param {Array<{startFrame:number, durationFrames:number, srcIn?:number}>} opts.cuts
34
+ * @param {Array<{startFrame:number, durationFrames:number, srcIn?:number, track?:number}>} opts.cuts
34
35
  * Timeline placements. startFrame is timeline-absolute (origin 86400 on the
35
36
  * bundled templates — clips before the origin are dropped by Resolve on
36
37
  * import, silently). srcIn is the source in-point in TIMELINE frames.
38
+ * track is the 1-based VIDEO track (default 1); missing tracks are grown as
39
+ * empty clones. Cuts on track > 1 are placed VIDEO-ONLY — their audio would
40
+ * overlap the track-1 cuts' audio on A1 (the template has a single A1).
37
41
  * @param {string} [opts.timelineUuid]
38
42
  * @returns {Promise<{buffer: Buffer, cutCount: number, clipDbIds: string[]}>}
39
43
  */
@@ -52,6 +56,9 @@ async function cutSourceIntoClips(drpInput, opts = {}) {
52
56
  if (cut.mediaRef !== undefined && !/^[0-9a-f-]{36}$/.test(cut.mediaRef)) {
53
57
  throw new TypeError(`cutSourceIntoClips: cuts[${i}].mediaRef must be a uuid`);
54
58
  }
59
+ if (cut.track !== undefined && (!Number.isInteger(cut.track) || cut.track < 1)) {
60
+ throw new TypeError(`cutSourceIntoClips: cuts[${i}].track must be a positive integer`);
61
+ }
55
62
  });
56
63
 
57
64
  const zip = await loadDrpZip(drpInput);
@@ -59,6 +66,19 @@ async function cutSourceIntoClips(drpInput, opts = {}) {
59
66
  let xml = seqXml;
60
67
  const clipDbIds = [];
61
68
 
69
+ const cloneCut = (donor, cut) => {
70
+ let c = freshDbIds(donor);
71
+ c = setClipStart(c, cut.startFrame);
72
+ c = setClipDuration(c, cut.durationFrames);
73
+ c = setClipIn(c, cut.srcIn ?? 0);
74
+ if (cut.mediaRef) {
75
+ // Multi-source: point this cut at ITS source's transplanted pool
76
+ // element instead of the donor's.
77
+ c = c.replace(/<MediaRef>[0-9a-f-]{36}<\/MediaRef>/, `<MediaRef>${cut.mediaRef}</MediaRef>`);
78
+ }
79
+ return c;
80
+ };
81
+
62
82
  for (const trackType of ['video', 'audio']) {
63
83
  const { match, tracks } = getTrackVec(xml, trackType);
64
84
  if (!tracks.length) continue;
@@ -66,22 +86,23 @@ async function cutSourceIntoClips(drpInput, opts = {}) {
66
86
  const clips = splitClipElements(items);
67
87
  if (!clips.length) continue; // audio-less media: nothing to cut on A1
68
88
  const donor = clips[0];
69
- const clones = cuts.map((cut) => {
70
- let c = freshDbIds(donor);
71
- c = setClipStart(c, cut.startFrame);
72
- c = setClipDuration(c, cut.durationFrames);
73
- c = setClipIn(c, cut.srcIn ?? 0);
74
- if (cut.mediaRef) {
75
- // Multi-source: point this cut at ITS source's transplanted pool
76
- // element instead of the donor's.
77
- c = c.replace(/<MediaRef>[0-9a-f-]{36}<\/MediaRef>/, `<MediaRef>${cut.mediaRef}</MediaRef>`);
78
- }
79
- return c;
80
- });
81
- if (trackType === 'video') {
89
+ if (trackType === 'audio') {
90
+ // A1 mirrors track-1 video cuts only; higher video tracks stay video-only.
91
+ const a1 = cuts.filter((cut) => (cut.track ?? 1) === 1).map((cut) => cloneCut(donor, cut));
92
+ tracks[0] = setItemsInner(tracks[0], a1.join(''));
93
+ xml = replaceTrackVec(xml, trackType, match, tracks);
94
+ continue;
95
+ }
96
+ const maxTrack = Math.max(1, ...cuts.map((cut) => cut.track ?? 1));
97
+ const cloneSource = tracks[0];
98
+ while (tracks.length < maxTrack) tracks.push(emptyTrackClone(cloneSource));
99
+ for (let t = 1; t <= tracks.length; t += 1) {
100
+ const mine = cuts.filter((cut) => (cut.track ?? 1) === t);
101
+ if (t > 1 && !mine.length) continue; // grown-empty or untouched track keeps its items
102
+ const clones = mine.map((cut) => cloneCut(donor, cut));
82
103
  for (const c of clones) clipDbIds.push(clipDbId(c));
104
+ tracks[t - 1] = setItemsInner(tracks[t - 1], clones.join(''));
83
105
  }
84
- tracks[0] = setItemsInner(tracks[0], clones.join(''));
85
106
  xml = replaceTrackVec(xml, trackType, match, tracks);
86
107
  }
87
108
 
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "2.110.0"
90
+ VERSION = "2.112.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.110.0"
14
+ VERSION = "2.112.0"
15
15
 
16
16
  import base64
17
17
  import os