davinci-resolve-mcp 2.116.2 → 2.117.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.
@@ -47,6 +47,7 @@ window. `render.verify_output` covers the container-level checks.
47
47
  | Constant retimes, reverse | `cuts[].reverse` | v2.114 |
48
48
  | Audio placements, A1–A8 | `cuts[].audioOnly + track` | v2.115 |
49
49
  | Built-in generators | `elements: [{type:'generator', generatorName}]` | v2.110 |
50
+ | Custom start timecode | `spec.startFrame` / `preserveStartTimecode` | v2.117 |
50
51
  | Fusion titles | `elements: [{type:'title', text}]` — **21-gen hosts only** | v2.108 |
51
52
 
52
53
  `assemble_from_interchange` drives the same engine from an EDL / OTIO /
@@ -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, 44 bugs / unreliable behaviors.
15
+ **Totals:** 34 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
@@ -214,6 +214,13 @@ equivalent, blocking full automation.
214
214
  - **Workaround / current handling:** Treat as irreversible within a session. server returns _ok() unconditionally because there is nothing to check.
215
215
  - **Tags:** resolve-21, unreliable-return, irreversible, session-wide
216
216
 
217
+ ### A timeline's start timecode lives in the pool clip's MediaExtents blob (patchable offline)
218
+
219
+ - **Object:** `Sm2MpTimelineClip.MediaExtents`
220
+ - **Behavior:** The start timecode of a timeline is stored in exactly one non-cosmetic place in a .drp/.drt: the media pool timeline clip's MediaExtents blob, a 16-byte pair of LE doubles [startSeconds, durationSeconds] (measured: 02:03:04:05 @24 appears only as 7384.2083 there and in a UI-state blob). Patching startSeconds offline and importing yields a timeline at the new start timecode with clips at their absolute frames, and it renders.
221
+ - **Workaround / current handling:** To author a non-default start TC offline, patch MediaExtents (drt.assemble spec.startFrame does this) and keep clip Start frames >= the new origin - clips before it are silently dropped on import. For conform, assemble_from_interchange preserveStartTimecode=true anchors at the turnover's real first record frame instead of 01:00:00:00.
222
+ - **Tags:** timecode, drt, import
223
+
217
224
  ### MediaPool.ImportMedia (current-folder destination only)
218
225
 
219
226
  - **Object:** `MediaPool`
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "2.116.2"
40
+ VERSION = "2.117.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.116.2",
3
+ "version": "2.117.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -220,14 +220,14 @@ export function eventsToOTIO(events, opts = {}) {
220
220
  * @returns {{spec: object, report: object}}
221
221
  */
222
222
  export function eventsToAssembleSpec(events, opts = {}) {
223
- const { sourceMap, timelineName } = opts;
223
+ const { sourceMap, timelineName, preserveStartTimecode = false } = opts;
224
224
  if (!Array.isArray(events) || !events.length) {
225
225
  throw new TypeError('eventsToAssembleSpec: events must be a non-empty array');
226
226
  }
227
227
  if (!sourceMap || typeof sourceMap !== 'object') {
228
228
  throw new TypeError('eventsToAssembleSpec: sourceMap {reel: {mediaFilePath, spec}} is required');
229
229
  }
230
- const ORIGIN = 86400;
230
+ const DEFAULT_ORIGIN = 86400;
231
231
  const isAudio = (t) => /^A\d*$/.test(String(t || ''));
232
232
  const vids = events.filter((e) => !isAudio(e.track) && e.recIn != null && e.recOut != null);
233
233
  const auds = events.filter((e) => isAudio(e.track) && e.recIn != null && e.recOut != null);
@@ -252,7 +252,15 @@ export function eventsToAssembleSpec(events, opts = {}) {
252
252
  const perSource = new Map();
253
253
  const placements = [];
254
254
 
255
- const minRec = Math.min(...vids.map((e) => toTl(e.recIn, e.fps)));
255
+ // Default: anchor the earliest event at the template origin (86400).
256
+ // preserveStartTimecode keeps the interchange's ABSOLUTE record positions —
257
+ // the assembled timeline starts at the turnover's real first record frame
258
+ // (MediaExtents start-TC patch, measured: imports with the new start TC and
259
+ // renders). AAF conforms need this: build at THAT start, not 01:00:00:00.
260
+ const minRecRaw = Math.min(...vids.map((e) => toTl(e.recIn, e.fps)));
261
+ const minRec = preserveStartTimecode ? 0 : minRecRaw;
262
+ const ORIGIN = preserveStartTimecode ? 0 : DEFAULT_ORIGIN;
263
+ const startFrame = preserveStartTimecode ? minRecRaw : DEFAULT_ORIGIN;
256
264
  for (const e of vids) {
257
265
  const recIn = ORIGIN + (toTl(e.recIn, e.fps) - minRec);
258
266
  const recOut = ORIGIN + (toTl(e.recOut, e.fps) - minRec);
@@ -419,7 +427,7 @@ export function eventsToAssembleSpec(events, opts = {}) {
419
427
  }
420
428
 
421
429
  return {
422
- spec: { timelineName, media, ...(transitions.length ? { transitions } : {}) },
430
+ spec: { timelineName, media, ...(preserveStartTimecode ? { startFrame } : {}), ...(transitions.length ? { transitions } : {}) },
423
431
  report: {
424
432
  videoEvents: vids.length,
425
433
  sources: media.length,
@@ -431,7 +439,7 @@ export function eventsToAssembleSpec(events, opts = {}) {
431
439
  authoredRetimes,
432
440
  authoredTransitions: transitions,
433
441
  droppedTransitions,
434
- origin: ORIGIN,
442
+ origin: startFrame,
435
443
  },
436
444
  };
437
445
  }
@@ -41,12 +41,14 @@ const assembleFromInterchangeSchema = z.object({
41
41
  outputPath: z.string().describe('Where the importable .drt is written'),
42
42
  targetAppVersion: z.union([z.string(), z.number()]).optional()
43
43
  .describe("Host Resolve version, e.g. '19.1' for pre-21"),
44
+ preserveStartTimecode: z.boolean().optional()
45
+ .describe('Keep the interchange\'s ABSOLUTE record start: the assembled timeline starts at the turnover\'s real first record frame instead of 01:00:00:00 (start-TC patch render-verified on 19). AAF conforms should pass true.'),
44
46
  });
45
47
  const assembleSchema = z.object({
46
48
  spec: z
47
49
  .object({})
48
50
  .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), 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)."),
51
+ .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),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
52
  outputPath: z.string().describe('Absolute path where the importable .drt will be written'),
51
53
  targetAppVersion: z
52
54
  .union([z.string(), z.number()])
@@ -220,6 +222,7 @@ export const drtTool = {
220
222
  if (!events || !events.length) return { error: 'no events parsed from the interchange input' };
221
223
  const { spec, report } = eventsToAssembleSpec(events, {
222
224
  sourceMap: p.sourceMap, timelineName: p.timelineName,
225
+ preserveStartTimecode: p.preserveStartTimecode,
223
226
  });
224
227
  if (p.targetAppVersion !== undefined) {
225
228
  spec.templateVersion = parseFloat(p.targetAppVersion) >= 21 ? 21 : 19;
@@ -33,6 +33,13 @@ const { placeTransition } = require('./place-transition');
33
33
 
34
34
  async function assembleTimeline(spec = {}) {
35
35
  const { timelineName, elements = [], transitions = [], media, templateVersion } = spec;
36
+ // Timeline start (frames @24). The start timecode lives in ONE place — the
37
+ // pool timeline clip's MediaExtents [startSeconds, durationSeconds]
38
+ // double-LE pair (measured: patching it imports with the new start TC and
39
+ // renders; no other non-cosmetic copy exists). Clips are absolute frames,
40
+ // so a custom origin just moves the guard.
41
+ const originFrame = spec.startFrame ?? DEFAULT_START_FRAME;
42
+ if (!Number.isInteger(originFrame) || originFrame < 0) throw new TypeError('assembleTimeline: spec.startFrame must be a non-negative integer');
36
43
  if (!Array.isArray(elements)) throw new TypeError('assembleTimeline: elements must be an array');
37
44
  if (!Array.isArray(transitions)) throw new TypeError('assembleTimeline: transitions must be an array');
38
45
 
@@ -59,9 +66,9 @@ async function assembleTimeline(spec = {}) {
59
66
  const validateCuts = (src, label) => {
60
67
  const mediaSpec = src.spec;
61
68
  (src.cuts || []).forEach((cut, i) => {
62
- if (cut.startFrame < DEFAULT_START_FRAME) {
69
+ if (cut.startFrame < originFrame) {
63
70
  throw new RangeError(
64
- `assembleTimeline: ${label}.cuts[${i}].startFrame ${cut.startFrame} is before the timeline origin ${DEFAULT_START_FRAME} — Resolve silently drops it on import`,
71
+ `assembleTimeline: ${label}.cuts[${i}].startFrame ${cut.startFrame} is before the timeline origin ${originFrame} — Resolve silently drops it on import`,
65
72
  );
66
73
  }
67
74
  if (mediaSpec && Number.isFinite(mediaSpec.frameCount) && Number.isFinite(mediaSpec.fps)) {
@@ -81,7 +88,7 @@ async function assembleTimeline(spec = {}) {
81
88
  base = await addMediaClip({
82
89
  mediaFile: sources[0].mediaFilePath, spec: sources[0].spec, timelineName, templateVersion,
83
90
  });
84
- base.startFrame = DEFAULT_START_FRAME;
91
+ base.startFrame = originFrame;
85
92
 
86
93
  // Transplant/insert native pool elements BEFORE cutting, so each cut can
87
94
  // reference its source's MediaRef.
@@ -185,7 +192,28 @@ async function assembleTimeline(spec = {}) {
185
192
  }));
186
193
  }
187
194
 
188
- return { buffer, timelineName: tlName, startFrame, mediaDescriptor: mediaDescriptorState };
195
+ if (originFrame !== DEFAULT_START_FRAME) {
196
+ const zipF = await JSZip.loadAsync(buffer);
197
+ const mpP = 'MediaPool/Master/MpFolder.xml';
198
+ let mpF = await zipF.file(mpP).async('string');
199
+ const tlBlocks = mpF.match(/<Sm2MpTimelineClip[\s\S]*?<\/Sm2MpTimelineClip>/g) || [];
200
+ const tlBlock = tlBlocks.find((b) => b.includes(`<Name>${tlName}</Name>`)) || tlBlocks[0];
201
+ if (!tlBlock) throw new Error('assembleTimeline: timeline pool clip not found for startFrame patch');
202
+ const meM = tlBlock.match(/<MediaExtents>([0-9a-fA-F]*)<\/MediaExtents>/);
203
+ if (!meM) throw new Error('assembleTimeline: timeline pool clip has no MediaExtents to patch');
204
+ let maxEnd = originFrame;
205
+ const collect = (arr) => (arr || []).forEach((x) => { const e = (x.startFrame ?? 0) + (x.durationFrames ?? 0); if (e > maxEnd) maxEnd = e; });
206
+ (Array.isArray(media) ? media : media ? [media] : []).forEach((src) => collect(src.cuts));
207
+ collect(elements);
208
+ const me = Buffer.alloc(16);
209
+ me.writeDoubleLE(originFrame / 24, 0);
210
+ me.writeDoubleLE((maxEnd - originFrame) / 24, 8);
211
+ mpF = mpF.replace(tlBlock, tlBlock.replace(meM[0], `<MediaExtents>${me.toString('hex')}</MediaExtents>`));
212
+ zipF.file(mpP, mpF);
213
+ buffer = await zipF.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
214
+ }
215
+
216
+ return { buffer, timelineName: tlName, startFrame: originFrame, mediaDescriptor: mediaDescriptorState };
189
217
  }
190
218
 
191
219
  module.exports = { assembleTimeline };
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "2.116.2"
90
+ VERSION = "2.117.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.116.2"
14
+ VERSION = "2.117.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -1914,6 +1914,28 @@ 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": "A timeline's start timecode lives in the pool clip's MediaExtents blob (patchable offline)",
1919
+ "object": "Sm2MpTimelineClip.MediaExtents",
1920
+ "reality": "The start timecode of a timeline is stored in exactly "
1921
+ "one non-cosmetic place in a .drp/.drt: the media pool "
1922
+ "timeline clip's MediaExtents blob, a 16-byte pair of "
1923
+ "LE doubles [startSeconds, durationSeconds] (measured: "
1924
+ "02:03:04:05 @24 appears only as 7384.2083 there and in "
1925
+ "a UI-state blob). Patching startSeconds offline and "
1926
+ "importing yields a timeline at the new start timecode "
1927
+ "with clips at their absolute frames, and it renders.",
1928
+ "recommended": "To author a non-default start TC offline, patch "
1929
+ "MediaExtents (drt.assemble spec.startFrame does "
1930
+ "this) and keep clip Start frames >= the new origin "
1931
+ "- clips before it are silently dropped on import. "
1932
+ "For conform, assemble_from_interchange "
1933
+ "preserveStartTimecode=true anchors at the "
1934
+ "turnover's real first record frame instead of "
1935
+ "01:00:00:00.",
1936
+ "tags": ["timecode", "drt", "import"],
1937
+ "submit": "missing",
1938
+ },
1917
1939
  {
1918
1940
  "symbol": "Audio tracks cannot be grown in an imported timeline; Fairlight strips live in the pool Sm2Sequence.FieldsBlob",
1919
1941
  "object": "Sm2TiTrack (audio) / FLStudioModelBA",