davinci-resolve-mcp 2.111.0 → 2.113.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).
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.111.0"
40
+ VERSION = "2.113.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.111.0",
3
+ "version": "2.113.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,9 @@ 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
+ * reverse retimes (forward constant speeds are AUTHORED as real Sm2TimeMaps
206
+ * — r19 keyed form, render/readback-verified; reverse flattens with the
207
+ * reason), authored vs
206
208
  * dropped transitions (cross-dissolves are AUTHORED when the predecessor
207
209
  * abuts the cut and both sides have handle media — render-verified on
208
210
  * 19.1.3.7; otherwise dropped with the reason, as a cut at the boundary),
@@ -238,7 +240,10 @@ export function eventsToAssembleSpec(events, opts = {}) {
238
240
  }
239
241
 
240
242
  const toTl = (frames, fps) => Math.round((frames * 24) / Math.round(fps || 24));
243
+ // 'V'/'V1' → 1, 'V2' → 2, … (parsers number video tracks; EDL is single-V).
244
+ const trackNum = (t) => { const m = /^V(\d+)?$/.exec(String(t || 'V')); return m ? (m[1] ? parseInt(m[1], 10) : 1) : 1; };
241
245
  const flattenedRetimes = [];
246
+ const authoredRetimes = [];
242
247
  const droppedTransitions = [];
243
248
  const transitionCandidates = [];
244
249
  const perSource = new Map();
@@ -250,10 +255,20 @@ export function eventsToAssembleSpec(events, opts = {}) {
250
255
  const recOut = ORIGIN + (toTl(e.recOut, e.fps) - minRec);
251
256
  const durationFrames = recOut - recIn;
252
257
  if (durationFrames <= 0) continue;
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.speed ?? 100) !== 100 || e.reverse) {
254
- flattenedRetimes.push({ index: e.index, source: e.source, speed: e.speed, reverse: !!e.reverse });
261
+ if (e.reverse || !(e.speed > 0)) {
262
+ // Reverse needs a descending timemap (not yet measured) — flatten, with the reason.
263
+ flattenedRetimes.push({ index: e.index, source: e.source, speed: e.speed, reverse: !!e.reverse, reason: 'reverse not supported — played forward at 100%' });
264
+ } else {
265
+ // Forward constant speed: authored as a real Sm2TimeMap on the cut
266
+ // (r19 keyed form; render/readback-verified on 19.1.3.7). Audio for
267
+ // retimed cuts is video-only downstream.
268
+ cut.speed = e.speed / 100;
269
+ authoredRetimes.push({ index: e.index, source: e.source, speed: e.speed });
270
+ }
255
271
  }
256
- const cut = { startFrame: recIn, durationFrames, srcIn: toTl(e.srcIn ?? 0, e.fps) };
257
272
  if (e.transition) {
258
273
  // A dissolve INTO this event, at its record-in boundary. Whether it can
259
274
  // be authored (abutting predecessor + handles both sides) is decided
@@ -261,24 +276,33 @@ export function eventsToAssembleSpec(events, opts = {}) {
261
276
  let d = Math.max(2, toTl(e.transition.duration || 0, e.fps) || 2);
262
277
  d += d % 2; // placeTransition centers on the cut; keep it even
263
278
  transitionCandidates.push({
264
- atFrame: recIn, durationFrames: d,
279
+ atFrame: recIn, durationFrames: d, track: vTrack,
265
280
  index: e.index, type: e.transition.type, rawDuration: e.transition.duration,
266
281
  source: e.source, srcIn: cut.srcIn,
267
282
  });
268
283
  }
269
- placements.push({ start: recIn, end: recOut, index: e.index, source: e.source, srcIn: cut.srcIn, durationFrames });
284
+ placements.push({ start: recIn, end: recOut, index: e.index, source: e.source, srcIn: cut.srcIn, durationFrames, track: vTrack });
270
285
  if (!perSource.has(e.source)) perSource.set(e.source, []);
271
286
  perSource.get(e.source).push(cut);
272
287
  }
273
288
 
289
+ // Overlap is judged PER VIDEO TRACK — V2 stacking over V1 is legitimate
290
+ // conform geometry (render-verified: an upper-track clip covers the lower).
274
291
  placements.sort((a, b) => a.start - b.start);
275
- for (let i = 1; i < placements.length; i += 1) {
276
- if (placements[i].start < placements[i - 1].end) {
277
- throw new Error(
278
- `eventsToAssembleSpec: events ${placements[i - 1].index} and ${placements[i].index} ` +
279
- 'overlap on the record track after frame conversion — a single V1 cannot hold both. ' +
280
- 'Resolve the overlap upstream (transitions count as cuts at their boundary here).',
281
- );
292
+ const byTrack = new Map();
293
+ for (const pl of placements) {
294
+ if (!byTrack.has(pl.track)) byTrack.set(pl.track, []);
295
+ byTrack.get(pl.track).push(pl);
296
+ }
297
+ for (const [trk, pls] of byTrack) {
298
+ for (let i = 1; i < pls.length; i += 1) {
299
+ if (pls[i].start < pls[i - 1].end) {
300
+ throw new Error(
301
+ `eventsToAssembleSpec: events ${pls[i - 1].index} and ${pls[i].index} ` +
302
+ `overlap on video track ${trk} after frame conversion — one track cannot hold both. ` +
303
+ 'Resolve the overlap upstream (transitions count as cuts at their boundary here).',
304
+ );
305
+ }
282
306
  }
283
307
  }
284
308
 
@@ -297,7 +321,7 @@ export function eventsToAssembleSpec(events, opts = {}) {
297
321
  // with the reason.
298
322
  const transitions = [];
299
323
  for (const c of transitionCandidates) {
300
- const prev = placements.find((pl) => pl.end === c.atFrame);
324
+ const prev = placements.find((pl) => pl.track === c.track && pl.end === c.atFrame);
301
325
  if (!prev) {
302
326
  droppedTransitions.push({ index: c.index, type: c.type, duration: c.rawDuration, reason: 'no abutting predecessor at the cut' });
303
327
  continue;
@@ -315,7 +339,7 @@ export function eventsToAssembleSpec(events, opts = {}) {
315
339
  });
316
340
  continue;
317
341
  }
318
- transitions.push({ track: 1, atFrame: c.atFrame, durationFrames: c.durationFrames });
342
+ transitions.push({ track: c.track, atFrame: c.atFrame, durationFrames: c.durationFrames });
319
343
  }
320
344
 
321
345
  return {
@@ -324,7 +348,9 @@ export function eventsToAssembleSpec(events, opts = {}) {
324
348
  videoEvents: vids.length,
325
349
  sources: media.length,
326
350
  audioEventsSkipped: audioSkipped,
351
+ upperTrackCutsVideoOnly: placements.filter((pl) => pl.track > 1).length,
327
352
  flattenedRetimes,
353
+ authoredRetimes,
328
354
  authoredTransitions: transitions,
329
355
  droppedTransitions,
330
356
  origin: ORIGIN,
@@ -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),speed? (forward constant retime, e.g. 0.5; video-only; 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,22 @@ 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.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
+ if (!(cut.speed > 0)) throw new RangeError('assembleTimeline: cut.speed must be > 0 (reverse not supported)');
123
+ const fps = Math.round(src.spec.fps || 24);
124
+ out.timemap = buildConstantSpeedTimemapKeyed({
125
+ speed: cut.speed, sourceFrames: src.spec.frameCount, fps,
126
+ uniqueId: randomUUID(),
127
+ }).toString('hex');
128
+ out.srcIn = Math.round((cut.srcIn ?? 0) / cut.speed);
129
+ delete out.speed;
130
+ }
131
+ allCuts.push(out);
115
132
  });
116
133
  });
117
134
  allCuts.sort((a, b) => a.startFrame - b.startFrame);
@@ -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,12 @@ 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
+ }
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
+ }
55
65
  });
56
66
 
57
67
  const zip = await loadDrpZip(drpInput);
@@ -59,6 +69,25 @@ async function cutSourceIntoClips(drpInput, opts = {}) {
59
69
  let xml = seqXml;
60
70
  const clipDbIds = [];
61
71
 
72
+ const cloneCut = (donor, cut) => {
73
+ let c = freshDbIds(donor);
74
+ c = setClipStart(c, cut.startFrame);
75
+ c = setClipDuration(c, cut.durationFrames);
76
+ c = setClipIn(c, cut.srcIn ?? 0);
77
+ if (cut.mediaRef) {
78
+ // Multi-source: point this cut at ITS source's transplanted pool
79
+ // element instead of the donor's.
80
+ c = c.replace(/<MediaRef>[0-9a-f-]{36}<\/MediaRef>/, `<MediaRef>${cut.mediaRef}</MediaRef>`);
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
+ }
88
+ return c;
89
+ };
90
+
62
91
  for (const trackType of ['video', 'audio']) {
63
92
  const { match, tracks } = getTrackVec(xml, trackType);
64
93
  if (!tracks.length) continue;
@@ -66,22 +95,25 @@ async function cutSourceIntoClips(drpInput, opts = {}) {
66
95
  const clips = splitClipElements(items);
67
96
  if (!clips.length) continue; // audio-less media: nothing to cut on A1
68
97
  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') {
98
+ if (trackType === 'audio') {
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));
103
+ tracks[0] = setItemsInner(tracks[0], a1.join(''));
104
+ xml = replaceTrackVec(xml, trackType, match, tracks);
105
+ continue;
106
+ }
107
+ const maxTrack = Math.max(1, ...cuts.map((cut) => cut.track ?? 1));
108
+ const cloneSource = tracks[0];
109
+ while (tracks.length < maxTrack) tracks.push(emptyTrackClone(cloneSource));
110
+ for (let t = 1; t <= tracks.length; t += 1) {
111
+ const mine = cuts.filter((cut) => (cut.track ?? 1) === t);
112
+ if (t > 1 && !mine.length) continue; // grown-empty or untouched track keeps its items
113
+ const clones = mine.map((cut) => cloneCut(donor, cut));
82
114
  for (const c of clones) clipDbIds.push(clipDbId(c));
115
+ tracks[t - 1] = setItemsInner(tracks[t - 1], clones.join(''));
83
116
  }
84
- tracks[0] = setItemsInner(tracks[0], clones.join(''));
85
117
  xml = replaceTrackVec(xml, trackType, match, tracks);
86
118
  }
87
119
 
@@ -209,8 +209,55 @@ 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 }) {
231
+ if (!(speed > 0)) throw new RangeError('buildConstantSpeedTimemapKeyed: speed must be > 0 (reverse not supported here)');
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
+ const keyframes = encodeKeyedDict({ hdr: 1, entries: [
245
+ { key: '1', type: T_BYTES, subType: 0, value: kf(XMax, XMax * speed) },
246
+ { key: '0', type: T_BYTES, subType: 0, value: kf(0, 0) },
247
+ ] }).toString('hex');
248
+ return encodeKeyedDict({ hdr: 1, entries: [
249
+ { key: 'YMax', type: T_DOUBLE, subType: 0, value: YMax },
250
+ { key: 'XMax', type: T_DOUBLE, subType: 0, value: XMax },
251
+ { key: 'UniqueId', type: T_STRING, subType: 0, value: uniqueId },
252
+ { key: 'LastValidYOffset', type: T_DOUBLE, subType: 0, value: YMax },
253
+ { key: 'KeyframesBA', type: T_BYTES, subType: 0, value: keyframes },
254
+ { key: 'DbType', type: T_STRING, subType: 0, value: 'Sm2TimeMap' },
255
+ ] });
256
+ }
257
+
212
258
  module.exports = {
213
259
  decodeTimemap, encodeTimemap, encodeRetimedTimemap,
214
- identityTimemap, buildConstantSpeedTimemap, buildTimemap, decodeProtobuf,
260
+ identityTimemap, buildConstantSpeedTimemap, buildConstantSpeedTimemapKeyed,
261
+ buildTimemap, decodeProtobuf,
215
262
  TYPE_LINEAR,
216
263
  };
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "2.111.0"
90
+ VERSION = "2.113.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.111.0"
14
+ VERSION = "2.113.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -1914,6 +1914,28 @@ 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).",
1931
+ "recommended": "Author retimes for pre-21 hosts with the keyed "
1932
+ "form (drt.assemble cuts[].speed does this; encoder "
1933
+ "byte-exact against a live 19.1.3.7 harvest). Treat "
1934
+ "any cross-generation timemap as unverified until a "
1935
+ "readback shows the retimed source range.",
1936
+ "tags": ["retime", "import", "silent-failure", "drt"],
1937
+ "submit": "bug",
1938
+ },
1917
1939
  {
1918
1940
  "symbol": "Imported Fusion comps render via byte-keyed disk cache on 19.x (offline comp edits render black)",
1919
1941
  "object": "Fusion / render engine",