davinci-resolve-mcp 2.124.0 → 2.126.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.
@@ -111,6 +111,12 @@ Everything else stays in `droppedTransitions` with the reason.
111
111
  3. For audio: RMS per window (silence = -inf is a failed placement).
112
112
  4. Never trust `created_new: false` — a same-named timeline already in the
113
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.
114
120
 
115
121
  ## References
116
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.124.0"
40
+ VERSION = "2.126.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.124.0",
3
+ "version": "2.126.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -18,7 +18,13 @@ const PROBE = fileURLToPath(new URL('./aaf_probe.py', import.meta.url));
18
18
 
19
19
  /** python interpreter — overridable for environments/tests (must have `aaf2` importable). */
20
20
  function pythonCmd() {
21
- return process.env.AAF_PROBE_PYTHON || process.env.PYTHON || 'python3';
21
+ if (process.env.AAF_PROBE_PYTHON) return process.env.AAF_PROBE_PYTHON;
22
+ if (process.env.PYTHON) return process.env.PYTHON;
23
+ // The repo venv carries pyaaf2; a bare python3 usually does not. Prefer it
24
+ // when present so the tool path works without env plumbing.
25
+ const venvPy = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'venv', 'bin', 'python');
26
+ if (existsSync(venvPy)) return venvPy;
27
+ return 'python3';
22
28
  }
23
29
 
24
30
  const REMEDIATION =
@@ -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
+ }
@@ -213,8 +213,13 @@ export const drtTool = {
213
213
  let content = p.content;
214
214
  let events;
215
215
  if (p.format === 'aaf') {
216
+ // BUG FIX: this branch used to fall through to the sync
217
+ // parseInterchange, which THROWS for aaf ("parse it via the async
218
+ // AAF path") — the tool-layer AAF route never worked until now.
216
219
  if (!p.path) return { error: 'aaf input requires path' };
217
- content = p.path;
220
+ const { parseAAF } = await import('../aaf.mjs');
221
+ const parsed = await parseAAF(p.path);
222
+ events = Array.isArray(parsed) ? parsed : parsed.events;
218
223
  } else if (p.format === 'prproj') {
219
224
  // Premiere: offline gunzip+graph read (no Premiere, no Resolve import
220
225
  // path). parsePrproj flattens EVERY sequence's events; a multi-sequence
@@ -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.124.0"
90
+ VERSION = "2.126.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.124.0"
14
+ VERSION = "2.126.0"
15
15
 
16
16
  import base64
17
17
  import os