davinci-resolve-mcp 2.163.0 → 2.164.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,42 @@
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.164.0 — E106: the changelist sees junctions
6
+
7
+ `editorial.turnover_changelist` diffed clips and was blind to everything
8
+ that happens *between* them. Measured on a faded, dissolved, retimed EDL
9
+ pair: a 24→12-frame dissolve change reported nothing, both dropped fades
10
+ read as "BL gone", and the zero-length CMX carrier line of a dissolve's
11
+ outgoing side read as "B002 gone". The timing guards paired first-row-wins,
12
+ so an identical cut with one A2 leg dropped flagged a FALSE flattened
13
+ retime and never flagged the audio drop (`track === 'A'` missed `A2`).
14
+
15
+ ### Fixed
16
+
17
+ - **The changelist diffs junctions.** `transition_added` /
18
+ `transition_dropped` / `transition_changed` entries name the outgoing and
19
+ incoming sources, classify fade in/out vs dissolve, and carry the span and
20
+ duration/type/pre-roll deltas — a dissolve reshaped from centered to
21
+ start-at-cut is a change even when both clips stayed put. Spans derive
22
+ exactly as the bridge places them (CMX start-at-cut, OTIO `in_offset`,
23
+ XMEML/PrProj `recStart`, AAF overlap start).
24
+ - **Carrier lines and fade legs never read as sources.** Zero-length events
25
+ (CMX outgoing marker lines, the synthesized BL fade slugs) and the black
26
+ legs a transition references fold into the junction diff; the changelist
27
+ reports how many in `carriersFolded`. A cut diffed against itself is
28
+ silent on every axis.
29
+ - **`timingGuards` pairs instance-to-instance.** Same track+source, closest
30
+ record position, consumed once — the pairing the changelist already used —
31
+ so a source cut twice at two speeds no longer cross-compares. Flags carry
32
+ `recIn`. Dropped-audio detection reads any `A`-track (`A`, `A2`, …), and a
33
+ lost fade or dissolve is now a timing lie (`transition_dropped`).
34
+
35
+ ### Added
36
+
37
+ - `editorial.mjs` exports `transitionSpan(event)` and
38
+ `listTransitions(events)` — the junction model shared by the changelist
39
+ and the guards.
40
+
5
41
  ## What's New in v2.163.0 — E105: QC through every export format
6
42
 
7
43
  One faded, dissolved, retimed conform exported from Resolve three ways — OTIO,
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.163.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.164.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.163.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.164.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.163.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v2.164.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
@@ -142,7 +142,8 @@ files and the project DB.
142
142
  inside `.drp`/`.drt` as `<OfflineClip>` entries; patch them here.
143
143
  - **`editorial`** — `parse_interchange` (EDL/OTIO/XMEML natively; **AAF via pyaaf2**,
144
144
  multi-layer Avid turnovers included — honest refuse only when pyaaf2 is absent),
145
- `turnover_changelist` (moved/retimed/replaced/new/gone with timing guards),
145
+ `turnover_changelist` (moved/retimed/trimmed/replaced/new/gone plus transitions
146
+ added/dropped/changed with fade in/out, with timing guards),
146
147
  `conform_manifest`, `marker_roundtrip`.
147
148
  - **`drt` / `project_db`** — timeline file authoring and DB patching.
148
149
 
@@ -188,8 +188,10 @@ timelines and reasons over editorial interchange with **no Resolve running**:
188
188
 
189
189
  - **`editorial`** — `parse_interchange` (EDL/OTIO/XMEML natively; **AAF via pyaaf2**,
190
190
  multi-layer Avid turnovers included — honest refuse only when pyaaf2 is absent),
191
- `turnover_changelist` (moved/retimed/replaced/new/gone between two cuts, with
192
- timing silent-lie guards), `conform_manifest`, `marker_roundtrip`.
191
+ `turnover_changelist` (moved/retimed/trimmed/replaced/new/gone between two cuts PLUS
192
+ the junction diff transitions added/dropped/changed with fade in/out and span deltas,
193
+ CMX carriers and BL fade legs folded in — with timing silent-lie guards),
194
+ `conform_manifest`, `marker_roundtrip`.
193
195
  - **`drt`** — `.drt` timeline file authoring + structural diff.
194
196
 
195
197
  Use these to answer "what changed between v3 and v4" or to hand a conform an
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "2.163.0"
40
+ VERSION = "2.164.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.163.0",
3
+ "version": "2.164.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -105,7 +105,11 @@ Each dispatches on an `action`. Highlights:
105
105
  the `otio` and `edl` targets only — this flat `drt` target has no per-clip speed field and reports
106
106
  every retime it flattens in `flattened`; for a `.drt` that AUTHORS retimes (plus dissolves,
107
107
  multi-track, audio) use `drt.assemble_from_interchange` instead),
108
- `turnover_changelist` (moved/retimed/replaced/new/gone + timing silent-lie guards), `conform_manifest`,
108
+ `turnover_changelist` (moved/retimed/trimmed/replaced/new/gone PLUS the junction diff
109
+ `transition_added`/`transition_dropped`/`transition_changed` with fade in/out, outgoing/incoming, span and
110
+ duration/type/pre-roll deltas; CMX carrier lines and BL fade legs fold into the junctions instead of
111
+ reading as gone/new sources; events pair instance-to-instance by closest record position — + timing
112
+ silent-lie guards incl. lost transitions and dropped audio on any A-track), `conform_manifest`,
109
113
  `marker_roundtrip`.
110
114
  - **`provenance`** — provenance / audit: `gallery_lineage`, `grade_provenance` ("why is this graded this
111
115
  way"), `cdl_export` (+ `cdl_diff`, round-trip asserted), `revision_tracking`, `episode_report`.
@@ -196,7 +200,7 @@ plausible-looking short event list. Three limits are deliberate:
196
200
  mattes) applies to what shows through from below and references no media of its own. OTIO materializes
197
201
  such layers as tracks of gaps, so its track count can exceed the number of layers with media.
198
202
  - **Only `NestedScope` layers are numbered.** Non-nested slots keep the flat `V`/`A` label, which is what
199
- `editorial.mjs`'s `track === 'A'` audio-follows-video heuristic reads.
203
+ `editorial.mjs`'s `/^A\d*$/` audio-follows-video heuristic reads (`A`, `A2`, … all count as audio).
200
204
 
201
205
  ## Provenance & license
202
206
  Vendored libraries are clean offline format-interop and deterministic compute code: no secrets, no
@@ -476,38 +476,139 @@ export function parseInterchange(format, content, opts = {}) {
476
476
 
477
477
  // ── turnover_changelist ────────────────────────────────────────────────
478
478
  const sig = (e) => `${e.track}:${e.source}`;
479
+ const isBlackSource = (s) => /^(bl|black|solid color)$/i.test(String(s || '').trim());
480
+ const isAudioTrack = (t) => /^A\d*$/i.test(String(t || ''));
481
+ // A zero-length event is a CARRIER, never a cut: the outgoing marker line of
482
+ // a CMX dissolve pair, or the zero-length BL slug every parser synthesizes at
483
+ // a fade (E91/E92/E93). It has no picture of its own to be moved/gone/new.
484
+ const isCarrier = (e) => e.recIn != null && e.recOut != null && e.recIn === e.recOut;
479
485
 
480
486
  /**
481
- * Diff two normalized event lists per-event {kind: moved|retimed|replaced|new|gone|unchanged}.
487
+ * The record SPAN a transition event occupies, derived exactly the way the
488
+ * bridge places it (author-interchange): alignment 'start' → the span begins
489
+ * at the incoming's recIn (CMX start-at-cut / AAF overlap start); OTIO
490
+ * inOffset → that many frames before the cut; XMEML/PrProj recStart → the
491
+ * explicit span start; nothing declared → centered. `junction` is the
492
+ * incoming event's recIn — the same frame verify_roundtrip's fade windows
493
+ * key on.
494
+ * @param {Object} e normalized event carrying `transition`
495
+ */
496
+ export function transitionSpan(e) {
497
+ const t = e.transition;
498
+ if (!t || e.recIn == null) return null;
499
+ const d = Math.max(0, Number(t.duration) || 0);
500
+ let pre;
501
+ if (t.alignment === 'start') pre = 0;
502
+ else if (t.inOffset != null) pre = Math.max(0, Math.min(d, Number(t.inOffset) || 0));
503
+ else if (t.recStart != null) pre = Math.max(0, Math.min(d, e.recIn - Number(t.recStart)));
504
+ else pre = Math.floor(d / 2);
505
+ const start = e.recIn - pre;
506
+ return { start, end: start + d, junction: e.recIn, pre, duration: d };
507
+ }
508
+
509
+ /**
510
+ * Every junction in an event list as a transition record: span, type, the
511
+ * OUTGOING event (the same-track neighbour whose extent touches the span
512
+ * start — a CMX carrier line, an abutting clip, or the overlapping AAF
513
+ * predecessor) and the INCOMING (the carrier of the `transition` field), with
514
+ * black on either side classified as a fade. Pure over events.
515
+ * @param {Array} events
516
+ * @returns {Array<{track,type,duration,start,end,junction,pre,outgoing,incoming,fade,incomingEvent,outgoingEvent}>}
517
+ */
518
+ export function listTransitions(events) {
519
+ const out = [];
520
+ for (const e of events) {
521
+ if (!e.transition || !(Number(e.transition.duration) > 0)) continue;
522
+ const span = transitionSpan(e);
523
+ if (!span) continue;
524
+ const cands = events.filter((o) => o !== e && o.track === e.track && o.recIn != null && o.recOut != null
525
+ && o.recIn <= span.start && o.recOut >= span.start);
526
+ // The latest-ending toucher is the outgoing: a carrier line sits exactly
527
+ // at the span start, an abutting clip ends there, an AAF predecessor
528
+ // overlaps past it. Prefer a picture over black only when both touch.
529
+ cands.sort((a, b) => (b.recOut - a.recOut) || ((events.indexOf(b)) - events.indexOf(a)));
530
+ let outgoingEvent = cands[0] || null;
531
+ if (!outgoingEvent) {
532
+ // Nothing touches the span start (a pre-rolled fade-in at track start
533
+ // whose span begins before frame 0, or a gap-adjacent junction): the
534
+ // outgoing is whatever last ENDED at or before the junction — the
535
+ // zero-length BL slug the parsers synthesize sits exactly there.
536
+ const before = events.filter((o) => o !== e && o.track === e.track && o.recOut != null && o.recOut <= span.junction);
537
+ before.sort((a, b) => (b.recOut - a.recOut) || (events.indexOf(b) - events.indexOf(a)));
538
+ outgoingEvent = before[0] || null;
539
+ }
540
+ const outgoing = outgoingEvent ? outgoingEvent.source : null;
541
+ const incoming = e.source;
542
+ const fade = isBlackSource(outgoing) ? 'in' : isBlackSource(incoming) ? 'out' : null;
543
+ out.push({
544
+ track: e.track, type: e.transition.type || 'dissolve', duration: span.duration,
545
+ start: span.start, end: span.end, junction: span.junction, pre: span.pre,
546
+ outgoing, incoming, fade, incomingEvent: e, outgoingEvent,
547
+ });
548
+ }
549
+ return out;
550
+ }
551
+
552
+ /**
553
+ * Pair old and new PICTURE/AUDIO events once each: same track+source, closest
554
+ * record position wins, consumed on match (a source used twice at two speeds
555
+ * pairs each instance with its own — never first-row-wins). Carriers and the
556
+ * black legs a transition references never enter the pairing: they are the
557
+ * junction's business (listTransitions), not sources.
558
+ */
559
+ function pairEvents(oldEvents, newEvents, recTol = 1) {
560
+ const cutsOf = (events) => {
561
+ const trs = listTransitions(events);
562
+ const fadeBlack = new Set();
563
+ for (const t of trs) {
564
+ if (t.outgoingEvent && isBlackSource(t.outgoingEvent.source)) fadeBlack.add(t.outgoingEvent);
565
+ if (isBlackSource(t.incomingEvent.source)) fadeBlack.add(t.incomingEvent);
566
+ }
567
+ const cuts = [], carriers = [];
568
+ for (const e of events) {
569
+ if (e.track === 'MARKER') continue;
570
+ if (isCarrier(e) || fadeBlack.has(e)) carriers.push(e);
571
+ else cuts.push(e);
572
+ }
573
+ return { cuts, carriers, transitions: trs };
574
+ };
575
+ const o = cutsOf(oldEvents), n = cutsOf(newEvents);
576
+ const pool = o.cuts.map((e) => ({ e, used: false }));
577
+ const pairs = [], unmatchedNew = [];
578
+ for (const ne of n.cuts) {
579
+ const cands = pool.filter((p) => !p.used && sig(p.e) === sig(ne));
580
+ if (!cands.length) { unmatchedNew.push(ne); continue; }
581
+ cands.sort((a, b) => Math.abs((a.e.recIn ?? 0) - (ne.recIn ?? 0)) - Math.abs((b.e.recIn ?? 0) - (ne.recIn ?? 0)));
582
+ cands[0].used = true;
583
+ pairs.push({ oe: cands[0].e, ne });
584
+ }
585
+ const unmatchedOld = pool.filter((p) => !p.used).map((p) => p.e);
586
+ return { pairs, unmatchedOld, unmatchedNew, old: o, new: n, recTol };
587
+ }
588
+
589
+ /**
590
+ * Diff two normalized event lists → per-event {kind: moved|retimed|trimmed|replaced|new|gone}
591
+ * PLUS per-junction {kind: transition_added|transition_dropped|transition_changed}
592
+ * (fade 'in'/'out' or null for a dissolve). Zero-length carrier lines and the
593
+ * black legs that carry fades fold into the junction diff instead of reading
594
+ * as gone/new sources; a dissolve whose duration, type or span shape changed
595
+ * is a change even when both clips stayed put.
482
596
  * @param {Array} oldEvents
483
597
  * @param {Array} newEvents
484
598
  * @param {{recTolerance?:number}} [opts]
485
599
  */
486
600
  export function diffChangelist(oldEvents, newEvents, opts = {}) {
487
601
  const recTol = opts.recTolerance ?? 1;
488
- const oldPool = oldEvents.map((e) => ({ e, used: false }));
602
+ const P = pairEvents(oldEvents, newEvents, recTol);
489
603
  const changes = [];
490
604
 
491
- for (const ne of newEvents) {
492
- // Prefer a same-source match; among those, the closest record position.
493
- const candidates = oldPool.filter((o) => !o.used && sig(o.e) === sig(ne));
494
- let match = null;
495
- if (candidates.length) {
496
- candidates.sort((a, b) => Math.abs((a.e.recIn ?? 0) - (ne.recIn ?? 0)) - Math.abs((b.e.recIn ?? 0) - (ne.recIn ?? 0)));
497
- match = candidates[0];
498
- }
499
- if (!match) {
500
- changes.push({ kind: 'new', source: ne.source, track: ne.track, newRecIn: ne.recIn });
501
- continue;
502
- }
503
- match.used = true;
504
- const oe = match.e;
605
+ for (const { oe, ne } of P.pairs) {
505
606
  const deltas = {};
506
607
  let kind = 'unchanged';
507
- if (Math.abs((oe.speed ?? 100) - (ne.speed ?? 100)) > 0.01 || oe.reverse !== ne.reverse) {
608
+ if (Math.abs((oe.speed ?? 100) - (ne.speed ?? 100)) > 0.01 || Boolean(oe.reverse) !== Boolean(ne.reverse)) {
508
609
  kind = 'retimed';
509
610
  deltas.speed = { old: oe.speed, new: ne.speed };
510
- if (oe.reverse !== ne.reverse) deltas.reverse = { old: oe.reverse, new: ne.reverse };
611
+ if (Boolean(oe.reverse) !== Boolean(ne.reverse)) deltas.reverse = { old: oe.reverse, new: ne.reverse };
511
612
  } else if (Math.abs((oe.recIn ?? 0) - (ne.recIn ?? 0)) > recTol) {
512
613
  kind = 'moved';
513
614
  deltas.recIn = { old: oe.recIn, new: ne.recIn };
@@ -517,51 +618,97 @@ export function diffChangelist(oldEvents, newEvents, opts = {}) {
517
618
  }
518
619
  if (kind !== 'unchanged') changes.push({ kind, source: ne.source, track: ne.track, oldRecIn: oe.recIn, newRecIn: ne.recIn, deltas });
519
620
  }
621
+ for (const ne of P.unmatchedNew) changes.push({ kind: 'new', source: ne.source, track: ne.track, newRecIn: ne.recIn });
520
622
  // Unconsumed old events → gone (unless a 'new' at the same rec position → replaced).
521
- for (const o of oldPool.filter((x) => !x.used)) {
522
- const replacement = changes.find((c) => c.kind === 'new' && c.track === o.e.track && Math.abs((c.newRecIn ?? 0) - (o.e.recIn ?? 0)) <= recTol);
623
+ for (const oe of P.unmatchedOld) {
624
+ const replacement = changes.find((c) => c.kind === 'new' && c.track === oe.track && Math.abs((c.newRecIn ?? 0) - (oe.recIn ?? 0)) <= recTol);
523
625
  if (replacement) {
524
626
  replacement.kind = 'replaced';
525
- replacement.oldSource = o.e.source;
526
- replacement.oldRecIn = o.e.recIn;
627
+ replacement.oldSource = oe.source;
628
+ replacement.oldRecIn = oe.recIn;
527
629
  } else {
528
- changes.push({ kind: 'gone', source: o.e.source, track: o.e.track, oldRecIn: o.e.recIn });
630
+ changes.push({ kind: 'gone', source: oe.source, track: oe.track, oldRecIn: oe.recIn });
631
+ }
632
+ }
633
+
634
+ // Junction diff: a transition is identified by what it joins (track,
635
+ // outgoing, incoming, fade side) and pairs with the closest junction; only
636
+ // its SHAPE (duration / type / pre-roll) makes a change — a junction that
637
+ // travelled with its clips is the clips' move, already reported.
638
+ const tKey = (t) => `${t.track}|${t.outgoing ?? ''}|${t.incoming}|${t.fade ?? 'x'}`;
639
+ const tPool = P.old.transitions.map((t) => ({ t, used: false }));
640
+ const tDesc = (t) => ({ track: t.track, outgoing: t.outgoing, incoming: t.incoming, fade: t.fade, type: t.type, duration: t.duration });
641
+ for (const nt of P.new.transitions) {
642
+ const cands = tPool.filter((p) => !p.used && tKey(p.t) === tKey(nt));
643
+ if (!cands.length) {
644
+ changes.push({ kind: 'transition_added', ...tDesc(nt), newRecIn: nt.junction, newSpan: [nt.start, nt.end] });
645
+ continue;
646
+ }
647
+ cands.sort((a, b) => Math.abs(a.t.junction - nt.junction) - Math.abs(b.t.junction - nt.junction));
648
+ cands[0].used = true;
649
+ const ot = cands[0].t;
650
+ const deltas = {};
651
+ if (ot.duration !== nt.duration) deltas.duration = { old: ot.duration, new: nt.duration };
652
+ if (String(ot.type) !== String(nt.type)) deltas.type = { old: ot.type, new: nt.type };
653
+ if (ot.pre !== nt.pre) deltas.pre = { old: ot.pre, new: nt.pre };
654
+ if (Object.keys(deltas).length) {
655
+ changes.push({
656
+ kind: 'transition_changed', ...tDesc(nt), oldRecIn: ot.junction, newRecIn: nt.junction,
657
+ oldSpan: [ot.start, ot.end], newSpan: [nt.start, nt.end], deltas,
658
+ });
529
659
  }
530
660
  }
661
+ for (const p of tPool.filter((x) => !x.used)) {
662
+ changes.push({ kind: 'transition_dropped', ...tDesc(p.t), oldRecIn: p.t.junction, oldSpan: [p.t.start, p.t.end] });
663
+ }
664
+
531
665
  const counts = {};
532
666
  for (const c of changes) counts[c.kind] = (counts[c.kind] || 0) + 1;
533
667
  changes.sort((a, b) => (a.newRecIn ?? a.oldRecIn ?? 0) - (b.newRecIn ?? b.oldRecIn ?? 0));
534
- return { changes, counts, changedCount: changes.length, gate: 'review' };
668
+ return {
669
+ changes, counts, changedCount: changes.length,
670
+ transitions: { old: P.old.transitions.length, new: P.new.transitions.length },
671
+ carriersFolded: { old: P.old.carriers.length, new: P.new.carriers.length },
672
+ gate: 'review',
673
+ };
535
674
  }
536
675
 
537
676
  // ── TIMING silent-lie guards ───────────────────────────────────────────
538
677
  /**
539
678
  * Detect timing lies between an old (locked-cut) and new (conformed) event list.
679
+ * Pairs events the same way the changelist does (closest record position,
680
+ * consumed once) so a source cut twice at two speeds is compared instance to
681
+ * instance — never the second old against the first new.
540
682
  * @returns {{flags:Array<{kind, source, detail}>}}
541
683
  */
542
684
  export function timingGuards(oldEvents, newEvents) {
543
685
  const flags = [];
544
- const newBySig = new Map();
545
- for (const e of newEvents) {
546
- if (!newBySig.has(sig(e))) newBySig.set(sig(e), []);
547
- newBySig.get(sig(e)).push(e);
686
+ const P = pairEvents(oldEvents, newEvents);
687
+ for (const oe of P.unmatchedOld) {
688
+ // A dropped audio event where its video sibling survives → dropped J/L-cut audio.
689
+ if (isAudioTrack(oe.track) && P.new.cuts.some((x) => !isAudioTrack(x.track) && x.track !== 'MARKER' && x.source === oe.source))
690
+ flags.push({ kind: 'dropped_split_audio', source: oe.source, track: oe.track, detail: `audio event (${oe.track}) gone but video sibling present (J/L-cut lost)` });
548
691
  }
549
- for (const oe of oldEvents) {
550
- const matches = newBySig.get(sig(oe)) || [];
551
- const ne = matches[0];
552
- if (!ne) {
553
- // A dropped audio event where its video sibling survives → dropped J/L-cut audio.
554
- if (oe.track === 'A' && newEvents.some((x) => x.track !== 'A' && x.source === oe.source))
555
- flags.push({ kind: 'dropped_split_audio', source: oe.source, detail: 'audio event gone but video sibling present (J/L-cut lost)' });
556
- continue;
557
- }
692
+ for (const { oe, ne } of P.pairs) {
558
693
  // Flattened retime: a speed ramp/change flattened to 100%.
559
694
  if ((oe.speed ?? 100) !== 100 && (ne.speed ?? 100) === 100)
560
- flags.push({ kind: 'flattened_retime', source: oe.source, detail: `speed ${oe.speed}% → 100% (retime lost)` });
695
+ flags.push({ kind: 'flattened_retime', source: oe.source, recIn: oe.recIn, detail: `speed ${oe.speed}% → 100% (retime lost)` });
561
696
  // Reverse dropped.
562
- if (oe.reverse && !ne.reverse) flags.push({ kind: 'reverse_dropped', source: oe.source, detail: 'reversed clip conformed forward' });
697
+ if (oe.reverse && !ne.reverse) flags.push({ kind: 'reverse_dropped', source: oe.source, recIn: oe.recIn, detail: 'reversed clip conformed forward' });
563
698
  // Framerate/pulldown slip.
564
- if (oe.fps && ne.fps && oe.fps !== ne.fps) flags.push({ kind: 'framerate_slip', source: oe.source, detail: `fps ${oe.fps} → ${ne.fps}` });
699
+ if (oe.fps && ne.fps && oe.fps !== ne.fps) flags.push({ kind: 'framerate_slip', source: oe.source, recIn: oe.recIn, detail: `fps ${oe.fps} → ${ne.fps}` });
700
+ }
701
+ // A fade or dissolve the conform lost is a timing lie too: the picture
702
+ // edges stay put while the blend at the junction silently hard-cuts.
703
+ const tKey = (t) => `${t.track}|${t.outgoing ?? ''}|${t.incoming}|${t.fade ?? 'x'}`;
704
+ const newKeys = P.new.transitions.map(tKey);
705
+ for (const ot of P.old.transitions) {
706
+ const i = newKeys.indexOf(tKey(ot));
707
+ if (i >= 0) { newKeys.splice(i, 1); continue; }
708
+ flags.push({
709
+ kind: 'transition_dropped', source: ot.incoming, track: ot.track, recIn: ot.junction,
710
+ detail: ot.fade ? `fade-${ot.fade} (${ot.duration}f) at ${ot.junction} lost` : `${ot.outgoing}→${ot.incoming} dissolve (${ot.duration}f) at ${ot.junction} lost`,
711
+ });
565
712
  }
566
713
  return { flags, flagged: flags.length > 0 };
567
714
  }
@@ -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 — incl. Resolve-written FCP7 -1 junction edges + Solid Color generators, and CMX FROM/TO CLIP NAME comments over the generic AX reel — + AAF via pyaaf2 + PRPROJ via gunzip+XML → normalized events incl. span-explicit transitions, BL fade legs, and freezes as zero-speed events (OTIO FreezeFrame, XMEML timeremap 0, PrProj in==out, AAF 0% motion effects); 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 EDL target writes CMX transition pairs incl. BL fades — 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; BL-aware — black legs need no source, fades no black-side handles, and a fade-out tail requirement lands on the picture source), marker_roundtrip (markers with provenance tags), verify_roundtrip (input events vs re-export events -> pass/mismatches + fitted per-source TC offsets + marker compare w/ markersNotInExport honesty flag; FADE-AWARE: BL/Solid-Color legs merge out as blackSegments and fade-window boundary reshapes are excused into fadeReshapedBoundaries instead of failing; RETIME-AWARE: speed/reverse compare pairwise — EXPORT_OTIO carries an authored Sm2TimeMap back as LinearTimeWarp (measured), so a flattened/lost retime fails as drift geometry alone cannot catch; AUDIO-AWARE: declared audio events compare (a video-only export such as EXPORT_EDL flags audioNotInExport instead of failing) (channel legs deduped, mismatches tagged trackType audio) while the mirrored-A1 export of a video-only turnover stays informational; the conform QC loop-closer). 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 — incl. Resolve-written FCP7 -1 junction edges + Solid Color generators, and CMX FROM/TO CLIP NAME comments over the generic AX reel — + AAF via pyaaf2 + PRPROJ via gunzip+XML → normalized events incl. span-explicit transitions, BL fade legs, and freezes as zero-speed events (OTIO FreezeFrame, XMEML timeremap 0, PrProj in==out, AAF 0% motion effects); 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 EDL target writes CMX transition pairs incl. BL fades — 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/trimmed/replaced/new/gone PLUS the junction diff: transition_added/transition_dropped/transition_changed with fade in/out or dissolve, outgoing/incoming, span and duration/type/pre-roll deltas — zero-length CMX carrier lines and the BL legs that carry fades fold into the junction diff instead of reading as gone/new sources; events pair by closest record position, consumed once, so a source cut twice at two speeds compares instance to instance; + timing flags incl. transition_dropped and dropped_split_audio on any A-track), conform_manifest (per-event assert: source resolved/handles/retime/reverse/TC-base; BL-aware — black legs need no source, fades no black-side handles, and a fade-out tail requirement lands on the picture source), marker_roundtrip (markers with provenance tags), verify_roundtrip (input events vs re-export events -> pass/mismatches + fitted per-source TC offsets + marker compare w/ markersNotInExport honesty flag; FADE-AWARE: BL/Solid-Color legs merge out as blackSegments and fade-window boundary reshapes are excused into fadeReshapedBoundaries instead of failing; RETIME-AWARE: speed/reverse compare pairwise — EXPORT_OTIO carries an authored Sm2TimeMap back as LinearTimeWarp (measured), so a flattened/lost retime fails as drift geometry alone cannot catch; AUDIO-AWARE: declared audio events compare (a video-only export such as EXPORT_EDL flags audioNotInExport instead of failing) (channel legs deduped, mismatches tagged trackType audio) while the mirrored-A1 export of a video-only turnover stays informational; 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);
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "2.163.0"
90
+ VERSION = "2.164.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.163.0"
14
+ VERSION = "2.164.0"
15
15
 
16
16
  import base64
17
17
  import os