davinci-resolve-mcp 2.107.0 → 2.110.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,44 @@
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.108.0
6
+
7
+ **The conform emulator, coast to coast.** An interchange file goes in; an
8
+ importable, RENDERING native .drt comes out — one call.
9
+
10
+ ### Added
11
+
12
+ - **`drt.assemble_from_interchange`**: EDL/OTIO/XML/AAF + a sourceMap
13
+ (reel → {mediaFilePath, spec}) → parse → frame-convert → multi-source
14
+ assemble with native-descriptor transplant → stamped .drt. The
15
+ events→spec bridge (`eventsToAssembleSpec`) anchors the earliest video
16
+ event at the timeline origin, converts nominal-base event frames to the
17
+ 24fps template timeline (round(frames × 24 / nominalFps) — butt cuts stay
18
+ gapless through the conversion, verified at 29.97), groups cuts per
19
+ source, refuses overlapping record ranges and unmapped reels loudly, and
20
+ returns an honesty ledger: flattened retimes (the clip schema has no
21
+ per-clip speed), transitions treated as cuts, audio events skipped (cuts
22
+ carry their own linked A1).
23
+
24
+ Live-proofed on Studio 19.1.3.7 with the full route: a three-event EDL
25
+ cutting between two sources (with an M2 retime line) assembled, imported
26
+ 6/6 linked with exact source in-points, and rendered each event's OWN
27
+ pixels — YAVG 125.6 / 234 / 125.5 — with a full-range render verifying at
28
+ duration ratio 1.0 and the retime present in the ledger.
29
+
30
+ ### Scoped honestly
31
+
32
+ Title/generator elements on a pre-21 host are NOT render-verified: the
33
+ harvested snippets are Resolve-21 structures that import and read back
34
+ correctly but render black on 19.1.3 (measured), and the r19 snippet
35
+ harvest is incomplete (the generator's Sm2TiCompositionTable dependency,
36
+ the title's per-generation comp-blob layout) — with the partial harvest,
37
+ render jobs fail outright, which is worse. Snippet selection stays on the
38
+ R21 structures, `drt.assemble` warns when elements target a pre-21 host,
39
+ and the harvested r19 snippets ship in the templates directory for the
40
+ element-transplant expedition. Media cuts render everywhere the transplant
41
+ path covers.
42
+
5
43
  ## What's New in v2.107.0
6
44
 
7
45
  **Multi-source media authoring.** `drt.assemble`'s media support grows from
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.107.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.108.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.107.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.108.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.107.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v2.108.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
@@ -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, 41 bugs / unreliable behaviors.
15
+ **Totals:** 33 missing capabilities, 42 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
@@ -526,6 +526,13 @@ values, or automation-hostile modal prompts.
526
526
  - **Reference:** [issue #171](https://github.com/samuelgursky/davinci-resolve-mcp/issues/171)
527
527
  - **Tags:** timeline, import, silent-failure, unreliable-return
528
528
 
529
+ ### Imported Fusion comps render via byte-keyed disk cache on 19.x (offline comp edits render black)
530
+
531
+ - **Object:** `Fusion / render engine`
532
+ - **Behavior:** On Studio 19.1.3.7, a Fusion composition arriving via timeline import renders only when the machine's Fusion disk cache (CacheClip/) holds frames keyed to the comp blob's EXACT bytes. Measured by discrimination: the untouched harvested title rendered its text; the same blob after an IDENTITY recompression — byte-identical Lua, different zlib bytes, verified consistent framing — imported, read back perfectly, and rendered black; a text-patched blob (also byte-verified) rendered black the same way. The live-render fallback for imported comps does not produce frames on 19; 21-generation hosts render imported comps live (the template-splice title/generator primitives were proven there).
533
+ - **Workaround / current handling:** Never edit an imported comp's bytes offline for a 19.x host — no valid re-encoding can hit the cache. Author media offline (renders everywhere via the native-descriptor transplant) and set title text POST-IMPORT with timeline.set_title_text, whose Fusion-comp write path is live-verified on 19.1.3. Built-in GENERATORS are exempt: Sm2TiGenerator clips carry no Fusion comp, and offline-authored Solid Color / SMPTE Color Bar / Grey Scale all render live from an imported .drt (measured YAVG 16 / 104.9 / 125.1 over a 234 white base). Render-verify any imported Fusion TITLE before delivery; structural readback cannot see this.
534
+ - **Tags:** fusion, render, import, silent-failure
535
+
529
536
  ### MediaPool.ImportTimelineFromFile (.drt requirements and filename naming)
530
537
 
531
538
  - **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.107.0"
40
+ VERSION = "2.110.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.107.0",
3
+ "version": "2.110.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -192,6 +192,103 @@ export function eventsToOTIO(events, opts = {}) {
192
192
  }
193
193
 
194
194
  /** Build a CMX3600 EDL string (cuts + M2 speed). Video events only, per EDL convention. */
195
+ /**
196
+ * eventsToAssembleSpec — normalized interchange events → drt.assemble media spec.
197
+ *
198
+ * The coast-to-coast bridge: parseInterchange's events (EDL/AAF/OTIO/XML)
199
+ * become an importable, RENDERING native .drt via assembleTimeline's
200
+ * transplant path. Frame math: event frames are NOMINAL-base at the event's
201
+ * fps (v2.104.6 convention, measured against Resolve); the assemble template
202
+ * timeline runs 24fps with origin 86400, so rec/src frames convert as
203
+ * round(frames × 24 / nominalFps). Placement anchors the EARLIEST video
204
+ * event at the origin. Honesty ledger in the returned report: flattened
205
+ * retimes (the template clip schema has no per-clip speed), dropped
206
+ * transitions (treated as cuts at their boundary), and skipped audio events
207
+ * (cuts carry linked A1 audio from their own source already).
208
+ *
209
+ * @param {Array} events - normalized events (parseInterchange shape)
210
+ * @param {object} opts
211
+ * @param {Object<string,{mediaFilePath:string, spec:object}>} opts.sourceMap -
212
+ * event.source (reel) → media file + spec. Every VIDEO event's source must
213
+ * be mapped; unmapped reels refuse with the reel names listed.
214
+ * @param {string} [opts.timelineName]
215
+ * @returns {{spec: object, report: object}}
216
+ */
217
+ export function eventsToAssembleSpec(events, opts = {}) {
218
+ const { sourceMap, timelineName } = opts;
219
+ if (!Array.isArray(events) || !events.length) {
220
+ throw new TypeError('eventsToAssembleSpec: events must be a non-empty array');
221
+ }
222
+ if (!sourceMap || typeof sourceMap !== 'object') {
223
+ throw new TypeError('eventsToAssembleSpec: sourceMap {reel: {mediaFilePath, spec}} is required');
224
+ }
225
+ const ORIGIN = 86400;
226
+ const vids = events.filter((e) => e.track !== 'A' && e.recIn != null && e.recOut != null);
227
+ const audioSkipped = events.length - vids.length;
228
+ if (!vids.length) throw new Error('eventsToAssembleSpec: no video events with record ranges');
229
+
230
+ const unmapped = [...new Set(vids.map((e) => e.source).filter((srcName) => !sourceMap[srcName]))];
231
+ if (unmapped.length) {
232
+ throw new Error(
233
+ `eventsToAssembleSpec: unmapped source reel(s): ${unmapped.join(', ')} — ` +
234
+ 'every video event needs a sourceMap entry {mediaFilePath, spec}',
235
+ );
236
+ }
237
+
238
+ const toTl = (frames, fps) => Math.round((frames * 24) / Math.round(fps || 24));
239
+ const flattenedRetimes = [];
240
+ const droppedTransitions = [];
241
+ const perSource = new Map();
242
+ const placements = [];
243
+
244
+ const minRec = Math.min(...vids.map((e) => toTl(e.recIn, e.fps)));
245
+ for (const e of vids) {
246
+ const recIn = ORIGIN + (toTl(e.recIn, e.fps) - minRec);
247
+ const recOut = ORIGIN + (toTl(e.recOut, e.fps) - minRec);
248
+ const durationFrames = recOut - recIn;
249
+ if (durationFrames <= 0) continue;
250
+ if ((e.speed ?? 100) !== 100 || e.reverse) {
251
+ flattenedRetimes.push({ index: e.index, source: e.source, speed: e.speed, reverse: !!e.reverse });
252
+ }
253
+ if (e.transition) {
254
+ droppedTransitions.push({ index: e.index, type: e.transition.type, duration: e.transition.duration });
255
+ }
256
+ const cut = { startFrame: recIn, durationFrames, srcIn: toTl(e.srcIn ?? 0, e.fps) };
257
+ placements.push({ start: recIn, end: recOut, index: e.index });
258
+ if (!perSource.has(e.source)) perSource.set(e.source, []);
259
+ perSource.get(e.source).push(cut);
260
+ }
261
+
262
+ placements.sort((a, b) => a.start - b.start);
263
+ for (let i = 1; i < placements.length; i += 1) {
264
+ if (placements[i].start < placements[i - 1].end) {
265
+ throw new Error(
266
+ `eventsToAssembleSpec: events ${placements[i - 1].index} and ${placements[i].index} ` +
267
+ 'overlap on the record track after frame conversion — a single V1 cannot hold both. ' +
268
+ 'Resolve the overlap upstream (transitions count as cuts at their boundary here).',
269
+ );
270
+ }
271
+ }
272
+
273
+ const media = [...perSource.entries()].map(([reel, cuts]) => ({
274
+ mediaFilePath: sourceMap[reel].mediaFilePath,
275
+ spec: sourceMap[reel].spec,
276
+ cuts,
277
+ }));
278
+
279
+ return {
280
+ spec: { timelineName, media },
281
+ report: {
282
+ videoEvents: vids.length,
283
+ sources: media.length,
284
+ audioEventsSkipped: audioSkipped,
285
+ flattenedRetimes,
286
+ droppedTransitions,
287
+ origin: ORIGIN,
288
+ },
289
+ };
290
+ }
291
+
195
292
  export function eventsToEDL(events, opts = {}) {
196
293
  const fps = opts.fps || events.find((e) => e.fps)?.fps || 24;
197
294
  const vids = events.filter((e) => e.track !== 'A').sort((a, b) => (a.recIn ?? 0) - (b.recIn ?? 0));
@@ -29,11 +29,24 @@ const authorSchema = z.object({
29
29
  outputPath: z.string().describe('Absolute path where the .drt will be written'),
30
30
  });
31
31
  const validateSchema = z.object({ drtPath: z.string().describe('Absolute path to a .drt file') });
32
+ const assembleFromInterchangeSchema = z.object({
33
+ format: z.enum(['edl', 'otio', 'xml', 'aaf']).describe('Interchange format of the input'),
34
+ path: z.string().optional().describe('Path to the interchange file (aaf REQUIRES a path)'),
35
+ content: z.string().optional().describe('Inline interchange text (edl/otio/xml)'),
36
+ fps: z.number().optional().describe('Event frame rate for parsing (default 24; use e.g. 29.97 for NTSC EDLs)'),
37
+ sourceMap: z
38
+ .record(z.object({ mediaFilePath: z.string(), spec: z.object({}).passthrough() }))
39
+ .describe('reel/source name → {mediaFilePath, spec:{width,height,frameCount,fps}}; every video event must map'),
40
+ timelineName: z.string().optional(),
41
+ outputPath: z.string().describe('Where the importable .drt is written'),
42
+ targetAppVersion: z.union([z.string(), z.number()]).optional()
43
+ .describe("Host Resolve version, e.g. '19.1' for pre-21"),
44
+ });
32
45
  const assembleSchema = z.object({
33
46
  spec: z
34
47
  .object({})
35
48
  .passthrough()
36
- .describe("assembleTimeline spec: { timelineName?, media?: {mediaFilePath, spec:{width,height,frameCount,fps}, cuts:[{startFrame,durationFrames,srcIn?}]} | [same, ...] (multi-source needs media_pool.capture_media_template run once per file), elements?: [{type:'title'|'generator', track, startFrame, durationFrames?, text?, ...}], transitions? }. startFrame is timeline-absolute (origin 86400)."),
49
+ .describe("assembleTimeline spec: { timelineName?, media?: {mediaFilePath, spec:{width,height,frameCount,fps}, cuts:[{startFrame,durationFrames,srcIn?}]} | [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)."),
37
50
  outputPath: z.string().describe('Absolute path where the importable .drt will be written'),
38
51
  targetAppVersion: z
39
52
  .union([z.string(), z.number()])
@@ -135,7 +148,7 @@ function requirePathArg(args, key, action) {
135
148
  export const drtTool = {
136
149
  name: 'drt',
137
150
  description:
138
- 'DaVinci Resolve Timeline (.drt) operations — offline, no Resolve required. Actions: assemble (spec → IMPORTABLE native-schema .drt via template-spliced real structures; pass targetAppVersion e.g. \'19.1\' for pre-21 hosts), parse, list_sequences (enumerate the timelines inside a .drp/.drt → [{id,name,eventCount,index}] to drive a "which sequence?" picker), author, validate, inject_into_drp, extract_from_drp (pull one SeqContainer out as a .drt — feed the .drt to the Python davinci-resolve MCP timeline.import_timeline_checked, or use timeline.import_from_drp to do both), downgrade (stamp <ProjectVersion> down so an OLDER Resolve will import a .drt/.drp from a newer one — pass targetAppVersion like "19.1.3" or targetProjectVersion).',
151
+ 'DaVinci Resolve Timeline (.drt) operations — offline, no Resolve required. Actions: assemble_from_interchange (EDL/OTIO/XML/AAF + sourceMap → IMPORTABLE RENDERING native .drt in one call; retimes flatten, transitions become cuts, ledger in `conform`), assemble (spec → IMPORTABLE native-schema .drt via template-spliced real structures; pass targetAppVersion e.g. \'19.1\' for pre-21 hosts), parse, list_sequences (enumerate the timelines inside a .drp/.drt → [{id,name,eventCount,index}] to drive a "which sequence?" picker), author, validate, inject_into_drp, extract_from_drp (pull one SeqContainer out as a .drt — feed the .drt to the Python davinci-resolve MCP timeline.import_timeline_checked, or use timeline.import_from_drp to do both), downgrade (stamp <ProjectVersion> down so an OLDER Resolve will import a .drt/.drp from a newer one — pass targetAppVersion like "19.1.3" or targetProjectVersion).',
139
152
  async handler({ action, args }) {
140
153
  if (action === 'parse') {
141
154
  const p = parseSchema.parse(requirePathArg(args, 'drtPath', 'parse'));
@@ -190,6 +203,52 @@ export const drtTool = {
190
203
  await fs.writeFile(p.outputPath, outBuf);
191
204
  return { outputPath: p.outputPath, bytes: outBuf.length, seqContainersInjected: injected, projectFolder };
192
205
  }
206
+ if (action === 'assemble_from_interchange') {
207
+ // Coast-to-coast conform: interchange in, importable RENDERING .drt out.
208
+ const p = assembleFromInterchangeSchema.parse(args);
209
+ const { parseInterchange } = await import('../editorial.mjs');
210
+ const { eventsToAssembleSpec } = await import('../author-interchange.mjs');
211
+ let content = p.content;
212
+ if (p.format === 'aaf') {
213
+ if (!p.path) return { error: 'aaf input requires path' };
214
+ content = p.path;
215
+ } else if (!content) {
216
+ if (!p.path) return { error: 'provide content or path' };
217
+ content = await fs.readFile(p.path, 'utf8');
218
+ }
219
+ const events = parseInterchange(p.format, content, { fps: p.fps ?? 24 });
220
+ if (!events || !events.length) return { error: 'no events parsed from the interchange input' };
221
+ const { spec, report } = eventsToAssembleSpec(events, {
222
+ sourceMap: p.sourceMap, timelineName: p.timelineName,
223
+ });
224
+ if (p.targetAppVersion !== undefined) {
225
+ spec.templateVersion = parseFloat(p.targetAppVersion) >= 21 ? 21 : 19;
226
+ }
227
+ const { assembleTimeline } = drp();
228
+ const { buffer, timelineName, mediaDescriptor } = await assembleTimeline(spec);
229
+ let outBuf = buffer;
230
+ let stamped = null;
231
+ if (p.targetAppVersion !== undefined) {
232
+ const targetPV = resolveTargetProjectVersion({ targetAppVersion: p.targetAppVersion });
233
+ const appVer = `${p.targetAppVersion}${'.0'.repeat(Math.max(0, 4 - String(p.targetAppVersion).split('.').length))}`;
234
+ const zip = await JSZip.loadAsync(buffer);
235
+ const { out } = await applyVersionStamps(zip, targetPV, appVer);
236
+ outBuf = await out.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
237
+ stamped = { targetProjectVersion: targetPV };
238
+ }
239
+ await fs.writeFile(p.outputPath, outBuf);
240
+ return {
241
+ outputPath: p.outputPath,
242
+ bytes: outBuf.length,
243
+ timelineName,
244
+ mediaDescriptor,
245
+ stamped,
246
+ conform: report,
247
+ note:
248
+ 'Import with timeline.import_timeline_checked (timeline is named after the FILE). ' +
249
+ 'Retimes are flattened and transitions become cuts — see `conform` for the ledger.',
250
+ };
251
+ }
193
252
  if (action === 'assemble') {
194
253
  const p = assembleSchema.parse(args);
195
254
  // Native-schema authoring: template-spliced real Resolve structures
@@ -225,6 +284,19 @@ export const drtTool = {
225
284
  stamped,
226
285
  templateVersion: spec.templateVersion ?? 21,
227
286
  mediaDescriptor: mediaDescriptor ?? 'none',
287
+ ...((spec.elements || []).some((e) => e && e.type === 'title') && (spec.templateVersion ?? 21) < 21
288
+ ? {
289
+ elementsWarning:
290
+ 'On a pre-21 host, imported Fusion TITLE comps render only via the machine\'s ' +
291
+ 'Fusion disk cache, keyed to the EXACT comp bytes (measured: an identity ' +
292
+ 'recompression rendered black) — offline-authored titles may render black and ' +
293
+ 'offline text patching cannot work there. The working pre-21 title flow: assemble ' +
294
+ 'without title text, then set it post-import with timeline.set_title_text (its ' +
295
+ 'Fusion-comp path is live-verified on 19.1.3). Media cuts and built-in GENERATORS ' +
296
+ '(Solid Color / SMPTE Color Bar / Grey Scale — plain Sm2TiGenerator, no Fusion ' +
297
+ 'comp) render everywhere: generator kinds render-verified on 19.1.3.',
298
+ }
299
+ : {}),
228
300
  ...(mediaDescriptor === 'repoint-fallback'
229
301
  ? {
230
302
  warning:
@@ -131,12 +131,12 @@ async function assembleTimeline(spec = {}) {
131
131
  ({ buffer } = await placeFusionTitle(buffer, {
132
132
  trackIndex: el.track, startFrame: el.startFrame, durationFrames: el.durationFrames,
133
133
  text: el.text, font: el.font, style: el.style, size: el.size, color: el.color,
134
- vJustify: el.vJustify, hJustify: el.hJustify, name: el.name,
134
+ vJustify: el.vJustify, hJustify: el.hJustify, name: el.name, templateVersion,
135
135
  }));
136
136
  } else if (el.type === 'generator') {
137
137
  ({ buffer } = await placeGenerator(buffer, {
138
138
  generatorName: el.generatorName, trackIndex: el.track,
139
- startFrame: el.startFrame, durationFrames: el.durationFrames,
139
+ startFrame: el.startFrame, durationFrames: el.durationFrames, templateVersion,
140
140
  }));
141
141
  } else {
142
142
  throw new Error(`assembleTimeline: elements[${i}] unknown type "${el.type}" (title|generator)`);
@@ -82,6 +82,15 @@ function rewriteInner(hex, transform) {
82
82
  const b = Buffer.from(hex, 'hex');
83
83
  const outer = zlib.inflateSync(b.subarray(4));
84
84
 
85
+ // NOTE (measured 2026-08-30, Studio 19.1.3.7): the mechanics below are
86
+ // generation-agnostic and byte-correct — a patched blob re-inflates with
87
+ // StyledText updated and consistent framing. But on 19, IMPORTED comps
88
+ // render only via the machine's Fusion disk cache, keyed to the EXACT
89
+ // compressed bytes: even an identity reframe (same Lua, different
90
+ // compression) rendered black, while the untouched harvest rendered its
91
+ // cached frames. Offline text patching therefore works on 21-generation
92
+ // hosts (live Fusion render) and CANNOT work on 19 imports — set text
93
+ // post-import with timeline.set_title_text instead.
85
94
  const marker = outer.lastIndexOf(COMPRESSED_MARKER);
86
95
  if (marker < 0) throw new Error('composition-text: composition marker not found (unexpected framing)');
87
96
  const zNull = outer.indexOf(0x00, marker);
@@ -37,6 +37,13 @@ const {
37
37
  } = require('./seq-surgery');
38
38
 
39
39
  const TEMPLATE_PATH = path.join(__dirname, 'templates', 'fusion-title.xml');
40
+ // Snippets are generation-bound like the project templates: an R21-harvested
41
+ // clip imports on 19 and renders BLACK (measured 2026-08-30). r19 variant
42
+ // harvested live from Studio 19.1.3.7.
43
+ const TEMPLATE_PATH_R19 = path.join(__dirname, 'templates', 'fusion-title-r19.xml');
44
+ function snippetPathFor(templateVersion) {
45
+ return (Number(templateVersion) || 21) >= 21 ? TEMPLATE_PATH : TEMPLATE_PATH_R19;
46
+ }
40
47
 
41
48
  /**
42
49
  * Place a Fusion Title clip onto a chosen video track of a timeline in a .drp.
@@ -72,6 +79,7 @@ async function placeFusionTitle(drpInput, opts = {}) {
72
79
  color,
73
80
  timelineUuid,
74
81
  titleClipXml,
82
+ templateVersion,
75
83
  } = opts;
76
84
 
77
85
  if (!Number.isInteger(startFrame)) throw new TypeError('placeFusionTitle: startFrame (int) is required');
@@ -82,7 +90,7 @@ async function placeFusionTitle(drpInput, opts = {}) {
82
90
  let xml = seqXml;
83
91
 
84
92
  // Prepare the title clip (clone + rewrite identity/timing).
85
- let clip = titleClipXml || fs.readFileSync(TEMPLATE_PATH, 'utf8');
93
+ let clip = titleClipXml || fs.readFileSync(snippetPathFor(templateVersion), 'utf8');
86
94
  clip = clip.trim();
87
95
  if (!/PrettyType>\s*Fusion Title/.test(clip)) {
88
96
  throw new Error('placeFusionTitle: template is not a Fusion Title clip');
@@ -9,7 +9,10 @@
9
9
  *
10
10
  * Clone-based: carry the bundled Solid Color template and swap PrettyType/Name/Start/
11
11
  * Duration + a fresh DbId. The same Sm2TiGenerator shape serves the other simple built-in
12
- * generators by changing PrettyType (verify per type before relying on it).
12
+ * generators by changing PrettyType — RENDER-VERIFIED on Studio 19.1.3.7 (2026-08-30):
13
+ * Solid Color YAVG 16 over white 234, SMPTE Color Bar 104.9, Grey Scale 125.1, all from a
14
+ * fully offline-authored .drt. Unlike Fusion titles, Sm2TiGenerator has no comp blob, so
15
+ * the byte-keyed Fusion render-cache law does NOT apply: generators render live everywhere.
13
16
  *
14
17
  * @module drp-format/place-generator
15
18
  */
@@ -28,6 +31,12 @@ const {
28
31
  } = require('./seq-surgery');
29
32
 
30
33
  const TEMPLATE_PATH = path.join(__dirname, 'templates', 'generator-solid-color.xml');
34
+ const TEMPLATE_PATH_R19 = path.join(__dirname, 'templates', 'generator-solid-color-r19.xml');
35
+ // Generation-bound like every other harvested structure (R21 snippet renders
36
+ // black on 19 — measured); r19 variant harvested live from 19.1.3.7.
37
+ function snippetPathFor(templateVersion) {
38
+ return (Number(templateVersion) || 21) >= 21 ? TEMPLATE_PATH : TEMPLATE_PATH_R19;
39
+ }
31
40
 
32
41
  /**
33
42
  * Place a built-in generator on a chosen video track.
@@ -43,7 +52,7 @@ const TEMPLATE_PATH = path.join(__dirname, 'templates', 'generator-solid-color.x
43
52
  * generatorName:string, videoTrackCount:number, createdTracks:number}>}
44
53
  */
45
54
  async function placeGenerator(drpInput, opts = {}) {
46
- const { generatorName = 'Solid Color', trackIndex = 2, startFrame, durationFrames = 120, timelineUuid } = opts;
55
+ const { generatorName = 'Solid Color', trackIndex = 2, startFrame, durationFrames = 120, timelineUuid, templateVersion } = opts;
47
56
  if (!Number.isInteger(startFrame)) throw new TypeError('placeGenerator: startFrame (int) is required');
48
57
  if (!Number.isInteger(trackIndex) || trackIndex < 1) throw new TypeError('placeGenerator: trackIndex must be a positive integer');
49
58
  if (/[<>]/.test(generatorName)) throw new Error('placeGenerator: generatorName must not contain < or >');
@@ -51,7 +60,7 @@ async function placeGenerator(drpInput, opts = {}) {
51
60
  const zip = await loadDrpZip(drpInput);
52
61
  const { entry, xml: seqXml, seqId } = await selectTargetSeq(zip, timelineUuid);
53
62
 
54
- let gen = fs.readFileSync(TEMPLATE_PATH, 'utf8').trim();
63
+ let gen = fs.readFileSync(snippetPathFor(templateVersion), 'utf8').trim();
55
64
  gen = freshDbIds(gen);
56
65
  gen = gen.replace(/<PrettyType>[\s\S]*?<\/PrettyType>/, `<PrettyType>${escapeXml(generatorName)}</PrettyType>`);
57
66
  gen = gen.replace(/<Name>[\s\S]*?<\/Name>/, `<Name>${escapeXml(generatorName)}</Name>`);
@@ -0,0 +1,56 @@
1
+ <Element>
2
+ <Sm2TiVideoClip DbId="8c729549-aded-4c80-b143-c1fce23c5712">
3
+ <FieldsBlob>0000000200000027800a120a0e2205546578742b4a05546578742b200112100000000000000005ffffffffffffffff</FieldsBlob>
4
+ <PrettyType>Fusion Title</PrettyType>
5
+ <Name>Fusion Title</Name>
6
+ <Start>86400</Start>
7
+ <Duration>120</Duration>
8
+ <LinkedItemSync/>
9
+ <WasDisbanded>false</WasDisbanded>
10
+ <MarkersBA/>
11
+ <UiMemento>0</UiMemento>
12
+ <Flags>0</Flags>
13
+ <PriorityIndex>0</PriorityIndex>
14
+ <EffectFiltersBA/>
15
+ <ImportExportMetadataBA/>
16
+ <RenderTextEnabled>true</RenderTextEnabled>
17
+ <RenderTextGanged>true</RenderTextGanged>
18
+ <RenderTextPrefixed>true</RenderTextPrefixed>
19
+ <CompositionTable>
20
+ <Sm2TiCompositionTable DbId="fda722ba-c81f-4fa2-ae79-9f33b26a6bb1">
21
+ <FieldsBlob>000000010000000100000016004c006100730074004d006f006400540069006d0065000000040000000000002254b6</FieldsBlob>
22
+ <DbSavedTime>2309245</DbSavedTime>
23
+ <CompositionBA>000006b1789c75547f6c1445145e31d4585142a28d4222eb69c51ff763f7eedabb2bb4a6f67ab686daa3bd1e840acddcec6cbbedeece6677b677a536863420d5c81f448931261543c4a0a04453153585a860b531284ab46862556c4a358809218d4a9cd9dd6b8fa89bdbcbbc37efbd79effbbe598ee3aee138ee5afa967390c39ccde91ca1d612fa3a5bcb3881eba24ec0691c72c2e8b3926ba0b11a67d07f8b53688642573ac773e2428a4453087d99cd7165a906ac19d8528882757e906fb04d13e924a36888afe5053fdf867409996d40ef668e41e612c5043fe4e71f52710ea8ffb5e315694e527fc4cf37291be8829836f2f369150ce400eceb302440500b96bc63da093211f66c19a8168dcd22d3625dd5f2be24c82a3a5468371656fb110db72505f36222280623414110623e5a03f423a9d526864d2cb76a1352a50cc6aa6726150be45454ea5a8f61df558e7a56b855962d448a0058b6c6d21666708f685015c372067726b608d6928000c793696c49afafcf34763910f832a840eef7b1b8940934b490953691ec1a8c0467d1ac532000244abf0b6adac4850167d50e81d383e867a9f4975550decd662bb15834896460ab647127927496ad06a3d875b7f7e07c4736c3a8d3491641824dab883babc11ab64d944406e96103283a893bfdbb34b935b06dc2227b033af4e07194612b92376591b02276ed3a304a4f6aa12a3015a0b24691f9af46ffb711863956b1e9b5629224b22095aaa2772f0a88f9db8166a8c85a74a66c55a5c043c5d396d78a57340d488f7b740b7019e9f479333400d883ac1adf16d648a8c3a2ea0c5940b391da6d9b56df40a805f72bc80a39714c1d211416a02c08301046220c44e3914820511dad0e88f1980c130921918b89a1585c4e54e5c46820160d4b816804c881845c950b54272242541644291783a1388c851355d14400488806c1b810a0299100146588c211581513c3a194cd462ac2e3482d854d0d1097098a345d84a31e3fd4561893623056fac4998228da0c26f7fa3a26458c0e972ff15cadd4887366876e51c891e41ce87e1f1a7566791f86948af359b7cca04304bb78cd94350888c3e5822e525877db6ed470afe2593e57830652d5861e04fbdc00ddb9d30b0a6beed6b1893662537ac4d672c82ca17e71ab9e560086554abf2b29cd30916595941be20ed06f6e61eae93dc77b3f1496edbc7ccfdd336d5f0687cfbd719aef2ceb2c9f3dfae6d9570e1d3b327ce7e9c3fb2ed47dbcf5dea5abcbeb3e19affb76bc7279ee7aedc05ddb0bd3bf37dd706e9bb8ab2dbf7bdb99b28b2f4f6e3ef8e98a0bef4f4e3d7c5f2c3b29cf275f58f3e375aba64efe31bfeed2b1b53fe8ab6bef18bae9a3fcf21dcf066e3e31b6a7977ff0833978cbedafcf6eac8fd4ece6275e9d9b782b37b29fbff81820cfbc5673eaa5bd475b0f9f3cf1cecacd8faefafa2f587872c36cf4c6bf376daffcaee9d2f13ae3b315fbbee832767db56ee7955b9f5a3356c88f8c2f9d4e9f3f04464641e56d736f0f6f7a4e786ffe9b2bfed1ba1d4bb68e9e7af167f079fcf1113cb3e57cd9ee2315fba77fab1fffa5efa75fcb0eeac9e0e8c804ff67c5da5cf8ddb364eff7cf57cc3cf1003f76e6f23fb8ff3f78</CompositionBA>
24
+ <ActiveCompositionIdx>0</ActiveCompositionIdx>
25
+ </Sm2TiCompositionTable>
26
+ </CompositionTable>
27
+ <In/>
28
+ <MixedFrameRateAlignment>0</MixedFrameRateAlignment>
29
+ <MediaStartTime>0</MediaStartTime>
30
+ <MediaFilePath/>
31
+ <MediaReelNumber/>
32
+ <MediaFrameRate>00000000000038400000000000000000</MediaFrameRate>
33
+ <MediaTimemapBA>024013d55555555555</MediaTimemapBA>
34
+ <LastChangedTime>0</LastChangedTime>
35
+ <LastRenderedTime>0</LastRenderedTime>
36
+ <IsMarkedForCaching>false</IsMarkedForCaching>
37
+ <IsForceConformed>true</IsForceConformed>
38
+ <MatchConflictState>0</MatchConflictState>
39
+ <UseOppositeSrcForLeftEye>false</UseOppositeSrcForLeftEye>
40
+ <UseOppositeSrcForRightEye>false</UseOppositeSrcForRightEye>
41
+ <RenderCacheBA/>
42
+ <CurrentSelectorIdx>962869300</CurrentSelectorIdx>
43
+ <IsPreConformed>false</IsPreConformed>
44
+ <PreConformMediaExtents>00000100000030c20000010000003042</PreConformMediaExtents>
45
+ <MediaMetadata/>
46
+ <Thumbnail>
47
+ <BtThumnail DbId="ac0024f2-2407-47f8-9789-eafdbd585ae2">
48
+ <FieldsBlob/>
49
+ <ImgWidth>-1</ImgWidth>
50
+ <ImgHeight>-1</ImgHeight>
51
+ <Buffer/>
52
+ </BtThumnail>
53
+ </Thumbnail>
54
+ <ThumbnailDirtyFlag>true</ThumbnailDirtyFlag>
55
+ </Sm2TiVideoClip>
56
+ </Element>
@@ -0,0 +1,21 @@
1
+ <Element>
2
+ <Sm2TiGenerator DbId="9d9cd9a6-f64f-49b9-a661-18f4f9c51781">
3
+ <FieldsBlob/>
4
+ <PrettyType>Solid Color</PrettyType>
5
+ <Name>Solid Color</Name>
6
+ <Start>86520</Start>
7
+ <Duration>120</Duration>
8
+ <LinkedItemSync/>
9
+ <WasDisbanded>false</WasDisbanded>
10
+ <MarkersBA/>
11
+ <UiMemento>0</UiMemento>
12
+ <Flags>0</Flags>
13
+ <PriorityIndex>0</PriorityIndex>
14
+ <EffectFiltersBA/>
15
+ <ImportExportMetadataBA/>
16
+ <RenderTextEnabled>true</RenderTextEnabled>
17
+ <RenderTextGanged>true</RenderTextGanged>
18
+ <RenderTextPrefixed>true</RenderTextPrefixed>
19
+ <In/>
20
+ </Sm2TiGenerator>
21
+ </Element>
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "2.107.0"
90
+ VERSION = "2.110.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.107.0"
14
+ VERSION = "2.110.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -1914,6 +1914,38 @@ 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": "Imported Fusion comps render via byte-keyed disk cache on 19.x (offline comp edits render black)",
1919
+ "object": "Fusion / render engine",
1920
+ "reality": "On Studio 19.1.3.7, a Fusion composition arriving via "
1921
+ "timeline import renders only when the machine's Fusion "
1922
+ "disk cache (CacheClip/) holds frames keyed to the comp "
1923
+ "blob's EXACT bytes. Measured by discrimination: the "
1924
+ "untouched harvested title rendered its text; the same "
1925
+ "blob after an IDENTITY recompression — byte-identical "
1926
+ "Lua, different zlib bytes, verified consistent framing — "
1927
+ "imported, read back perfectly, and rendered black; a "
1928
+ "text-patched blob (also byte-verified) rendered black "
1929
+ "the same way. The live-render fallback for imported "
1930
+ "comps does not produce frames on 19; 21-generation "
1931
+ "hosts render imported comps live (the template-splice "
1932
+ "title/generator primitives were proven there).",
1933
+ "recommended": "Never edit an imported comp's bytes offline for a "
1934
+ "19.x host — no valid re-encoding can hit the cache. "
1935
+ "Author media offline (renders everywhere via the "
1936
+ "native-descriptor transplant) and set title text "
1937
+ "POST-IMPORT with timeline.set_title_text, whose "
1938
+ "Fusion-comp write path is live-verified on 19.1.3. "
1939
+ "Built-in GENERATORS are exempt: Sm2TiGenerator "
1940
+ "clips carry no Fusion comp, and offline-authored "
1941
+ "Solid Color / SMPTE Color Bar / Grey Scale all "
1942
+ "render live from an imported .drt (measured YAVG "
1943
+ "16 / 104.9 / 125.1 over a 234 white base). "
1944
+ "Render-verify any imported Fusion TITLE before "
1945
+ "delivery; structural readback cannot see this.",
1946
+ "tags": ["fusion", "render", "import", "silent-failure"],
1947
+ "submit": "bug",
1948
+ },
1917
1949
  {
1918
1950
  "symbol": "MediaPool.ImportTimelineFromFile (.drt requirements and filename naming)",
1919
1951
  "object": "MediaPool",