davinci-resolve-mcp 2.164.0 → 2.166.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,76 @@
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.166.0 — E108: XMEML audio cross-fades conform and render
6
+
7
+ The XMEML walker only looked at `<transitionitem>`s on VIDEO tracks, so an
8
+ audio cross-fade vanished at parse and never reached the bridge that
9
+ authors them (OTIO carried its audio Transition all along). Every audio
10
+ track also walked as `A`, collapsing multi-track audio onto one lane where
11
+ OTIO and AAF number `A`, `A2`, `A3` …
12
+
13
+ ### Fixed
14
+
15
+ - **XMEML audio-track transitionitems attach** to the incoming audio event
16
+ exactly as video's do (one shared attach pass), and the bridge authors the
17
+ cross-fade. Render-measured on 19.1.3.7 against a control timeline: a 24f
18
+ `Cross Fade (+3dB)` from a −21 dBFS tone to a −41 dBFS tone ramps
19
+ −27.1 → −30.6 → −34.2 → −40.2 → −47.1 dBFS across the window in 0.25 s
20
+ RMS steps where the control steps hard at the cut.
21
+ - **XMEML audio tracks number like OTIO/AAF** (`A`, `A2`, …), so the
22
+ bridge places each lane on its own audio track instead of stacking
23
+ everything on A1.
24
+ - **`media_pool.capture_media_template` saves the current project before
25
+ switching to its scratch project.** `CreateProject` replaces the current
26
+ project, and an unsaved one is simply gone afterwards — the restore cannot
27
+ `LoadProject` a name that existed only in memory (measured: a freshly
28
+ created project with two imported timelines vanished, and Resolve fell
29
+ back to a transient "Untitled Project"). A failed save refuses the capture.
30
+
31
+ ### Measured (filed in api-limitations)
32
+
33
+ - `ProjectManager.CreateProject` while an UNSAVED project is current
34
+ discards that project without error.
35
+
36
+ ## What's New in v2.165.0 — E107: frame QC reads Resolve's own XML and samples clear of transitions
37
+
38
+ The lineage store's `ingest_xml` was measured against a verbatim
39
+ `EXPORT_FCP_7_XML` of a fade-in → clip → centered dissolve → clip → fade-out
40
+ timeline (rendered and luma-verified: 18→123 over the fade-in, a blend over
41
+ 96–119, 230→21 over the fade-out). Every transition-adjacent cut landed at
42
+ record `-1` with no oracle frame — the reference sampled at frame 0 read
43
+ black and the conform side could not be sampled at all, so each read as a
44
+ false yellow turnover. Two laws of Resolve's writer explain it.
45
+
46
+ ### Fixed
47
+
48
+ - **`-1` clip edges resolve to junctions in the lineage ingest** (the E105
49
+ law the editorial parser already knew), and with a record-order cursor:
50
+ under three centered transitions two equal-length clips both carry
51
+ `-1/-1` edges, and the first junction pair that fits placed BOTH clips at
52
+ the same position — in the editorial parser too. Both parsers now walk
53
+ clips with a cursor; the verbatim export is a permanent fixture.
54
+ - **Resolve writes no `pproTicksIn`.** The oracle insisted on Premiere ticks
55
+ and derived no source frame for any cut of a Resolve export. When ticks
56
+ are absent, `<in>` (record-aligned by the `-1` resolution) is the oracle
57
+ frame; the ingest reports `ticksAbsent`, `resolvedEdges`, `unresolvedEdges`.
58
+ - **Frame QC samples clear of transition windows.** Each cut records the
59
+ windows its edges sit in (`cuts.transition`, plus `cuts.speed`; existing
60
+ sidecars migrate in place) and `qc` compares the first frame past the
61
+ incoming window and before the outgoing one, advancing the source frame
62
+ at the cut's speed (reverse walks backward). Measured on the render:
63
+ structure 0.982 at the dissolve junction → 0.999 clear of it; the result
64
+ carries `sample_note` saying where it looked, and a cut swallowed whole by
65
+ its windows samples its midpoint and says so.
66
+
67
+ ### Measured (filed in api-limitations)
68
+
69
+ - `EXPORT_FCP_7_XML` writes no `pproTicksIn`/`pproTicksOut`, `-1` on every
70
+ transition-adjacent edge (junction), `center` alignment for every
71
+ centered-authored dissolve and fade, and Solid Color generatoritems for
72
+ black legs. A flat/untextured frame (a white card) is `UNREADABLE` to the
73
+ brightness-robust classifier — an honest review, not a false verdict.
74
+
5
75
  ## What's New in v2.164.0 — E106: the changelist sees junctions
6
76
 
7
77
  `editorial.turnover_changelist` diffed clips and was blind to everything
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.164.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.166.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.164.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.166.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.164.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v2.166.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
@@ -135,7 +135,9 @@ files and the project DB.
135
135
  source). Also: reverse/retimed subclip DB repair (reversed `source_start` =
136
136
  `masterFrames − 1 − endoffset`, live-validated), sequence lineage store + diff +
137
137
  rollback (hashed snapshots), and per-cut frame QC (oracle-frame vs
138
- reference-render, scale-corrected, red/yellow/cyan verdicts).
138
+ reference-render, scale-corrected, red/yellow/cyan verdicts; each cut is
139
+ compared clear of its transition windows, and Resolve's own FCP7 export
140
+ ingests with its `-1` edges resolved and its missing ticks tolerated).
139
141
  - **`color_trace`** — cross-project clip matching → a trace plan for carrying
140
142
  grades across a re-conform (pairs with the color kernel's `drx grade_transfer`).
141
143
  - **`offline_ref`** — offline-reference clips have **no scripting API** but live
@@ -12,7 +12,7 @@ that none exists).
12
12
 
13
13
  **Verified on:** DaVinci Resolve Studio 21.0.2
14
14
 
15
- **Totals:** 36 missing capabilities, 50 bugs / unreliable behaviors.
15
+ **Totals:** 38 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
@@ -309,6 +309,22 @@ equivalent, blocking full automation.
309
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
310
  - **Tags:** timeline, export, edl, audio, silent-failure
311
311
 
312
+ ### ProjectManager.CreateProject (discards an unsaved current project)
313
+
314
+ - **Object:** `ProjectManager`
315
+ - **Signature:** `(projectName) -> Project`
316
+ - **Behavior:** CreateProject replaces the CURRENT project with the new one. If the current project was never saved it is simply gone — no dialog headless, no error, and a later LoadProject of its name fails because the name existed only in memory (measured on Studio 19.1.3.7, E108: a project created via CreateProject with two imported timelines vanished when a media-template capture created its scratch project; the restore landed on a transient "Untitled Project" that is not in the project list either).
317
+ - **Workaround / current handling:** SaveProject() before any CreateProject/LoadProject switch when the current project may be unsaved; the MCP's capture_media_template now does and refuses on a failed save.
318
+ - **Tags:** project, lifecycle, silent-failure, headless
319
+
320
+ ### Timeline.Export EXPORT_FCP_7_XML (no pproTicksIn, -1 edges under transitions)
321
+
322
+ - **Object:** `Timeline`
323
+ - **Signature:** `(filePath, EXPORT_FCP_7_XML, EXPORT_NONE) -> bool`
324
+ - **Behavior:** Resolve's FCP7 XML writer (measured on Studio 19.1.3.7, E107, verbatim export kept as a fixture) emits NO pproTicksIn/pproTicksOut on any clipitem — the Premiere tick fields a Premiere-shaped oracle treats as the authoritative source position are simply absent, so a reader that requires them derives no source frame for ANY cut of a Resolve export. Every clipitem edge that sits under a transitionitem is written as -1 and means the transition's junction (span center for alignment center; the writer emitted `center` for every dissolve and fade authored centered), `out - in` is the record duration, and a -1 START edge's <in> is the source at the overlap start. With three centered transitions two equal-length clips both carry -1/-1 edges, so a reader must pair junctions in record order — the first pair that fits places both clips at the same position. Black legs are Solid Color generatoritems whose -1 edge resolves the same way.
325
+ - **Workaround / current handling:** Read <in> as the literal source frame (Resolve reads and writes it that way), record-align it by the junction-minus-span-start offset on a -1 start, and walk -1/-1 clips with a record-order cursor. conform.snapshot ingest_xml and editorial.parse_interchange both do; the frame QC then samples each cut CLEAR of its transition windows (inside one the reference is a blend, or black for a fade).
326
+ - **Tags:** timeline, export, xml, fcp7, transitions, silent-failure
327
+
312
328
  ## Bugs / Unreliable Behavior (please fix)
313
329
 
314
330
  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.164.0"
40
+ VERSION = "2.166.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.164.0",
3
+ "version": "2.166.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -80,7 +80,8 @@ Each dispatches on an `action`. Highlights:
80
80
  `export_cdl`, `merge`, plus the **grading/QC catalog** below.
81
81
  - **`drp` / `drt`** — project / timeline file authoring + editing + grade injection + structural diff.
82
82
  - **`conform`** — offline conform/relink QC engine (frame-oracle math, not filename matching),
83
- reverse-clip DB repair, sequence lineage store + diff, per-cut frame QC.
83
+ reverse-clip DB repair, sequence lineage store + diff, per-cut frame QC (sampled clear of
84
+ transition windows; ingests Resolve's own FCP7 export — `-1` edges → junctions, no ticks needed).
84
85
  - **`color_trace`** — cross-project clip matching → a trace plan for carrying grades across a re-conform.
85
86
  - **`project_read` / `project_db`** — read/patch the Resolve project DB (SQLite or Postgres).
86
87
  Includes `list_subtitle_styles` / `set_subtitle_style` — caption font family/size/weight/italic
@@ -97,7 +98,8 @@ Each dispatches on an `action`. Highlights:
97
98
  - **`media`** — media front-end / AE: `ingest_verify` (hash seal/verify/dupes-by-hash), `media_inventory`
98
99
  (fps/codec/colorspace/TC + card gaps), `sync` (TC picture↔sound + drift/MOS), `relink_manifest`,
99
100
  `rename_plan` (refuses camera originals) / `reel_normalize`, `turnover_package`, `project_hygiene`.
100
- - **`editorial`** — editorial integrity: `parse_interchange` (EDL/OTIO/XMEML natively, AAF via pyaaf2,
101
+ - **`editorial`** — editorial integrity: `parse_interchange` (EDL/OTIO/XMEML natively XMEML audio
102
+ cross-fades and numbered audio lanes included — AAF via pyaaf2,
101
103
  **.prproj via gunzip+XML** — pass the file PATH for the binary ones), `list_sequences` (one picker entry
102
104
  point across xml/edl/otio/drt/drp/aaf/prproj), `convert_to_interchange` (author OTIO/EDL/DRT that Resolve
103
105
  imports, from events or a parsed source — **the .prproj→Resolve conform bridge**, no Premiere needed;
@@ -297,7 +297,13 @@ export function parseXMEMLEvents(xml, opts = {}) {
297
297
  let idx = 1;
298
298
  const seqRate = opts.fps || 24;
299
299
  let currentJunctions = [];
300
- const walk = (node, track) => {
300
+ // Record-order cursor for -1/-1 edge resolution (E107): a clip whose both
301
+ // edges are -1 pairs junctions at or after the previous CLIP's end —
302
+ // generator legs (walked first) never advance it. Without this, two
303
+ // equal-length clips under three centered transitions both resolved to
304
+ // the first junction pair (measured against Resolve's own writer).
305
+ let clipCursor = 0;
306
+ const walk = (node, track, advanceCursor = true) => {
301
307
  if (!node) return;
302
308
  const items = Array.isArray(node) ? node : [node];
303
309
  for (const it of items) {
@@ -340,9 +346,11 @@ export function parseXMEMLEvents(xml, opts = {}) {
340
346
  else if (end === -1 && start !== -1 && dur != null) end = start + dur;
341
347
  else if (start === -1 && end === -1 && dur != null && junctions.length) {
342
348
  const js = junctions.map((j) => j.frame).sort((a, b) => a - b);
343
- const pair = js.find((a) => js.includes(a + dur));
349
+ const floor = advanceCursor ? clipCursor : 0;
350
+ const pair = js.find((a) => a >= floor && js.includes(a + dur));
344
351
  if (pair != null) { start = pair; end = pair + dur; }
345
352
  }
353
+ if (advanceCursor && Number.isFinite(end) && end >= 0) clipCursor = Math.max(clipCursor, end);
346
354
  // A -1 START edge's `in` is the source at the OVERLAP start (the
347
355
  // clip's material begins under the transition), while the resolved
348
356
  // start is the junction — so `in` advances by the same offset to stay
@@ -362,6 +370,55 @@ export function parseXMEMLEvents(xml, opts = {}) {
362
370
  }
363
371
  }
364
372
  };
373
+ // Attach a track's <transitionitem> siblings to its walked events. Shared
374
+ // by VIDEO and AUDIO tracks (E108): audio cross-fades used to be dropped at
375
+ // parse because only the video walk looked at transitionitems, so an XMEML
376
+ // audio cross-fade never reached the bridge that authors them.
377
+ const attachTransitions = (t, label, before) => {
378
+ // <transitionitem> siblings: attach each to the INCOMING clip event —
379
+ // the one whose record-in falls inside the transition's span. Resolve's
380
+ // OWN XMEML importer writes these as elements that render INERT on
381
+ // 19.1.3 (measured E66: dip AND plain cross dissolve both played the
382
+ // outgoing clip through the window, hard cut at the end), so routing
383
+ // the turnover through assemble_from_interchange authors transitions
384
+ // that actually render where the host importer's do not.
385
+ const titems = t.transitionitem ? (Array.isArray(t.transitionitem) ? t.transitionitem : [t.transitionitem]) : [];
386
+ for (const tr of titems) {
387
+ const s = Number(tr.start), e2 = Number(tr.end);
388
+ if (!Number.isFinite(s) || !Number.isFinite(e2)) continue;
389
+ const effectId = (tr.effect && (tr.effect.effectid || tr.effect.name)) || 'Cross Dissolve';
390
+ const isBLev = (ev) => /^(BL|BLACK)$/i.test(String(ev.source || '').trim());
391
+ const inSpan = (ev) => ev.track === label && ev.recIn > s - 1 && ev.recIn <= e2;
392
+ // Prefer the PICTURE as the incoming: a Solid Color generatoritem in
393
+ // the same span is the fade's black side, not the clip fading in.
394
+ const incoming = events.slice(before).find((ev) => inSpan(ev) && !isBLev(ev)) || events.slice(before).find(inSpan);
395
+ // recStart carries the transitionitem's EXPLICIT record span start
396
+ // (sequence-relative) so the bridge reproduces the editor's actual
397
+ // alignment instead of assuming centered.
398
+ if (incoming) {
399
+ incoming.transition = { type: String(effectId), duration: e2 - s, recStart: s };
400
+ // No clip ENDS inside the span → nothing precedes: a fade-IN from
401
+ // black. Synthesize the zero-length BL leg (E91/E92) so the bridge
402
+ // authors a real black-to-picture dissolve instead of dropping it.
403
+ const outgoing = events.slice(before).find((ev) => ev.track === label && ev !== incoming && ev.recOut > s - 1 && ev.recOut <= e2);
404
+ if (!outgoing) {
405
+ events.push(evt({ index: idx++, track: label, source: 'BL', recIn: incoming.recIn, recOut: incoming.recIn, fps: seqRate }));
406
+ }
407
+ } else {
408
+ // No clip STARTS inside the span: a fade-OUT into black past the
409
+ // last clip. Attach the transition to a synthetic BL leg at the
410
+ // outgoing clip's end (the bridge grows it forward).
411
+ const outgoing = events.slice(before).find((ev) => ev.track === label && ev.recOut > s - 1 && ev.recOut <= e2);
412
+ if (outgoing) {
413
+ events.push(evt({
414
+ index: idx++, track: label, source: 'BL',
415
+ recIn: outgoing.recOut, recOut: outgoing.recOut,
416
+ transition: { type: String(effectId), duration: e2 - s, recStart: s }, fps: seqRate,
417
+ }));
418
+ }
419
+ }
420
+ }
421
+ };
365
422
  const seq = doc.xmeml && doc.xmeml.sequence;
366
423
  const media = seq && seq.media;
367
424
  if (media) {
@@ -383,56 +440,25 @@ export function parseXMEMLEvents(xml, opts = {}) {
383
440
  const gens = t.generatoritem ? (Array.isArray(t.generatoritem) ? t.generatoritem : [t.generatoritem]) : [];
384
441
  for (const g of gens) {
385
442
  if (!/solid color|black/i.test(String(g.name || ''))) continue;
386
- walk([{ ...g, name: 'BL', filter: undefined, marker: undefined }], label);
443
+ walk([{ ...g, name: 'BL', filter: undefined, marker: undefined }], label, false);
387
444
  }
445
+ clipCursor = 0;
388
446
  if (t.clipitem) walk(t.clipitem, label);
389
447
  currentJunctions = [];
390
- // <transitionitem> siblings: attach each to the INCOMING clip event —
391
- // the one whose record-in falls inside the transition's span. Resolve's
392
- // OWN XMEML importer writes these as elements that render INERT on
393
- // 19.1.3 (measured E66: dip AND plain cross dissolve both played the
394
- // outgoing clip through the window, hard cut at the end), so routing
395
- // the turnover through assemble_from_interchange authors transitions
396
- // that actually render where the host importer's do not.
397
- const titems = t.transitionitem ? (Array.isArray(t.transitionitem) ? t.transitionitem : [t.transitionitem]) : [];
398
- for (const tr of titems) {
399
- const s = Number(tr.start), e2 = Number(tr.end);
400
- if (!Number.isFinite(s) || !Number.isFinite(e2)) continue;
401
- const effectId = (tr.effect && (tr.effect.effectid || tr.effect.name)) || 'Cross Dissolve';
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);
407
- // recStart carries the transitionitem's EXPLICIT record span start
408
- // (sequence-relative) so the bridge reproduces the editor's actual
409
- // alignment instead of assuming centered.
410
- if (incoming) {
411
- incoming.transition = { type: String(effectId), duration: e2 - s, recStart: s };
412
- // No clip ENDS inside the span → nothing precedes: a fade-IN from
413
- // black. Synthesize the zero-length BL leg (E91/E92) so the bridge
414
- // authors a real black-to-picture dissolve instead of dropping it.
415
- const outgoing = events.slice(before).find((ev) => ev.track === label && ev !== incoming && ev.recOut > s - 1 && ev.recOut <= e2);
416
- if (!outgoing) {
417
- events.push(evt({ index: idx++, track: label, source: 'BL', recIn: incoming.recIn, recOut: incoming.recIn, fps: seqRate }));
418
- }
419
- } else {
420
- // No clip STARTS inside the span: a fade-OUT into black past the
421
- // last clip. Attach the transition to a synthetic BL leg at the
422
- // outgoing clip's end (the bridge grows it forward).
423
- const outgoing = events.slice(before).find((ev) => ev.track === label && ev.recOut > s - 1 && ev.recOut <= e2);
424
- if (outgoing) {
425
- events.push(evt({
426
- index: idx++, track: label, source: 'BL',
427
- recIn: outgoing.recOut, recOut: outgoing.recOut,
428
- transition: { type: String(effectId), duration: e2 - s, recStart: s }, fps: seqRate,
429
- }));
430
- }
431
- }
432
- }
448
+ attachTransitions(t, label, before);
433
449
  });
450
+ // Audio tracks number like OTIO/AAF (A, A2, A3 …) so multi-track audio
451
+ // keeps its lanes instead of collapsing onto A1 (E108), and their
452
+ // transitionitems attach the same way video's do.
434
453
  const atracks = media.audio && media.audio.track ? (Array.isArray(media.audio.track) ? media.audio.track : [media.audio.track]) : [];
435
- for (const t of atracks) if (t.clipitem) walk(t.clipitem, 'A');
454
+ atracks.forEach((t, ai) => {
455
+ const label = ai === 0 ? 'A' : `A${ai + 1}`;
456
+ const before = events.length;
457
+ clipCursor = 0;
458
+ if (t.clipitem) walk(t.clipitem, label);
459
+ currentJunctions = [];
460
+ attachTransitions(t, label, before);
461
+ });
436
462
  }
437
463
  return events;
438
464
  }
@@ -34,7 +34,7 @@ CREATE TABLE IF NOT EXISTS cuts (snapshot_id TEXT, cut_index INTEGER, record_sta
34
34
  source_basename TEXT, source_path TEXT, xml_in INTEGER, xml_out INTEGER,
35
35
  ppro_ticks_in INTEGER, oracle_source_frame INTEGER, is_subclip INTEGER,
36
36
  subclip_startoffset INTEGER, subclip_endoffset INTEGER, reverse INTEGER,
37
- scale_corrected REAL, pan_h REAL, pan_v REAL, rotation REAL, transition TEXT,
37
+ scale_corrected REAL, pan_h REAL, pan_v REAL, rotation REAL, transition TEXT, speed REAL,
38
38
  cut_hash TEXT, PRIMARY KEY (snapshot_id, cut_index));
39
39
  CREATE TABLE IF NOT EXISTS qc_verdicts (snapshot_id TEXT, cut_index INTEGER, reference_ref TEXT, reference_frame INTEGER,
40
40
  verdict TEXT, category TEXT, structure REAL, psnr REAL, dx INTEGER, dy INTEGER,
@@ -48,6 +48,10 @@ export function openStore(dbPath) {
48
48
  const db = new Database(dbPath);
49
49
  db.pragma('journal_mode = WAL');
50
50
  db.exec(SCHEMA);
51
+ // Sidecars created before E107 lack cuts.speed — CREATE IF NOT EXISTS
52
+ // cannot add a column, so migrate in place (idempotent).
53
+ const cols = db.prepare('PRAGMA table_info(cuts)').all().map((c) => c.name);
54
+ if (!cols.includes('speed')) db.exec('ALTER TABLE cuts ADD COLUMN speed REAL');
51
55
  return db;
52
56
  }
53
57
 
@@ -79,6 +83,9 @@ function cutHash(cut) {
79
83
 
80
84
  function buildCuts(xml, mediaFrames) {
81
85
  const g = parse.parseGeometry(xml);
86
+ const unresolvedEdges = [];
87
+ let resolvedEdges = 0;
88
+ let ticksAbsent = 0;
82
89
  const seqW = g.sequence.width || null;
83
90
  const seqH = g.sequence.height || null;
84
91
  const fps = g.sequence.fps || 24;
@@ -92,6 +99,15 @@ function buildCuts(xml, mediaFrames) {
92
99
  } catch {
93
100
  /* reverse w/o frame count, etc. */
94
101
  }
102
+ // Resolve's FCP7 writer emits NO pproTicksIn (measured, E107) and the
103
+ // oracle insists on ticks, so every cut of a Resolve export lost its
104
+ // source frame — the conform side could never be sampled. Resolve reads
105
+ // and writes <in> as the literal source frame (api-truth), and the -1
106
+ // resolution above has already record-aligned it, so <in> IS the frame.
107
+ if (oracleFrame == null && c.pproTicksIn == null && c.xml_in != null && !c.reverse) {
108
+ oracleFrame = (c.is_subclip ? c.subclip_startoffset || 0 : 0) + c.xml_in;
109
+ ticksAbsent += 1;
110
+ }
95
111
  try {
96
112
  scaleCorrected = resolveTarget.deriveScaleCorrected(c, ctx);
97
113
  } catch {
@@ -115,12 +131,20 @@ function buildCuts(xml, mediaFrames) {
115
131
  pan_h: c.center ? c.center.h : null,
116
132
  pan_v: c.center ? c.center.v : null,
117
133
  rotation: c.rotation ?? null,
118
- transition: null,
134
+ // The transition windows this cut's edges sit in (E107): frame-QC
135
+ // samples clear of them — inside a window the reference is a blend, or
136
+ // black for a fade, and the first frame of the cut proves nothing.
137
+ transition: c.transition_in || c.transition_out
138
+ ? JSON.stringify({ in: c.transition_in || null, out: c.transition_out || null })
139
+ : null,
140
+ speed: c.speed ?? null,
119
141
  };
142
+ if (c.edges_resolved) resolvedEdges += 1;
143
+ if (c.edges_unresolved) unresolvedEdges.push({ cut_index: i, source_basename: cut.source_basename });
120
144
  cut.cut_hash = cutHash(cut);
121
145
  return cut;
122
146
  });
123
- return { cuts, seqW, seqH, fps, transitions: g.transitions };
147
+ return { cuts, seqW, seqH, fps, transitions: g.transitions, unresolvedEdges, resolvedEdges, ticksAbsent };
124
148
  }
125
149
 
126
150
  // Write a built cut list as a snapshot (dedup on (reel, content_hash)). Shared by
@@ -136,8 +160,8 @@ function writeSnapshot(dbPath, cuts, meta) {
136
160
  (snapshot_id, reel, kind, source_ref, label, parent_id, content_hash, seq_w, seq_h, fps, cut_count, created_at, provenance)
137
161
  VALUES (@snapshot_id,@reel,@kind,@source_ref,@label,@parent_id,@content_hash,@seq_w,@seq_h,@fps,@cut_count,@created_at,@provenance)`);
138
162
  const insertCut = db.prepare(`INSERT INTO cuts
139
- (snapshot_id, cut_index, record_start, record_end, source_basename, source_path, xml_in, xml_out, ppro_ticks_in, oracle_source_frame, is_subclip, subclip_startoffset, subclip_endoffset, reverse, scale_corrected, pan_h, pan_v, rotation, transition, cut_hash)
140
- VALUES (@snapshot_id,@cut_index,@record_start,@record_end,@source_basename,@source_path,@xml_in,@xml_out,@ppro_ticks_in,@oracle_source_frame,@is_subclip,@subclip_startoffset,@subclip_endoffset,@reverse,@scale_corrected,@pan_h,@pan_v,@rotation,@transition,@cut_hash)`);
163
+ (snapshot_id, cut_index, record_start, record_end, source_basename, source_path, xml_in, xml_out, ppro_ticks_in, oracle_source_frame, is_subclip, subclip_startoffset, subclip_endoffset, reverse, scale_corrected, pan_h, pan_v, rotation, transition, speed, cut_hash)
164
+ VALUES (@snapshot_id,@cut_index,@record_start,@record_end,@source_basename,@source_path,@xml_in,@xml_out,@ppro_ticks_in,@oracle_source_frame,@is_subclip,@subclip_startoffset,@subclip_endoffset,@reverse,@scale_corrected,@pan_h,@pan_v,@rotation,@transition,@speed,@cut_hash)`);
141
165
  db.transaction(() => {
142
166
  insertSnap.run({
143
167
  snapshot_id: snapshotId,
@@ -170,8 +194,11 @@ function writeSnapshot(dbPath, cuts, meta) {
170
194
  */
171
195
  export function ingestXml(dbPath, xmlPath, opts = {}) {
172
196
  const xml = fs.readFileSync(xmlPath, 'utf8');
173
- const { cuts, seqW, seqH, fps } = buildCuts(xml, opts.mediaFrames || {});
174
- return writeSnapshot(dbPath, cuts, {
197
+ const { cuts, seqW, seqH, fps, unresolvedEdges, resolvedEdges, ticksAbsent } = buildCuts(xml, opts.mediaFrames || {});
198
+ // Edge accounting rides on the ingest result: how many Resolve-written -1
199
+ // edges resolved to junctions, and which (if any) could not be anchored.
200
+ const withEdges = (r) => ({ ...r, resolvedEdges, unresolvedEdges, ticksAbsent });
201
+ return withEdges(writeSnapshot(dbPath, cuts, {
175
202
  ...opts,
176
203
  kind: opts.kind ?? 'conform_xml',
177
204
  source_ref: xmlPath,
@@ -179,7 +206,7 @@ export function ingestXml(dbPath, xmlPath, opts = {}) {
179
206
  seqH,
180
207
  fps,
181
208
  provenance: opts.provenance ?? 'ingestXml',
182
- });
209
+ }));
183
210
  }
184
211
 
185
212
  function loadPg() {
@@ -267,6 +294,7 @@ export async function ingestLiveTimeline(dbPath, opts = {}) {
267
294
  pan_v: t.pan_v ?? null,
268
295
  rotation: t.rotation ?? null,
269
296
  transition: null,
297
+ speed: null,
270
298
  };
271
299
  cut.cut_hash = cutHash(cut);
272
300
  return cut;
@@ -95,6 +95,60 @@ export function markerCategory(verdict, sat = {}) {
95
95
  return { category: 'conform', color: 'Red' };
96
96
  }
97
97
 
98
+
99
+ /**
100
+ * Which frame to compare for a cut (E107). A cut whose edge sits under a
101
+ * transition is a BLEND there — or black, for a fade — so its first record
102
+ * frame proves nothing: a fade-in reads REF_OFFLINE, a dissolve reads WRONG,
103
+ * both false. Sample the first frame CLEAR of the incoming window and before
104
+ * the outgoing one; advance the source frame by the same record offset at the
105
+ * cut's speed (reverse walks backward). A cut that lies entirely inside its
106
+ * windows samples its midpoint and says so — the verdict is then a blend
107
+ * compare, reported, never silently trusted.
108
+ * @returns {{recordFrame:number|null, sourceFrame:number|null, offset:number, note:string|null}}
109
+ */
110
+ export function pickQcFrame(cut) {
111
+ const rs = cut.record_start;
112
+ if (rs == null) return { recordFrame: null, sourceFrame: cut.oracle_source_frame ?? null, offset: 0, note: null };
113
+ let win = null;
114
+ if (cut.transition) {
115
+ try {
116
+ win = typeof cut.transition === 'string' ? JSON.parse(cut.transition) : cut.transition;
117
+ } catch {
118
+ win = null;
119
+ }
120
+ }
121
+ let rec = rs;
122
+ let note = null;
123
+ if (win && (win.in || win.out)) {
124
+ const last = cut.record_end != null ? cut.record_end - 1 : null;
125
+ let lo = rs;
126
+ let hi = last;
127
+ if (win.in && win.in.end > lo) lo = win.in.end;
128
+ if (win.out && hi != null && win.out.start - 1 < hi) hi = win.out.start - 1;
129
+ if (hi == null || lo <= hi) {
130
+ rec = lo;
131
+ if (rec !== rs) note = `sampled at ${rec}, clear of the transition window (cut starts ${rs})`;
132
+ } else {
133
+ rec = Math.round((rs + (cut.record_end ?? rs)) / 2);
134
+ note = `cut lies entirely inside transition windows — sampled midpoint ${rec} (a blend; verdict is indicative)`;
135
+ }
136
+ }
137
+ const offset = rec - rs;
138
+ let src = cut.oracle_source_frame ?? null;
139
+ if (src != null && offset) {
140
+ let k = 1;
141
+ if (cut.speed != null && Number.isFinite(Number(cut.speed)) && Number(cut.speed) !== 0) k = Math.abs(Number(cut.speed)) / 100;
142
+ else if (cut.record_end != null && cut.xml_in != null && cut.xml_out != null && cut.record_end > rs) {
143
+ const recSpan = cut.record_end - rs;
144
+ const srcSpan = Math.abs(cut.xml_out - cut.xml_in);
145
+ if (srcSpan && srcSpan !== recSpan) k = srcSpan / recSpan;
146
+ }
147
+ src = Math.round(src + (cut.reverse ? -1 : 1) * offset * k);
148
+ }
149
+ return { recordFrame: rec, sourceFrame: src, offset, note };
150
+ }
151
+
98
152
  /**
99
153
  * QC a whole snapshot against a reference. Samplers are injected:
100
154
  * opts.sampleConform(cut) -> Float64 gray | null
@@ -119,8 +173,13 @@ export async function qcSnapshot(dbPath, snapshotId, opts = {}) {
119
173
  continue;
120
174
  }
121
175
  }
122
- const conform = await opts.sampleConform(cut);
123
- const reference = await opts.sampleReference(cut);
176
+ // E107: compare a frame CLEAR of the cut's transition windows — samplers
177
+ // read qc_record_frame / qc_source_frame when present.
178
+ const pick = pickQcFrame(cut);
179
+ const cutForSample = { ...cut, qc_record_frame: pick.recordFrame, qc_source_frame: pick.sourceFrame };
180
+ const conform = await opts.sampleConform(cutForSample);
181
+ const reference = await opts.sampleReference(cutForSample);
182
+ const refFrame = pick.recordFrame ?? cut.record_start;
124
183
  let v;
125
184
  if (!reference) {
126
185
  // can't sample the reference → can't verify → human review
@@ -128,7 +187,7 @@ export async function qcSnapshot(dbPath, snapshotId, opts = {}) {
128
187
  snapshot_id: snapshotId,
129
188
  cut_index: cut.cut_index,
130
189
  reference_ref: ref,
131
- reference_frame: cut.record_start,
190
+ reference_frame: refFrame,
132
191
  verdict: 'UNREADABLE',
133
192
  category: 'review',
134
193
  ran_at: opts.now ?? null,
@@ -139,7 +198,7 @@ export async function qcSnapshot(dbPath, snapshotId, opts = {}) {
139
198
  snapshot_id: snapshotId,
140
199
  cut_index: cut.cut_index,
141
200
  reference_ref: ref,
142
- reference_frame: cut.record_start,
201
+ reference_frame: refFrame,
143
202
  verdict: 'REF_OFFLINE',
144
203
  category: 'ref_offline',
145
204
  ran_at: opts.now ?? null,
@@ -150,7 +209,7 @@ export async function qcSnapshot(dbPath, snapshotId, opts = {}) {
150
209
  snapshot_id: snapshotId,
151
210
  cut_index: cut.cut_index,
152
211
  reference_ref: ref,
153
- reference_frame: cut.record_start,
212
+ reference_frame: refFrame,
154
213
  verdict: 'WRONG',
155
214
  category: 'turnover',
156
215
  ran_at: opts.now ?? null,
@@ -163,7 +222,7 @@ export async function qcSnapshot(dbPath, snapshotId, opts = {}) {
163
222
  snapshot_id: snapshotId,
164
223
  cut_index: cut.cut_index,
165
224
  reference_ref: ref,
166
- reference_frame: cut.record_start,
225
+ reference_frame: refFrame,
167
226
  verdict: c.verdict,
168
227
  category: mc.category,
169
228
  structure: c.structure,
@@ -175,7 +234,9 @@ export async function qcSnapshot(dbPath, snapshotId, opts = {}) {
175
234
  };
176
235
  }
177
236
  lineage.writeVerdict(dbPath, v);
178
- results.push(v);
237
+ // The sampling note rides on the result (not the verdict row): where the
238
+ // frame came from is part of the evidence, not part of the verdict.
239
+ results.push(pick.note ? { ...v, sample_note: pick.note, sample_offset: pick.offset } : v);
179
240
  scanned += 1;
180
241
  }
181
242
  const counts = {};
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * ffmpeg sampler adapter for frame-QC (Phase 9 wiring). Builds the two injected
3
3
  * samplers qcSnapshot expects, from real media:
4
- * sampleReference(cut) — the reference render at the cut's record position (burn-in
5
- * masked, brightness-robust).
6
- * sampleConform(cut) — the highres source at oracle_source_frame, scaled by the
4
+ * sampleReference(cut) — the reference render at the cut's QC record frame (the record
5
+ * start, or the first frame clear of a transition window — E107), burn-in masked.
6
+ * sampleConform(cut) — the highres source at the matching QC source frame, scaled by the
7
7
  * corrected zoom and centre-cropped to the sequence frame (the picture Resolve
8
8
  * shows). Residual pan is left to classify's findOffset → OFFSET (still flagged).
9
9
  *
@@ -42,8 +42,9 @@ export function makeSamplers(opts = {}) {
42
42
  let n = 0;
43
43
 
44
44
  const sampleReference = async (cut) => {
45
- if (cut.record_start == null) return null;
46
- const frame = cut.record_start + (opts.refOffset || 0);
45
+ const recFrame = cut.qc_record_frame ?? cut.record_start;
46
+ if (recFrame == null) return null;
47
+ const frame = recFrame + (opts.refOffset || 0);
47
48
  const p = path.join(tmp, `ref-${n}.png`);
48
49
  if (!grab(opts.referenceMovie, frame / fps, p, `scale=${DW}:${DH}`, opts.ffmpegPath)) return null;
49
50
  // classify()/referenceIsBlank() consume a raw Float64Array — unwrap the decode result.
@@ -52,13 +53,14 @@ export function makeSamplers(opts = {}) {
52
53
 
53
54
  const sampleConform = async (cut) => {
54
55
  const src = (opts.mediaMap && opts.mediaMap[cut.source_basename]) || cut.source_path;
55
- if (!src || cut.oracle_source_frame == null) return null;
56
+ const srcFrame = cut.qc_source_frame ?? cut.oracle_source_frame;
57
+ if (!src || srcFrame == null) return null;
56
58
  const zoom = cut.scale_corrected != null ? cut.scale_corrected / 100 : 1;
57
59
  const H = Math.round(opts.seqH * zoom);
58
60
  const W = Math.round(H * (opts.hrW / opts.hrH));
59
61
  const vf = `scale=${W}:${H},crop=${opts.seqW}:${opts.seqH}:(iw-${opts.seqW})/2:(ih-${opts.seqH})/2,scale=${DW}:${DH}`;
60
62
  const p = path.join(tmp, `conf-${n++}.png`);
61
- if (!grab(src, cut.oracle_source_frame / fps, p, vf, opts.ffmpegPath)) return null;
63
+ if (!grab(src, srcFrame / fps, p, vf, opts.ffmpegPath)) return null;
62
64
  // classify() consumes a raw Float64Array — unwrap the decode result.
63
65
  return (await decodeGrayNormalized(p, { width: DW, height: DH })).data;
64
66
  };
@@ -90,7 +90,7 @@ const reportSchema = z.object({ meta: z.object({}).passthrough().optional(), cut
90
90
  export const conformTool = {
91
91
  name: 'conform',
92
92
  description:
93
- 'Conform/relink QC engine (offline core). Actions: parse_geometry, oracle_derive, list_targets, repair_ladder, build_manifest, analyze_media (ffprobe target media → res/aspect/codec/bitdepth/fps + quality rank), relink_scalefix (surgical: repoint proxy→highres pathurls + fix the scale double-count per clip, preserving retimes/tracks/audio byte-for-byte; index MULTIPLE footage roots via footageDirs, PROXY excluded; returns warnings.leftOnProxyOrTest + unresolvedSources so a source stuck on its proxy cannot slip through), report; verify (gated on optional sharp); fix_reverse_clip (LIVE DB patch — restore a reversed clip dropped on import: copy a generic reverse MediaTimemapBA blob + set the mirrored "In"; SQLite or PostgreSQL; project must be CLOSED; locate→probe→calibrate K→apply); snapshot (sequence LINEAGE store — ingest_xml/ingest_live an editorial-or-conform XML or a live timeline into one content-hashed schema, then list/diff/show/rollback_plan; the diff is the incremental frame-QC worklist); qc (comprehensive per-cut frame diff vs the reference render → MATCH/OFFSET/WRONG/REF_OFFLINE/UNREADABLE cached per snapshot; conform frame is sampled at the oracle source frame AND scale-corrected to the edit framing before diffing, so reversed/scaled clips compare apples-to-apples; REF_OFFLINE = the reference is black/flat at that record position because the shot was offline upstream in editorial — inconclusive, NOT a conform error; op run [ffmpeg+sharp] / markers [red conform / yellow turnover / blue ref-offline / cyan review marker plan] / propagate [carry unchanged verdicts to a new snapshot]). Frame-math is the truth, not filename matches.',
93
+ 'Conform/relink QC engine (offline core). Actions: parse_geometry, oracle_derive, list_targets, repair_ladder, build_manifest, analyze_media (ffprobe target media → res/aspect/codec/bitdepth/fps + quality rank), relink_scalefix (surgical: repoint proxy→highres pathurls + fix the scale double-count per clip, preserving retimes/tracks/audio byte-for-byte; index MULTIPLE footage roots via footageDirs, PROXY excluded; returns warnings.leftOnProxyOrTest + unresolvedSources so a source stuck on its proxy cannot slip through), report; verify (gated on optional sharp); fix_reverse_clip (LIVE DB patch — restore a reversed clip dropped on import: copy a generic reverse MediaTimemapBA blob + set the mirrored "In"; SQLite or PostgreSQL; project must be CLOSED; locate→probe→calibrate K→apply); snapshot (sequence LINEAGE store — ingest_xml/ingest_live an editorial-or-conform XML or a live timeline into one content-hashed schema, then list/diff/show/rollback_plan; the diff is the incremental frame-QC worklist; ingest_xml reads Resolve\'s OWN FCP7 export: `-1` clip edges resolve to the adjacent transition\'s junction in record order, each cut records the transition windows its edges sit in, and a missing pproTicksIn (Resolve writes none — measured) falls back to `<in>` for the oracle frame instead of losing it, reported as resolvedEdges/unresolvedEdges/ticksAbsent); qc (comprehensive per-cut frame diff vs the reference render → MATCH/OFFSET/WRONG/REF_OFFLINE/UNREADABLE cached per snapshot; each cut is compared at the first frame CLEAR of its transition windows — inside a window the reference is a blend, or black for a fade — with the source frame advanced at the cut\'s speed, and the result says where it sampled (sample_note); a flat/untextured frame is UNREADABLE (review), never a fake MATCH; conform frame is sampled at the oracle source frame AND scale-corrected to the edit framing before diffing, so reversed/scaled clips compare apples-to-apples; REF_OFFLINE = the reference is black/flat at that record position because the shot was offline upstream in editorial — inconclusive, NOT a conform error; op run [ffmpeg+sharp] / markers [red conform / yellow turnover / blue ref-offline / cyan review marker plan] / propagate [carry unchanged verdicts to a new snapshot]). Frame-math is the truth, not filename matches.',
94
94
  async handler({ action, args }) {
95
95
  if (action === 'parse_geometry') {
96
96
  const p = parseSchema.parse(args);
@@ -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/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).',
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 (paired in record order) + Solid Color generators, XMEML audio-track transitionitems as audio cross-fades on numbered lanes A/A2/…, 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);
@@ -11,8 +11,10 @@
11
11
  *
12
12
  * Output per clip:
13
13
  * { seqstart, seqend, xml_in, xml_out, pproTicksIn, pproTicksOut,
14
- * is_subclip, subclip_startoffset, scale_premiere, fileId,
15
- * source_basename, srcW, srcH }
14
+ * is_subclip, subclip_startoffset, scale_premiere, speed, reverse, fileId,
15
+ * source_basename, srcW, srcH, edges_resolved, transition_in, transition_out }
16
+ * (-1 edges from Resolve's FCP7 writer resolve to the adjacent transition's
17
+ * junction; transition_in/out are the windows the clip's edges sit in)
16
18
  *
17
19
  * Pure: takes an XML string, returns plain data. No IO.
18
20
  */
@@ -146,17 +148,24 @@ function captureTransform(clipitem) {
146
148
  };
147
149
  }
148
150
 
149
- /** The video-track clipitems, in order. */
150
- function videoClipitems(sequence) {
151
- const tracks = asArray(
152
- sequence && sequence.media && sequence.media.video && sequence.media.video.track,
153
- );
151
+
152
+ /**
153
+ * A track's transitionitems as junctions: the frame a `-1` clip edge resolves
154
+ * to (alignment-dependent) plus the span. Alignment vocabulary per Resolve's
155
+ * writer: start/start-black → span start, end/end-black → span end, center
156
+ * (or absent) → span center.
157
+ */
158
+ function trackJunctions(track) {
154
159
  const out = [];
155
- for (const track of tracks) {
156
- for (const ci of asArray(track.clipitem)) {
157
- // A real clip has a record position + a source in-point.
158
- if (ci && ci.start !== undefined && ci.in !== undefined) out.push(ci);
159
- }
160
+ for (const tr of asArray(track && track.transitionitem)) {
161
+ if (!tr) continue;
162
+ const s0 = num(tr.start);
163
+ const e0 = num(tr.end);
164
+ if (s0 == null || e0 == null) continue;
165
+ const al = String(tr.alignment || 'center').toLowerCase();
166
+ const frame = al === 'start-black' || al === 'start' ? s0 : al === 'end-black' || al === 'end' ? e0 : Math.round((s0 + e0) / 2);
167
+ const effect = tr.effect || {};
168
+ out.push({ frame, start: s0, end: e0, alignment: al, effectid: effect.effectid != null ? String(effect.effectid) : null });
160
169
  }
161
170
  return out;
162
171
  }
@@ -217,37 +226,95 @@ function parseGeometry(xml) {
217
226
  const fileDefs = {};
218
227
  collectFileDefs(doc, fileDefs);
219
228
 
220
- // Pass 2 — capture per-clip geometry.
229
+ // Pass 2 — capture per-clip geometry, track by track so each clip can be
230
+ // resolved against ITS track's transitionitems.
231
+ //
232
+ // Resolve's FCP7 writer (measured, E105/E107): a clipitem edge that sits
233
+ // under a transitionitem is written as `-1` and means "this edge is the
234
+ // adjacent transition's JUNCTION" — the span center for alignment center,
235
+ // the span start/end for start(-black)/end(-black). `out - in` is the
236
+ // RECORD duration even under a retime, which anchors the missing edge. A
237
+ // -1 START edge's <in> is the source at the OVERLAP start (the material
238
+ // begins under the transition), so <in>/<out> advance by (junction -
239
+ // span start) to stay record-aligned. Left raw, a -1 record position poisons
240
+ // every consumer downstream (oracle null, reference sampled at frame 0).
221
241
  const clips = [];
222
- for (const ci of videoClipitems(sequence)) {
223
- const fileNode = ci.file;
224
- const fileId = fileNode && fileNode['@_id'] != null ? String(fileNode['@_id']) : null;
225
- const def = fileId ? fileDefs[fileId] : null;
226
- const sub = ci.subclipinfo;
227
- const startoffset = sub ? num(sub.startoffset) : null;
228
- const endoffset = sub ? num(sub.endoffset) : null;
229
- const transform = captureTransform(ci);
230
- clips.push({
231
- seqstart: num(ci.start),
232
- seqend: num(ci.end),
233
- xml_in: num(ci.in),
234
- xml_out: num(ci.out),
235
- pproTicksIn: num(ci.pproTicksIn),
236
- pproTicksOut: num(ci.pproTicksOut),
237
- is_subclip: startoffset != null,
238
- subclip_startoffset: startoffset != null ? startoffset : 0,
239
- subclip_endoffset: endoffset != null ? endoffset : 0,
240
- scale_premiere: scalarParam(ci, 'scale'),
241
- speed: captureSpeed(ci),
242
- reverse: captureReverse(ci),
243
- center: transform.center,
244
- rotation: transform.rotation,
245
- crop: transform.crop,
246
- fileId,
247
- source_basename: def ? def.basename : null,
248
- srcW: def ? def.srcW : null,
249
- srcH: def ? def.srcH : null,
250
- });
242
+ const tracks = asArray(sequence && sequence.media && sequence.media.video && sequence.media.video.track);
243
+ for (const track of tracks) {
244
+ const junctions = trackJunctions(track);
245
+ const js = junctions.map((j) => j.frame).sort((a, b) => a - b);
246
+ // Clips are written in record order, so a clip whose BOTH edges are -1
247
+ // can only pair junctions at or after the previous clip's end. Without
248
+ // the cursor, two equal-length clips under three centered transitions
249
+ // both resolve to the FIRST pair (measured, E107).
250
+ let cursor = 0;
251
+ for (const ci of asArray(track.clipitem)) {
252
+ // A real clip has a record position + a source in-point.
253
+ if (!(ci && ci.start !== undefined && ci.in !== undefined)) continue;
254
+ const fileNode = ci.file;
255
+ const fileId = fileNode && fileNode['@_id'] != null ? String(fileNode['@_id']) : null;
256
+ const def = fileId ? fileDefs[fileId] : null;
257
+ const sub = ci.subclipinfo;
258
+ const startoffset = sub ? num(sub.startoffset) : null;
259
+ const endoffset = sub ? num(sub.endoffset) : null;
260
+ const transform = captureTransform(ci);
261
+ const start0 = num(ci.start);
262
+ const end0 = num(ci.end);
263
+ const inF = num(ci.in);
264
+ const outF = num(ci.out);
265
+ const dur = inF != null && outF != null ? outF - inF : null;
266
+ let start = start0;
267
+ let end = end0;
268
+ let inAdj = 0;
269
+ if (start === -1 && end !== -1 && end != null && dur != null) start = end - dur;
270
+ else if (end === -1 && start !== -1 && start != null && dur != null) end = start + dur;
271
+ else if (start === -1 && end === -1 && dur != null && js.length) {
272
+ const pair = js.find((a) => a >= cursor && js.includes(a + dur));
273
+ if (pair != null) {
274
+ start = pair;
275
+ end = pair + dur;
276
+ }
277
+ }
278
+ if (end != null && end >= 0) cursor = Math.max(cursor, end);
279
+ if (start0 === -1 && start != null && start >= 0) {
280
+ const j = junctions.find((jj) => jj.frame === start);
281
+ if (j) inAdj = start - j.start;
282
+ }
283
+ const edgesResolved = start0 === -1 || end0 === -1;
284
+ const unresolved = (start0 === -1 && (start == null || start < 0)) || (end0 === -1 && (end == null || end < 0));
285
+ // The transition windows this clip's edges sit in: the incoming window
286
+ // contains the record start, the outgoing window contains the record
287
+ // end. Downstream frame-QC must sample CLEAR of both (the reference is
288
+ // a blend — or black, for a fade — inside them).
289
+ const win = (j) => (j ? { start: j.start, end: j.end, junction: j.frame, alignment: j.alignment, effectid: j.effectid } : null);
290
+ const inWin = start != null && start >= 0 ? junctions.find((j) => start >= j.start && start <= j.end) : null;
291
+ const outWin = end != null && end >= 0 ? junctions.find((j) => end >= j.start && end <= j.end) : null;
292
+ clips.push({
293
+ seqstart: start,
294
+ seqend: end,
295
+ xml_in: inF != null ? inF + inAdj : inF,
296
+ xml_out: outF != null ? outF + inAdj : outF,
297
+ pproTicksIn: num(ci.pproTicksIn),
298
+ pproTicksOut: num(ci.pproTicksOut),
299
+ is_subclip: startoffset != null,
300
+ subclip_startoffset: startoffset != null ? startoffset : 0,
301
+ subclip_endoffset: endoffset != null ? endoffset : 0,
302
+ scale_premiere: scalarParam(ci, 'scale'),
303
+ speed: captureSpeed(ci),
304
+ reverse: captureReverse(ci),
305
+ center: transform.center,
306
+ rotation: transform.rotation,
307
+ crop: transform.crop,
308
+ fileId,
309
+ source_basename: def ? def.basename : null,
310
+ srcW: def ? def.srcW : null,
311
+ srcH: def ? def.srcH : null,
312
+ edges_resolved: edgesResolved,
313
+ edges_unresolved: unresolved,
314
+ transition_in: win(inWin),
315
+ transition_out: win(outWin),
316
+ });
317
+ }
251
318
  }
252
319
 
253
320
  return { sequence: sequenceInfo(sequence), fileDefs, clips, transitions: videoTransitions(sequence) };
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "2.164.0"
90
+ VERSION = "2.166.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.164.0"
14
+ VERSION = "2.166.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -13885,8 +13885,10 @@ def _capture_media_template(r, pm, p: Dict[str, Any]) -> Dict[str, Any]:
13885
13885
  <Element> + the timeline clips' <MediaRef> id. drt.assemble then
13886
13886
  TRANSPLANTS the cached element, which renders identically to native.
13887
13887
 
13888
- Switches the current project to a scratch project during capture and
13889
- switches back; requires the media file to exist locally.
13888
+ Saves the current project, switches to a scratch project during capture
13889
+ and switches back (an unsaved current project would otherwise be LOST —
13890
+ CreateProject replaces it and the restore cannot load an unsaved name);
13891
+ requires the media file to exist locally.
13890
13892
  """
13891
13893
  import hashlib
13892
13894
  import zipfile as _zipfile
@@ -13913,6 +13915,21 @@ def _capture_media_template(r, pm, p: Dict[str, Any]) -> Dict[str, Any]:
13913
13915
  except Exception:
13914
13916
  previous = None
13915
13917
 
13918
+ # CreateProject(scratch) replaces the CURRENT project, and a current
13919
+ # project that was never saved is simply gone afterwards — the restore
13920
+ # below cannot LoadProject a name that exists only in memory (measured
13921
+ # 2026-09-01, E108: a freshly created project with two imported timelines
13922
+ # vanished; Resolve fell back to a transient "Untitled Project"). Save it
13923
+ # first so the switch-back lands on the caller's own project.
13924
+ if previous:
13925
+ try:
13926
+ if not pm.SaveProject():
13927
+ return _err(
13928
+ f"could not save the current project {previous!r} before the capture switches "
13929
+ "away from it — an unsaved project would be lost; save it (project_manager.save) and retry"
13930
+ )
13931
+ except Exception as exc: # pragma: no cover - API surprise
13932
+ return _err(f"SaveProject before capture raised: {exc}")
13916
13933
  scratch = f"_mcp_media_tpl_{int(time.time())}"
13917
13934
  proj = pm.CreateProject(scratch)
13918
13935
  if not proj:
@@ -19829,7 +19846,7 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
19829
19846
  capture_media_template(media_path) -> {cache_path, media_ref}
19830
19847
  Captures the file's NATIVE Resolve media descriptors into
19831
19848
  ~/.config/davinci-resolve-mcp/media-templates/ via a disposable
19832
- project (switches projects during capture and switches back).
19849
+ project (saves the current project, switches to a scratch project during capture and switches back — an unsaved current project would otherwise be lost).
19833
19850
  Offline drt.assemble then transplants them — the only measured way
19834
19851
  authored media actually RENDERS; synthesized descriptors import and
19835
19852
  read back fine but render black or 'Full resolution media not found'.
@@ -2794,6 +2794,63 @@ API_TRUTH: List[Dict[str, Any]] = [
2794
2794
  "submit": "missing",
2795
2795
  "mitigation": ["editorial.verify_roundtrip audioNotInExport", "timeline.export_timeline_checked"],
2796
2796
  },
2797
+ {
2798
+ "symbol": "ProjectManager.CreateProject (discards an unsaved current project)",
2799
+ "object": "ProjectManager",
2800
+ "signature": "(projectName) -> Project",
2801
+ "reality": "CreateProject replaces the CURRENT project with the new "
2802
+ "one. If the current project was never saved it is simply "
2803
+ "gone — no dialog headless, no error, and a later "
2804
+ "LoadProject of its name fails because the name existed "
2805
+ "only in memory (measured on Studio 19.1.3.7, E108: a "
2806
+ "project created via CreateProject with two imported "
2807
+ "timelines vanished when a media-template capture created "
2808
+ "its scratch project; the restore landed on a transient "
2809
+ "\"Untitled Project\" that is not in the project list "
2810
+ "either).",
2811
+ "recommended": "SaveProject() before any CreateProject/LoadProject "
2812
+ "switch when the current project may be unsaved; the "
2813
+ "MCP's capture_media_template now does and refuses on a "
2814
+ "failed save.",
2815
+ "tags": ["project", "lifecycle", "silent-failure", "headless"],
2816
+ "submit": "missing",
2817
+ "mitigation": ["media_pool.capture_media_template saves first", "project_manager.save"],
2818
+ },
2819
+ {
2820
+ "symbol": "Timeline.Export EXPORT_FCP_7_XML (no pproTicksIn, -1 edges under transitions)",
2821
+ "object": "Timeline",
2822
+ "signature": "(filePath, EXPORT_FCP_7_XML, EXPORT_NONE) -> bool",
2823
+ "reality": "Resolve's FCP7 XML writer (measured on Studio 19.1.3.7, "
2824
+ "E107, verbatim export kept as a fixture) emits NO "
2825
+ "pproTicksIn/pproTicksOut on any clipitem — the Premiere "
2826
+ "tick fields a Premiere-shaped oracle treats as the "
2827
+ "authoritative source position are simply absent, so a "
2828
+ "reader that requires them derives no source frame for "
2829
+ "ANY cut of a Resolve export. Every clipitem edge that "
2830
+ "sits under a transitionitem is written as -1 and means "
2831
+ "the transition's junction (span center for alignment "
2832
+ "center; the writer emitted `center` for every dissolve "
2833
+ "and fade authored centered), `out - in` is the record "
2834
+ "duration, and a -1 START edge's <in> is the source at the "
2835
+ "overlap start. With three centered transitions two "
2836
+ "equal-length clips both carry -1/-1 edges, so a reader "
2837
+ "must pair junctions in record order — the first pair "
2838
+ "that fits places both clips at the same position. Black "
2839
+ "legs are Solid Color generatoritems whose -1 edge "
2840
+ "resolves the same way.",
2841
+ "recommended": "Read <in> as the literal source frame (Resolve reads "
2842
+ "and writes it that way), record-align it by the "
2843
+ "junction-minus-span-start offset on a -1 start, and "
2844
+ "walk -1/-1 clips with a record-order cursor. "
2845
+ "conform.snapshot ingest_xml and "
2846
+ "editorial.parse_interchange both do; the frame QC "
2847
+ "then samples each cut CLEAR of its transition "
2848
+ "windows (inside one the reference is a blend, or "
2849
+ "black for a fade).",
2850
+ "tags": ["timeline", "export", "xml", "fcp7", "transitions", "silent-failure"],
2851
+ "submit": "missing",
2852
+ "mitigation": ["conform.snapshot ingest_xml", "editorial.parse_interchange", "conform.qc"],
2853
+ },
2797
2854
  {
2798
2855
  "symbol": "Project.SetRenderSettings ExportSubtitle/SubtitleFormat (inert on 19.x)",
2799
2856
  "object": "Project",