davinci-resolve-mcp 2.113.0 → 2.115.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, 43 bugs / unreliable behaviors.
15
+ **Totals:** 33 missing capabilities, 44 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,10 +526,17 @@ 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
+ ### Audio tracks cannot be grown in an imported timeline; Fairlight strips live in the pool Sm2Sequence.FieldsBlob
530
+
531
+ - **Object:** `Sm2TiTrack (audio) / FLStudioModelBA`
532
+ - **Behavior:** An audio track added to a SeqContainer by cloning imports fine, reads back fine (track count, items, everything), and renders SILENT. The per-timeline Fairlight model (FLStudioModelBA, ~7KB compressed to ~420KB, inside the media pool's Sm2Sequence.FieldsBlob) holds one strip per audio track, and a track without a strip is mute. Measured by elimination on 19.1.3.7: clip byte-identical to a live-authored one, track byte-identical (SubType is the CHANNEL FORMAT code - 1=mono - not an ordinal), pool entry shared with a playing A1 clip - still silent; only a timeline whose template was CAPTURED with the tracks plays.
533
+ - **Workaround / current handling:** Never clone audio tracks offline. Capture the template with the audio tracks already present (the r19 media template carries 8 mono tracks with valid strips; drt.assemble audioOnly cuts land on them and render at native level). Refuse placements beyond the captured ceiling. Audio aliveness is readback-blind: verify by rendered RMS, not structure.
534
+ - **Tags:** fairlight, audio, import, silent-failure, drt
535
+
529
536
  ### MediaTimemapBA keyframes are generation-split; 19.x silently ignores the R21 protobuf form
530
537
 
531
538
  - **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).
539
+ - **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
540
  - **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
541
  - **Tags:** retime, import, silent-failure, drt
535
542
 
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "2.113.0"
40
+ VERSION = "2.115.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.113.0",
3
+ "version": "2.115.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,13 +202,14 @@ 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
- * 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
205
+ * zero-speed freezes (constant retimes — forward AND reverse — are AUTHORED
206
+ * as real Sm2TimeMaps, r19 keyed form, readback/render-verified), authored vs
208
207
  * dropped transitions (cross-dissolves are AUTHORED when the predecessor
209
208
  * abuts the cut and both sides have handle media — render-verified on
210
209
  * 19.1.3.7; otherwise dropped with the reason, as a cut at the boundary),
211
- * and skipped audio events (cuts carry linked A1 audio already).
210
+ * and audio: A-track events are AUTHORED as audioOnly cuts on their own
211
+ * audio tracks (render-verified; the template carries 8 Fairlight-valid
212
+ * audio tracks) — their presence suppresses the A1 convenience mirror.
212
213
  *
213
214
  * @param {Array} events - normalized events (parseInterchange shape)
214
215
  * @param {object} opts
@@ -227,11 +228,13 @@ export function eventsToAssembleSpec(events, opts = {}) {
227
228
  throw new TypeError('eventsToAssembleSpec: sourceMap {reel: {mediaFilePath, spec}} is required');
228
229
  }
229
230
  const ORIGIN = 86400;
230
- const vids = events.filter((e) => e.track !== 'A' && e.recIn != null && e.recOut != null);
231
- const audioSkipped = events.length - vids.length;
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
235
  if (!vids.length) throw new Error('eventsToAssembleSpec: no video events with record ranges');
233
236
 
234
- const unmapped = [...new Set(vids.map((e) => e.source).filter((srcName) => !sourceMap[srcName]))];
237
+ const unmapped = [...new Set([...vids, ...auds].map((e) => e.source).filter((srcName) => !sourceMap[srcName]))];
235
238
  if (unmapped.length) {
236
239
  throw new Error(
237
240
  `eventsToAssembleSpec: unmapped source reel(s): ${unmapped.join(', ')} — ` +
@@ -258,15 +261,17 @@ export function eventsToAssembleSpec(events, opts = {}) {
258
261
  const vTrack = trackNum(e.track);
259
262
  const cut = { startFrame: recIn, durationFrames, srcIn: toTl(e.srcIn ?? 0, e.fps), ...(vTrack > 1 ? { track: vTrack } : {}) };
260
263
  if ((e.speed ?? 100) !== 100 || 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
+ const spd = Math.abs(e.speed ?? 100);
265
+ if (!(spd > 0)) {
266
+ flattenedRetimes.push({ index: e.index, source: e.source, speed: e.speed, reverse: !!e.reverse, reason: 'zero speed (freeze) not supported — played forward at 100%' });
264
267
  } 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 });
268
+ // Constant speed, forward or reverse: authored as a real Sm2TimeMap
269
+ // on the cut (r19 keyed form; readback/render-verified on 19.1.3.7 —
270
+ // reverse reads back source 71→23 for a srcIn-24 dur-48 cut). Audio
271
+ // for retimed cuts is video-only downstream.
272
+ if (spd !== 100) cut.speed = spd / 100;
273
+ if (e.reverse) cut.reverse = true;
274
+ authoredRetimes.push({ index: e.index, source: e.source, speed: spd, ...(e.reverse ? { reverse: true } : {}) });
270
275
  }
271
276
  }
272
277
  if (e.transition) {
@@ -286,6 +291,44 @@ export function eventsToAssembleSpec(events, opts = {}) {
286
291
  perSource.get(e.source).push(cut);
287
292
  }
288
293
 
294
+ // Audio events become explicit audioOnly cuts on their own audio tracks —
295
+ // this SUPPRESSES the convenience A1 mirror downstream (explicit audio
296
+ // wins). Render-verified on 19.1.3.7: an offline A3 cut plays at the
297
+ // native control level through the captured 8-audio-track template's
298
+ // Fairlight strips. Retimed audio is not authored (no audio timemap yet).
299
+ const audioTrackNum = (t) => { const m = /^A(\d+)?$/.exec(String(t)); return m && m[1] ? parseInt(m[1], 10) : 1; };
300
+ const audioPlacements = [];
301
+ const audioRetimesSkipped = [];
302
+ for (const e of auds) {
303
+ const recIn = ORIGIN + (toTl(e.recIn, e.fps) - minRec);
304
+ const recOut = ORIGIN + (toTl(e.recOut, e.fps) - minRec);
305
+ const durationFrames = recOut - recIn;
306
+ if (durationFrames <= 0) continue;
307
+ if ((e.speed ?? 100) !== 100 || e.reverse) {
308
+ audioRetimesSkipped.push({ index: e.index, source: e.source, speed: e.speed, reverse: !!e.reverse, reason: 'audio retime not supported — played at 100%' });
309
+ }
310
+ const track = audioTrackNum(e.track);
311
+ const cut = { startFrame: recIn, durationFrames, srcIn: toTl(e.srcIn ?? 0, e.fps), audioOnly: true, track };
312
+ audioPlacements.push({ start: recIn, end: recOut, index: e.index, track });
313
+ if (!perSource.has(e.source)) perSource.set(e.source, []);
314
+ perSource.get(e.source).push(cut);
315
+ }
316
+ audioPlacements.sort((a, b) => a.start - b.start);
317
+ const audByTrack = new Map();
318
+ for (const pl of audioPlacements) {
319
+ if (!audByTrack.has(pl.track)) audByTrack.set(pl.track, []);
320
+ audByTrack.get(pl.track).push(pl);
321
+ }
322
+ for (const [trk, pls] of audByTrack) {
323
+ for (let i = 1; i < pls.length; i += 1) {
324
+ if (pls[i].start < pls[i - 1].end) {
325
+ throw new Error(
326
+ `eventsToAssembleSpec: audio events ${pls[i - 1].index} and ${pls[i].index} ` +
327
+ `overlap on audio track ${trk} after frame conversion — one track cannot hold both.`);
328
+ }
329
+ }
330
+ }
331
+
289
332
  // Overlap is judged PER VIDEO TRACK — V2 stacking over V1 is legitimate
290
333
  // conform geometry (render-verified: an upper-track clip covers the lower).
291
334
  placements.sort((a, b) => a.start - b.start);
@@ -348,6 +391,8 @@ export function eventsToAssembleSpec(events, opts = {}) {
348
391
  videoEvents: vids.length,
349
392
  sources: media.length,
350
393
  audioEventsSkipped: audioSkipped,
394
+ authoredAudioEvents: audioPlacements.length,
395
+ audioRetimesSkipped,
351
396
  upperTrackCutsVideoOnly: placements.filter((pl) => pl.track > 1).length,
352
397
  flattenedRetimes,
353
398
  authoredRetimes,
@@ -91,7 +91,9 @@ export function parseEDL(text, opts = {}) {
91
91
  const head = tokens.slice(0, tokens.indexOf(tcs[tcs.length - 4]));
92
92
  const [eventNum, reel, channel, transition] = head;
93
93
  const dur = transition && transition !== 'C' ? Number(head[4]) : 0;
94
- const track = /A/i.test(channel || '') && !/V/i.test(channel || '') ? 'A' : 'V';
94
+ const chan = String(channel || '');
95
+ const am = /^A(\d+)?$/i.exec(chan);
96
+ const track = am && !/V/i.test(chan) ? (am[1] && am[1] !== '1' ? `A${am[1]}` : 'A') : (/A/i.test(chan) && !/V/i.test(chan) ? 'A' : 'V');
95
97
  events.push(
96
98
  evt({
97
99
  index: Number(eventNum),
@@ -117,11 +119,13 @@ export function parseOTIO(otio, opts = {}) {
117
119
  const events = [];
118
120
  let idx = 1;
119
121
  let vNum = 0;
122
+ let aNum = 0;
120
123
  for (const track of tracks) {
121
124
  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}`);
125
+ if (isAudio) aNum += 1; else vNum += 1;
126
+ // First track of each kind keeps the bare letter (compat); higher tracks
127
+ // are numbered ('V2', 'A2', …).
128
+ const kind = isAudio ? (aNum === 1 ? 'A' : `A${aNum}`) : (vNum === 1 ? 'V' : `V${vNum}`);
125
129
  let rec = 0;
126
130
  for (const child of track.children || []) {
127
131
  const schema = child.OTIO_SCHEMA || '';
@@ -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),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)."),
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),audioOnly?+track? (explicit AUDIO placement on audio track 1-8; presence suppresses the A1 mirror; 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()])
@@ -114,19 +114,36 @@ async function assembleTimeline(spec = {}) {
114
114
  sources.forEach((src, i) => {
115
115
  (src.cuts || []).forEach((cut) => {
116
116
  const out = mediaRefs[i] ? { ...cut, mediaRef: mediaRefs[i] } : { ...cut };
117
- if (cut.speed !== undefined && cut.speed !== 1) {
117
+ if (cut.audioOnly) {
118
+ out.srcMeta = {
119
+ name: src.mediaFilePath.split('/').pop(),
120
+ mediaFilePath: src.mediaFilePath,
121
+ fps: Math.round(src.spec.fps || 24),
122
+ frameCount: src.spec.frameCount,
123
+ };
124
+ }
125
+ if (cut.reverse || (cut.speed !== undefined && cut.speed !== 1)) {
118
126
  // Constant-speed retime (forward only). The Sm2TimeMap spans the
119
127
  // whole source stretched by 1/speed; the clip windows into it with
120
128
  // RECORD-domain In/Duration (measured live on 19.1.3.7), so the
121
129
  // source-domain srcIn converts by /speed here.
122
- if (!(cut.speed > 0)) throw new RangeError('assembleTimeline: cut.speed must be > 0 (reverse not supported)');
130
+ const speed = cut.speed ?? 1;
131
+ if (!(speed > 0)) throw new RangeError('assembleTimeline: cut.speed must be > 0 (use cut.reverse for backwards)');
123
132
  const fps = Math.round(src.spec.fps || 24);
124
133
  out.timemap = buildConstantSpeedTimemapKeyed({
125
- speed: cut.speed, sourceFrames: src.spec.frameCount, fps,
126
- uniqueId: randomUUID(),
134
+ speed, sourceFrames: src.spec.frameCount, fps,
135
+ uniqueId: randomUUID(), reverse: !!cut.reverse,
127
136
  }).toString('hex');
128
- out.srcIn = Math.round((cut.srcIn ?? 0) / cut.speed);
137
+ // Record-domain In. Forward: srcIn/speed. Reverse: the clip plays
138
+ // source [srcIn, srcIn + dur*speed) BACKWARDS — the map descends
139
+ // from the source tail, so In measures from the END:
140
+ // (sourceFrames - srcIn - dur*speed)/speed (measured live: In=0
141
+ // on a reversed full-speed clip reads back source 191→143).
142
+ out.srcIn = cut.reverse
143
+ ? Math.round((src.spec.frameCount - (cut.srcIn ?? 0) - cut.durationFrames * speed) / speed)
144
+ : Math.round((cut.srcIn ?? 0) / speed);
129
145
  delete out.speed;
146
+ delete out.reverse;
130
147
  }
131
148
  allCuts.push(out);
132
149
  });
@@ -38,6 +38,10 @@ const { clipDbId, setClipStart, setClipDuration, setClipIn } = require('./splice
38
38
  * track is the 1-based VIDEO track (default 1); missing tracks are grown as
39
39
  * empty clones. Cuts on track > 1 are placed VIDEO-ONLY — their audio would
40
40
  * overlap the track-1 cuts' audio on A1 (the template has a single A1).
41
+ * audioOnly:true makes a cut an AUDIO clip placement instead (track then
42
+ * indexes AUDIO tracks, grown as needed) — and the presence of ANY
43
+ * audioOnly cut suppresses the implicit A1 mirror of track-1 video cuts
44
+ * (explicit audio wins over the convenience mirror).
41
45
  * @param {string} [opts.timelineUuid]
42
46
  * @returns {Promise<{buffer: Buffer, cutCount: number, clipDbIds: string[]}>}
43
47
  */
@@ -79,6 +83,26 @@ async function cutSourceIntoClips(drpInput, opts = {}) {
79
83
  // element instead of the donor's.
80
84
  c = c.replace(/<MediaRef>[0-9a-f-]{36}<\/MediaRef>/, `<MediaRef>${cut.mediaRef}</MediaRef>`);
81
85
  }
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.
93
+ const { name, mediaFilePath, fps, frameCount } = cut.srcMeta;
94
+ c = c.replace(/<Name>[\s\S]*?<\/Name>/, `<Name>${name}</Name>`);
95
+ c = c.replace(/<MediaFilePath>[\s\S]*?<\/MediaFilePath>/, `<MediaFilePath>${mediaFilePath}</MediaFilePath>`);
96
+ const rate = Buffer.alloc(16);
97
+ rate.writeDoubleLE(fps, 0);
98
+ c = c.replace(/<MediaFrameRate>[0-9a-fA-F]*<\/MediaFrameRate>/, `<MediaFrameRate>${rate.toString('hex')}</MediaFrameRate>`);
99
+ const idm = Buffer.alloc(9);
100
+ idm.writeUInt8(0x02, 0);
101
+ idm.writeDoubleBE((frameCount - 1) / fps, 1);
102
+ 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>');
105
+ }
82
106
  if (cut.timemap) {
83
107
  // Constant-speed retime: swap the identity MediaTimemapBA for the
84
108
  // caller-built Sm2TimeMap (r19 keyed form render/readback-verified;
@@ -96,19 +120,44 @@ async function cutSourceIntoClips(drpInput, opts = {}) {
96
120
  if (!clips.length) continue; // audio-less media: nothing to cut on A1
97
121
  const donor = clips[0];
98
122
  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(''));
123
+ const audioCuts = cuts.filter((cut) => cut.audioOnly);
124
+ if (audioCuts.length) {
125
+ // Explicit audio placements: grow audio tracks and place per track;
126
+ // the implicit A1 mirror is suppressed (explicit audio wins).
127
+ const maxA = Math.max(1, ...audioCuts.map((cut) => cut.track ?? 1));
128
+ if (maxA > tracks.length) {
129
+ // Audio tracks CANNOT be grown by cloning: the per-timeline
130
+ // Fairlight model (FLStudioModelBA inside the pool's
131
+ // Sm2Sequence.FieldsBlob) holds one strip per track, and a cloned
132
+ // track without a strip imports fine, reads back fine, and renders
133
+ // SILENT (measured). The r19 media template is captured with 8
134
+ // audio tracks (valid strips included); beyond that, refuse.
135
+ throw new Error(
136
+ `cutSourceIntoClips: audio track ${maxA} exceeds the template's ${tracks.length} ` +
137
+ 'audio tracks — audio tracks cannot be grown offline (no Fairlight strip = silent). ' +
138
+ 'Re-capture a template with more audio tracks to raise the ceiling.');
139
+ }
140
+ for (let t = 1; t <= tracks.length; t += 1) {
141
+ const mine = audioCuts.filter((cut) => (cut.track ?? 1) === t);
142
+ if (t > 1 && !mine.length) continue;
143
+ tracks[t - 1] = setItemsInner(tracks[t - 1], mine.map((cut) => cloneCut(donor, cut)).join(''));
144
+ }
145
+ } else {
146
+ // A1 mirrors track-1 video cuts only; higher video tracks stay
147
+ // video-only, and so do RETIMED cuts (the audio clone would need its
148
+ // 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));
150
+ tracks[0] = setItemsInner(tracks[0], a1.join(''));
151
+ }
104
152
  xml = replaceTrackVec(xml, trackType, match, tracks);
105
153
  continue;
106
154
  }
107
- const maxTrack = Math.max(1, ...cuts.map((cut) => cut.track ?? 1));
155
+ const vCuts = cuts.filter((cut) => !cut.audioOnly);
156
+ const maxTrack = Math.max(1, ...vCuts.map((cut) => cut.track ?? 1));
108
157
  const cloneSource = tracks[0];
109
158
  while (tracks.length < maxTrack) tracks.push(emptyTrackClone(cloneSource));
110
159
  for (let t = 1; t <= tracks.length; t += 1) {
111
- const mine = cuts.filter((cut) => (cut.track ?? 1) === t);
160
+ const mine = vCuts.filter((cut) => (cut.track ?? 1) === t);
112
161
  if (t > 1 && !mine.length) continue; // grown-empty or untouched track keeps its items
113
162
  const clones = mine.map((cut) => cloneCut(donor, cut));
114
163
  for (const c of clones) clipDbIds.push(clipDbId(c));
@@ -227,8 +227,8 @@ function buildConstantSpeedTimemap({ speed, sourceDurationSec, uniqueId, recordD
227
227
  * @param {string} p.uniqueId - fresh uuid (bare, no braces).
228
228
  * @returns {Buffer}
229
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)');
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
232
  if (!Number.isInteger(sourceFrames) || sourceFrames < 1) throw new TypeError('buildConstantSpeedTimemapKeyed: sourceFrames must be a positive integer');
233
233
  const YMax = (sourceFrames - 1) / fps;
234
234
  const XMax = (sourceFrames / speed - 1) / fps;
@@ -241,9 +241,13 @@ function buildConstantSpeedTimemapKeyed({ speed, sourceFrames, fps = 24, uniqueI
241
241
  { key: 'XIn', type: T_DOUBLE, subType: 0, value: 0 },
242
242
  { key: 'X', type: T_DOUBLE, subType: 0, value: X },
243
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).
244
248
  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) },
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) },
247
251
  ] }).toString('hex');
248
252
  return encodeKeyedDict({ hdr: 1, entries: [
249
253
  { key: 'YMax', type: T_DOUBLE, subType: 0, value: YMax },
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "2.113.0"
90
+ VERSION = "2.115.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.113.0"
14
+ VERSION = "2.115.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": "Audio tracks cannot be grown in an imported timeline; Fairlight strips live in the pool Sm2Sequence.FieldsBlob",
1919
+ "object": "Sm2TiTrack (audio) / FLStudioModelBA",
1920
+ "reality": "An audio track added to a SeqContainer by cloning "
1921
+ "imports fine, reads back fine (track count, items, "
1922
+ "everything), and renders SILENT. The per-timeline "
1923
+ "Fairlight model (FLStudioModelBA, ~7KB compressed to "
1924
+ "~420KB, inside the media pool's Sm2Sequence.FieldsBlob) "
1925
+ "holds one strip per audio track, and a track without a "
1926
+ "strip is mute. Measured by elimination on 19.1.3.7: "
1927
+ "clip byte-identical to a live-authored one, track "
1928
+ "byte-identical (SubType is the CHANNEL FORMAT code - "
1929
+ "1=mono - not an ordinal), pool entry shared with a "
1930
+ "playing A1 clip - still silent; only a timeline whose "
1931
+ "template was CAPTURED with the tracks plays.",
1932
+ "recommended": "Never clone audio tracks offline. Capture the "
1933
+ "template with the audio tracks already present "
1934
+ "(the r19 media template carries 8 mono tracks with "
1935
+ "valid strips; drt.assemble audioOnly cuts land on "
1936
+ "them and render at native level). Refuse placements "
1937
+ "beyond the captured ceiling. Audio aliveness is "
1938
+ "readback-blind: verify by rendered RMS, not "
1939
+ "structure.",
1940
+ "tags": ["fairlight", "audio", "import", "silent-failure", "drt"],
1941
+ "submit": "bug",
1942
+ },
1917
1943
  {
1918
1944
  "symbol": "MediaTimemapBA keyframes are generation-split; 19.x silently ignores the R21 protobuf form",
1919
1945
  "object": "Sm2TimeMap (per-clip retime blob)",
@@ -1927,7 +1953,11 @@ API_TRUTH: List[Dict[str, Any]] = [
1927
1953
  "source 0..48 over 96 frames and a live 50% render). "
1928
1954
  "The map spans the WHOLE source stretched by 1/speed; "
1929
1955
  "the clip's <In>/<Duration> window into it in RECORD "
1930
- "frames (srcIn converts by /speed).",
1956
+ "frames (srcIn converts by /speed). REVERSE is the same "
1957
+ "envelope with the Y endpoints swapped - kf0=(0,YMax), "
1958
+ "kf1=(XMax,0) - and In then measures from the source "
1959
+ "END: (frames - srcIn - dur*speed)/speed (measured: a "
1960
+ "reversed srcIn-24 dur-48 cut reads back source 71->23).",
1931
1961
  "recommended": "Author retimes for pre-21 hosts with the keyed "
1932
1962
  "form (drt.assemble cuts[].speed does this; encoder "
1933
1963
  "byte-exact against a live 19.1.3.7 harvest). Treat "