davinci-resolve-mcp 2.140.0 → 2.142.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,46 @@
2
2
 
3
3
  Release history for the DaVinci Resolve MCP Server. The latest release is summarized in the root README; older entries live here to keep the README focused.
4
4
 
5
+ ## What's New in v2.142.0 — transition span fidelity, and the edge law
6
+
7
+ ### Changed (conform fidelity)
8
+
9
+ - **Transition spans now follow the turnover's actual geometry** instead of
10
+ always centering: EDL dissolves/wipes span `[cut, cut+dur)` (the CMX
11
+ convention), OTIO transitions use their explicit in/out offsets, XMEML
12
+ transitionitems use their own record span, and spec-level
13
+ `transitions[].startFrame` overrides. Handle requirements follow the real
14
+ span — a start-at-cut EDL dissolve needs NO incoming handle and a
15
+ full-duration outgoing tail. Render-verified: the authored EDL dissolve
16
+ blends exactly `[cut, cut+24)` (pure outgoing through cut−1, linear blend,
17
+ midpoint 182), matching Resolve's own EDL importer frame-for-frame.
18
+
19
+ ### Measured (the edge law)
20
+
21
+ - A transition's rendered span follows `<Start>`/`<Duration>`
22
+ (`AlignmentType` is cosmetic), **but the clip boundary must sit strictly
23
+ inside the span** — an edge-aligned span (Start == the boundary) renders
24
+ inert. That is why Resolve's own EDL importer moves the cut +dur/2 and
25
+ centers; the bridge now reproduces exactly that cut-reshaping. Off-center
26
+ spans render fine (an uneven `[cut−6, cut+18)` span blended linearly
27
+ across its full width).
28
+
29
+ ## What's New in v2.141.0 — OTIO transitions, depth-4, and honest gaps
30
+
31
+ ### Added
32
+
33
+ - **OTIO `Transition` children parse and author** — they occupy no record
34
+ time (the AAF overlap convention), attach to the incoming clip, and route
35
+ through the same style table as XMEML effectids (`SMPTE_Dissolve` → plain
36
+ dissolve; wipe-named types → the wipe). A Gap breaks the junction.
37
+
38
+ ### Measured
39
+
40
+ - **Depth-4 nested compounds render** (234 through four levels via the spec
41
+ route) — nesting depth is effectively unbounded on 19.1.3.
42
+ - **Record gaps render clean black** (cut 122.9 / gap 16.0 / white 234) —
43
+ sparse turnovers assemble without synthetic filler.
44
+
5
45
  ## What's New in v2.140.0 — the transition style registry
6
46
 
7
47
  ### Added
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  English | [简体中文](README.zh-CN.md)
4
4
 
5
- [![Version](https://img.shields.io/badge/version-2.140.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.142.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-36%20(353%20full)-blue.svg)](#server-modes)
package/README.zh-CN.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [English](README.md) | 简体中文
4
4
 
5
- [![Version](https://img.shields.io/badge/version-2.140.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.142.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-36%20(353%20full)-blue.svg)](#服务器模式)
@@ -12,7 +12,7 @@
12
12
  [![Python](https://img.shields.io/badge/python-3.10+-green.svg)](https://www.python.org/downloads/)
13
13
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT)
14
14
 
15
- > 本翻译对应 v2.140.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v2.142.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
@@ -58,7 +58,7 @@ window. `render.verify_output` covers the container-level checks.
58
58
  | Subtitles | `spec.subtitles` / `spec.subtitlesSrt` (raw SRT) | v2.128 |
59
59
  | Compound clips | survive `extract_from_drp` → `.drt` (inner containers kept, recursive) | v2.130 |
60
60
  | Compound authoring | `spec.compounds` (multiple compose; nested edits render) | v2.131–2.132 |
61
- | Nested compounds | `compounds[].compounds` (depth-2 and depth-3 playback render-verified) | v2.134 |
61
+ | Nested compounds | `compounds[].compounds` (depth-2 through depth-4 playback render-verified) | v2.134–2.141 |
62
62
  | Fusion titles | `elements: [{type:'title', text}]` — **21-gen hosts only** | v2.108 |
63
63
 
64
64
  `assemble_from_interchange` drives the same engine from an EDL / OTIO /
@@ -115,6 +115,10 @@ runs in the "working" direction. The real freeze (harvested from a live EDL
115
115
  left **empty**. `cuts[].freeze: true` authors exactly that; EDL `M2 000.0`
116
116
  and zero-speed warps route to it through `assemble_from_interchange`.
117
117
 
118
+ **Gaps are legitimate.** Cuts need not abut: record gaps between placements
119
+ render as clean black (measured: cut 122.9 / gap 16 / white 234), so sparse
120
+ turnovers assemble without synthetic filler.
121
+
118
122
  **Timeline origin.** Template timelines start at frame 86400
119
123
  (01:00:00:00 @ 24fps). Clips placed before the origin are silently dropped
120
124
  by Resolve on import — `startFrame` is timeline-absolute.
@@ -131,10 +135,20 @@ outgoing clip through the window) — so routing an XMEML turnover through
131
135
  `assemble_from_interchange` produces transitions that render where the host
132
136
  importer's do not.
133
137
 
134
- **Dissolve geometry.** A transition is authored only when the predecessor
135
- ends exactly at the cut and both sides have handle media for the centered
136
- span (incoming `srcIn dur/2`; outgoing `srcIn + dur + dur/2 frameCount`).
137
- Everything else stays in `droppedTransitions` with the reason.
138
+ **Dissolve geometry spans follow the turnover, and the edge law.** A
139
+ transition's rendered span follows `<Start>`/`<Duration>`; `AlignmentType`
140
+ is cosmetic. The clip boundary must sit STRICTLY INSIDE the span an
141
+ edge-aligned span (Start == the boundary) renders inert (measured), which is
142
+ why Resolve's own EDL importer moves the cut +dur/2 and centers. The bridge
143
+ reproduces per-format geometry exactly: EDL dissolves span [cut, cut+dur)
144
+ with the boundary shifted +dur/2 (CMX convention, host-importer parity —
145
+ render-verified: blend f48→f71 on a cut at 48); OTIO uses its explicit
146
+ in/out offsets; XMEML uses the transitionitem's own record span; spec-level
147
+ `transitions[].startFrame` overrides. Handles follow the actual span: the
148
+ incoming needs `srcIn ≥` the pre-cut portion, the outgoing needs tail media
149
+ for the post-cut portion. Off-center spans render (blend measured across an
150
+ uneven [cut-6, cut+18) span); everything unauthorable stays in
151
+ `droppedTransitions` with the reason.
138
152
 
139
153
  ## Verification checklist for a delivered .drt
140
154
 
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "2.140.0"
40
+ VERSION = "2.142.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.140.0",
3
+ "version": "2.142.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -307,14 +307,26 @@ export function eventsToAssembleSpec(events, opts = {}) {
307
307
  // be authored (abutting predecessor + handles both sides) is decided
308
308
  // after all placements are known.
309
309
  let d = Math.max(2, toTl(e.transition.duration || 0, e.fps) || 2);
310
- d += d % 2; // placeTransition centers on the cut; keep it even
310
+ d += d % 2; // even duration keeps every alignment on whole frames
311
+ // Span geometry per format (E73): `pre` = frames of the span BEFORE
312
+ // the cut. EDL: 0 (CMX start-at-cut — matches what Resolve's own EDL
313
+ // importer authors, E61 harvest); OTIO: the explicit in_offset; XMEML:
314
+ // derived from the transitionitem's own record start; no info: centered.
315
+ let pre = Math.floor(d / 2);
316
+ if (e.transition.alignment === 'start') pre = 0;
317
+ else if (e.transition.inOffset != null) pre = Math.min(d, Math.max(0, toTl(e.transition.inOffset, e.fps)));
318
+ else if (e.transition.recStart != null) {
319
+ const spanStartAbs = ORIGIN + (toTl(e.transition.recStart, e.fps) - minRec);
320
+ pre = Math.min(d, Math.max(0, recIn - spanStartAbs));
321
+ }
311
322
  transitionCandidates.push({
312
- atFrame: recIn, durationFrames: d, track: vTrack,
323
+ atFrame: recIn, durationFrames: d, track: vTrack, pre,
324
+ explicitSpan: e.transition.alignment != null || e.transition.inOffset != null || e.transition.recStart != null,
313
325
  index: e.index, type: e.transition.type, rawDuration: e.transition.duration,
314
- source: e.source, srcIn: cut.srcIn,
326
+ source: e.source, srcIn: cut.srcIn, incomingCutRef: cut,
315
327
  });
316
328
  }
317
- placements.push({ start: recIn, end: recOut, index: e.index, source: e.source, srcIn: cut.srcIn, durationFrames, track: vTrack });
329
+ placements.push({ start: recIn, end: recOut, index: e.index, source: e.source, srcIn: cut.srcIn, durationFrames, track: vTrack, cutRef: cut });
318
330
  if (!perSource.has(e.source)) perSource.set(e.source, []);
319
331
  perSource.get(e.source).push(cut);
320
332
  }
@@ -341,13 +353,17 @@ export function eventsToAssembleSpec(events, opts = {}) {
341
353
  if (e.transition) {
342
354
  let d = Math.max(2, toTl(e.transition.duration || 0, e.fps) || 2);
343
355
  d += d % 2;
356
+ let pre = Math.floor(d / 2);
357
+ if (e.transition.alignment === 'start') pre = 0;
358
+ else if (e.transition.inOffset != null) pre = Math.min(d, Math.max(0, toTl(e.transition.inOffset, e.fps)));
344
359
  audioTransCandidates.push({
345
- atFrame: recIn, durationFrames: d, track,
360
+ atFrame: recIn, durationFrames: d, track, pre,
361
+ explicitSpan: e.transition.alignment != null || e.transition.inOffset != null,
346
362
  index: e.index, type: e.transition.type, rawDuration: e.transition.duration,
347
- source: e.source, srcIn: cut.srcIn,
363
+ source: e.source, srcIn: cut.srcIn, incomingCutRef: cut,
348
364
  });
349
365
  }
350
- audioPlacements.push({ start: recIn, end: recOut, index: e.index, track, source: e.source, srcIn: cut.srcIn, durationFrames });
366
+ audioPlacements.push({ start: recIn, end: recOut, index: e.index, track, source: e.source, srcIn: cut.srcIn, durationFrames, cutRef: cut });
351
367
  if (!perSource.has(e.source)) perSource.set(e.source, []);
352
368
  perSource.get(e.source).push(cut);
353
369
  }
@@ -412,16 +428,21 @@ export function eventsToAssembleSpec(events, opts = {}) {
412
428
  droppedTransitions.push({ index: c.index, type: c.type, duration: c.rawDuration, reason: 'no abutting predecessor at the cut' });
413
429
  continue;
414
430
  }
415
- const half = c.durationFrames / 2;
416
- const bHandle = c.srcIn >= half;
431
+ // Handle math follows the actual span: `pre` frames before the cut need
432
+ // incoming pre-roll (srcIn >= pre); `post` frames after need outgoing
433
+ // tail media. A start-at-cut EDL dissolve needs NO incoming handle and a
434
+ // FULL-duration outgoing tail.
435
+ const pre = c.pre ?? c.durationFrames / 2;
436
+ const post = c.durationFrames - pre;
437
+ const bHandle = c.srcIn >= pre;
417
438
  const aSpec = sourceMap[prev.source] && sourceMap[prev.source].spec;
418
439
  const aFrames = aSpec && Number(aSpec.frameCount);
419
- const aHandle = Number.isFinite(aFrames) ? prev.srcIn + prev.durationFrames + half <= aFrames : false;
440
+ const aHandle = Number.isFinite(aFrames) ? prev.srcIn + prev.durationFrames + post <= aFrames : false;
420
441
  if (!bHandle || !aHandle) {
421
442
  droppedTransitions.push({
422
443
  index: c.index, type: c.type, duration: c.rawDuration,
423
- reason: `insufficient handles for a centered ${c.durationFrames}f dissolve` +
424
- `${bHandle ? '' : ' (incoming srcIn < half)'}${aHandle ? '' : ' (outgoing tail media < half)'}`,
444
+ reason: `insufficient handles for a ${c.durationFrames}f dissolve spanning [cut-${pre}, cut+${post})` +
445
+ `${bHandle ? '' : ` (incoming srcIn < ${pre})`}${aHandle ? '' : ` (outgoing tail media < ${post})`}`,
425
446
  });
426
447
  continue;
427
448
  }
@@ -438,7 +459,28 @@ export function eventsToAssembleSpec(events, opts = {}) {
438
459
  else if (/additive/i.test(raw)) kind = 'additive';
439
460
  else if (/fade to color/i.test(raw)) kind = 'fade-to-color';
440
461
  else if (/smooth cut/i.test(raw)) kind = 'smooth-cut';
441
- transitions.push({ track: c.track, atFrame: c.atFrame, durationFrames: c.durationFrames, ...(kind === 'dissolve' ? {} : { type: kind }) });
462
+ // EDGE LAW (E73, measured): the clip boundary must sit STRICTLY INSIDE
463
+ // the transition span — an edge-aligned span (Start == boundary) renders
464
+ // INERT. Resolve's own EDL importer solves the CMX start-at-cut case by
465
+ // MOVING the cut half the duration and centering; reproduce exactly that:
466
+ // extend the outgoing clip, trim the incoming head (source stays
467
+ // aligned), and keep the RENDERED span [cut-pre, cut-pre+d) unchanged.
468
+ let atFrame = c.atFrame;
469
+ const spanStartAbs = c.atFrame - pre;
470
+ if (c.explicitSpan && (pre === 0 || pre === c.durationFrames)) {
471
+ const shift = (pre === 0 ? 1 : -1) * (c.durationFrames / 2);
472
+ prev.cutRef.durationFrames += shift;
473
+ c.incomingCutRef.startFrame += shift;
474
+ c.incomingCutRef.srcIn += shift;
475
+ c.incomingCutRef.durationFrames -= shift;
476
+ prev.end += shift;
477
+ atFrame += shift;
478
+ }
479
+ transitions.push({
480
+ track: c.track, atFrame, durationFrames: c.durationFrames,
481
+ ...(kind === 'dissolve' ? {} : { type: kind }),
482
+ ...(c.explicitSpan ? { startFrame: spanStartAbs } : {}),
483
+ });
442
484
  }
443
485
  // Audio cross-fades, same geometry rules (render-verified on 19.1.3.7 via
444
486
  // the harvested cross-fade template: the highpass RMS ramps, not steps).
@@ -448,20 +490,35 @@ export function eventsToAssembleSpec(events, opts = {}) {
448
490
  droppedTransitions.push({ index: c.index, type: c.type, duration: c.rawDuration, trackType: 'audio', reason: 'no abutting predecessor at the cut' });
449
491
  continue;
450
492
  }
451
- const half = c.durationFrames / 2;
452
- const bHandle = c.srcIn >= half;
493
+ const pre = c.pre ?? c.durationFrames / 2;
494
+ const post = c.durationFrames - pre;
495
+ const bHandle = c.srcIn >= pre;
453
496
  const aSpec = sourceMap[prev.source] && sourceMap[prev.source].spec;
454
497
  const aFrames = aSpec && Number(aSpec.frameCount);
455
- const aHandle = Number.isFinite(aFrames) ? prev.srcIn + prev.durationFrames + half <= aFrames : false;
498
+ const aHandle = Number.isFinite(aFrames) ? prev.srcIn + prev.durationFrames + post <= aFrames : false;
456
499
  if (!bHandle || !aHandle) {
457
500
  droppedTransitions.push({
458
501
  index: c.index, type: c.type, duration: c.rawDuration, trackType: 'audio',
459
- reason: `insufficient handles for a centered ${c.durationFrames}f cross-fade` +
460
- `${bHandle ? '' : ' (incoming srcIn < half)'}${aHandle ? '' : ' (outgoing tail media < half)'}`,
502
+ reason: `insufficient handles for a ${c.durationFrames}f cross-fade spanning [cut-${pre}, cut+${post})` +
503
+ `${bHandle ? '' : ` (incoming srcIn < ${pre})`}${aHandle ? '' : ` (outgoing tail media < ${post})`}`,
461
504
  });
462
505
  continue;
463
506
  }
464
- transitions.push({ track: c.track, atFrame: c.atFrame, durationFrames: c.durationFrames, trackType: 'audio' });
507
+ let atFrame = c.atFrame;
508
+ const spanStartAbs = c.atFrame - pre;
509
+ if (c.explicitSpan && (pre === 0 || pre === c.durationFrames)) {
510
+ const shift = (pre === 0 ? 1 : -1) * (c.durationFrames / 2);
511
+ prev.cutRef.durationFrames += shift;
512
+ c.incomingCutRef.startFrame += shift;
513
+ c.incomingCutRef.srcIn += shift;
514
+ c.incomingCutRef.durationFrames -= shift;
515
+ prev.end += shift;
516
+ atFrame += shift;
517
+ }
518
+ transitions.push({
519
+ track: c.track, atFrame, durationFrames: c.durationFrames, trackType: 'audio',
520
+ ...(c.explicitSpan ? { startFrame: spanStartAbs } : {}),
521
+ });
465
522
  }
466
523
 
467
524
  // Turnover markers (EDL * LOC: locators, OTIO Marker objects) → authored
@@ -118,7 +118,10 @@ export function parseEDL(text, opts = {}) {
118
118
  srcOut: tcToFrames(srcOut, fps),
119
119
  recIn: tcToFrames(recIn, fps),
120
120
  recOut: tcToFrames(recOut, fps),
121
- transition: transition && transition !== 'C' ? { type: transition, duration: dur || 0 } : null,
121
+ // CMX convention: the dissolve/wipe span STARTS at the cut (occupies
122
+ // the first `dur` frames of the incoming event) — matching what
123
+ // Resolve's own EDL importer authors (E61 harvest: Start == the cut).
124
+ transition: transition && transition !== 'C' ? { type: transition, duration: dur || 0, alignment: 'start' } : null,
122
125
  fps,
123
126
  }),
124
127
  );
@@ -152,12 +155,27 @@ export function parseOTIO(otio, opts = {}) {
152
155
  }));
153
156
  }
154
157
  let rec = 0;
158
+ // An OTIO Transition occupies NO record time (like an AAF transition —
159
+ // it overlaps its neighbours); it describes the junction between the
160
+ // previous and NEXT clip. Carry it to the next clip event's transition
161
+ // field so the bridge can author it (type strings like SMPTE_Dissolve
162
+ // map through the same style table as XMEML effectids).
163
+ let pendingTransition = null;
155
164
  for (const child of track.children || []) {
156
165
  const schema = child.OTIO_SCHEMA || '';
157
166
  const dur = (child.source_range && child.source_range.duration && child.source_range.duration.value) || 0;
158
167
  const rate = (child.source_range && child.source_range.duration && child.source_range.duration.rate) || opts.fps || 24;
159
168
  if (schema.startsWith('Gap')) {
160
169
  rec += dur;
170
+ pendingTransition = null; // a gap breaks the junction
171
+ continue;
172
+ }
173
+ if (schema.startsWith('Transition')) {
174
+ const inOff = (child.in_offset && child.in_offset.value) || 0;
175
+ const outOff = (child.out_offset && child.out_offset.value) || 0;
176
+ // in_offset = frames BEFORE the cut, out_offset = frames after; the
177
+ // bridge places the span [cut - inOffset, cut + outOffset).
178
+ pendingTransition = { type: String(child.transition_type || 'SMPTE_Dissolve'), duration: inOff + outOff, inOffset: inOff };
161
179
  continue;
162
180
  }
163
181
  if (schema.startsWith('Clip')) {
@@ -191,9 +209,11 @@ export function parseOTIO(otio, opts = {}) {
191
209
  recOut: rec + dur,
192
210
  speed: Math.abs(speed),
193
211
  reverse,
212
+ transition: pendingTransition,
194
213
  fps: rate,
195
214
  }),
196
215
  );
216
+ pendingTransition = null;
197
217
  rec += dur;
198
218
  }
199
219
  }
@@ -257,7 +277,10 @@ export function parseXMEMLEvents(xml, opts = {}) {
257
277
  if (!Number.isFinite(s) || !Number.isFinite(e2)) continue;
258
278
  const effectId = (tr.effect && (tr.effect.effectid || tr.effect.name)) || 'Cross Dissolve';
259
279
  const incoming = events.slice(before).find((ev) => ev.track === label && ev.recIn > s - 1 && ev.recIn <= e2);
260
- if (incoming) incoming.transition = { type: String(effectId), duration: e2 - s };
280
+ // recStart carries the transitionitem's EXPLICIT record span start
281
+ // (sequence-relative) so the bridge reproduces the editor's actual
282
+ // alignment instead of assuming centered.
283
+ if (incoming) incoming.transition = { type: String(effectId), duration: e2 - s, recStart: s };
261
284
  }
262
285
  });
263
286
  const atracks = media.audio && media.audio.track ? (Array.isArray(media.audio.track) ? media.audio.track : [media.audio.track]) : [];
@@ -51,7 +51,7 @@ const assembleSchema = z.object({
51
51
  spec: z
52
52
  .object({})
53
53
  .passthrough()
54
- .describe("assembleTimeline spec: { timelineName?, startFrame? (timeline start frame @24, default 86400=01:00:00:00 — sets the start TIMECODE, render-verified on 19), 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),freeze? (true = hold source frame srcIn for the whole cut; video-only; render-proven frozen on 19 via freezedetect),ramp? ([{durationFrames,speed},...] >=2 LINEAR segments from the cut head, srcIn honored; video-only; render-proven cadence on 19 — eased/curved ramps are NOT authorable, an interp!=0 keyframe crashes Resolve on import (measured)),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' | 'audio' cross-fade), type? ('dissolve' default | 'wipe' | 'dip' | 'additive' | 'fade-to-color' | 'smooth-cut' | 'non-additive' — all render-verified on 19 w/ midpoint fingerprints; XMEML/EDL transition codes route automatically, and NOTE Resolve's own XMEML importer writes transitions that render INERT, so this route beats it)}], markers?: [{frame (timeline-absolute), color? (16 names), name?, note?, duration?, customData?}] (readback-verified on 19), compounds?: [{name, startFrame (parent, absolute), durationFrames, track?, cuts:[{mediaFilePath, startFrame (INNER, 0-based), durationFrames, srcIn?}], compounds?: [same, nested — frames inner-relative]}] (multiple PARALLEL compounds compose AND compounds NEST recursively — depth-2 AND depth-3 playback render-verified on 19 (the old depth-2 black was a missing SequenceSetup key, fixed); inner cuts need captured templates w/ native clips), subtitles?: [{startFrame (timeline-absolute), durationFrames, text}] + subtitlesSrt? (raw SRT, cues anchor at the origin; readback-verified on 19; angle-bracket runs read as SRT markup), 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)."),
54
+ .describe("assembleTimeline spec: { timelineName?, startFrame? (timeline start frame @24, default 86400=01:00:00:00 — sets the start TIMECODE, render-verified on 19), 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),freeze? (true = hold source frame srcIn for the whole cut; video-only; render-proven frozen on 19 via freezedetect),ramp? ([{durationFrames,speed},...] >=2 LINEAR segments from the cut head, srcIn honored; video-only; render-proven cadence on 19 — eased/curved ramps are NOT authorable, an interp!=0 keyframe crashes Resolve on import (measured)),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' | 'audio' cross-fade), type? ('dissolve' default | 'wipe' | 'dip' | 'additive' | 'fade-to-color' | 'smooth-cut' | 'non-additive' — all render-verified on 19 w/ midpoint fingerprints; XMEML/EDL transition codes route automatically, and NOTE Resolve's own XMEML importer writes transitions that render INERT, so this route beats it)}], markers?: [{frame (timeline-absolute), color? (16 names), name?, note?, duration?, customData?}] (readback-verified on 19), compounds?: [{name, startFrame (parent, absolute), durationFrames, track?, cuts:[{mediaFilePath, startFrame (INNER, 0-based), durationFrames, srcIn?}], compounds?: [same, nested — frames inner-relative]}] (multiple PARALLEL compounds compose AND compounds NEST recursively — depth-2 through depth-4 playback render-verified on 19 (the old depth-2 black was a missing SequenceSetup key, fixed); inner cuts need captured templates w/ native clips), subtitles?: [{startFrame (timeline-absolute), durationFrames, text}] + subtitlesSrt? (raw SRT, cues anchor at the origin; readback-verified on 19; angle-bracket runs read as SRT markup), 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)."),
55
55
  outputPath: z.string().describe('Absolute path where the importable .drt will be written'),
56
56
  targetAppVersion: z
57
57
  .union([z.string(), z.number()])
@@ -233,6 +233,7 @@ async function assembleTimeline(spec = {}) {
233
233
  track: tr.track, atFrame: tr.atFrame, durationFrames: tr.durationFrames,
234
234
  trackType: tr.trackType || 'video',
235
235
  type: tr.type || 'dissolve',
236
+ spanStart: tr.startFrame,
236
237
  }));
237
238
  }
238
239
 
@@ -85,7 +85,7 @@ const STYLE_PRETTY_TYPES = {
85
85
  const TRANSITION_TYPES = ['dissolve', 'wipe', ...Object.keys(STYLE_PRETTY_TYPES)];
86
86
 
87
87
  async function placeTransition(drpInput, opts = {}) {
88
- const { track, atFrame, durationFrames = 24, trackType = 'video', type = 'dissolve', timelineUuid } = opts;
88
+ const { track, atFrame, durationFrames = 24, trackType = 'video', type = 'dissolve', spanStart, timelineUuid } = opts;
89
89
  if (!TRANSITION_TYPES.includes(type)) throw new Error(`placeTransition: type must be one of ${TRANSITION_TYPES.join(', ')}`);
90
90
  if (type !== 'dissolve' && trackType === 'audio') throw new Error('placeTransition: styled transitions are video-only (audio junctions cross-fade)');
91
91
  if (!Number.isInteger(track) || track < 1) throw new TypeError('placeTransition: track must be a positive integer');
@@ -114,7 +114,19 @@ async function placeTransition(drpInput, opts = {}) {
114
114
  } else if (STYLE_PRETTY_TYPES[type]) {
115
115
  trans = trans.replace(/<PrettyType>Cross Dissolve<\/PrettyType>/, `<PrettyType>${STYLE_PRETTY_TYPES[type]}</PrettyType>`);
116
116
  }
117
- const start = atFrame - Math.floor(durationFrames / 2); // centered (AlignmentType 2)
117
+ // Span geometry follows <Start>/<Duration>, NOT AlignmentType (the E61
118
+ // harvest of Resolve's own EDL import carries AlignmentType 2 with the
119
+ // span STARTING at the cut — the CMX convention — while our default
120
+ // centers it). spanStart (absolute) overrides for format parity: EDL
121
+ // dissolves start at the cut, OTIO splits by in/out offsets, XMEML
122
+ // transitionitems carry their explicit record span.
123
+ if (spanStart !== undefined) {
124
+ if (!Number.isInteger(spanStart)) throw new TypeError('placeTransition: spanStart must be an integer frame');
125
+ if (spanStart > atFrame || spanStart + durationFrames < atFrame) {
126
+ throw new RangeError(`placeTransition: span [${spanStart}, ${spanStart + durationFrames}) does not cover the cut at ${atFrame}`);
127
+ }
128
+ }
129
+ const start = spanStart !== undefined ? spanStart : atFrame - Math.floor(durationFrames / 2);
118
130
  trans = trans.replace(/<Start>\d+<\/Start>/, `<Start>${start}</Start>`);
119
131
  trans = trans.replace(/<Duration>\d+<\/Duration>/, `<Duration>${durationFrames}</Duration>`);
120
132
  const transitionDbId = (trans.match(/<Sm2TiTransition DbId="([^"]+)"/) || [])[1] || null;
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "2.140.0"
90
+ VERSION = "2.142.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.140.0"
14
+ VERSION = "2.142.0"
15
15
 
16
16
  import base64
17
17
  import os