davinci-resolve-mcp 2.138.0 → 2.139.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,28 @@
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.139.0 — speed ramps, and the easing crash law
6
+
7
+ ### Added
8
+
9
+ - **Variable-speed ramps authored offline** (`cuts[].ramp:
10
+ [{durationFrames, speed}, …]` — two or more linear segments from the cut
11
+ head, `srcIn` honored). No harvest was needed: the engine honors
12
+ intermediate keyframes in the same seconds-domain keyed `Sm2TimeMap` the
13
+ constant retimes use. E63/E64 proof on 19.1.3.7: a 50%→100% knee read back
14
+ the exact source window AND rendered the predicted frame cadence (11/23
15
+ doubled frames in the half-speed window, none at full speed; the 2×
16
+ segment moved at 4.3× the 0.5× segment's per-frame motion), with `srcIn`
17
+ landing on the right source frame by luma.
18
+
19
+ ### Measured and closed
20
+
21
+ - **Eased ramps are a crasher, not a boundary.** A keyframe with
22
+ `interp = 2` crashed Resolve outright on import (E65 — app death, headless
23
+ recovery per doctrine). The builders hardcode `interp = 0`, the test suite
24
+ asserts it, and the guide records the law: linear segments are the
25
+ authorable envelope on 19.1.3.
26
+
5
27
  ## What's New in v2.138.0 — wipes join the conform
6
28
 
7
29
  ### 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.138.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.139.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.138.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.139.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.138.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v2.139.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
@@ -47,6 +47,7 @@ window. `render.verify_output` covers the container-level checks.
47
47
  | Constant retimes, forward | `cuts[].speed` (e.g. `0.5`) | v2.113 |
48
48
  | Constant retimes, reverse | `cuts[].reverse` | v2.114 |
49
49
  | Freeze frames | `cuts[].freeze` (holds source frame `srcIn`) | v2.134 |
50
+ | Speed ramps | `cuts[].ramp: [{durationFrames, speed}, …]` (linear segments only) | v2.139 |
50
51
  | Audio placements, A1–A8 | `cuts[].audioOnly + track` | v2.115 |
51
52
  | Built-in generators | `elements: [{type:'generator', generatorName}]` | v2.110 |
52
53
  | Custom start timecode | `spec.startFrame` / `preserveStartTimecode` | v2.117 |
@@ -96,6 +97,14 @@ the clip's `<In>`/`<Duration>` window into it in record-domain frames.
96
97
  Reverse is the same map with the Y endpoints swapped, and `In` then measures
97
98
  from the source end.
98
99
 
100
+ **Ramps are just more keyframes — but easing is a crasher.** The engine
101
+ honors intermediate keyframes in the same seconds-domain map (E63/E64:
102
+ a synthesized 50%→100% knee read back AND rendered with the exact predicted
103
+ frame cadence, srcIn baked into the first keyframe's Y, clip `In` at the cut
104
+ head). `cuts[].ramp` authors piecewise-constant segments. The keyframes'
105
+ `interp` field must stay **0**: an `interp=2` keyframe crashed Resolve
106
+ 19.1.3 outright on import (app death, E65) — eased ramps are not authorable.
107
+
99
108
  **Freezes are a third shape, not a flat retime.** A flat line in the retime's
100
109
  frame domain reads back frozen but *renders moving* — the one divergence that
101
110
  runs in the "working" direction. The real freeze (harvested from a live EDL
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "2.138.0"
40
+ VERSION = "2.139.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.138.0",
3
+ "version": "2.139.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -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),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' — the single soft-edge wipe style Resolve's own EDL importer maps every W-code to; all render-verified on 19)}], 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' — the single soft-edge wipe style Resolve's own EDL importer maps every W-code to; all render-verified on 19)}], 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)."),
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()])
@@ -25,7 +25,7 @@ const { createEmptyProject, addMediaClip, DEFAULT_START_FRAME } = require('./aut
25
25
  const { loadMediaTemplate, transplantMediaElement, insertMediaElement } = require('./media-template-cache');
26
26
  const JSZip = require('jszip');
27
27
  const { cutSourceIntoClips } = require('./cut-media');
28
- const { buildConstantSpeedTimemapKeyed, buildFreezeTimemapKeyed } = require('./media-timemap');
28
+ const { buildConstantSpeedTimemapKeyed, buildFreezeTimemapKeyed, buildRampTimemapKeyed } = require('./media-timemap');
29
29
  const { encodeTimelineMarkersBlob } = require('./timeline-markers-blob');
30
30
  const { placeSubtitles, parseSrt } = require('./place-subtitles');
31
31
  const { placeCompound } = require('./place-compound');
@@ -78,7 +78,9 @@ async function assembleTimeline(spec = {}) {
78
78
  // srcIn/duration are TIMELINE frames (24fps template); the media's
79
79
  // extent converts: frameCount / mediaFps × 24.
80
80
  const maxTimelineFrames = Math.floor((mediaSpec.frameCount / mediaSpec.fps) * 24);
81
- if ((cut.srcIn ?? 0) + cut.durationFrames > maxTimelineFrames) {
81
+ // Ramps consume sum(duration × speed) source, not durationFrames
82
+ // the ramp builder enforces its own source-extent bound.
83
+ if (!Array.isArray(cut.ramp) && (cut.srcIn ?? 0) + cut.durationFrames > maxTimelineFrames) {
82
84
  throw new RangeError(
83
85
  `assembleTimeline: ${label}.cuts[${i}] reads past the media's end — (srcIn ?? 0) + durationFrames exceeds ${maxTimelineFrames} timeline frames (media ${mediaSpec.frameCount} frames @ ${mediaSpec.fps} fps on the 24fps template timeline)`,
84
86
  );
@@ -137,7 +139,26 @@ async function assembleTimeline(spec = {}) {
137
139
  // captured before clip elements were harvested.
138
140
  if (caches[i] && caches[i].videoClipElement) out.donorClipVideo = caches[i].videoClipElement;
139
141
  if (caches[i] && caches[i].audioClipElement) out.donorClipAudio = caches[i].audioClipElement;
140
- if (cut.freeze || cut.speed === 0) {
142
+ if (Array.isArray(cut.ramp)) {
143
+ // VARIABLE-SPEED RAMP (E63, render-verified): piecewise-constant
144
+ // segments as one multi-keyframe Sm2TimeMap. Record domain starts
145
+ // at the cut head (In stays 0); srcIn bakes into the map's first
146
+ // keyframe. Video-only, like every retime.
147
+ if (cut.freeze || cut.reverse || (cut.speed !== undefined && cut.speed !== 1)) {
148
+ throw new RangeError('assembleTimeline: cut.ramp cannot combine with speed/reverse/freeze');
149
+ }
150
+ const fpsR = Math.round(src.spec.fps || 24);
151
+ const total = cut.ramp.reduce((s, seg) => s + (seg && seg.durationFrames || 0), 0);
152
+ if (total !== cut.durationFrames) {
153
+ throw new RangeError(`assembleTimeline: ramp segments sum to ${total} frames but the cut is ${cut.durationFrames}`);
154
+ }
155
+ out.timemap = buildRampTimemapKeyed({
156
+ segments: cut.ramp, srcIn: cut.srcIn ?? 0,
157
+ sourceFrames: src.spec.frameCount, fps: fpsR, uniqueId: randomUUID(),
158
+ }).toString('hex');
159
+ out.srcIn = 0; // record-domain In is the cut head; the map carries srcIn
160
+ delete out.ramp;
161
+ } else if (cut.freeze || cut.speed === 0) {
141
162
  // FREEZE frame (r19 keyed map harvested from a live EDL M2 000.0
142
163
  // import, E55; render-proven frozen). Holds source frame srcIn for
143
164
  // the whole cut. The frozen clip's <In> stays EMPTY; the position
@@ -304,10 +304,62 @@ function buildFreezeTimemapKeyed({ freezeFrame, sourceFrames, fps = 24, uniqueId
304
304
  ] });
305
305
  }
306
306
 
307
+ /**
308
+ * VARIABLE-SPEED RAMP — piecewise-constant speed segments as a multi-keyframe
309
+ * r19 keyed Sm2TimeMap. E63 (2026-08-31): the engine honors intermediate
310
+ * keyframes with the SAME seconds-domain conventions as the constant maps —
311
+ * a synthesized 3-keyframe 50%→100% ramp read back source 0..36 over 48
312
+ * record frames AND rendered with exactly the predicted cadence (11/23
313
+ * doubled frames in the 50% window, 0/24 at 100%). Record domain starts at
314
+ * the cut head (clip In stays 0); srcIn bakes into the first keyframe's Y.
315
+ *
316
+ * @param {Array<{durationFrames:number, speed:number}>} segments - record-domain
317
+ * pieces, in order from the cut head; speeds are source/record multipliers.
318
+ */
319
+ function buildRampTimemapKeyed({ segments, srcIn = 0, sourceFrames, fps = 24, uniqueId }) {
320
+ if (!Array.isArray(segments) || segments.length < 2) throw new TypeError('buildRampTimemapKeyed: segments must be an array of >= 2 {durationFrames, speed} pieces (use speed/freeze for a single one)');
321
+ if (!Number.isInteger(sourceFrames) || sourceFrames < 1) throw new TypeError('buildRampTimemapKeyed: sourceFrames must be a positive integer');
322
+ if (!Number.isInteger(srcIn) || srcIn < 0) throw new TypeError('buildRampTimemapKeyed: srcIn must be a non-negative integer');
323
+ const kf = (X, Y) => encodeKeyedDict({ hdr: 1, entries: [
324
+ { key: 'interp', type: 0x02, subType: 0, value: 0 },
325
+ { key: 'YOut', type: T_DOUBLE, subType: 0, value: 0 },
326
+ { key: 'YIn', type: T_DOUBLE, subType: 0, value: 0 },
327
+ { key: 'Y', type: T_DOUBLE, subType: 0, value: Y },
328
+ { key: 'XOut', type: T_DOUBLE, subType: 0, value: 0 },
329
+ { key: 'XIn', type: T_DOUBLE, subType: 0, value: 0 },
330
+ { key: 'X', type: T_DOUBLE, subType: 0, value: X },
331
+ ] }).toString('hex');
332
+ let x = 0;
333
+ let y = srcIn / fps;
334
+ const points = [[x, y]];
335
+ for (const [i, seg] of segments.entries()) {
336
+ if (!Number.isInteger(seg.durationFrames) || seg.durationFrames < 1) throw new TypeError(`buildRampTimemapKeyed: segments[${i}].durationFrames must be a positive integer`);
337
+ if (!(seg.speed > 0)) throw new RangeError(`buildRampTimemapKeyed: segments[${i}].speed must be > 0 (freeze/reverse segments are not authorable in a ramp)`);
338
+ x += seg.durationFrames / fps;
339
+ y += (seg.durationFrames * seg.speed) / fps;
340
+ points.push([x, y]);
341
+ }
342
+ const sourceEnd = y * fps;
343
+ if (sourceEnd > sourceFrames) {
344
+ throw new RangeError(`buildRampTimemapKeyed: ramp consumes source frame ${Math.ceil(sourceEnd)} but the media has ${sourceFrames}`);
345
+ }
346
+ const entries = points.map(([X, Y], i) => ({ key: String(i), type: T_BYTES, subType: 0, value: kf(X, Y) })).reverse();
347
+ const keyframes = encodeKeyedDict({ hdr: 1, entries }).toString('hex');
348
+ const [XMax, YMax] = points[points.length - 1];
349
+ return encodeKeyedDict({ hdr: 1, entries: [
350
+ { key: 'YMax', type: T_DOUBLE, subType: 0, value: YMax },
351
+ { key: 'XMax', type: T_DOUBLE, subType: 0, value: XMax },
352
+ { key: 'UniqueId', type: T_STRING, subType: 0, value: uniqueId },
353
+ { key: 'LastValidYOffset', type: T_DOUBLE, subType: 0, value: (sourceFrames - 1) / fps },
354
+ { key: 'KeyframesBA', type: T_BYTES, subType: 0, value: keyframes },
355
+ { key: 'DbType', type: T_STRING, subType: 0, value: 'Sm2TimeMap' },
356
+ ] });
357
+ }
358
+
307
359
  module.exports = {
308
360
  decodeTimemap, encodeTimemap, encodeRetimedTimemap,
309
361
  identityTimemap, buildConstantSpeedTimemap, buildConstantSpeedTimemapKeyed,
310
- buildFreezeTimemapKeyed,
362
+ buildFreezeTimemapKeyed, buildRampTimemapKeyed,
311
363
  buildTimemap, decodeProtobuf,
312
364
  TYPE_LINEAR,
313
365
  };
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "2.138.0"
90
+ VERSION = "2.139.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.138.0"
14
+ VERSION = "2.139.0"
15
15
 
16
16
  import base64
17
17
  import os