davinci-resolve-mcp 2.115.0 → 2.116.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.
@@ -0,0 +1,110 @@
1
+ # Native DRT Authoring — offline timelines that import AND render
2
+
3
+ The `drt` tool in the advanced server (`drt.assemble`,
4
+ `drt.assemble_from_interchange`) writes a **native-schema `.drt`** — a real
5
+ Resolve project archive that `ImportTimelineFromFile` accepts — entirely
6
+ offline. Unlike XML/AAF/OTIO interchange (which lands offline and needs a
7
+ relink), a correctly assembled `.drt` arrives **linked and rendering**,
8
+ because it carries native media-pool descriptors captured from a live Resolve.
9
+
10
+ Everything below was measured live on Studio 19.1.3.7 and render-verified
11
+ (frame luma via ffmpeg signalstats; audio via rendered RMS). Version numbers
12
+ mark the release where each capability shipped.
13
+
14
+ ## The core doctrine: readback is blind, render is truth
15
+
16
+ The recurring bug class of this entire subsystem: a structure that imports
17
+ fine, **reads back fine through every API**, and renders black or silent.
18
+ Structural readback cannot detect any of these:
19
+
20
+ - media clips without native pool descriptors (black)
21
+ - Fusion title comps whose bytes were edited offline (black — see below)
22
+ - audio clips cloned from a donor with the donor's identity (silent)
23
+ - audio tracks added by cloning (silent — no Fairlight strip)
24
+ - a timemap in the wrong generation's encoding (plays at 100%, no warning)
25
+
26
+ Always verify with a render: video by frame luma, audio by RMS in the target
27
+ window. `render.verify_output` covers the container-level checks.
28
+
29
+ ## Prerequisites
30
+
31
+ - **Per media file, once**: `media_pool.capture_media_template` (Python
32
+ server, live Resolve required once). It caches the file's native pool
33
+ `<Element>` + MediaRef under `~/.config/davinci-resolve-mcp/media-templates/`.
34
+ After that, all assembly for that file is fully offline.
35
+ - Pass `targetAppVersion` (e.g. `"19.1.3"`) when the importing host is
36
+ pre-21: it selects the r19-generation templates and version stamps.
37
+
38
+ ## What you can author (all render-verified on 19.1.3)
39
+
40
+ | Capability | Spec surface | Since |
41
+ |---|---|---|
42
+ | Media cuts, multi-source | `media: [{mediaFilePath, spec, cuts}]` | v2.106–2.107 |
43
+ | Multi-track video (V2+ stacking) | `cuts[].track` (video-only above V1) | v2.112 |
44
+ | Cross-dissolves | `transitions: [{track, atFrame, durationFrames}]` | v2.111 |
45
+ | Audio cross-fades | `transitions[].trackType: 'audio'` | v2.116 |
46
+ | Constant retimes, forward | `cuts[].speed` (e.g. `0.5`) | v2.113 |
47
+ | Constant retimes, reverse | `cuts[].reverse` | v2.114 |
48
+ | Audio placements, A1–A8 | `cuts[].audioOnly + track` | v2.115 |
49
+ | Built-in generators | `elements: [{type:'generator', generatorName}]` | v2.110 |
50
+ | Fusion titles | `elements: [{type:'title', text}]` — **21-gen hosts only** | v2.108 |
51
+
52
+ `assemble_from_interchange` drives the same engine from an EDL / OTIO /
53
+ FCP7-XML / AAF plus a `sourceMap`, and returns an honesty ledger
54
+ (`authoredTransitions`, `droppedTransitions` with reasons, `authoredRetimes`,
55
+ `flattenedRetimes`, `authoredAudioEvents`, `upperTrackCutsVideoOnly`).
56
+
57
+ ## The laws (why the constraints are what they are)
58
+
59
+ **Fusion comp cache law (titles).** On 19.x, an imported Fusion comp renders
60
+ only via the machine's disk cache, keyed to the comp blob's *exact
61
+ compressed bytes*. Any offline byte change — even an identity recompression —
62
+ misses the cache and renders black; live Fusion render of imported comps
63
+ produces no frames on 19. Therefore offline title *text* cannot be authored
64
+ for pre-21 hosts. The working flow: assemble everything else offline, then
65
+ set title text post-import with `timeline.set_title_text` (live-verified on
66
+ 19.1.3). Generators are exempt — plain `Sm2TiGenerator`, no comp — and
67
+ render everywhere (Solid Color, SMPTE Color Bar, Grey Scale verified).
68
+
69
+ **Fairlight strip law (audio).** Audio tracks cannot be grown offline: the
70
+ per-timeline Fairlight model (`FLStudioModelBA` in the pool's
71
+ `Sm2Sequence.FieldsBlob`) holds one mixer strip per audio track, and a
72
+ cloned track without a strip is mute. The r19 media template is captured
73
+ with **8 mono audio tracks** (strips included); audio placements beyond
74
+ track 8 refuse with instructions to re-capture a bigger template. Explicit
75
+ `audioOnly` cuts suppress the A1 convenience mirror (which otherwise
76
+ mirrors track-1 video cuts).
77
+
78
+ **Timemap generation split (retimes).** `Sm2TimeMap` keyframes are protobuf
79
+ points on 21 but keyed-dicts on 19, and 19 *silently ignores* the protobuf
80
+ form (clip plays 100%). The r19 keyed encoder is byte-exact against
81
+ Resolve's own output. The map spans the whole source stretched by 1/speed;
82
+ the clip's `<In>`/`<Duration>` window into it in record-domain frames.
83
+ Reverse is the same map with the Y endpoints swapped, and `In` then measures
84
+ from the source end.
85
+
86
+ **Timeline origin.** Template timelines start at frame 86400
87
+ (01:00:00:00 @ 24fps). Clips placed before the origin are silently dropped
88
+ by Resolve on import — `startFrame` is timeline-absolute.
89
+
90
+ **Dissolve geometry.** A transition is authored only when the predecessor
91
+ ends exactly at the cut and both sides have handle media for the centered
92
+ span (incoming `srcIn ≥ dur/2`; outgoing `srcIn + dur + dur/2 ≤ frameCount`).
93
+ Everything else stays in `droppedTransitions` with the reason.
94
+
95
+ ## Verification checklist for a delivered .drt
96
+
97
+ 1. `timeline.import_timeline_checked` — expect `linked == total` for media
98
+ (generators legitimately count as offline).
99
+ 2. Render a probe range; check frame luma at cut boundaries, dissolve
100
+ midpoints (expect the blend average), and retime windows.
101
+ 3. For audio: RMS per window (silence = -inf is a failed placement).
102
+ 4. Never trust `created_new: false` — a same-named timeline already in the
103
+ project is returned as "success" (internal-name-wins law).
104
+
105
+ ## References
106
+
107
+ - `docs/reference/api-limitations.md` — the measured laws in report form
108
+ - `resolve-advanced/vendor/drp-format/` — the codec layer (each module's
109
+ header documents its measured ground truth)
110
+ - Guides: `conforming-an-avid-aaf.md` for the AAF ingest side
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "2.115.0"
40
+ VERSION = "2.116.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.115.0",
3
+ "version": "2.116.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -299,6 +299,7 @@ export function eventsToAssembleSpec(events, opts = {}) {
299
299
  const audioTrackNum = (t) => { const m = /^A(\d+)?$/.exec(String(t)); return m && m[1] ? parseInt(m[1], 10) : 1; };
300
300
  const audioPlacements = [];
301
301
  const audioRetimesSkipped = [];
302
+ const audioTransCandidates = [];
302
303
  for (const e of auds) {
303
304
  const recIn = ORIGIN + (toTl(e.recIn, e.fps) - minRec);
304
305
  const recOut = ORIGIN + (toTl(e.recOut, e.fps) - minRec);
@@ -309,7 +310,16 @@ export function eventsToAssembleSpec(events, opts = {}) {
309
310
  }
310
311
  const track = audioTrackNum(e.track);
311
312
  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 (e.transition) {
314
+ let d = Math.max(2, toTl(e.transition.duration || 0, e.fps) || 2);
315
+ d += d % 2;
316
+ audioTransCandidates.push({
317
+ atFrame: recIn, durationFrames: d, track,
318
+ index: e.index, type: e.transition.type, rawDuration: e.transition.duration,
319
+ source: e.source, srcIn: cut.srcIn,
320
+ });
321
+ }
322
+ audioPlacements.push({ start: recIn, end: recOut, index: e.index, track, source: e.source, srcIn: cut.srcIn, durationFrames });
313
323
  if (!perSource.has(e.source)) perSource.set(e.source, []);
314
324
  perSource.get(e.source).push(cut);
315
325
  }
@@ -384,6 +394,29 @@ export function eventsToAssembleSpec(events, opts = {}) {
384
394
  }
385
395
  transitions.push({ track: c.track, atFrame: c.atFrame, durationFrames: c.durationFrames });
386
396
  }
397
+ // Audio cross-fades, same geometry rules (render-verified on 19.1.3.7 via
398
+ // the harvested cross-fade template: the highpass RMS ramps, not steps).
399
+ for (const c of audioTransCandidates) {
400
+ const prev = audioPlacements.find((pl) => pl.track === c.track && pl.end === c.atFrame);
401
+ if (!prev) {
402
+ droppedTransitions.push({ index: c.index, type: c.type, duration: c.rawDuration, trackType: 'audio', reason: 'no abutting predecessor at the cut' });
403
+ continue;
404
+ }
405
+ const half = c.durationFrames / 2;
406
+ const bHandle = c.srcIn >= half;
407
+ const aSpec = sourceMap[prev.source] && sourceMap[prev.source].spec;
408
+ const aFrames = aSpec && Number(aSpec.frameCount);
409
+ const aHandle = Number.isFinite(aFrames) ? prev.srcIn + prev.durationFrames + half <= aFrames : false;
410
+ if (!bHandle || !aHandle) {
411
+ droppedTransitions.push({
412
+ index: c.index, type: c.type, duration: c.rawDuration, trackType: 'audio',
413
+ reason: `insufficient handles for a centered ${c.durationFrames}f cross-fade` +
414
+ `${bHandle ? '' : ' (incoming srcIn < half)'}${aHandle ? '' : ' (outgoing tail media < half)'}`,
415
+ });
416
+ continue;
417
+ }
418
+ transitions.push({ track: c.track, atFrame: c.atFrame, durationFrames: c.durationFrames, trackType: 'audio' });
419
+ }
387
420
 
388
421
  return {
389
422
  spec: { timelineName, media, ...(transitions.length ? { transitions } : {}) },
@@ -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),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)."),
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), transitions?: [{track, atFrame, durationFrames?, trackType? ('video' dissolve | 'audio' cross-fade, both render-verified on 19)}], elements?: [{type:'title'|'generator', track, startFrame, durationFrames?, text?, generatorName? ('Solid Color'|'SMPTE Color Bar'|'Grey Scale' render-verified on 19), ...}] }. 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()])
@@ -44,5 +44,7 @@ test('placeTransition errors when no abutting boundary at atFrame', async () =>
44
44
  test('placeTransition validates args', async () => {
45
45
  const buf = await synth2();
46
46
  await assert.rejects(() => placeTransition(buf, { track: 1, atFrame: 100, durationFrames: 1 }), /durationFrames/);
47
- await assert.rejects(() => placeTransition(buf, { track: 1, atFrame: 100, trackType: 'audio' }), /only video/);
47
+ // trackType 'audio' is now supported (harvested cross-fade template,
48
+ // v2.116.0); an unknown trackType still refuses.
49
+ await assert.rejects(() => placeTransition(buf, { track: 1, atFrame: 100, trackType: 'subtitle' }), /video or audio/);
48
50
  });
@@ -181,6 +181,7 @@ async function assembleTimeline(spec = {}) {
181
181
  if (!tr || typeof tr !== 'object') throw new TypeError(`assembleTimeline: transitions[${i}] must be an object`);
182
182
  ({ buffer } = await placeTransition(buffer, {
183
183
  track: tr.track, atFrame: tr.atFrame, durationFrames: tr.durationFrames,
184
+ trackType: tr.trackType || 'video',
184
185
  }));
185
186
  }
186
187
 
@@ -33,6 +33,11 @@ function splitItems(itemsInner) {
33
33
  }
34
34
 
35
35
  const TEMPLATE_PATH = path.join(__dirname, 'templates', 'transition-cross-dissolve.xml');
36
+ // Audio cross-fade, harvested from a live 19.1.3.7 XMEML import of an FCP7
37
+ // KGAudioTransCrossFade (render-verified: the highpass RMS ramps through the
38
+ // junction instead of stepping). PrettyType reads "Final Cut Pro 7" — that is
39
+ // what Resolve itself stores for it, and it renders.
40
+ const AUDIO_TEMPLATE_PATH = path.join(__dirname, 'templates', 'transition-cross-fade-r19.xml');
36
41
 
37
42
  const clipStart = (c) => { const m = c.match(/<Start>(\d+)<\/Start>/); return m ? parseInt(m[1], 10) : null; };
38
43
  const clipDuration = (c) => { const m = c.match(/<Duration>(\d+)<\/Duration>/); return m ? parseInt(m[1], 10) : null; };
@@ -45,7 +50,7 @@ const clipDuration = (c) => { const m = c.match(/<Duration>(\d+)<\/Duration>/);
45
50
  * @param {number} opts.track - 1-based video track.
46
51
  * @param {number} opts.atFrame - the cut frame (where one clip ends and the next begins).
47
52
  * @param {number} [opts.durationFrames=24] - transition length (even number recommended; centered).
48
- * @param {'video'} [opts.trackType='video'] - audio cross-fade uses a different template (not bundled).
53
+ * @param {'video'|'audio'} [opts.trackType='video'] - 'audio' places the harvested cross-fade (render-verified on 19.1.3).
49
54
  * @param {string} [opts.timelineUuid]
50
55
  * @returns {Promise<{buffer:Buffer, entry:string, timelineUuid:string, track:number,
51
56
  * atFrame:number, start:number, durationFrames:number, transitionDbId:string|null}>}
@@ -55,7 +60,7 @@ async function placeTransition(drpInput, opts = {}) {
55
60
  if (!Number.isInteger(track) || track < 1) throw new TypeError('placeTransition: track must be a positive integer');
56
61
  if (!Number.isInteger(atFrame)) throw new TypeError('placeTransition: atFrame must be an integer');
57
62
  if (!Number.isInteger(durationFrames) || durationFrames < 2) throw new TypeError('placeTransition: durationFrames must be an integer >= 2');
58
- if (trackType !== 'video') throw new Error('placeTransition: only video cross-dissolve is supported (no bundled audio template)');
63
+ if (trackType !== 'video' && trackType !== 'audio') throw new Error('placeTransition: trackType must be video or audio');
59
64
 
60
65
  const zip = await loadDrpZip(drpInput);
61
66
  const { entry, xml: seqXml, seqId } = await selectTargetSeq(zip, timelineUuid);
@@ -71,7 +76,7 @@ async function placeTransition(drpInput, opts = {}) {
71
76
  }
72
77
  if (leftIdx < 0) throw new Error(`placeTransition: no abutting clip boundary at frame ${atFrame} on track ${track}`);
73
78
 
74
- let trans = fs.readFileSync(TEMPLATE_PATH, 'utf8').trim();
79
+ let trans = fs.readFileSync(trackType === 'audio' ? AUDIO_TEMPLATE_PATH : TEMPLATE_PATH, 'utf8').trim();
75
80
  trans = freshDbIds(trans);
76
81
  const start = atFrame - Math.floor(durationFrames / 2); // centered (AlignmentType 2)
77
82
  trans = trans.replace(/<Start>\d+<\/Start>/, `<Start>${start}</Start>`);
@@ -0,0 +1,22 @@
1
+ <Element>
2
+ <Sm2TiTransition DbId="43e55250-3117-4f6e-abf0-ce028e94bf71">
3
+ <FieldsBlob>000000020000001b8012180000002c789c636660640001e62b75aa0c04000033c3017f</FieldsBlob>
4
+ <PrettyType>Final Cut Pro 7</PrettyType>
5
+ <Name/>
6
+ <Start>36</Start>
7
+ <Duration>24</Duration>
8
+ <LinkedItemSync/>
9
+ <WasDisbanded>false</WasDisbanded>
10
+ <MarkersBA/>
11
+ <UiMemento>0</UiMemento>
12
+ <Flags>0</Flags>
13
+ <PriorityIndex>0</PriorityIndex>
14
+ <EffectFiltersBA>00000002000001418128b52ffd60ea02b50900d24e3631608bda00016b1886611886618aaa90c45a8b2c4bb74e225968cc77bbadcdb43f34bdb7510008cd286032991041dcafba3a6db563c78e1f207ad278bd56901d3be66054645e53f64ec70e0cfdd6395ec67bbd5ad6a24a3d7f9d724c23acdc237c3969bcd14eafb71f24ba8e1ffab3d3eb4423ac3212140da3e3030d6c4098b087fe98b5b46efaa5cf4b7da7f3948a06887acc1863ab5b85577fe4cb10e35c6c4c1b8b08035e8d6996843765b48b674648a9f9ac295016da65d268eb7bb08f0c615802d148a3258514040c7448221f13001601828924325a012300e9f0abc6e90af86f499c4a043085b2b044a134bcb479940460f4f9df4213e27805a1a8862fe8bcb86058bb0b23aeab01486d4073cdcda64fc1d02980a688b20aada10ad6ead65663955acbce363a856d7330570bd2da6206</EffectFiltersBA>
15
+ <ImportExportMetadataBA/>
16
+ <RenderTextEnabled>true</RenderTextEnabled>
17
+ <RenderTextGanged>true</RenderTextGanged>
18
+ <RenderTextPrefixed>true</RenderTextPrefixed>
19
+ <AlignmentType>2</AlignmentType>
20
+ <Position>2</Position>
21
+ </Sm2TiTransition>
22
+ </Element>
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "2.115.0"
90
+ VERSION = "2.116.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.115.0"
14
+ VERSION = "2.116.0"
15
15
 
16
16
  import base64
17
17
  import os