davinci-resolve-mcp 2.163.0 → 2.165.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 +5 -2
- 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 +8 -3
- package/resolve-advanced/server/editorial.mjs +199 -43
- package/resolve-advanced/server/lineage-db.mjs +36 -8
- package/resolve-advanced/server/qc-frame.mjs +68 -7
- package/resolve-advanced/server/qc-sampler.mjs +9 -7
- package/resolve-advanced/server/tools/conform.mjs +1 -1
- package/resolve-advanced/server/tools/editorial.mjs +1 -1
- package/resolve-advanced/vendor/conform-qc/parse/xmeml-geometry.js +109 -42
- package/src/granular/common.py +1 -1
- package/src/server.py +1 -1
- package/src/utils/api_truth.py +35 -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.165.0 — E107: frame QC reads Resolve's own XML and samples clear of transitions
|
|
6
|
+
|
|
7
|
+
The lineage store's `ingest_xml` was measured against a verbatim
|
|
8
|
+
`EXPORT_FCP_7_XML` of a fade-in → clip → centered dissolve → clip → fade-out
|
|
9
|
+
timeline (rendered and luma-verified: 18→123 over the fade-in, a blend over
|
|
10
|
+
96–119, 230→21 over the fade-out). Every transition-adjacent cut landed at
|
|
11
|
+
record `-1` with no oracle frame — the reference sampled at frame 0 read
|
|
12
|
+
black and the conform side could not be sampled at all, so each read as a
|
|
13
|
+
false yellow turnover. Two laws of Resolve's writer explain it.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **`-1` clip edges resolve to junctions in the lineage ingest** (the E105
|
|
18
|
+
law the editorial parser already knew), and with a record-order cursor:
|
|
19
|
+
under three centered transitions two equal-length clips both carry
|
|
20
|
+
`-1/-1` edges, and the first junction pair that fits placed BOTH clips at
|
|
21
|
+
the same position — in the editorial parser too. Both parsers now walk
|
|
22
|
+
clips with a cursor; the verbatim export is a permanent fixture.
|
|
23
|
+
- **Resolve writes no `pproTicksIn`.** The oracle insisted on Premiere ticks
|
|
24
|
+
and derived no source frame for any cut of a Resolve export. When ticks
|
|
25
|
+
are absent, `<in>` (record-aligned by the `-1` resolution) is the oracle
|
|
26
|
+
frame; the ingest reports `ticksAbsent`, `resolvedEdges`, `unresolvedEdges`.
|
|
27
|
+
- **Frame QC samples clear of transition windows.** Each cut records the
|
|
28
|
+
windows its edges sit in (`cuts.transition`, plus `cuts.speed`; existing
|
|
29
|
+
sidecars migrate in place) and `qc` compares the first frame past the
|
|
30
|
+
incoming window and before the outgoing one, advancing the source frame
|
|
31
|
+
at the cut's speed (reverse walks backward). Measured on the render:
|
|
32
|
+
structure 0.982 at the dissolve junction → 0.999 clear of it; the result
|
|
33
|
+
carries `sample_note` saying where it looked, and a cut swallowed whole by
|
|
34
|
+
its windows samples its midpoint and says so.
|
|
35
|
+
|
|
36
|
+
### Measured (filed in api-limitations)
|
|
37
|
+
|
|
38
|
+
- `EXPORT_FCP_7_XML` writes no `pproTicksIn`/`pproTicksOut`, `-1` on every
|
|
39
|
+
transition-adjacent edge (junction), `center` alignment for every
|
|
40
|
+
centered-authored dissolve and fade, and Solid Color generatoritems for
|
|
41
|
+
black legs. A flat/untextured frame (a white card) is `UNREADABLE` to the
|
|
42
|
+
brightness-robust classifier — an honest review, not a false verdict.
|
|
43
|
+
|
|
44
|
+
## What's New in v2.164.0 — E106: the changelist sees junctions
|
|
45
|
+
|
|
46
|
+
`editorial.turnover_changelist` diffed clips and was blind to everything
|
|
47
|
+
that happens *between* them. Measured on a faded, dissolved, retimed EDL
|
|
48
|
+
pair: a 24→12-frame dissolve change reported nothing, both dropped fades
|
|
49
|
+
read as "BL gone", and the zero-length CMX carrier line of a dissolve's
|
|
50
|
+
outgoing side read as "B002 gone". The timing guards paired first-row-wins,
|
|
51
|
+
so an identical cut with one A2 leg dropped flagged a FALSE flattened
|
|
52
|
+
retime and never flagged the audio drop (`track === 'A'` missed `A2`).
|
|
53
|
+
|
|
54
|
+
### Fixed
|
|
55
|
+
|
|
56
|
+
- **The changelist diffs junctions.** `transition_added` /
|
|
57
|
+
`transition_dropped` / `transition_changed` entries name the outgoing and
|
|
58
|
+
incoming sources, classify fade in/out vs dissolve, and carry the span and
|
|
59
|
+
duration/type/pre-roll deltas — a dissolve reshaped from centered to
|
|
60
|
+
start-at-cut is a change even when both clips stayed put. Spans derive
|
|
61
|
+
exactly as the bridge places them (CMX start-at-cut, OTIO `in_offset`,
|
|
62
|
+
XMEML/PrProj `recStart`, AAF overlap start).
|
|
63
|
+
- **Carrier lines and fade legs never read as sources.** Zero-length events
|
|
64
|
+
(CMX outgoing marker lines, the synthesized BL fade slugs) and the black
|
|
65
|
+
legs a transition references fold into the junction diff; the changelist
|
|
66
|
+
reports how many in `carriersFolded`. A cut diffed against itself is
|
|
67
|
+
silent on every axis.
|
|
68
|
+
- **`timingGuards` pairs instance-to-instance.** Same track+source, closest
|
|
69
|
+
record position, consumed once — the pairing the changelist already used —
|
|
70
|
+
so a source cut twice at two speeds no longer cross-compares. Flags carry
|
|
71
|
+
`recIn`. Dropped-audio detection reads any `A`-track (`A`, `A2`, …), and a
|
|
72
|
+
lost fade or dissolve is now a timing lie (`transition_dropped`).
|
|
73
|
+
|
|
74
|
+
### Added
|
|
75
|
+
|
|
76
|
+
- `editorial.mjs` exports `transitionSpan(event)` and
|
|
77
|
+
`listTransitions(events)` — the junction model shared by the changelist
|
|
78
|
+
and the guards.
|
|
79
|
+
|
|
5
80
|
## What's New in v2.163.0 — E105: QC through every export format
|
|
6
81
|
|
|
7
82
|
One faded, dissolved, retimed conform exported from Resolve three ways — OTIO,
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
English | [简体中文](README.zh-CN.md)
|
|
4
4
|
|
|
5
|
-
[](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.165.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
|
|
16
16
|
|
|
17
17
|
一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
|
|
18
18
|
|
|
@@ -135,14 +135,17 @@ 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
|
|
142
144
|
inside `.drp`/`.drt` as `<OfflineClip>` entries; patch them here.
|
|
143
145
|
- **`editorial`** — `parse_interchange` (EDL/OTIO/XMEML natively; **AAF via pyaaf2**,
|
|
144
146
|
multi-layer Avid turnovers included — honest refuse only when pyaaf2 is absent),
|
|
145
|
-
`turnover_changelist` (moved/retimed/replaced/new/gone
|
|
147
|
+
`turnover_changelist` (moved/retimed/trimmed/replaced/new/gone plus transitions
|
|
148
|
+
added/dropped/changed with fade in/out, with timing guards),
|
|
146
149
|
`conform_manifest`, `marker_roundtrip`.
|
|
147
150
|
- **`drt` / `project_db`** — timeline file authoring and DB patching.
|
|
148
151
|
|
|
@@ -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:** 37 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,14 @@ 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
|
+
### Timeline.Export EXPORT_FCP_7_XML (no pproTicksIn, -1 edges under transitions)
|
|
313
|
+
|
|
314
|
+
- **Object:** `Timeline`
|
|
315
|
+
- **Signature:** `(filePath, EXPORT_FCP_7_XML, EXPORT_NONE) -> bool`
|
|
316
|
+
- **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.
|
|
317
|
+
- **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).
|
|
318
|
+
- **Tags:** timeline, export, xml, fcp7, transitions, silent-failure
|
|
319
|
+
|
|
312
320
|
## Bugs / Unreliable Behavior (please fix)
|
|
313
321
|
|
|
314
322
|
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.165.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
|
@@ -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
|
|
@@ -105,7 +106,11 @@ Each dispatches on an `action`. Highlights:
|
|
|
105
106
|
the `otio` and `edl` targets only — this flat `drt` target has no per-clip speed field and reports
|
|
106
107
|
every retime it flattens in `flattened`; for a `.drt` that AUTHORS retimes (plus dissolves,
|
|
107
108
|
multi-track, audio) use `drt.assemble_from_interchange` instead),
|
|
108
|
-
`turnover_changelist` (moved/retimed/replaced/new/gone
|
|
109
|
+
`turnover_changelist` (moved/retimed/trimmed/replaced/new/gone PLUS the junction diff —
|
|
110
|
+
`transition_added`/`transition_dropped`/`transition_changed` with fade in/out, outgoing/incoming, span and
|
|
111
|
+
duration/type/pre-roll deltas; CMX carrier lines and BL fade legs fold into the junctions instead of
|
|
112
|
+
reading as gone/new sources; events pair instance-to-instance by closest record position — + timing
|
|
113
|
+
silent-lie guards incl. lost transitions and dropped audio on any A-track), `conform_manifest`,
|
|
109
114
|
`marker_roundtrip`.
|
|
110
115
|
- **`provenance`** — provenance / audit: `gallery_lineage`, `grade_provenance` ("why is this graded this
|
|
111
116
|
way"), `cdl_export` (+ `cdl_diff`, round-trip asserted), `revision_tracking`, `episode_report`.
|
|
@@ -196,7 +201,7 @@ plausible-looking short event list. Three limits are deliberate:
|
|
|
196
201
|
mattes) applies to what shows through from below and references no media of its own. OTIO materializes
|
|
197
202
|
such layers as tracks of gaps, so its track count can exceed the number of layers with media.
|
|
198
203
|
- **Only `NestedScope` layers are numbered.** Non-nested slots keep the flat `V`/`A` label, which is what
|
|
199
|
-
`editorial.mjs`'s
|
|
204
|
+
`editorial.mjs`'s `/^A\d*$/` audio-follows-video heuristic reads (`A`, `A2`, … all count as audio).
|
|
200
205
|
|
|
201
206
|
## Provenance & license
|
|
202
207
|
Vendored libraries are clean offline format-interop and deterministic compute code: no secrets, no
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
@@ -383,8 +391,9 @@ export function parseXMEMLEvents(xml, opts = {}) {
|
|
|
383
391
|
const gens = t.generatoritem ? (Array.isArray(t.generatoritem) ? t.generatoritem : [t.generatoritem]) : [];
|
|
384
392
|
for (const g of gens) {
|
|
385
393
|
if (!/solid color|black/i.test(String(g.name || ''))) continue;
|
|
386
|
-
walk([{ ...g, name: 'BL', filter: undefined, marker: undefined }], label);
|
|
394
|
+
walk([{ ...g, name: 'BL', filter: undefined, marker: undefined }], label, false);
|
|
387
395
|
}
|
|
396
|
+
clipCursor = 0;
|
|
388
397
|
if (t.clipitem) walk(t.clipitem, label);
|
|
389
398
|
currentJunctions = [];
|
|
390
399
|
// <transitionitem> siblings: attach each to the INCOMING clip event —
|
|
@@ -476,38 +485,139 @@ export function parseInterchange(format, content, opts = {}) {
|
|
|
476
485
|
|
|
477
486
|
// ── turnover_changelist ────────────────────────────────────────────────
|
|
478
487
|
const sig = (e) => `${e.track}:${e.source}`;
|
|
488
|
+
const isBlackSource = (s) => /^(bl|black|solid color)$/i.test(String(s || '').trim());
|
|
489
|
+
const isAudioTrack = (t) => /^A\d*$/i.test(String(t || ''));
|
|
490
|
+
// A zero-length event is a CARRIER, never a cut: the outgoing marker line of
|
|
491
|
+
// a CMX dissolve pair, or the zero-length BL slug every parser synthesizes at
|
|
492
|
+
// a fade (E91/E92/E93). It has no picture of its own to be moved/gone/new.
|
|
493
|
+
const isCarrier = (e) => e.recIn != null && e.recOut != null && e.recIn === e.recOut;
|
|
479
494
|
|
|
480
495
|
/**
|
|
481
|
-
*
|
|
496
|
+
* The record SPAN a transition event occupies, derived exactly the way the
|
|
497
|
+
* bridge places it (author-interchange): alignment 'start' → the span begins
|
|
498
|
+
* at the incoming's recIn (CMX start-at-cut / AAF overlap start); OTIO
|
|
499
|
+
* inOffset → that many frames before the cut; XMEML/PrProj recStart → the
|
|
500
|
+
* explicit span start; nothing declared → centered. `junction` is the
|
|
501
|
+
* incoming event's recIn — the same frame verify_roundtrip's fade windows
|
|
502
|
+
* key on.
|
|
503
|
+
* @param {Object} e normalized event carrying `transition`
|
|
504
|
+
*/
|
|
505
|
+
export function transitionSpan(e) {
|
|
506
|
+
const t = e.transition;
|
|
507
|
+
if (!t || e.recIn == null) return null;
|
|
508
|
+
const d = Math.max(0, Number(t.duration) || 0);
|
|
509
|
+
let pre;
|
|
510
|
+
if (t.alignment === 'start') pre = 0;
|
|
511
|
+
else if (t.inOffset != null) pre = Math.max(0, Math.min(d, Number(t.inOffset) || 0));
|
|
512
|
+
else if (t.recStart != null) pre = Math.max(0, Math.min(d, e.recIn - Number(t.recStart)));
|
|
513
|
+
else pre = Math.floor(d / 2);
|
|
514
|
+
const start = e.recIn - pre;
|
|
515
|
+
return { start, end: start + d, junction: e.recIn, pre, duration: d };
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Every junction in an event list as a transition record: span, type, the
|
|
520
|
+
* OUTGOING event (the same-track neighbour whose extent touches the span
|
|
521
|
+
* start — a CMX carrier line, an abutting clip, or the overlapping AAF
|
|
522
|
+
* predecessor) and the INCOMING (the carrier of the `transition` field), with
|
|
523
|
+
* black on either side classified as a fade. Pure over events.
|
|
524
|
+
* @param {Array} events
|
|
525
|
+
* @returns {Array<{track,type,duration,start,end,junction,pre,outgoing,incoming,fade,incomingEvent,outgoingEvent}>}
|
|
526
|
+
*/
|
|
527
|
+
export function listTransitions(events) {
|
|
528
|
+
const out = [];
|
|
529
|
+
for (const e of events) {
|
|
530
|
+
if (!e.transition || !(Number(e.transition.duration) > 0)) continue;
|
|
531
|
+
const span = transitionSpan(e);
|
|
532
|
+
if (!span) continue;
|
|
533
|
+
const cands = events.filter((o) => o !== e && o.track === e.track && o.recIn != null && o.recOut != null
|
|
534
|
+
&& o.recIn <= span.start && o.recOut >= span.start);
|
|
535
|
+
// The latest-ending toucher is the outgoing: a carrier line sits exactly
|
|
536
|
+
// at the span start, an abutting clip ends there, an AAF predecessor
|
|
537
|
+
// overlaps past it. Prefer a picture over black only when both touch.
|
|
538
|
+
cands.sort((a, b) => (b.recOut - a.recOut) || ((events.indexOf(b)) - events.indexOf(a)));
|
|
539
|
+
let outgoingEvent = cands[0] || null;
|
|
540
|
+
if (!outgoingEvent) {
|
|
541
|
+
// Nothing touches the span start (a pre-rolled fade-in at track start
|
|
542
|
+
// whose span begins before frame 0, or a gap-adjacent junction): the
|
|
543
|
+
// outgoing is whatever last ENDED at or before the junction — the
|
|
544
|
+
// zero-length BL slug the parsers synthesize sits exactly there.
|
|
545
|
+
const before = events.filter((o) => o !== e && o.track === e.track && o.recOut != null && o.recOut <= span.junction);
|
|
546
|
+
before.sort((a, b) => (b.recOut - a.recOut) || (events.indexOf(b) - events.indexOf(a)));
|
|
547
|
+
outgoingEvent = before[0] || null;
|
|
548
|
+
}
|
|
549
|
+
const outgoing = outgoingEvent ? outgoingEvent.source : null;
|
|
550
|
+
const incoming = e.source;
|
|
551
|
+
const fade = isBlackSource(outgoing) ? 'in' : isBlackSource(incoming) ? 'out' : null;
|
|
552
|
+
out.push({
|
|
553
|
+
track: e.track, type: e.transition.type || 'dissolve', duration: span.duration,
|
|
554
|
+
start: span.start, end: span.end, junction: span.junction, pre: span.pre,
|
|
555
|
+
outgoing, incoming, fade, incomingEvent: e, outgoingEvent,
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
return out;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Pair old and new PICTURE/AUDIO events once each: same track+source, closest
|
|
563
|
+
* record position wins, consumed on match (a source used twice at two speeds
|
|
564
|
+
* pairs each instance with its own — never first-row-wins). Carriers and the
|
|
565
|
+
* black legs a transition references never enter the pairing: they are the
|
|
566
|
+
* junction's business (listTransitions), not sources.
|
|
567
|
+
*/
|
|
568
|
+
function pairEvents(oldEvents, newEvents, recTol = 1) {
|
|
569
|
+
const cutsOf = (events) => {
|
|
570
|
+
const trs = listTransitions(events);
|
|
571
|
+
const fadeBlack = new Set();
|
|
572
|
+
for (const t of trs) {
|
|
573
|
+
if (t.outgoingEvent && isBlackSource(t.outgoingEvent.source)) fadeBlack.add(t.outgoingEvent);
|
|
574
|
+
if (isBlackSource(t.incomingEvent.source)) fadeBlack.add(t.incomingEvent);
|
|
575
|
+
}
|
|
576
|
+
const cuts = [], carriers = [];
|
|
577
|
+
for (const e of events) {
|
|
578
|
+
if (e.track === 'MARKER') continue;
|
|
579
|
+
if (isCarrier(e) || fadeBlack.has(e)) carriers.push(e);
|
|
580
|
+
else cuts.push(e);
|
|
581
|
+
}
|
|
582
|
+
return { cuts, carriers, transitions: trs };
|
|
583
|
+
};
|
|
584
|
+
const o = cutsOf(oldEvents), n = cutsOf(newEvents);
|
|
585
|
+
const pool = o.cuts.map((e) => ({ e, used: false }));
|
|
586
|
+
const pairs = [], unmatchedNew = [];
|
|
587
|
+
for (const ne of n.cuts) {
|
|
588
|
+
const cands = pool.filter((p) => !p.used && sig(p.e) === sig(ne));
|
|
589
|
+
if (!cands.length) { unmatchedNew.push(ne); continue; }
|
|
590
|
+
cands.sort((a, b) => Math.abs((a.e.recIn ?? 0) - (ne.recIn ?? 0)) - Math.abs((b.e.recIn ?? 0) - (ne.recIn ?? 0)));
|
|
591
|
+
cands[0].used = true;
|
|
592
|
+
pairs.push({ oe: cands[0].e, ne });
|
|
593
|
+
}
|
|
594
|
+
const unmatchedOld = pool.filter((p) => !p.used).map((p) => p.e);
|
|
595
|
+
return { pairs, unmatchedOld, unmatchedNew, old: o, new: n, recTol };
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Diff two normalized event lists → per-event {kind: moved|retimed|trimmed|replaced|new|gone}
|
|
600
|
+
* PLUS per-junction {kind: transition_added|transition_dropped|transition_changed}
|
|
601
|
+
* (fade 'in'/'out' or null for a dissolve). Zero-length carrier lines and the
|
|
602
|
+
* black legs that carry fades fold into the junction diff instead of reading
|
|
603
|
+
* as gone/new sources; a dissolve whose duration, type or span shape changed
|
|
604
|
+
* is a change even when both clips stayed put.
|
|
482
605
|
* @param {Array} oldEvents
|
|
483
606
|
* @param {Array} newEvents
|
|
484
607
|
* @param {{recTolerance?:number}} [opts]
|
|
485
608
|
*/
|
|
486
609
|
export function diffChangelist(oldEvents, newEvents, opts = {}) {
|
|
487
610
|
const recTol = opts.recTolerance ?? 1;
|
|
488
|
-
const
|
|
611
|
+
const P = pairEvents(oldEvents, newEvents, recTol);
|
|
489
612
|
const changes = [];
|
|
490
613
|
|
|
491
|
-
for (const ne of
|
|
492
|
-
// Prefer a same-source match; among those, the closest record position.
|
|
493
|
-
const candidates = oldPool.filter((o) => !o.used && sig(o.e) === sig(ne));
|
|
494
|
-
let match = null;
|
|
495
|
-
if (candidates.length) {
|
|
496
|
-
candidates.sort((a, b) => Math.abs((a.e.recIn ?? 0) - (ne.recIn ?? 0)) - Math.abs((b.e.recIn ?? 0) - (ne.recIn ?? 0)));
|
|
497
|
-
match = candidates[0];
|
|
498
|
-
}
|
|
499
|
-
if (!match) {
|
|
500
|
-
changes.push({ kind: 'new', source: ne.source, track: ne.track, newRecIn: ne.recIn });
|
|
501
|
-
continue;
|
|
502
|
-
}
|
|
503
|
-
match.used = true;
|
|
504
|
-
const oe = match.e;
|
|
614
|
+
for (const { oe, ne } of P.pairs) {
|
|
505
615
|
const deltas = {};
|
|
506
616
|
let kind = 'unchanged';
|
|
507
|
-
if (Math.abs((oe.speed ?? 100) - (ne.speed ?? 100)) > 0.01 || oe.reverse !== ne.reverse) {
|
|
617
|
+
if (Math.abs((oe.speed ?? 100) - (ne.speed ?? 100)) > 0.01 || Boolean(oe.reverse) !== Boolean(ne.reverse)) {
|
|
508
618
|
kind = 'retimed';
|
|
509
619
|
deltas.speed = { old: oe.speed, new: ne.speed };
|
|
510
|
-
if (oe.reverse !== ne.reverse) deltas.reverse = { old: oe.reverse, new: ne.reverse };
|
|
620
|
+
if (Boolean(oe.reverse) !== Boolean(ne.reverse)) deltas.reverse = { old: oe.reverse, new: ne.reverse };
|
|
511
621
|
} else if (Math.abs((oe.recIn ?? 0) - (ne.recIn ?? 0)) > recTol) {
|
|
512
622
|
kind = 'moved';
|
|
513
623
|
deltas.recIn = { old: oe.recIn, new: ne.recIn };
|
|
@@ -517,51 +627,97 @@ export function diffChangelist(oldEvents, newEvents, opts = {}) {
|
|
|
517
627
|
}
|
|
518
628
|
if (kind !== 'unchanged') changes.push({ kind, source: ne.source, track: ne.track, oldRecIn: oe.recIn, newRecIn: ne.recIn, deltas });
|
|
519
629
|
}
|
|
630
|
+
for (const ne of P.unmatchedNew) changes.push({ kind: 'new', source: ne.source, track: ne.track, newRecIn: ne.recIn });
|
|
520
631
|
// Unconsumed old events → gone (unless a 'new' at the same rec position → replaced).
|
|
521
|
-
for (const
|
|
522
|
-
const replacement = changes.find((c) => c.kind === 'new' && c.track ===
|
|
632
|
+
for (const oe of P.unmatchedOld) {
|
|
633
|
+
const replacement = changes.find((c) => c.kind === 'new' && c.track === oe.track && Math.abs((c.newRecIn ?? 0) - (oe.recIn ?? 0)) <= recTol);
|
|
523
634
|
if (replacement) {
|
|
524
635
|
replacement.kind = 'replaced';
|
|
525
|
-
replacement.oldSource =
|
|
526
|
-
replacement.oldRecIn =
|
|
636
|
+
replacement.oldSource = oe.source;
|
|
637
|
+
replacement.oldRecIn = oe.recIn;
|
|
527
638
|
} else {
|
|
528
|
-
changes.push({ kind: 'gone', source:
|
|
639
|
+
changes.push({ kind: 'gone', source: oe.source, track: oe.track, oldRecIn: oe.recIn });
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Junction diff: a transition is identified by what it joins (track,
|
|
644
|
+
// outgoing, incoming, fade side) and pairs with the closest junction; only
|
|
645
|
+
// its SHAPE (duration / type / pre-roll) makes a change — a junction that
|
|
646
|
+
// travelled with its clips is the clips' move, already reported.
|
|
647
|
+
const tKey = (t) => `${t.track}|${t.outgoing ?? ''}|${t.incoming}|${t.fade ?? 'x'}`;
|
|
648
|
+
const tPool = P.old.transitions.map((t) => ({ t, used: false }));
|
|
649
|
+
const tDesc = (t) => ({ track: t.track, outgoing: t.outgoing, incoming: t.incoming, fade: t.fade, type: t.type, duration: t.duration });
|
|
650
|
+
for (const nt of P.new.transitions) {
|
|
651
|
+
const cands = tPool.filter((p) => !p.used && tKey(p.t) === tKey(nt));
|
|
652
|
+
if (!cands.length) {
|
|
653
|
+
changes.push({ kind: 'transition_added', ...tDesc(nt), newRecIn: nt.junction, newSpan: [nt.start, nt.end] });
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
cands.sort((a, b) => Math.abs(a.t.junction - nt.junction) - Math.abs(b.t.junction - nt.junction));
|
|
657
|
+
cands[0].used = true;
|
|
658
|
+
const ot = cands[0].t;
|
|
659
|
+
const deltas = {};
|
|
660
|
+
if (ot.duration !== nt.duration) deltas.duration = { old: ot.duration, new: nt.duration };
|
|
661
|
+
if (String(ot.type) !== String(nt.type)) deltas.type = { old: ot.type, new: nt.type };
|
|
662
|
+
if (ot.pre !== nt.pre) deltas.pre = { old: ot.pre, new: nt.pre };
|
|
663
|
+
if (Object.keys(deltas).length) {
|
|
664
|
+
changes.push({
|
|
665
|
+
kind: 'transition_changed', ...tDesc(nt), oldRecIn: ot.junction, newRecIn: nt.junction,
|
|
666
|
+
oldSpan: [ot.start, ot.end], newSpan: [nt.start, nt.end], deltas,
|
|
667
|
+
});
|
|
529
668
|
}
|
|
530
669
|
}
|
|
670
|
+
for (const p of tPool.filter((x) => !x.used)) {
|
|
671
|
+
changes.push({ kind: 'transition_dropped', ...tDesc(p.t), oldRecIn: p.t.junction, oldSpan: [p.t.start, p.t.end] });
|
|
672
|
+
}
|
|
673
|
+
|
|
531
674
|
const counts = {};
|
|
532
675
|
for (const c of changes) counts[c.kind] = (counts[c.kind] || 0) + 1;
|
|
533
676
|
changes.sort((a, b) => (a.newRecIn ?? a.oldRecIn ?? 0) - (b.newRecIn ?? b.oldRecIn ?? 0));
|
|
534
|
-
return {
|
|
677
|
+
return {
|
|
678
|
+
changes, counts, changedCount: changes.length,
|
|
679
|
+
transitions: { old: P.old.transitions.length, new: P.new.transitions.length },
|
|
680
|
+
carriersFolded: { old: P.old.carriers.length, new: P.new.carriers.length },
|
|
681
|
+
gate: 'review',
|
|
682
|
+
};
|
|
535
683
|
}
|
|
536
684
|
|
|
537
685
|
// ── TIMING silent-lie guards ───────────────────────────────────────────
|
|
538
686
|
/**
|
|
539
687
|
* Detect timing lies between an old (locked-cut) and new (conformed) event list.
|
|
688
|
+
* Pairs events the same way the changelist does (closest record position,
|
|
689
|
+
* consumed once) so a source cut twice at two speeds is compared instance to
|
|
690
|
+
* instance — never the second old against the first new.
|
|
540
691
|
* @returns {{flags:Array<{kind, source, detail}>}}
|
|
541
692
|
*/
|
|
542
693
|
export function timingGuards(oldEvents, newEvents) {
|
|
543
694
|
const flags = [];
|
|
544
|
-
const
|
|
545
|
-
for (const
|
|
546
|
-
|
|
547
|
-
|
|
695
|
+
const P = pairEvents(oldEvents, newEvents);
|
|
696
|
+
for (const oe of P.unmatchedOld) {
|
|
697
|
+
// A dropped audio event where its video sibling survives → dropped J/L-cut audio.
|
|
698
|
+
if (isAudioTrack(oe.track) && P.new.cuts.some((x) => !isAudioTrack(x.track) && x.track !== 'MARKER' && x.source === oe.source))
|
|
699
|
+
flags.push({ kind: 'dropped_split_audio', source: oe.source, track: oe.track, detail: `audio event (${oe.track}) gone but video sibling present (J/L-cut lost)` });
|
|
548
700
|
}
|
|
549
|
-
for (const oe of
|
|
550
|
-
const matches = newBySig.get(sig(oe)) || [];
|
|
551
|
-
const ne = matches[0];
|
|
552
|
-
if (!ne) {
|
|
553
|
-
// A dropped audio event where its video sibling survives → dropped J/L-cut audio.
|
|
554
|
-
if (oe.track === 'A' && newEvents.some((x) => x.track !== 'A' && x.source === oe.source))
|
|
555
|
-
flags.push({ kind: 'dropped_split_audio', source: oe.source, detail: 'audio event gone but video sibling present (J/L-cut lost)' });
|
|
556
|
-
continue;
|
|
557
|
-
}
|
|
701
|
+
for (const { oe, ne } of P.pairs) {
|
|
558
702
|
// Flattened retime: a speed ramp/change flattened to 100%.
|
|
559
703
|
if ((oe.speed ?? 100) !== 100 && (ne.speed ?? 100) === 100)
|
|
560
|
-
flags.push({ kind: 'flattened_retime', source: oe.source, detail: `speed ${oe.speed}% → 100% (retime lost)` });
|
|
704
|
+
flags.push({ kind: 'flattened_retime', source: oe.source, recIn: oe.recIn, detail: `speed ${oe.speed}% → 100% (retime lost)` });
|
|
561
705
|
// Reverse dropped.
|
|
562
|
-
if (oe.reverse && !ne.reverse) flags.push({ kind: 'reverse_dropped', source: oe.source, detail: 'reversed clip conformed forward' });
|
|
706
|
+
if (oe.reverse && !ne.reverse) flags.push({ kind: 'reverse_dropped', source: oe.source, recIn: oe.recIn, detail: 'reversed clip conformed forward' });
|
|
563
707
|
// Framerate/pulldown slip.
|
|
564
|
-
if (oe.fps && ne.fps && oe.fps !== ne.fps) flags.push({ kind: 'framerate_slip', source: oe.source, detail: `fps ${oe.fps} → ${ne.fps}` });
|
|
708
|
+
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}` });
|
|
709
|
+
}
|
|
710
|
+
// A fade or dissolve the conform lost is a timing lie too: the picture
|
|
711
|
+
// edges stay put while the blend at the junction silently hard-cuts.
|
|
712
|
+
const tKey = (t) => `${t.track}|${t.outgoing ?? ''}|${t.incoming}|${t.fade ?? 'x'}`;
|
|
713
|
+
const newKeys = P.new.transitions.map(tKey);
|
|
714
|
+
for (const ot of P.old.transitions) {
|
|
715
|
+
const i = newKeys.indexOf(tKey(ot));
|
|
716
|
+
if (i >= 0) { newKeys.splice(i, 1); continue; }
|
|
717
|
+
flags.push({
|
|
718
|
+
kind: 'transition_dropped', source: ot.incoming, track: ot.track, recIn: ot.junction,
|
|
719
|
+
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`,
|
|
720
|
+
});
|
|
565
721
|
}
|
|
566
722
|
return { flags, flagged: flags.length > 0 };
|
|
567
723
|
}
|
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
|
5
|
-
*
|
|
6
|
-
* sampleConform(cut) — the highres source at
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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,
|
|
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/replaced/new/gone + timing flags), conform_manifest (per-event assert: source resolved/handles/retime/reverse/TC-base; BL-aware — black legs need no source, fades no black-side handles, and a fade-out tail requirement lands on the picture source), marker_roundtrip (markers with provenance tags), verify_roundtrip (input events vs re-export events -> pass/mismatches + fitted per-source TC offsets + marker compare w/ markersNotInExport honesty flag; FADE-AWARE: BL/Solid-Color legs merge out as blackSegments and fade-window boundary reshapes are excused into fadeReshapedBoundaries instead of failing; RETIME-AWARE: speed/reverse compare pairwise — EXPORT_OTIO carries an authored Sm2TimeMap back as LinearTimeWarp (measured), so a flattened/lost retime fails as drift geometry alone cannot catch; AUDIO-AWARE: declared audio events compare (a video-only export such as EXPORT_EDL flags audioNotInExport instead of failing) (channel legs deduped, mismatches tagged trackType audio) while the mirrored-A1 export of a video-only turnover stays informational; the conform QC loop-closer). Offline (AAF needs pyaaf2; live AAF/DRP import is on the Python davinci-resolve MCP).',
|
|
89
|
+
'Editorial integrity (Cluster E) — turnover interchange → normalized events → changelist + conform manifest with TIMING silent-lie guards (flattened retime / dropped J/L-cut audio / framerate-pulldown slip / reverse dropped / transition-handle starvation → flag, skip-not-fake). Report-only (gate: review). Actions: parse_interchange (EDL/OTIO/XMEML natively — incl. Resolve-written FCP7 -1 junction edges + Solid Color generators, and CMX FROM/TO CLIP NAME comments over the generic AX reel — + AAF via pyaaf2 + PRPROJ via gunzip+XML → normalized events incl. span-explicit transitions, BL fade legs, and freezes as zero-speed events (OTIO FreezeFrame, XMEML timeremap 0, PrProj in==out, AAF 0% motion effects); for AAF/PRPROJ pass the file PATH as content; AAF also returns per-sequence startTimecode/startFrame — build the timeline at THAT start, not the Resolve 01:00:00:00 default — and per-clip `geometry` for Avid transform effects), list_sequences (ONE offline picker entry point across xml/edl/otio/drt/drp/aaf/prproj → [{id,name,eventCount}], plus startTimecode/startFrame for AAF), convert_to_interchange (author OTIO/EDL/DRT Resolve CAN import from events or a parsed source; the EDL target writes CMX transition pairs incl. BL fades — the .prproj→Resolve conform bridge, no Premiere needed; editorial timing/transitions survive and per-clip effects/color do not. SPEED/REVERSE survive on the otio (LinearTimeWarp) and edl (M2) targets ONLY — this FLAT drt target flattens every retime to 100% forward and returns `flattened`/`flattenedCount` naming each event that lost one (`flattened` is always present on `drt`, empty when there were none); for a .drt that AUTHORS retimes/dissolves/multi-track/audio, use drt.assemble_from_interchange), turnover_changelist (diff old vs new → moved/retimed/trimmed/replaced/new/gone PLUS the junction diff: transition_added/transition_dropped/transition_changed with fade in/out or dissolve, outgoing/incoming, span and duration/type/pre-roll deltas — zero-length CMX carrier lines and the BL legs that carry fades fold into the junction diff instead of reading as gone/new sources; events pair by closest record position, consumed once, so a source cut twice at two speeds compares instance to instance; + timing flags incl. transition_dropped and dropped_split_audio on any A-track), conform_manifest (per-event assert: source resolved/handles/retime/reverse/TC-base; BL-aware — black legs need no source, fades no black-side handles, and a fade-out tail requirement lands on the picture source), marker_roundtrip (markers with provenance tags), verify_roundtrip (input events vs re-export events -> pass/mismatches + fitted per-source TC offsets + marker compare w/ markersNotInExport honesty flag; FADE-AWARE: BL/Solid-Color legs merge out as blackSegments and fade-window boundary reshapes are excused into fadeReshapedBoundaries instead of failing; RETIME-AWARE: speed/reverse compare pairwise — EXPORT_OTIO carries an authored Sm2TimeMap back as LinearTimeWarp (measured), so a flattened/lost retime fails as drift geometry alone cannot catch; AUDIO-AWARE: declared audio events compare (a video-only export such as EXPORT_EDL flags audioNotInExport instead of failing) (channel legs deduped, mismatches tagged trackType audio) while the mirrored-A1 export of a video-only turnover stays informational; the conform QC loop-closer). Offline (AAF needs pyaaf2; live AAF/DRP import is on the Python davinci-resolve MCP).',
|
|
90
90
|
async handler({ action, args }) {
|
|
91
91
|
if (action === 'parse_interchange') {
|
|
92
92
|
const p = parseSchema.parse(args);
|
|
@@ -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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
const
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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) };
|
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.165.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
package/src/utils/api_truth.py
CHANGED
|
@@ -2794,6 +2794,41 @@ 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": "Timeline.Export EXPORT_FCP_7_XML (no pproTicksIn, -1 edges under transitions)",
|
|
2799
|
+
"object": "Timeline",
|
|
2800
|
+
"signature": "(filePath, EXPORT_FCP_7_XML, EXPORT_NONE) -> bool",
|
|
2801
|
+
"reality": "Resolve's FCP7 XML writer (measured on Studio 19.1.3.7, "
|
|
2802
|
+
"E107, verbatim export kept as a fixture) emits NO "
|
|
2803
|
+
"pproTicksIn/pproTicksOut on any clipitem — the Premiere "
|
|
2804
|
+
"tick fields a Premiere-shaped oracle treats as the "
|
|
2805
|
+
"authoritative source position are simply absent, so a "
|
|
2806
|
+
"reader that requires them derives no source frame for "
|
|
2807
|
+
"ANY cut of a Resolve export. Every clipitem edge that "
|
|
2808
|
+
"sits under a transitionitem is written as -1 and means "
|
|
2809
|
+
"the transition's junction (span center for alignment "
|
|
2810
|
+
"center; the writer emitted `center` for every dissolve "
|
|
2811
|
+
"and fade authored centered), `out - in` is the record "
|
|
2812
|
+
"duration, and a -1 START edge's <in> is the source at the "
|
|
2813
|
+
"overlap start. With three centered transitions two "
|
|
2814
|
+
"equal-length clips both carry -1/-1 edges, so a reader "
|
|
2815
|
+
"must pair junctions in record order — the first pair "
|
|
2816
|
+
"that fits places both clips at the same position. Black "
|
|
2817
|
+
"legs are Solid Color generatoritems whose -1 edge "
|
|
2818
|
+
"resolves the same way.",
|
|
2819
|
+
"recommended": "Read <in> as the literal source frame (Resolve reads "
|
|
2820
|
+
"and writes it that way), record-align it by the "
|
|
2821
|
+
"junction-minus-span-start offset on a -1 start, and "
|
|
2822
|
+
"walk -1/-1 clips with a record-order cursor. "
|
|
2823
|
+
"conform.snapshot ingest_xml and "
|
|
2824
|
+
"editorial.parse_interchange both do; the frame QC "
|
|
2825
|
+
"then samples each cut CLEAR of its transition "
|
|
2826
|
+
"windows (inside one the reference is a blend, or "
|
|
2827
|
+
"black for a fade).",
|
|
2828
|
+
"tags": ["timeline", "export", "xml", "fcp7", "transitions", "silent-failure"],
|
|
2829
|
+
"submit": "missing",
|
|
2830
|
+
"mitigation": ["conform.snapshot ingest_xml", "editorial.parse_interchange", "conform.qc"],
|
|
2831
|
+
},
|
|
2797
2832
|
{
|
|
2798
2833
|
"symbol": "Project.SetRenderSettings ExportSubtitle/SubtitleFormat (inert on 19.x)",
|
|
2799
2834
|
"object": "Project",
|