davinci-resolve-mcp 2.123.0 → 2.125.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.
@@ -54,8 +54,9 @@ window. `render.verify_output` covers the container-level checks.
54
54
  | Fusion titles | `elements: [{type:'title', text}]` — **21-gen hosts only** | v2.108 |
55
55
 
56
56
  `assemble_from_interchange` drives the same engine from an EDL / OTIO /
57
- FCP7-XML / AAF plus a `sourceMap` all four formats are route-proven
58
- end-to-end (parse assemble import measured frames and RMS) — and
57
+ FCP7-XML / AAF / **.prproj** (Premiere, read offline no Premiere needed)
58
+ plus a `sourceMap` all five formats are route-proven end-to-end
59
+ (parse → assemble → import → measured frames and RMS) — and
59
60
  returns an honesty ledger
60
61
  (`authoredTransitions`, `droppedTransitions` with reasons, `authoredRetimes`,
61
62
  `flattenedRetimes`, `authoredAudioEvents`, `upperTrackCutsVideoOnly`).
@@ -110,6 +111,12 @@ Everything else stays in `droppedTransitions` with the reason.
110
111
  3. For audio: RMS per window (silence = -inf is a failed placement).
111
112
  4. Never trust `created_new: false` — a same-named timeline already in the
112
113
  project is returned as "success" (internal-name-wins law).
114
+ 5. Close the loop with `editorial.verify_roundtrip`: parse the original
115
+ interchange and Resolve's own re-export of the imported timeline, and the
116
+ verifier normalizes track labels, source naming, and per-source
117
+ TC-absolute source frames (fitting the offsets, e.g. 86400 for a
118
+ 01:00:00:00 source) — `pass: true` means the authored timeline's export
119
+ matches the turnover's intent event-for-event.
113
120
 
114
121
  ## References
115
122
 
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "2.123.0"
40
+ VERSION = "2.125.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.123.0",
3
+ "version": "2.125.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -575,3 +575,62 @@ export async function authorInterchange(events, target, opts = {}) {
575
575
  }
576
576
  throw new Error(`authorInterchange: unknown target '${target}' (otio|edl|drt)`);
577
577
  }
578
+
579
+ /**
580
+ * verifyRoundtrip — assert that a re-EXPORT of an authored timeline matches
581
+ * the interchange it was built from, normalizing the three cross-format
582
+ * conventions measured on a live AAF→assemble→import→OTIO-export loop
583
+ * (19.1.3.7):
584
+ * 1. track labels: first tracks read 'V'/'A' in one format, 'V1'/'A1' in
585
+ * the other — canonicalized to the numbered form;
586
+ * 2. source names: AAF mob name vs file basename ('rt_source_1' vs
587
+ * 'rt_source_1.mov') — compared after stripping the extension,
588
+ * case-insensitively;
589
+ * 3. source frames: Resolve's OTIO export is TIMECODE-ABSOLUTE while
590
+ * event lists are usually source-relative — a CONSTANT per-source
591
+ * offset is fitted from the first pair and every other pair must agree
592
+ * (the offset itself is reported, e.g. 86400 for a 01:00:00:00 source).
593
+ * Record positions are min-anchored per side. Video events only (audio
594
+ * channel legs merge by design).
595
+ *
596
+ * @returns {{pass:boolean, pairs:number, srcOffsets:Object, mismatches:Array}}
597
+ */
598
+ export function verifyRoundtrip(inputEvents, exportedEvents, opts = {}) {
599
+ const recTol = opts.recTol ?? 1;
600
+ const srcTol = opts.srcTol ?? 1;
601
+ const canonTrack = (t) => {
602
+ const m = /^([VA])(\d+)?$/.exec(String(t || ''));
603
+ return m ? `${m[1]}${m[2] || '1'}` : String(t);
604
+ };
605
+ const canonSource = (x) => String(x || '').replace(/\.[^.]+$/, '').toLowerCase();
606
+ const vids = (evts) => evts.filter((e) => /^V\d*$/.test(String(e.track)) && e.recIn != null && e.recOut != null);
607
+ const norm = (evts) => {
608
+ const v = vids(evts);
609
+ if (!v.length) return [];
610
+ const off = Math.min(...v.map((e) => e.recIn));
611
+ return v
612
+ .map((e) => ({ track: canonTrack(e.track), source: canonSource(e.source), recIn: e.recIn - off, recOut: e.recOut - off, srcIn: e.srcIn ?? 0 }))
613
+ .sort((a, b) => a.track.localeCompare(b.track) || a.recIn - b.recIn);
614
+ };
615
+ const a = norm(inputEvents);
616
+ const b = norm(exportedEvents);
617
+ const mismatches = [];
618
+ if (a.length !== b.length) mismatches.push({ kind: 'count', input: a.length, exported: b.length });
619
+ const srcOffsets = {};
620
+ const n = Math.min(a.length, b.length);
621
+ for (let i = 0; i < n; i += 1) {
622
+ const x = a[i], y = b[i];
623
+ if (x.track !== y.track) { mismatches.push({ kind: 'track', at: i, input: x.track, exported: y.track }); continue; }
624
+ if (x.source !== y.source) { mismatches.push({ kind: 'source', at: i, input: x.source, exported: y.source }); continue; }
625
+ if (Math.abs(x.recIn - y.recIn) > recTol || Math.abs(x.recOut - y.recOut) > recTol) {
626
+ mismatches.push({ kind: 'record', at: i, input: [x.recIn, x.recOut], exported: [y.recIn, y.recOut] });
627
+ continue;
628
+ }
629
+ const off = y.srcIn - x.srcIn;
630
+ if (srcOffsets[x.source] === undefined) srcOffsets[x.source] = off;
631
+ else if (Math.abs(off - srcOffsets[x.source]) > srcTol) {
632
+ mismatches.push({ kind: 'source-frames', at: i, source: x.source, expectedOffset: srcOffsets[x.source], gotOffset: off });
633
+ }
634
+ }
635
+ return { pass: mismatches.length === 0, pairs: n, srcOffsets, mismatches };
636
+ }
@@ -30,7 +30,7 @@ const authorSchema = z.object({
30
30
  });
31
31
  const validateSchema = z.object({ drtPath: z.string().describe('Absolute path to a .drt file') });
32
32
  const assembleFromInterchangeSchema = z.object({
33
- format: z.enum(['edl', 'otio', 'xml', 'aaf']).describe('Interchange format of the input'),
33
+ format: z.enum(['edl', 'otio', 'xml', 'aaf', 'prproj']).describe('Interchange format of the input'),
34
34
  path: z.string().optional().describe('Path to the interchange file (aaf REQUIRES a path)'),
35
35
  content: z.string().optional().describe('Inline interchange text (edl/otio/xml)'),
36
36
  fps: z.number().optional().describe('Event frame rate for parsing (default 24; use e.g. 29.97 for NTSC EDLs)'),
@@ -211,14 +211,23 @@ export const drtTool = {
211
211
  const { parseInterchange } = await import('../editorial.mjs');
212
212
  const { eventsToAssembleSpec } = await import('../author-interchange.mjs');
213
213
  let content = p.content;
214
+ let events;
214
215
  if (p.format === 'aaf') {
215
216
  if (!p.path) return { error: 'aaf input requires path' };
216
217
  content = p.path;
218
+ } else if (p.format === 'prproj') {
219
+ // Premiere: offline gunzip+graph read (no Premiere, no Resolve import
220
+ // path). parsePrproj flattens EVERY sequence's events; a multi-sequence
221
+ // .prproj with overlapping record ranges refuses naturally in the
222
+ // overlap check — pre-split via editorial.list_sequences if needed.
223
+ if (!p.path) return { error: 'prproj input requires path' };
224
+ const { parsePrproj } = await import('../prproj.mjs');
225
+ events = parsePrproj(p.path);
217
226
  } else if (!content) {
218
227
  if (!p.path) return { error: 'provide content or path' };
219
228
  content = await fs.readFile(p.path, 'utf8');
220
229
  }
221
- const events = parseInterchange(p.format, content, { fps: p.fps ?? 24 });
230
+ if (!events) events = parseInterchange(p.format, content, { fps: p.fps ?? 24 });
222
231
  if (!events || !events.length) return { error: 'no events parsed from the interchange input' };
223
232
  const { spec, report } = eventsToAssembleSpec(events, {
224
233
  sourceMap: p.sourceMap, timelineName: p.timelineName,
@@ -248,7 +257,7 @@ export const drtTool = {
248
257
  stamped,
249
258
  conform: report,
250
259
  note:
251
- 'Import with timeline.import_timeline_checked (timeline is named after the FILE). ' +
260
+ 'Import with timeline.import_timeline_checked (timeline is named after the FILE). Dissolves/cross-fades, forward+reverse retimes, multi-track video, audio events and markers are AUTHORED when geometry allows; everything else drops WITH a reason — see `conform` for the ledger.' +
252
261
  'Retimes are flattened and transitions become cuts — see `conform` for the ledger.',
253
262
  };
254
263
  }
@@ -17,7 +17,7 @@ import { parseInterchange, diffChangelist, timingGuards, conformManifest, marker
17
17
  import { parseAAF, parseAafDocument } from '../aaf.mjs';
18
18
  import { parsePrproj, parsePrprojDoc } from '../prproj.mjs';
19
19
  import { listSequences, detectFormat } from '../sequences.mjs';
20
- import { authorInterchange } from '../author-interchange.mjs';
20
+ import { authorInterchange, verifyRoundtrip } from '../author-interchange.mjs';
21
21
 
22
22
  const eventArray = z.array(z.object({}).passthrough());
23
23
 
@@ -86,7 +86,7 @@ const markerSchema = z.object({
86
86
  export const editorialTool = {
87
87
  name: 'editorial',
88
88
  description:
89
- 'Editorial integrity (Cluster E) — turnover interchange → normalized events → changelist + conform manifest with TIMING silent-lie guards (flattened retime / dropped J/L-cut audio / framerate-pulldown slip / reverse dropped / transition-handle starvation → flag, skip-not-fake). Report-only (gate: review). Actions: parse_interchange (EDL/OTIO/XMEML natively + AAF via pyaaf2 + PRPROJ via gunzip+XML → normalized events; for AAF/PRPROJ pass the file PATH as content; AAF also returns per-sequence startTimecode/startFrame — build the timeline at THAT start, not the Resolve 01:00:00:00 default — and per-clip `geometry` for Avid transform effects), list_sequences (ONE offline picker entry point across xml/edl/otio/drt/drp/aaf/prproj → [{id,name,eventCount}], plus startTimecode/startFrame for AAF), convert_to_interchange (author OTIO/EDL/DRT Resolve CAN import from events or a parsed source — the .prproj→Resolve conform bridge, no Premiere needed; editorial timing/transitions survive and per-clip effects/color do not. SPEED/REVERSE survive on the otio (LinearTimeWarp) and edl (M2) targets ONLY — this FLAT drt target flattens every retime to 100% forward and returns `flattened`/`flattenedCount` naming each event that lost one (`flattened` is always present on `drt`, empty when there were none); for a .drt that AUTHORS retimes/dissolves/multi-track/audio, use drt.assemble_from_interchange), turnover_changelist (diff old vs new → moved/retimed/replaced/new/gone + timing flags), conform_manifest (per-event assert: source resolved/handles/retime/reverse/TC-base), marker_roundtrip (markers with provenance tags). Offline (AAF needs pyaaf2; live AAF/DRP import is on the Python davinci-resolve MCP).',
89
+ 'Editorial integrity (Cluster E) — turnover interchange → normalized events → changelist + conform manifest with TIMING silent-lie guards (flattened retime / dropped J/L-cut audio / framerate-pulldown slip / reverse dropped / transition-handle starvation → flag, skip-not-fake). Report-only (gate: review). Actions: parse_interchange (EDL/OTIO/XMEML natively + AAF via pyaaf2 + PRPROJ via gunzip+XML → normalized events; for AAF/PRPROJ pass the file PATH as content; AAF also returns per-sequence startTimecode/startFrame — build the timeline at THAT start, not the Resolve 01:00:00:00 default — and per-clip `geometry` for Avid transform effects), list_sequences (ONE offline picker entry point across xml/edl/otio/drt/drp/aaf/prproj → [{id,name,eventCount}], plus startTimecode/startFrame for AAF), convert_to_interchange (author OTIO/EDL/DRT Resolve CAN import from events or a parsed source — the .prproj→Resolve conform bridge, no Premiere needed; editorial timing/transitions survive and per-clip effects/color do not. SPEED/REVERSE survive on the otio (LinearTimeWarp) and edl (M2) targets ONLY — this FLAT drt target flattens every retime to 100% forward and returns `flattened`/`flattenedCount` naming each event that lost one (`flattened` is always present on `drt`, empty when there were none); for a .drt that AUTHORS retimes/dissolves/multi-track/audio, use drt.assemble_from_interchange), turnover_changelist (diff old vs new → moved/retimed/replaced/new/gone + timing flags), conform_manifest (per-event assert: source resolved/handles/retime/reverse/TC-base), marker_roundtrip (markers with provenance tags), verify_roundtrip (input events vs re-export events -> pass/mismatches + fitted per-source TC offsets; the conform QC loop-closer). Offline (AAF needs pyaaf2; live AAF/DRP import is on the Python davinci-resolve MCP).',
90
90
  async handler({ action, args }) {
91
91
  if (action === 'parse_interchange') {
92
92
  const p = parseSchema.parse(args);
@@ -145,6 +145,20 @@ export const editorialTool = {
145
145
  const p = conformManifestSchema.parse(args);
146
146
  return conformManifest(p.events, p.resolution, { minHandle: p.minHandle, expectTcBase: p.expectTcBase });
147
147
  }
148
+ if (action === 'verify_roundtrip') {
149
+ // Round-trip QC: input interchange events vs a re-EXPORT of the
150
+ // authored timeline, normalized for the three measured cross-format
151
+ // conventions (track label, source naming, per-source TC-absolute
152
+ // source frames). Live-proven: AAF -> assemble -> import -> Resolve
153
+ // OTIO export verified pass with srcOffsets 86400.
154
+ const p = z.object({
155
+ input: z.array(z.any()).describe('Normalized events of the ORIGINAL interchange (parse_interchange output)'),
156
+ exported: z.array(z.any()).describe('Normalized events of the re-export (parse_interchange on the exported OTIO/EDL/XML)'),
157
+ recTol: z.number().optional(),
158
+ srcTol: z.number().optional(),
159
+ }).parse(args);
160
+ return verifyRoundtrip(p.input, p.exported, { recTol: p.recTol, srcTol: p.srcTol });
161
+ }
148
162
  if (action === 'marker_roundtrip') {
149
163
  const p = markerSchema.parse(args);
150
164
  return markerRoundtrip(p.markers, { provenanceTag: p.provenanceTag });
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "2.123.0"
90
+ VERSION = "2.125.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.123.0"
14
+ VERSION = "2.125.0"
15
15
 
16
16
  import base64
17
17
  import os