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 +75 -0
- package/README.md +1 -1
- package/README.zh-CN.md +2 -2
- package/docs/kernels/timeline-conform-interchange-kernel.md +2 -1
- package/docs/kernels/timeline-edit-kernel.md +4 -2
- package/docs/reference/api-limitations.md +9 -1
- package/install.py +1 -1
- package/package.json +1 -1
- package/resolve-advanced/README.md +6 -2
- package/resolve-advanced/server/author-interchange.mjs +9 -1
- package/resolve-advanced/server/editorial.mjs +273 -49
- package/resolve-advanced/server/tools/editorial.mjs +1 -1
- package/src/granular/common.py +1 -1
- package/src/server.py +21 -1
- package/src/utils/api_truth.py +30 -0
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
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
8
|
[-blue.svg)](#server-modes)
|
package/README.zh-CN.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.md) | 简体中文
|
|
4
4
|
|
|
5
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
8
|
[-blue.svg)](#服务器模式)
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
[](https://www.python.org/downloads/)
|
|
13
13
|
[](https://opensource.org/licenses/MIT)
|
|
14
14
|
|
|
15
|
-
> 本翻译对应 v2.
|
|
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
|
|
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
|
|
192
|
-
|
|
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:**
|
|
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.
|
|
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
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
282
|
-
const
|
|
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 (
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
602
|
+
const P = pairEvents(oldEvents, newEvents, recTol);
|
|
412
603
|
const changes = [];
|
|
413
604
|
|
|
414
|
-
for (const ne of
|
|
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
|
|
445
|
-
const replacement = changes.find((c) => c.kind === 'new' && c.track ===
|
|
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 =
|
|
449
|
-
replacement.oldRecIn =
|
|
627
|
+
replacement.oldSource = oe.source;
|
|
628
|
+
replacement.oldRecIn = oe.recIn;
|
|
450
629
|
} else {
|
|
451
|
-
changes.push({ kind: 'gone', source:
|
|
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 {
|
|
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
|
|
468
|
-
for (const
|
|
469
|
-
|
|
470
|
-
|
|
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
|
|
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);
|
package/src/granular/common.py
CHANGED
|
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
|
|
|
87
87
|
handlers=[logging.StreamHandler()],
|
|
88
88
|
)
|
|
89
89
|
|
|
90
|
-
VERSION = "2.
|
|
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.
|
|
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
|
|
package/src/utils/api_truth.py
CHANGED
|
@@ -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",
|