davinci-resolve-mcp 2.112.0 → 2.114.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.
@@ -12,7 +12,7 @@ that none exists).
12
12
 
13
13
  **Verified on:** DaVinci Resolve Studio 21.0.2
14
14
 
15
- **Totals:** 33 missing capabilities, 42 bugs / unreliable behaviors.
15
+ **Totals:** 33 missing capabilities, 43 bugs / unreliable behaviors.
16
16
 
17
17
  The authoritative source is the runtime-queryable `api_truth` ledger
18
18
  (`resolve_control api_truth "<query>"`); this document is generated from
@@ -526,6 +526,13 @@ values, or automation-hostile modal prompts.
526
526
  - **Reference:** [issue #171](https://github.com/samuelgursky/davinci-resolve-mcp/issues/171)
527
527
  - **Tags:** timeline, import, silent-failure, unreliable-return
528
528
 
529
+ ### MediaTimemapBA keyframes are generation-split; 19.x silently ignores the R21 protobuf form
530
+
531
+ - **Object:** `Sm2TimeMap (per-clip retime blob)`
532
+ - **Behavior:** Resolve 21 encodes a retimed clip's KeyframesBA as protobuf points; Resolve 19.1.3 encodes it as a keyed-dict of keyed-dict keyframes ({interp, YOut, YIn, Y, XOut, XIn, X}). On import, 19 SILENTLY IGNORES the protobuf form — the clip reads back and plays at 100% with no warning (measured: identical timelines, one per form; protobuf → source 0..96 over 96 frames, keyed → source 0..48 over 96 frames and a live 50% render). The map spans the WHOLE source stretched by 1/speed; the clip's <In>/<Duration> window into it in RECORD frames (srcIn converts by /speed). REVERSE is the same envelope with the Y endpoints swapped - kf0=(0,YMax), kf1=(XMax,0) - and In then measures from the source END: (frames - srcIn - dur*speed)/speed (measured: a reversed srcIn-24 dur-48 cut reads back source 71->23).
533
+ - **Workaround / current handling:** Author retimes for pre-21 hosts with the keyed form (drt.assemble cuts[].speed does this; encoder byte-exact against a live 19.1.3.7 harvest). Treat any cross-generation timemap as unverified until a readback shows the retimed source range.
534
+ - **Tags:** retime, import, silent-failure, drt
535
+
529
536
  ### Imported Fusion comps render via byte-keyed disk cache on 19.x (offline comp edits render black)
530
537
 
531
538
  - **Object:** `Fusion / render engine`
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "2.112.0"
40
+ VERSION = "2.114.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.112.0",
3
+ "version": "2.114.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,7 +202,8 @@ 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), authored vs
205
+ * zero-speed freezes (constant retimes — forward AND reverse — are AUTHORED
206
+ * as real Sm2TimeMaps, r19 keyed form, readback/render-verified), authored vs
206
207
  * dropped transitions (cross-dissolves are AUTHORED when the predecessor
207
208
  * abuts the cut and both sides have handle media — render-verified on
208
209
  * 19.1.3.7; otherwise dropped with the reason, as a cut at the boundary),
@@ -241,6 +242,7 @@ export function eventsToAssembleSpec(events, opts = {}) {
241
242
  // 'V'/'V1' → 1, 'V2' → 2, … (parsers number video tracks; EDL is single-V).
242
243
  const trackNum = (t) => { const m = /^V(\d+)?$/.exec(String(t || 'V')); return m ? (m[1] ? parseInt(m[1], 10) : 1) : 1; };
243
244
  const flattenedRetimes = [];
245
+ const authoredRetimes = [];
244
246
  const droppedTransitions = [];
245
247
  const transitionCandidates = [];
246
248
  const perSource = new Map();
@@ -252,11 +254,22 @@ export function eventsToAssembleSpec(events, opts = {}) {
252
254
  const recOut = ORIGIN + (toTl(e.recOut, e.fps) - minRec);
253
255
  const durationFrames = recOut - recIn;
254
256
  if (durationFrames <= 0) continue;
255
- if ((e.speed ?? 100) !== 100 || e.reverse) {
256
- flattenedRetimes.push({ index: e.index, source: e.source, speed: e.speed, reverse: !!e.reverse });
257
- }
258
257
  const vTrack = trackNum(e.track);
259
258
  const cut = { startFrame: recIn, durationFrames, srcIn: toTl(e.srcIn ?? 0, e.fps), ...(vTrack > 1 ? { track: vTrack } : {}) };
259
+ if ((e.speed ?? 100) !== 100 || e.reverse) {
260
+ const spd = Math.abs(e.speed ?? 100);
261
+ if (!(spd > 0)) {
262
+ flattenedRetimes.push({ index: e.index, source: e.source, speed: e.speed, reverse: !!e.reverse, reason: 'zero speed (freeze) not supported — played forward at 100%' });
263
+ } else {
264
+ // Constant speed, forward or reverse: authored as a real Sm2TimeMap
265
+ // on the cut (r19 keyed form; readback/render-verified on 19.1.3.7 —
266
+ // reverse reads back source 71→23 for a srcIn-24 dur-48 cut). Audio
267
+ // for retimed cuts is video-only downstream.
268
+ if (spd !== 100) cut.speed = spd / 100;
269
+ if (e.reverse) cut.reverse = true;
270
+ authoredRetimes.push({ index: e.index, source: e.source, speed: spd, ...(e.reverse ? { reverse: true } : {}) });
271
+ }
272
+ }
260
273
  if (e.transition) {
261
274
  // A dissolve INTO this event, at its record-in boundary. Whether it can
262
275
  // be authored (abutting predecessor + handles both sides) is decided
@@ -338,6 +351,7 @@ export function eventsToAssembleSpec(events, opts = {}) {
338
351
  audioEventsSkipped: audioSkipped,
339
352
  upperTrackCutsVideoOnly: placements.filter((pl) => pl.track > 1).length,
340
353
  flattenedRetimes,
354
+ authoredRetimes,
341
355
  authoredTransitions: transitions,
342
356
  droppedTransitions,
343
357
  origin: ORIGIN,
@@ -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?,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)."),
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),speed?/reverse? (constant retime, e.g. 0.5, forward or backwards; video-only; readback+render-verified on 19)}]} | [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()])
@@ -25,6 +25,8 @@ const { createEmptyProject, addMediaClip, DEFAULT_START_FRAME } = require('./aut
25
25
  const { loadMediaTemplate, transplantMediaElement, insertMediaElement } = require('./media-template-cache');
26
26
  const JSZip = require('jszip');
27
27
  const { cutSourceIntoClips } = require('./cut-media');
28
+ const { buildConstantSpeedTimemapKeyed } = require('./media-timemap');
29
+ const { randomUUID } = require('node:crypto');
28
30
  const { placeFusionTitle } = require('./place-fusion-title');
29
31
  const { placeGenerator } = require('./place-generator');
30
32
  const { placeTransition } = require('./place-transition');
@@ -111,7 +113,31 @@ async function assembleTimeline(spec = {}) {
111
113
  const allCuts = [];
112
114
  sources.forEach((src, i) => {
113
115
  (src.cuts || []).forEach((cut) => {
114
- allCuts.push(mediaRefs[i] ? { ...cut, mediaRef: mediaRefs[i] } : { ...cut });
116
+ const out = mediaRefs[i] ? { ...cut, mediaRef: mediaRefs[i] } : { ...cut };
117
+ if (cut.reverse || (cut.speed !== undefined && cut.speed !== 1)) {
118
+ // Constant-speed retime (forward only). The Sm2TimeMap spans the
119
+ // whole source stretched by 1/speed; the clip windows into it with
120
+ // RECORD-domain In/Duration (measured live on 19.1.3.7), so the
121
+ // source-domain srcIn converts by /speed here.
122
+ const speed = cut.speed ?? 1;
123
+ if (!(speed > 0)) throw new RangeError('assembleTimeline: cut.speed must be > 0 (use cut.reverse for backwards)');
124
+ const fps = Math.round(src.spec.fps || 24);
125
+ out.timemap = buildConstantSpeedTimemapKeyed({
126
+ speed, sourceFrames: src.spec.frameCount, fps,
127
+ uniqueId: randomUUID(), reverse: !!cut.reverse,
128
+ }).toString('hex');
129
+ // Record-domain In. Forward: srcIn/speed. Reverse: the clip plays
130
+ // source [srcIn, srcIn + dur*speed) BACKWARDS — the map descends
131
+ // from the source tail, so In measures from the END:
132
+ // (sourceFrames - srcIn - dur*speed)/speed (measured live: In=0
133
+ // on a reversed full-speed clip reads back source 191→143).
134
+ out.srcIn = cut.reverse
135
+ ? Math.round((src.spec.frameCount - (cut.srcIn ?? 0) - cut.durationFrames * speed) / speed)
136
+ : Math.round((cut.srcIn ?? 0) / speed);
137
+ delete out.speed;
138
+ delete out.reverse;
139
+ }
140
+ allCuts.push(out);
115
141
  });
116
142
  });
117
143
  allCuts.sort((a, b) => a.startFrame - b.startFrame);
@@ -59,6 +59,9 @@ async function cutSourceIntoClips(drpInput, opts = {}) {
59
59
  if (cut.track !== undefined && (!Number.isInteger(cut.track) || cut.track < 1)) {
60
60
  throw new TypeError(`cutSourceIntoClips: cuts[${i}].track must be a positive integer`);
61
61
  }
62
+ if (cut.timemap !== undefined && !/^[0-9a-fA-F]+$/.test(cut.timemap)) {
63
+ throw new TypeError(`cutSourceIntoClips: cuts[${i}].timemap must be a hex MediaTimemapBA blob`);
64
+ }
62
65
  });
63
66
 
64
67
  const zip = await loadDrpZip(drpInput);
@@ -76,6 +79,12 @@ async function cutSourceIntoClips(drpInput, opts = {}) {
76
79
  // element instead of the donor's.
77
80
  c = c.replace(/<MediaRef>[0-9a-f-]{36}<\/MediaRef>/, `<MediaRef>${cut.mediaRef}</MediaRef>`);
78
81
  }
82
+ if (cut.timemap) {
83
+ // Constant-speed retime: swap the identity MediaTimemapBA for the
84
+ // caller-built Sm2TimeMap (r19 keyed form render/readback-verified;
85
+ // the clip's <In>/<Duration> are RECORD-domain, measured live).
86
+ c = c.replace(/<MediaTimemapBA>[0-9a-fA-F]*<\/MediaTimemapBA>/, `<MediaTimemapBA>${cut.timemap}</MediaTimemapBA>`);
87
+ }
79
88
  return c;
80
89
  };
81
90
 
@@ -87,8 +96,10 @@ async function cutSourceIntoClips(drpInput, opts = {}) {
87
96
  if (!clips.length) continue; // audio-less media: nothing to cut on A1
88
97
  const donor = clips[0];
89
98
  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));
99
+ // A1 mirrors track-1 video cuts only; higher video tracks stay video-only,
100
+ // and so do RETIMED cuts (the audio clone would need its own timemap and
101
+ // pitch handling — video-only is stated, not silent).
102
+ const a1 = cuts.filter((cut) => (cut.track ?? 1) === 1 && !cut.timemap).map((cut) => cloneCut(donor, cut));
92
103
  tracks[0] = setItemsInner(tracks[0], a1.join(''));
93
104
  xml = replaceTrackVec(xml, trackType, match, tracks);
94
105
  continue;
@@ -209,8 +209,59 @@ function buildConstantSpeedTimemap({ speed, sourceDurationSec, uniqueId, recordD
209
209
  });
210
210
  }
211
211
 
212
+ /**
213
+ * r19-generation constant-speed Sm2TimeMap. Resolve 19.x encodes KeyframesBA
214
+ * as a keyed-dict of keyed-dict keyframes ({interp,YOut,YIn,Y,XOut,XIn,X}),
215
+ * NOT the R21 protobuf points — and 19 silently IGNORES the protobuf form on
216
+ * import (measured: item read back at 100%). Shape harvested from a live
217
+ * 19.1.3.7 XMEML retime and rebuilt byte-exact. The map spans the ENTIRE
218
+ * source stretched by 1/speed (the clip's Start/Duration/In window into it):
219
+ * YMax = (sourceFrames-1)/fps — full source extent, seconds
220
+ * XMax = (sourceFrames/speed - 1)/fps — full retimed extent, seconds
221
+ * kf0 = (0,0); kf1 = (XMax, XMax*speed); linear (zero handles, interp 0)
222
+ *
223
+ * @param {object} p
224
+ * @param {number} p.speed - source/record ratio (0.5 = 50%). Forward only.
225
+ * @param {number} p.sourceFrames - full source frame count at p.fps.
226
+ * @param {number} [p.fps=24]
227
+ * @param {string} p.uniqueId - fresh uuid (bare, no braces).
228
+ * @returns {Buffer}
229
+ */
230
+ function buildConstantSpeedTimemapKeyed({ speed, sourceFrames, fps = 24, uniqueId, reverse = false }) {
231
+ if (!(speed > 0)) throw new RangeError('buildConstantSpeedTimemapKeyed: speed must be > 0 (pass reverse:true for backwards)');
232
+ if (!Number.isInteger(sourceFrames) || sourceFrames < 1) throw new TypeError('buildConstantSpeedTimemapKeyed: sourceFrames must be a positive integer');
233
+ const YMax = (sourceFrames - 1) / fps;
234
+ const XMax = (sourceFrames / speed - 1) / fps;
235
+ const kf = (X, Y) => encodeKeyedDict({ hdr: 1, entries: [
236
+ { key: 'interp', type: 0x02, subType: 0, value: 0 },
237
+ { key: 'YOut', type: T_DOUBLE, subType: 0, value: 0 },
238
+ { key: 'YIn', type: T_DOUBLE, subType: 0, value: 0 },
239
+ { key: 'Y', type: T_DOUBLE, subType: 0, value: Y },
240
+ { key: 'XOut', type: T_DOUBLE, subType: 0, value: 0 },
241
+ { key: 'XIn', type: T_DOUBLE, subType: 0, value: 0 },
242
+ { key: 'X', type: T_DOUBLE, subType: 0, value: X },
243
+ ] }).toString('hex');
244
+ // Reverse (harvested from a live 19.1.3.7 -100% XMEML retime): the SAME
245
+ // envelope with the Y endpoints swapped — kf0=(0, YMax), kf1=(XMax, 0),
246
+ // a descending line. Forward keeps kf1 Y = XMax*speed (harvest: +half-frame
247
+ // convention falls out of the arithmetic, byte-exact either way).
248
+ const keyframes = encodeKeyedDict({ hdr: 1, entries: [
249
+ { key: '1', type: T_BYTES, subType: 0, value: reverse ? kf(XMax, 0) : kf(XMax, XMax * speed) },
250
+ { key: '0', type: T_BYTES, subType: 0, value: reverse ? kf(0, YMax) : kf(0, 0) },
251
+ ] }).toString('hex');
252
+ return encodeKeyedDict({ hdr: 1, entries: [
253
+ { key: 'YMax', type: T_DOUBLE, subType: 0, value: YMax },
254
+ { key: 'XMax', type: T_DOUBLE, subType: 0, value: XMax },
255
+ { key: 'UniqueId', type: T_STRING, subType: 0, value: uniqueId },
256
+ { key: 'LastValidYOffset', type: T_DOUBLE, subType: 0, value: YMax },
257
+ { key: 'KeyframesBA', type: T_BYTES, subType: 0, value: keyframes },
258
+ { key: 'DbType', type: T_STRING, subType: 0, value: 'Sm2TimeMap' },
259
+ ] });
260
+ }
261
+
212
262
  module.exports = {
213
263
  decodeTimemap, encodeTimemap, encodeRetimedTimemap,
214
- identityTimemap, buildConstantSpeedTimemap, buildTimemap, decodeProtobuf,
264
+ identityTimemap, buildConstantSpeedTimemap, buildConstantSpeedTimemapKeyed,
265
+ buildTimemap, decodeProtobuf,
215
266
  TYPE_LINEAR,
216
267
  };
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "2.112.0"
90
+ VERSION = "2.114.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.112.0"
14
+ VERSION = "2.114.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -1914,6 +1914,32 @@ API_TRUTH: List[Dict[str, Any]] = [
1914
1914
  "structural readback cannot see this class.",
1915
1915
  "tags": ["media-pool", "import", "render", "silent-failure", "readback"],
1916
1916
  },
1917
+ {
1918
+ "symbol": "MediaTimemapBA keyframes are generation-split; 19.x silently ignores the R21 protobuf form",
1919
+ "object": "Sm2TimeMap (per-clip retime blob)",
1920
+ "reality": "Resolve 21 encodes a retimed clip's KeyframesBA as "
1921
+ "protobuf points; Resolve 19.1.3 encodes it as a "
1922
+ "keyed-dict of keyed-dict keyframes ({interp, YOut, YIn, "
1923
+ "Y, XOut, XIn, X}). On import, 19 SILENTLY IGNORES the "
1924
+ "protobuf form — the clip reads back and plays at 100% "
1925
+ "with no warning (measured: identical timelines, one per "
1926
+ "form; protobuf → source 0..96 over 96 frames, keyed → "
1927
+ "source 0..48 over 96 frames and a live 50% render). "
1928
+ "The map spans the WHOLE source stretched by 1/speed; "
1929
+ "the clip's <In>/<Duration> window into it in RECORD "
1930
+ "frames (srcIn converts by /speed). REVERSE is the same "
1931
+ "envelope with the Y endpoints swapped - kf0=(0,YMax), "
1932
+ "kf1=(XMax,0) - and In then measures from the source "
1933
+ "END: (frames - srcIn - dur*speed)/speed (measured: a "
1934
+ "reversed srcIn-24 dur-48 cut reads back source 71->23).",
1935
+ "recommended": "Author retimes for pre-21 hosts with the keyed "
1936
+ "form (drt.assemble cuts[].speed does this; encoder "
1937
+ "byte-exact against a live 19.1.3.7 harvest). Treat "
1938
+ "any cross-generation timemap as unverified until a "
1939
+ "readback shows the retimed source range.",
1940
+ "tags": ["retime", "import", "silent-failure", "drt"],
1941
+ "submit": "bug",
1942
+ },
1917
1943
  {
1918
1944
  "symbol": "Imported Fusion comps render via byte-keyed disk cache on 19.x (offline comp edits render black)",
1919
1945
  "object": "Fusion / render engine",