davinci-resolve-mcp 2.162.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,81 @@
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
+
41
+ ## What's New in v2.163.0 — E105: QC through every export format
42
+
43
+ One faded, dissolved, retimed conform exported from Resolve three ways — OTIO,
44
+ FCP7 XML, and CMX EDL — and pushed through `editorial.verify_roundtrip`. All
45
+ three now close `pass: true`; getting there measured four laws of Resolve's own
46
+ writers and fixed the parsers to match.
47
+
48
+ ### Fixed
49
+
50
+ - **Every FCP7-XML retime read as a FREEZE**: Resolve's timeremap effect
51
+ writes `speed` 50 followed by `variablespeed` 0, and a loose `/speed/`
52
+ parameter match let the second overwrite the first. Exact parameter
53
+ matching now, with the `reverse` flag read alongside.
54
+ - **FCP7 `-1` clip edges** (transition-adjacent) are resolved to the
55
+ transition's junction — the span center for `center` alignment — with the
56
+ clip's `in` advanced by the overlap offset so source stays record-aligned;
57
+ `out - in` is the record duration even under a retime. Solid Color
58
+ `generatoritem`s walk as black legs, and fade transitions attach to the
59
+ picture rather than the black.
60
+ - **CMX EDL exports name every file source reel `AX`** with the real names in
61
+ `* FROM/TO CLIP NAME` comments; `parseEDL` now applies them (TO = incoming,
62
+ FROM = outgoing of a dissolve pair), keeping specific reels intact.
63
+ - **`timeline.export_timeline_checked` refuses unresolved export constants
64
+ loudly.** A made-up `EXPORT_CMX_3600` reached `Timeline.Export` as a string
65
+ and came back as a bare `success: false`; the real constant is `EXPORT_EDL`,
66
+ and the refusal now lists the vocabulary.
67
+
68
+ ### Added
69
+
70
+ - `verify_roundtrip` reports `audioNotInExport` when the export carries no
71
+ audio at all (Resolve's EDL writer is video-only — measured) instead of
72
+ failing, mirroring `markersNotInExport`.
73
+
74
+ ### Measured (filed in api-limitations)
75
+
76
+ - `EXPORT_EDL` is video-only, writes reel `AX` + clip-name comments, places
77
+ dissolve junctions at the CMX start-at-cut position, and writes the BL
78
+ fades its own importer drops.
79
+
5
80
  ## What's New in v2.162.0 — freeze parity: Avid 0% motion effects are freezes
6
81
 
7
82
  ### Fixed
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.162.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.162.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.162.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
@@ -12,7 +12,7 @@ that none exists).
12
12
 
13
13
  **Verified on:** DaVinci Resolve Studio 21.0.2
14
14
 
15
- **Totals:** 35 missing capabilities, 50 bugs / unreliable behaviors.
15
+ **Totals:** 36 missing capabilities, 50 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
@@ -301,6 +301,14 @@ equivalent, blocking full automation.
301
301
  - **Workaround / current handling:** Read item.GetNodeGraph().GetNumNodes() before SetCDL and diagnose a False against the node count and clip type. Prove the applied look with a rendered frame (gallery_stills grab_and_export or Project.ExportCurrentFrameAsStill), not the return value.
302
302
  - **Tags:** color, cdl, readback, silent-failure
303
303
 
304
+ ### Timeline.Export EXPORT_EDL (video-only, reel AX, clip-name comments)
305
+
306
+ - **Object:** `Timeline`
307
+ - **Signature:** `(filePath, EXPORT_EDL, EXPORT_NONE) -> bool`
308
+ - **Behavior:** Resolve's CMX EDL writer (measured on Studio 19.1.3.7, E105) emits VIDEO events only — audio legs never appear; names every file source by the generic reel AX and carries the real names in `* FROM CLIP NAME:` / `* TO CLIP NAME:` comments; writes black legs as reel BL; places dissolve junctions at the CMX start-at-cut position (the overlap start, not the centered junction the timeline holds); and WRITES BL fades that its own EDL importer then drops. The FCP7 XML writer, by contrast, carries audio, writes transition-adjacent clip edges as -1 (the junction), and emits `speed` followed by `variablespeed` 0 in the same timeremap effect.
309
+ - **Workaround / current handling:** For round-trip QC prefer EXPORT_OTIO (carries audio, retimes as LinearTimeWarp/FreezeFrame, exact spans). When an EDL is the required deliverable, expect no audio (editorial.verify_roundtrip reports audioNotInExport) and resolve AX reels through the clip-name comments (parseEDL does). Use EXPORT_EDL — there is no EXPORT_CMX_3600 constant, and an unknown name reaches Export as a string that returns a bare False (timeline.export_timeline_checked now refuses it loudly).
310
+ - **Tags:** timeline, export, edl, audio, silent-failure
311
+
304
312
  ## Bugs / Unreliable Behavior (please fix)
305
313
 
306
314
  Methods that exist but misbehave — silent failures, unreliable return
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "2.162.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.162.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
@@ -990,7 +990,14 @@ export function verifyRoundtrip(inputEvents, exportedEvents, opts = {}) {
990
990
  const aa = dedupeAud(norm(inputEvents, audsOf).filter((e) => !isBlackSeg(e)));
991
991
  const ba = dedupeAud(norm(exportedEvents, audsOf).filter((e) => !isBlackSeg(e)));
992
992
  let audio;
993
- if (aa.length) {
993
+ let audioNotInExport = false;
994
+ if (aa.length && !ba.length) {
995
+ // The export carries NO audio at all while the input declares some: a
996
+ // capability gap (Resolve's EXPORT_EDL is video-only, measured E105),
997
+ // reported as audioNotInExport rather than failed — like markers.
998
+ audio = { input: aa.length, exported: 0, compared: false, note: 'export carries no audio events (format capability, e.g. EDL is video-only) — not compared' };
999
+ audioNotInExport = true;
1000
+ } else if (aa.length) {
994
1001
  audio = { input: aa.length, exported: ba.length, compared: true };
995
1002
  comparePairs(aa, ba, junctionsFor(/^A\d*$/), 'audio');
996
1003
  } else if (ba.length) {
@@ -1033,5 +1040,6 @@ export function verifyRoundtrip(inputEvents, exportedEvents, opts = {}) {
1033
1040
  ...(blackSegments.input || blackSegments.exported ? { blackSegments } : {}),
1034
1041
  ...(fadeReshapedBoundaries.length ? { fadeReshapedBoundaries } : {}),
1035
1042
  ...(audio ? { audio } : {}),
1043
+ ...(audioNotInExport ? { audioNotInExport } : {}),
1036
1044
  };
1037
1045
  }
@@ -90,6 +90,29 @@ export function parseEDL(text, opts = {}) {
90
90
  // at an absolute record timecode. Emitted as track 'MARKER' pseudo-events
91
91
  // (recIn only) so the assemble bridge can author them; consumers that
92
92
  // filter video/audio by track shape ignore them.
93
+ // `* FROM CLIP NAME:` / `* TO CLIP NAME:` comments (E105): Resolve's own
94
+ // EDL writer names every file source by the generic reel AX and carries
95
+ // the real clip name here. TO names the incoming (the last event, a D
96
+ // line's), FROM the outgoing (the event before a transition pair, else
97
+ // the last event). A generic reel (AX/BL-less) takes the clip name as
98
+ // its source so sourceMap and QC key on something real; a specific reel
99
+ // keeps it, with clipName carried alongside.
100
+ const cn = /^\*\s*(FROM|TO)\s+CLIP\s+NAME:\s*(.+?)\s*$/i.exec(line);
101
+ if (cn) {
102
+ const which = cn[1].toUpperCase();
103
+ const clipName = cn[2];
104
+ const last = [...events].reverse().find((e) => e.track !== 'MARKER');
105
+ let target = last;
106
+ if (which === 'FROM' && last && last.transition) {
107
+ const prev = [...events].reverse().find((e) => e !== last && e.track === last.track && e.recOut === last.recIn);
108
+ if (prev) target = prev;
109
+ }
110
+ if (target && !/^(BL|BLACK)$/i.test(String(target.source))) {
111
+ target.clipName = clipName;
112
+ if (/^AX$/i.test(String(target.source))) target.source = clipName;
113
+ }
114
+ continue;
115
+ }
93
116
  const loc = /^\*\s*LOC:\s*(\d{2}:\d{2}:\d{2}[:;]\d{2})\s+(\S+)\s*(.*)$/i.exec(line);
94
117
  if (loc) {
95
118
  events.push(evt({
@@ -273,36 +296,69 @@ export function parseXMEMLEvents(xml, opts = {}) {
273
296
  const events = [];
274
297
  let idx = 1;
275
298
  const seqRate = opts.fps || 24;
299
+ let currentJunctions = [];
276
300
  const walk = (node, track) => {
277
301
  if (!node) return;
278
302
  const items = Array.isArray(node) ? node : [node];
279
303
  for (const it of items) {
280
304
  const name = it.name || (it.file && it.file.name) || 'UNKNOWN';
281
- const start = Number(it.start);
282
- const end = Number(it.end);
305
+ const start0 = Number(it.start);
306
+ const end0 = Number(it.end);
283
307
  const inF = Number(it.in);
284
308
  const outF = Number(it.out);
285
309
  let speed = 100,
286
310
  reverse = false;
287
- // speed via a timeremap/motion filter (best-effort)
311
+ // speed via a timeremap/motion filter. EXACT parameter match (E105):
312
+ // Resolve's own FCP7 writer emits `speed` 50 followed by
313
+ // `variablespeed` 0 in the same effect, and a loose /speed/ match let
314
+ // the second overwrite the first — every retime read as a FREEZE.
288
315
  const filters = it.filter ? (Array.isArray(it.filter) ? it.filter : [it.filter]) : [];
289
316
  for (const fl of filters) {
290
317
  const params = fl.effect && fl.effect.parameter ? (Array.isArray(fl.effect.parameter) ? fl.effect.parameter : [fl.effect.parameter]) : [];
291
318
  for (const pm of params) {
292
- if (/speed/i.test(pm.name || '') && pm.value != null) {
293
- speed = Math.abs(Number(pm.value));
294
- reverse = Number(pm.value) < 0;
319
+ const pid = String(pm.parameterid || pm.name || '').trim().toLowerCase();
320
+ if (pid === 'speed' && pm.value != null && typeof pm.value !== 'object') {
321
+ const v = Number(pm.value);
322
+ if (Number.isFinite(v)) { speed = Math.abs(v); reverse = v < 0; }
323
+ } else if (pid === 'reverse' && /^true$/i.test(String(pm.value))) {
324
+ reverse = true;
295
325
  }
296
326
  }
297
327
  }
298
- if (Number.isFinite(start) && Number.isFinite(end)) {
328
+ // FCP7 `-1` EDGES (E105, measured against Resolve's own FCP7 writer):
329
+ // a clipitem edge under a transitionitem is written as -1 and means
330
+ // "this edge is the adjacent transition's JUNCTION" — the span center
331
+ // for alignment center, its start/end for start-black/end-black.
332
+ // `out - in` is the RECORD duration even under a retime (Resolve
333
+ // writes out = in + record length), which anchors the missing edge.
334
+ let start = start0;
335
+ let end = end0;
336
+ let inAdj = 0;
337
+ const dur = Number.isFinite(inF) && Number.isFinite(outF) ? outF - inF : null;
338
+ const junctions = currentJunctions;
339
+ if (start === -1 && end !== -1 && dur != null) start = end - dur;
340
+ else if (end === -1 && start !== -1 && dur != null) end = start + dur;
341
+ else if (start === -1 && end === -1 && dur != null && junctions.length) {
342
+ const js = junctions.map((j) => j.frame).sort((a, b) => a - b);
343
+ const pair = js.find((a) => js.includes(a + dur));
344
+ if (pair != null) { start = pair; end = pair + dur; }
345
+ }
346
+ // A -1 START edge's `in` is the source at the OVERLAP start (the
347
+ // clip's material begins under the transition), while the resolved
348
+ // start is the junction — so `in` advances by the same offset to stay
349
+ // record-aligned (measured: srcIn read 12 short without this).
350
+ if (start0 === -1 && Number.isFinite(start)) {
351
+ const j = junctions.find((jj) => jj.frame === start);
352
+ if (j) inAdj = start - j.start;
353
+ }
354
+ if (Number.isFinite(start) && Number.isFinite(end) && start >= 0 && end >= 0) {
299
355
  // FCP7 clipitem <marker> children are CLIP markers (frames relative
300
356
  // to the clip's own <in>) — routed to item markers like OTIO's (E81).
301
357
  const mks = it.marker ? (Array.isArray(it.marker) ? it.marker : [it.marker]) : [];
302
358
  const itemMarkers = mks
303
359
  .filter((mk) => Number.isFinite(Number(mk.in)))
304
360
  .map((mk) => ({ frame: Number(mk.in) - inF, name: mk.name != null ? String(mk.name) : undefined, note: mk.comment != null ? String(mk.comment) : undefined }));
305
- events.push(evt({ index: idx++, track, source: name, srcIn: inF, srcOut: outF, recIn: start, recOut: end, speed, reverse, itemMarkers: itemMarkers.length ? itemMarkers : undefined, fps: seqRate }));
361
+ events.push(evt({ index: idx++, track, source: name, srcIn: Number.isFinite(inF) ? inF + inAdj : inF, srcOut: Number.isFinite(outF) ? outF + inAdj : outF, recIn: start, recOut: end, speed, reverse, itemMarkers: itemMarkers.length ? itemMarkers : undefined, fps: seqRate }));
306
362
  }
307
363
  }
308
364
  };
@@ -313,7 +369,24 @@ export function parseXMEMLEvents(xml, opts = {}) {
313
369
  vtracks.forEach((t, vi) => {
314
370
  const label = vi === 0 ? 'V' : `V${vi + 1}`;
315
371
  const before = events.length;
372
+ // Junctions of this track's transitionitems, for -1 edge resolution.
373
+ const tlist = t.transitionitem ? (Array.isArray(t.transitionitem) ? t.transitionitem : [t.transitionitem]) : [];
374
+ currentJunctions = tlist.map((tr) => {
375
+ const s0 = Number(tr.start), e0 = Number(tr.end);
376
+ if (!Number.isFinite(s0) || !Number.isFinite(e0)) return null;
377
+ const al = String(tr.alignment || 'center').toLowerCase();
378
+ const frame = al === 'start-black' || al === 'start' ? s0 : al === 'end-black' || al === 'end' ? e0 : Math.round((s0 + e0) / 2);
379
+ return { frame, start: s0, end: e0 };
380
+ }).filter(Boolean);
381
+ // Solid Color generatoritems are BLACK legs (what Resolve writes for a
382
+ // fade's black side); they walk as BL events so fades round-trip.
383
+ const gens = t.generatoritem ? (Array.isArray(t.generatoritem) ? t.generatoritem : [t.generatoritem]) : [];
384
+ for (const g of gens) {
385
+ if (!/solid color|black/i.test(String(g.name || ''))) continue;
386
+ walk([{ ...g, name: 'BL', filter: undefined, marker: undefined }], label);
387
+ }
316
388
  if (t.clipitem) walk(t.clipitem, label);
389
+ currentJunctions = [];
317
390
  // <transitionitem> siblings: attach each to the INCOMING clip event —
318
391
  // the one whose record-in falls inside the transition's span. Resolve's
319
392
  // OWN XMEML importer writes these as elements that render INERT on
@@ -326,7 +399,11 @@ export function parseXMEMLEvents(xml, opts = {}) {
326
399
  const s = Number(tr.start), e2 = Number(tr.end);
327
400
  if (!Number.isFinite(s) || !Number.isFinite(e2)) continue;
328
401
  const effectId = (tr.effect && (tr.effect.effectid || tr.effect.name)) || 'Cross Dissolve';
329
- const incoming = events.slice(before).find((ev) => ev.track === label && ev.recIn > s - 1 && ev.recIn <= e2);
402
+ const isBLev = (ev) => /^(BL|BLACK)$/i.test(String(ev.source || '').trim());
403
+ const inSpan = (ev) => ev.track === label && ev.recIn > s - 1 && ev.recIn <= e2;
404
+ // Prefer the PICTURE as the incoming: a Solid Color generatoritem in
405
+ // the same span is the fade's black side, not the clip fading in.
406
+ const incoming = events.slice(before).find((ev) => inSpan(ev) && !isBLev(ev)) || events.slice(before).find(inSpan);
330
407
  // recStart carries the transitionitem's EXPLICIT record span start
331
408
  // (sequence-relative) so the bridge reproduces the editor's actual
332
409
  // alignment instead of assuming centered.
@@ -399,38 +476,139 @@ export function parseInterchange(format, content, opts = {}) {
399
476
 
400
477
  // ── turnover_changelist ────────────────────────────────────────────────
401
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;
485
+
486
+ /**
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
+ }
402
588
 
403
589
  /**
404
- * Diff two normalized event lists → per-event {kind: moved|retimed|replaced|new|gone|unchanged}.
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.
405
596
  * @param {Array} oldEvents
406
597
  * @param {Array} newEvents
407
598
  * @param {{recTolerance?:number}} [opts]
408
599
  */
409
600
  export function diffChangelist(oldEvents, newEvents, opts = {}) {
410
601
  const recTol = opts.recTolerance ?? 1;
411
- const oldPool = oldEvents.map((e) => ({ e, used: false }));
602
+ const P = pairEvents(oldEvents, newEvents, recTol);
412
603
  const changes = [];
413
604
 
414
- for (const ne of newEvents) {
415
- // Prefer a same-source match; among those, the closest record position.
416
- const candidates = oldPool.filter((o) => !o.used && sig(o.e) === sig(ne));
417
- let match = null;
418
- if (candidates.length) {
419
- candidates.sort((a, b) => Math.abs((a.e.recIn ?? 0) - (ne.recIn ?? 0)) - Math.abs((b.e.recIn ?? 0) - (ne.recIn ?? 0)));
420
- match = candidates[0];
421
- }
422
- if (!match) {
423
- changes.push({ kind: 'new', source: ne.source, track: ne.track, newRecIn: ne.recIn });
424
- continue;
425
- }
426
- match.used = true;
427
- const oe = match.e;
605
+ for (const { oe, ne } of P.pairs) {
428
606
  const deltas = {};
429
607
  let kind = 'unchanged';
430
- 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)) {
431
609
  kind = 'retimed';
432
610
  deltas.speed = { old: oe.speed, new: ne.speed };
433
- 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 };
434
612
  } else if (Math.abs((oe.recIn ?? 0) - (ne.recIn ?? 0)) > recTol) {
435
613
  kind = 'moved';
436
614
  deltas.recIn = { old: oe.recIn, new: ne.recIn };
@@ -440,51 +618,97 @@ export function diffChangelist(oldEvents, newEvents, opts = {}) {
440
618
  }
441
619
  if (kind !== 'unchanged') changes.push({ kind, source: ne.source, track: ne.track, oldRecIn: oe.recIn, newRecIn: ne.recIn, deltas });
442
620
  }
621
+ for (const ne of P.unmatchedNew) changes.push({ kind: 'new', source: ne.source, track: ne.track, newRecIn: ne.recIn });
443
622
  // Unconsumed old events → gone (unless a 'new' at the same rec position → replaced).
444
- for (const o of oldPool.filter((x) => !x.used)) {
445
- 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);
446
625
  if (replacement) {
447
626
  replacement.kind = 'replaced';
448
- replacement.oldSource = o.e.source;
449
- replacement.oldRecIn = o.e.recIn;
627
+ replacement.oldSource = oe.source;
628
+ replacement.oldRecIn = oe.recIn;
450
629
  } else {
451
- 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
+ });
452
659
  }
453
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
+
454
665
  const counts = {};
455
666
  for (const c of changes) counts[c.kind] = (counts[c.kind] || 0) + 1;
456
667
  changes.sort((a, b) => (a.newRecIn ?? a.oldRecIn ?? 0) - (b.newRecIn ?? b.oldRecIn ?? 0));
457
- 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
+ };
458
674
  }
459
675
 
460
676
  // ── TIMING silent-lie guards ───────────────────────────────────────────
461
677
  /**
462
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.
463
682
  * @returns {{flags:Array<{kind, source, detail}>}}
464
683
  */
465
684
  export function timingGuards(oldEvents, newEvents) {
466
685
  const flags = [];
467
- const newBySig = new Map();
468
- for (const e of newEvents) {
469
- if (!newBySig.has(sig(e))) newBySig.set(sig(e), []);
470
- 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)` });
471
691
  }
472
- for (const oe of oldEvents) {
473
- const matches = newBySig.get(sig(oe)) || [];
474
- const ne = matches[0];
475
- if (!ne) {
476
- // A dropped audio event where its video sibling survives → dropped J/L-cut audio.
477
- if (oe.track === 'A' && newEvents.some((x) => x.track !== 'A' && x.source === oe.source))
478
- flags.push({ kind: 'dropped_split_audio', source: oe.source, detail: 'audio event gone but video sibling present (J/L-cut lost)' });
479
- continue;
480
- }
692
+ for (const { oe, ne } of P.pairs) {
481
693
  // Flattened retime: a speed ramp/change flattened to 100%.
482
694
  if ((oe.speed ?? 100) !== 100 && (ne.speed ?? 100) === 100)
483
- 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)` });
484
696
  // Reverse dropped.
485
- 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' });
486
698
  // Framerate/pulldown slip.
487
- 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
+ });
488
712
  }
489
713
  return { flags, flagged: flags.length > 0 };
490
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 + 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 (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.162.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.162.0"
14
+ VERSION = "2.164.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -6927,6 +6927,26 @@ def _export_timeline_checked(tl, p: Dict[str, Any]):
6927
6927
  # (which can be None and silently degrade the EXPORT_* args to strings — the
6928
6928
  # same failure class as issue #70).
6929
6929
  spec = _timeline_export_spec(p, get_resolve())
6930
+ # An export type that did not resolve to a Resolve constant reaches
6931
+ # Timeline.Export as a STRING, which returns a bare False — reported as
6932
+ # success:false with no reason (measured E105 with a made-up
6933
+ # EXPORT_CMX_3600: the real constant is EXPORT_EDL). Refuse loudly with
6934
+ # the vocabulary instead of letting a typo read as an export failure.
6935
+ # Only when this build exposes the export vocabulary at all (a bare stub
6936
+ # or a degraded connection resolves nothing, and the old passthrough
6937
+ # stays the honest behavior there).
6938
+ _probe_const, _ = _timeline_export_value("EXPORT_OTIO", get_resolve())
6939
+ vocab_known = not isinstance(_probe_const, str)
6940
+ for field in ("export_type", "export_subtype"):
6941
+ val = spec.get(field)
6942
+ if vocab_known and isinstance(val, str) and val.startswith("EXPORT_"):
6943
+ return _err(
6944
+ f"{field} {val!r} is not a Resolve export constant on this build. "
6945
+ f"Known aliases: {', '.join(sorted(_TIMELINE_EXPORT_ALIASES))}; "
6946
+ "constants: EXPORT_AAF, EXPORT_DRT, EXPORT_EDL, EXPORT_FCP_7_XML, "
6947
+ "EXPORT_FCPXML_1_8/1_9/1_10, EXPORT_OTIO (subtypes EXPORT_NONE, "
6948
+ "EXPORT_AAF_NEW, EXPORT_AAF_EXISTING, EXPORT_CDL, EXPORT_SDL, EXPORT_MISSING_CLIPS)."
6949
+ )
6930
6950
  if p.get("dry_run"):
6931
6951
  return _ok(path=path, would_export=True, spec={k: v for k, v in spec.items() if k != "export_type"})
6932
6952
 
@@ -2764,6 +2764,36 @@ API_TRUTH: List[Dict[str, Any]] = [
2764
2764
  "submit": "bug",
2765
2765
  "mitigation": ["drt.assemble_from_interchange black-leg authoring"],
2766
2766
  },
2767
+ {
2768
+ "symbol": "Timeline.Export EXPORT_EDL (video-only, reel AX, clip-name comments)",
2769
+ "object": "Timeline",
2770
+ "signature": "(filePath, EXPORT_EDL, EXPORT_NONE) -> bool",
2771
+ "reality": "Resolve's CMX EDL writer (measured on Studio 19.1.3.7, "
2772
+ "E105) emits VIDEO events only — audio legs never appear; "
2773
+ "names every file source by the generic reel AX and carries "
2774
+ "the real names in `* FROM CLIP NAME:` / `* TO CLIP NAME:` "
2775
+ "comments; writes black legs as reel BL; places dissolve "
2776
+ "junctions at the CMX start-at-cut position (the overlap "
2777
+ "start, not the centered junction the timeline holds); and "
2778
+ "WRITES BL fades that its own EDL importer then drops. The "
2779
+ "FCP7 XML writer, by contrast, carries audio, writes "
2780
+ "transition-adjacent clip edges as -1 (the junction), and "
2781
+ "emits `speed` followed by `variablespeed` 0 in the same "
2782
+ "timeremap effect.",
2783
+ "recommended": "For round-trip QC prefer EXPORT_OTIO (carries audio, "
2784
+ "retimes as LinearTimeWarp/FreezeFrame, exact spans). "
2785
+ "When an EDL is the required deliverable, expect no "
2786
+ "audio (editorial.verify_roundtrip reports "
2787
+ "audioNotInExport) and resolve AX reels through the "
2788
+ "clip-name comments (parseEDL does). Use EXPORT_EDL — "
2789
+ "there is no EXPORT_CMX_3600 constant, and an unknown "
2790
+ "name reaches Export as a string that returns a bare "
2791
+ "False (timeline.export_timeline_checked now refuses "
2792
+ "it loudly).",
2793
+ "tags": ["timeline", "export", "edl", "audio", "silent-failure"],
2794
+ "submit": "missing",
2795
+ "mitigation": ["editorial.verify_roundtrip audioNotInExport", "timeline.export_timeline_checked"],
2796
+ },
2767
2797
  {
2768
2798
  "symbol": "Project.SetRenderSettings ExportSubtitle/SubtitleFormat (inert on 19.x)",
2769
2799
  "object": "Project",