davinci-resolve-mcp 2.114.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,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
+ ### 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)`
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "2.114.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.114.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>",
@@ -207,7 +207,9 @@ export function eventsToOTIO(events, opts = {}) {
207
207
  * dropped transitions (cross-dissolves are AUTHORED when the predecessor
208
208
  * abuts the cut and both sides have handle media — render-verified on
209
209
  * 19.1.3.7; otherwise dropped with the reason, as a cut at the boundary),
210
- * 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.
211
213
  *
212
214
  * @param {Array} events - normalized events (parseInterchange shape)
213
215
  * @param {object} opts
@@ -226,11 +228,13 @@ export function eventsToAssembleSpec(events, opts = {}) {
226
228
  throw new TypeError('eventsToAssembleSpec: sourceMap {reel: {mediaFilePath, spec}} is required');
227
229
  }
228
230
  const ORIGIN = 86400;
229
- const vids = events.filter((e) => e.track !== 'A' && e.recIn != null && e.recOut != null);
230
- 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;
231
235
  if (!vids.length) throw new Error('eventsToAssembleSpec: no video events with record ranges');
232
236
 
233
- 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]))];
234
238
  if (unmapped.length) {
235
239
  throw new Error(
236
240
  `eventsToAssembleSpec: unmapped source reel(s): ${unmapped.join(', ')} — ` +
@@ -287,6 +291,44 @@ export function eventsToAssembleSpec(events, opts = {}) {
287
291
  perSource.get(e.source).push(cut);
288
292
  }
289
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
+
290
332
  // Overlap is judged PER VIDEO TRACK — V2 stacking over V1 is legitimate
291
333
  // conform geometry (render-verified: an upper-track clip covers the lower).
292
334
  placements.sort((a, b) => a.start - b.start);
@@ -349,6 +391,8 @@ export function eventsToAssembleSpec(events, opts = {}) {
349
391
  videoEvents: vids.length,
350
392
  sources: media.length,
351
393
  audioEventsSkipped: audioSkipped,
394
+ authoredAudioEvents: audioPlacements.length,
395
+ audioRetimesSkipped,
352
396
  upperTrackCutsVideoOnly: placements.filter((pl) => pl.track > 1).length,
353
397
  flattenedRetimes,
354
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?/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)."),
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,6 +114,14 @@ 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.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
+ }
117
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
@@ -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));
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "2.114.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.114.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)",