davinci-resolve-mcp 2.116.2 → 2.118.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,8 @@ 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 |
51
+ | Timeline markers | `spec.markers` (16 colors, notes, durations, customData) | v2.118 |
50
52
  | Fusion titles | `elements: [{type:'title', text}]` — **21-gen hosts only** | v2.108 |
51
53
 
52
54
  `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.118.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.118.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)}], markers?: [{frame (timeline-absolute), color? (16 names), name?, note?, duration?, customData?}] (readback-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;
@@ -26,6 +26,7 @@ const { loadMediaTemplate, transplantMediaElement, insertMediaElement } = requir
26
26
  const JSZip = require('jszip');
27
27
  const { cutSourceIntoClips } = require('./cut-media');
28
28
  const { buildConstantSpeedTimemapKeyed } = require('./media-timemap');
29
+ const { encodeTimelineMarkersBlob } = require('./timeline-markers-blob');
29
30
  const { randomUUID } = require('node:crypto');
30
31
  const { placeFusionTitle } = require('./place-fusion-title');
31
32
  const { placeGenerator } = require('./place-generator');
@@ -33,6 +34,13 @@ const { placeTransition } = require('./place-transition');
33
34
 
34
35
  async function assembleTimeline(spec = {}) {
35
36
  const { timelineName, elements = [], transitions = [], media, templateVersion } = spec;
37
+ // Timeline start (frames @24). The start timecode lives in ONE place — the
38
+ // pool timeline clip's MediaExtents [startSeconds, durationSeconds]
39
+ // double-LE pair (measured: patching it imports with the new start TC and
40
+ // renders; no other non-cosmetic copy exists). Clips are absolute frames,
41
+ // so a custom origin just moves the guard.
42
+ const originFrame = spec.startFrame ?? DEFAULT_START_FRAME;
43
+ if (!Number.isInteger(originFrame) || originFrame < 0) throw new TypeError('assembleTimeline: spec.startFrame must be a non-negative integer');
36
44
  if (!Array.isArray(elements)) throw new TypeError('assembleTimeline: elements must be an array');
37
45
  if (!Array.isArray(transitions)) throw new TypeError('assembleTimeline: transitions must be an array');
38
46
 
@@ -59,9 +67,9 @@ async function assembleTimeline(spec = {}) {
59
67
  const validateCuts = (src, label) => {
60
68
  const mediaSpec = src.spec;
61
69
  (src.cuts || []).forEach((cut, i) => {
62
- if (cut.startFrame < DEFAULT_START_FRAME) {
70
+ if (cut.startFrame < originFrame) {
63
71
  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`,
72
+ `assembleTimeline: ${label}.cuts[${i}].startFrame ${cut.startFrame} is before the timeline origin ${originFrame} — Resolve silently drops it on import`,
65
73
  );
66
74
  }
67
75
  if (mediaSpec && Number.isFinite(mediaSpec.frameCount) && Number.isFinite(mediaSpec.fps)) {
@@ -81,7 +89,7 @@ async function assembleTimeline(spec = {}) {
81
89
  base = await addMediaClip({
82
90
  mediaFile: sources[0].mediaFilePath, spec: sources[0].spec, timelineName, templateVersion,
83
91
  });
84
- base.startFrame = DEFAULT_START_FRAME;
92
+ base.startFrame = originFrame;
85
93
 
86
94
  // Transplant/insert native pool elements BEFORE cutting, so each cut can
87
95
  // reference its source's MediaRef.
@@ -185,7 +193,55 @@ async function assembleTimeline(spec = {}) {
185
193
  }));
186
194
  }
187
195
 
188
- return { buffer, timelineName: tlName, startFrame, mediaDescriptor: mediaDescriptorState };
196
+ if (Array.isArray(spec.markers) && spec.markers.length) {
197
+ // Timeline markers ride in project.xml as a Sm2SequenceLockableBlob whose
198
+ // BlobOwner is the timeline's Sm2Sequence DbId (the uuid every track's
199
+ // <Sequence> references). Encoder byte-exact vs a live 19.1.3.7 export.
200
+ // Marker frames here are TIMELINE-ABSOLUTE for consistency with cuts;
201
+ // the blob stores them start-relative.
202
+ const zipM = await JSZip.loadAsync(buffer);
203
+ const seqName = Object.keys(zipM.files).find((n) => !zipM.files[n].dir && /SeqContainer\/.+\.xml$/.test(n));
204
+ const seqXml2 = await zipM.file(seqName).async('string');
205
+ const seqIdM = (seqXml2.match(/<Sequence>([0-9a-f-]{36})<\/Sequence>/) || [])[1];
206
+ if (!seqIdM) throw new Error('assembleTimeline: cannot find the Sm2Sequence id for markers');
207
+ const rel = spec.markers.map((m) => {
208
+ if (!Number.isInteger(m.frame) || m.frame < originFrame) {
209
+ throw new RangeError(`assembleTimeline: marker frame ${m.frame} is before the timeline origin ${originFrame} (frames are timeline-absolute)`);
210
+ }
211
+ return { ...m, frame: m.frame - originFrame };
212
+ });
213
+ const blob = encodeTimelineMarkersBlob(rel);
214
+ let pjX = await zipM.file('project.xml').async('string');
215
+ const setM = pjX.match(/<LocableBlobSet>[\s\S]*?<\/LocableBlobSet>/);
216
+ if (!setM) throw new Error('assembleTimeline: project.xml has no LocableBlobSet to hold markers');
217
+ const el = `<Element>\n <Sm2SequenceLockableBlob DbId="${randomUUID()}">\n <FieldsBlob>${blob.toString('hex')}</FieldsBlob>\n <BlobOwner>${seqIdM}</BlobOwner>\n <DbSavedTime>0</DbSavedTime>\n </Sm2SequenceLockableBlob>\n </Element>\n `;
218
+ pjX = pjX.replace('</LocableBlobSet>', `${el}</LocableBlobSet>`);
219
+ zipM.file('project.xml', pjX);
220
+ buffer = await zipM.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
221
+ }
222
+
223
+ if (originFrame !== DEFAULT_START_FRAME) {
224
+ const zipF = await JSZip.loadAsync(buffer);
225
+ const mpP = 'MediaPool/Master/MpFolder.xml';
226
+ let mpF = await zipF.file(mpP).async('string');
227
+ const tlBlocks = mpF.match(/<Sm2MpTimelineClip[\s\S]*?<\/Sm2MpTimelineClip>/g) || [];
228
+ const tlBlock = tlBlocks.find((b) => b.includes(`<Name>${tlName}</Name>`)) || tlBlocks[0];
229
+ if (!tlBlock) throw new Error('assembleTimeline: timeline pool clip not found for startFrame patch');
230
+ const meM = tlBlock.match(/<MediaExtents>([0-9a-fA-F]*)<\/MediaExtents>/);
231
+ if (!meM) throw new Error('assembleTimeline: timeline pool clip has no MediaExtents to patch');
232
+ let maxEnd = originFrame;
233
+ const collect = (arr) => (arr || []).forEach((x) => { const e = (x.startFrame ?? 0) + (x.durationFrames ?? 0); if (e > maxEnd) maxEnd = e; });
234
+ (Array.isArray(media) ? media : media ? [media] : []).forEach((src) => collect(src.cuts));
235
+ collect(elements);
236
+ const me = Buffer.alloc(16);
237
+ me.writeDoubleLE(originFrame / 24, 0);
238
+ me.writeDoubleLE((maxEnd - originFrame) / 24, 8);
239
+ mpF = mpF.replace(tlBlock, tlBlock.replace(meM[0], `<MediaExtents>${me.toString('hex')}</MediaExtents>`));
240
+ zipF.file(mpP, mpF);
241
+ buffer = await zipF.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
242
+ }
243
+
244
+ return { buffer, timelineName: tlName, startFrame: originFrame, mediaDescriptor: mediaDescriptorState };
189
245
  }
190
246
 
191
247
  module.exports = { assembleTimeline };
@@ -1,6 +1,12 @@
1
1
  /**
2
2
  * DaVinci Resolve Timeline Marker Encoder/Decoder
3
3
  *
4
+ * DEPRECATED for .drp/.drt authoring: this module's color map and emitted
5
+ * bytes never matched a real Resolve export (measured 2026-08-30 — Yellow is
6
+ * 16 not 8, Purple is 128, and the framing differs). Use
7
+ * timeline-markers-blob.js, whose encoder is byte-exact against a live
8
+ * 19.1.3.7 export and render/readback-verified through drt.assemble.
9
+ *
4
10
  * Encodes and decodes timeline markers into DaVinci Resolve's
5
11
  * compressed protobuf format stored in Sm2SequenceLockableBlob.FieldsBlob.
6
12
  *
@@ -0,0 +1,159 @@
1
+ /**
2
+ * timeline-markers-blob — encode/decode TIMELINE markers as the
3
+ * Sm2SequenceLockableBlob.FieldsBlob that lives in project.xml.
4
+ *
5
+ * Ground truth (harvested live from Studio 19.1.3.7, all 16 colors + custom
6
+ * data + durations, decoded round-trip against the API's own readback):
7
+ *
8
+ * FieldsBlob = keyed-dict { "BlobData": bytes }
9
+ * BlobData = [u32BE 10001][u32BE innerLen][0x81][zstd frame]
10
+ * zstd frame = magic 28b52ffd + single-segment header + RAW block(s)
11
+ * (Resolve itself emits raw-block zstd for small payloads —
12
+ * accepted on import; no real compressor needed)
13
+ * payload = protobuf: field2 { repeated field1 MarkerEntry }
14
+ * MarkerEntry= f1 varint frameRelative, f2 bytes {
15
+ * [u32BE 2][u32BE innerLen] f1 bytes {
16
+ * f1 varint colorBit,
17
+ * f3 string note, f3 string durationString, f3 string name,
18
+ * f6 string customData (present only when non-empty)
19
+ * } }
20
+ *
21
+ * Marker frames are RELATIVE to the timeline start (frame 0 = first frame),
22
+ * matching the scripting API's marker frame space. The blob attaches inside
23
+ * project.xml's <LocableBlobSet> (Resolve's own spelling) with
24
+ * <BlobOwner> = the timeline's Sm2Sequence DbId (the same uuid every track's
25
+ * <Sequence> references).
26
+ *
27
+ * Color bits (measured, one marker per color): sequential powers of two with
28
+ * 256 unassigned. This supersedes marker-encoder.js, whose map was wrong for
29
+ * Yellow/Purple/Lavender and whose emitted bytes never matched a real export.
30
+ *
31
+ * @module drp-format/timeline-markers-blob
32
+ */
33
+
34
+ const { encodeKeyedDict, decodeKeyedDict } = require('./keyed-dict');
35
+
36
+ const MARKER_COLOR_BITS = {
37
+ Blue: 2, Cyan: 4, Green: 8, Yellow: 16, Red: 32, Pink: 64, Purple: 128,
38
+ Fuchsia: 512, Rose: 1024, Lavender: 2048, Sky: 4096, Mint: 8192,
39
+ Lemon: 16384, Sand: 32768, Cocoa: 65536, Cream: 131072,
40
+ };
41
+ const BITS_TO_COLOR = Object.fromEntries(Object.entries(MARKER_COLOR_BITS).map(([k, v]) => [v, k]));
42
+
43
+ function varint(n) {
44
+ const out = [];
45
+ let v = n >>> 0;
46
+ do { out.push((v & 0x7f) | (v > 0x7f ? 0x80 : 0)); v >>>= 7; } while (v);
47
+ return Buffer.from(out);
48
+ }
49
+ const lenDelim = (field, payload) => Buffer.concat([varint((field << 3) | 2), varint(payload.length), payload]);
50
+ const varField = (field, n) => Buffer.concat([varint(field << 3), varint(n)]);
51
+
52
+ function encodeMarkerEntry(m) {
53
+ const colorBit = MARKER_COLOR_BITS[m.color] ?? MARKER_COLOR_BITS.Blue;
54
+ const strs = [m.note ?? '', String(m.duration ?? 1), m.name ?? ''];
55
+ const body = Buffer.concat([
56
+ varField(1, colorBit),
57
+ ...strs.map((s) => lenDelim(3, Buffer.from(s, 'utf8'))),
58
+ ...(m.customData ? [lenDelim(6, Buffer.from(m.customData, 'utf8'))] : []),
59
+ ]);
60
+ const inner = lenDelim(1, body);
61
+ const head = Buffer.alloc(8);
62
+ head.writeUInt32BE(2, 0);
63
+ head.writeUInt32BE(inner.length, 4);
64
+ const wrapped = Buffer.concat([head, inner]);
65
+ return lenDelim(1, Buffer.concat([varField(1, m.frame), lenDelim(2, wrapped)]));
66
+ }
67
+
68
+ /** zstd single-segment frame with one RAW block (no compression). */
69
+ function zstdRawFrame(payload) {
70
+ const magic = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
71
+ let header;
72
+ if (payload.length <= 255) {
73
+ header = Buffer.from([0x20, payload.length]); // single-segment, 1-byte FCS
74
+ } else {
75
+ header = Buffer.alloc(5);
76
+ header[0] = 0xa0; // single-segment, 4-byte FCS
77
+ header.writeUInt32LE(payload.length, 1);
78
+ }
79
+ const block = Buffer.alloc(3);
80
+ block.writeUIntLE((payload.length << 3) | 1, 0, 3); // last=1, type=raw
81
+ return Buffer.concat([magic, header, block, payload]);
82
+ }
83
+
84
+ function zstdRawInflate(buf) {
85
+ if (buf.readUInt32LE(0) !== 0xfd2fb528) throw new Error('timeline-markers-blob: not a zstd frame');
86
+ const fhd = buf[4];
87
+ const single = (fhd >> 5) & 1;
88
+ const fcsCode = fhd >> 6;
89
+ let o = 5 + (single ? [1, 2, 4, 8][fcsCode] : [0, 2, 4, 8][fcsCode]);
90
+ if (fhd & 0x03) throw new Error('timeline-markers-blob: dictionary frames unsupported');
91
+ const out = [];
92
+ for (;;) {
93
+ const bh = buf.readUIntLE(o, 3); o += 3;
94
+ const last = bh & 1, type = (bh >> 1) & 3, size = bh >> 3;
95
+ if (type === 0) { out.push(buf.subarray(o, o + size)); o += size; }
96
+ else if (type === 1) { out.push(Buffer.alloc(size, buf[o])); o += 1; }
97
+ else throw new Error('timeline-markers-blob: compressed zstd block — use a real zstd decoder');
98
+ if (last) break;
99
+ }
100
+ return Buffer.concat(out);
101
+ }
102
+
103
+ /**
104
+ * Encode timeline markers → Sm2SequenceLockableBlob FieldsBlob buffer.
105
+ * @param {Array<{frame:number,color?:string,name?:string,note?:string,duration?:number,customData?:string}>} markers
106
+ * frame is timeline-RELATIVE (0 = first frame of the timeline).
107
+ */
108
+ function encodeTimelineMarkersBlob(markers) {
109
+ for (const m of markers) {
110
+ if (!Number.isInteger(m.frame) || m.frame < 0) throw new TypeError('encodeTimelineMarkersBlob: marker.frame must be a non-negative integer (timeline-relative)');
111
+ if (m.color && !MARKER_COLOR_BITS[m.color]) {
112
+ throw new Error(`encodeTimelineMarkersBlob: unknown color "${m.color}" (known: ${Object.keys(MARKER_COLOR_BITS).join(', ')})`);
113
+ }
114
+ }
115
+ const entries = [...markers].sort((a, b) => b.frame - a.frame).map(encodeMarkerEntry);
116
+ const pb = lenDelim(2, Buffer.concat(entries));
117
+ const frame = zstdRawFrame(pb);
118
+ const head = Buffer.alloc(8);
119
+ head.writeUInt32BE(10001, 0);
120
+ head.writeUInt32BE(frame.length + 1, 4);
121
+ const blobData = Buffer.concat([head, Buffer.from([0x81]), frame]);
122
+ return encodeKeyedDict({ hdr: 1, entries: [
123
+ { key: 'BlobData', type: 0x0c, subType: 0, value: blobData.toString('hex') },
124
+ ] });
125
+ }
126
+
127
+ /** Decode a Sm2SequenceLockableBlob FieldsBlob → markers (raw/RLE zstd only). */
128
+ function decodeTimelineMarkersBlob(buf) {
129
+ const d = decodeKeyedDict(buf);
130
+ const bd = d.entries.find((e) => e.key === 'BlobData');
131
+ if (!bd) throw new Error('decodeTimelineMarkersBlob: no BlobData entry');
132
+ const val = Buffer.from(bd.value, 'hex');
133
+ if (val.readUInt32BE(0) !== 10001 || val[8] !== 0x81) throw new Error('decodeTimelineMarkersBlob: unexpected BlobData framing');
134
+ const pb = zstdRawInflate(val.subarray(9));
135
+ let o = 0;
136
+ const rv = () => { let v = 0, s = 0; for (;;) { const b = pb[o++]; v |= (b & 0x7f) << s; if (!(b & 0x80)) return v >>> 0; s += 7; } };
137
+ const markers = [];
138
+ if (pb[o] === 0x12) { o++; rv(); }
139
+ while (o < pb.length && pb[o] === 0x0a) {
140
+ o++; const el = rv(); const end = o + el;
141
+ const m = { frame: null, color: null, note: '', duration: 1, name: '', customData: '' };
142
+ if (pb[o] === 0x08) { o++; m.frame = rv(); }
143
+ if (pb[o] === 0x12) {
144
+ o++; rv(); o += 8;
145
+ if (pb[o] === 0x0a) { o++; rv(); }
146
+ if (pb[o] === 0x08) { o++; m.color = BITS_TO_COLOR[rv()] ?? null; }
147
+ const strs = [];
148
+ while (o < end && pb[o] === 0x1a) { o++; const sl = rv(); strs.push(pb.subarray(o, o + sl).toString('utf8')); o += sl; }
149
+ [m.note = '', , m.name = ''] = strs;
150
+ m.duration = parseInt(strs[1] ?? '1', 10) || 1;
151
+ if (o < end && pb[o] === 0x32) { o++; const sl = rv(); m.customData = pb.subarray(o, o + sl).toString('utf8'); o += sl; }
152
+ }
153
+ o = end;
154
+ markers.push(m);
155
+ }
156
+ return markers;
157
+ }
158
+
159
+ module.exports = { encodeTimelineMarkersBlob, decodeTimelineMarkersBlob, MARKER_COLOR_BITS };
@@ -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.118.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.118.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",