davinci-resolve-mcp 2.73.0 → 2.73.2
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 +56 -0
- package/README.md +1 -1
- package/install.py +1 -1
- package/package.json +2 -1
- package/resolve-advanced/server/aaf_probe.py +457 -0
- package/src/granular/common.py +1 -1
- package/src/server.py +45 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,62 @@
|
|
|
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.73.2
|
|
6
|
+
|
|
7
|
+
Two honesty fixes in the conform path, both found by running a real 83-minute
|
|
8
|
+
Avid AAF turnover end to end. Neither adds tool surface; `detect_missing_media`
|
|
9
|
+
gains two additive response fields.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **`detect_missing_media` counted an unknown item as a present one.** A timeline
|
|
14
|
+
item with no media pool item has no file path and no offline marker, so it fell
|
|
15
|
+
through to `present` — absence of information reported as presence. An AAF
|
|
16
|
+
imported with `importSourceClips=false` yields 882 such items, and the probe
|
|
17
|
+
answered `present_count: 882, missing_count: 0` while every one of them returned
|
|
18
|
+
`None` from `GetMediaPoolItem()`. The payload contradicted itself: the diagnosis
|
|
19
|
+
already said `unique_media_pool_item_count: 0`.
|
|
20
|
+
|
|
21
|
+
Those items now report under `unlinked` / `unlinked_count` and never inflate
|
|
22
|
+
`present_count`. They are deliberately **not** folded into `missing`: those rows
|
|
23
|
+
drive relink plans keyed on `media_pool_item_id`, and there is no pool item here
|
|
24
|
+
to relink. When a timeline is entirely unlinked the diagnosis says so, instead of
|
|
25
|
+
"No offline media detected" — technically true and completely misleading.
|
|
26
|
+
|
|
27
|
+
- **The "Resolve created no timeline" remediation named the wrong fix.** It advised
|
|
28
|
+
converting the file to FCP7 XML / FCPXML. The far more common cause is that
|
|
29
|
+
`importSourceClips` defaults to `True`, so Resolve tries to pull in the sequence's
|
|
30
|
+
source clips and fails the entire import when those paths do not resolve — the
|
|
31
|
+
normal state of a turnover, whose paths belong to the offline editor.
|
|
32
|
+
`import_source_clips=false` lands the timeline offline and now leads the
|
|
33
|
+
remediation. Note `sourceClipsPath` does not rescue it: Resolve matches source
|
|
34
|
+
clips by the filenames recorded in the sequence, so an AAF referencing Avid MXF
|
|
35
|
+
finds nothing in a folder of differently-named finishing media.
|
|
36
|
+
|
|
37
|
+
## What's New in v2.73.1
|
|
38
|
+
|
|
39
|
+
Packaging fix. The npm package shipped the AAF reader's Node half without its
|
|
40
|
+
Python half, so offline AAF preview could never have worked from an npm install.
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
|
|
44
|
+
- **`aaf_probe.py` was missing from the published npm package.** The `files`
|
|
45
|
+
allowlist was written in v2.58.0 as `resolve-advanced/server/**/*.mjs`, and
|
|
46
|
+
when `aaf_probe.py` landed in v2.59.0 the allowlist was not extended. `aaf.mjs`
|
|
47
|
+
shipped and shelled out to a file that did not exist on disk, so every
|
|
48
|
+
`parse_interchange` / `list_sequences` call against a `.aaf` failed for
|
|
49
|
+
npm-installed users. Repository clones were unaffected, which is why it
|
|
50
|
+
survived a year of releases unnoticed.
|
|
51
|
+
|
|
52
|
+
The failure was at least loud rather than a fake parse — but its remediation
|
|
53
|
+
was actively misleading. The probe exited 2 (`can't open file …aaf_probe.py`),
|
|
54
|
+
which fell through to the generic branch and appended "Install the offline AAF
|
|
55
|
+
reader (`pip install pyaaf2`)". Installing pyaaf2 cannot fix a file that was
|
|
56
|
+
never packaged, so the message sent anyone who hit it down a dead end.
|
|
57
|
+
|
|
58
|
+
This means the v2.73.0 multi-layer AAF fix did not reach npm users at all;
|
|
59
|
+
2.73.1 is what actually delivers it.
|
|
60
|
+
|
|
5
61
|
## What's New in v2.73.0
|
|
6
62
|
|
|
7
63
|
The offline AAF reader could not read a multi-layer Avid timeline, and said so
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# DaVinci Resolve MCP Server
|
|
2
2
|
|
|
3
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
4
4
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
5
5
|
[](docs/reference/api-coverage.md)
|
|
6
6
|
[-blue.svg)](#server-modes)
|
package/install.py
CHANGED
|
@@ -36,7 +36,7 @@ from src.utils.update_check import (
|
|
|
36
36
|
|
|
37
37
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
38
38
|
|
|
39
|
-
VERSION = "2.73.
|
|
39
|
+
VERSION = "2.73.2"
|
|
40
40
|
# Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
|
|
41
41
|
# Resolve's scripting bridge loads into newer interpreters on recent builds
|
|
42
42
|
# (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "davinci-resolve-mcp",
|
|
3
|
-
"version": "2.73.
|
|
3
|
+
"version": "2.73.2",
|
|
4
4
|
"description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Samuel Gursky <samgursky@gmail.com>",
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
"bin/",
|
|
38
38
|
"src/**/*.py",
|
|
39
39
|
"resolve-advanced/server/**/*.mjs",
|
|
40
|
+
"resolve-advanced/server/**/*.py",
|
|
40
41
|
"resolve-advanced/vendor/**",
|
|
41
42
|
"resolve-advanced/package.json",
|
|
42
43
|
"resolve-advanced/README.md",
|
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
aaf_probe — offline AAF (.aaf) reader for the editorial `parse_interchange` /
|
|
4
|
+
`list_sequences` picker+preview.
|
|
5
|
+
|
|
6
|
+
AAF is a binary Structured-Storage container; there is no pure-JS reader worth
|
|
7
|
+
trusting, so the Node server shells out to this helper, which uses the pure-Python
|
|
8
|
+
`aaf2` library (pyaaf2). It emits ONE JSON object on stdout:
|
|
9
|
+
|
|
10
|
+
{
|
|
11
|
+
"ok": true,
|
|
12
|
+
"sequences": [
|
|
13
|
+
{ "id": <mob-id str>, "name": <str>, "eventCount": <int>,
|
|
14
|
+
"unhandled": { "<ComponentClass>": <int>, ... },
|
|
15
|
+
"events": [ {normalized-event}, ... ] }
|
|
16
|
+
]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
Normalized event shape mirrors resolve-advanced/server/editorial.mjs `evt()`:
|
|
20
|
+
{ index, track, source, srcIn, srcOut, recIn, recOut, speed, reverse, transition, fps }
|
|
21
|
+
|
|
22
|
+
Honest-refuse discipline (no fake parses):
|
|
23
|
+
* exit 3 → pyaaf2 not installed (stderr: AAF_PROBE_NO_PYAAF2)
|
|
24
|
+
* exit 4 → file unreadable / not an AAF (stderr: AAF_PROBE_UNREADABLE: <detail>)
|
|
25
|
+
* exit 2 → bad invocation
|
|
26
|
+
Per-sequence event extraction is best-effort and defensive: if a component can't
|
|
27
|
+
be decoded we still report the sequence with its clip count — we never fabricate.
|
|
28
|
+
|
|
29
|
+
`unhandled` is the teeth behind that promise. A structural miss (a component class
|
|
30
|
+
this walker does not model) used to be swallowed silently, so a whole multi-layer
|
|
31
|
+
timeline could come back as `ok:true` with `eventCount: 0` — indistinguishable from
|
|
32
|
+
an genuinely empty sequence, and worse than an honest refusal because downstream
|
|
33
|
+
consumers gate on `ok`. Every component we skip is now counted by class name and
|
|
34
|
+
reported per sequence, so a miss is VISIBLE without changing the exit-code contract.
|
|
35
|
+
|
|
36
|
+
Segment model (Avid Media Composer picture turnovers):
|
|
37
|
+
* NestedScope — a multi-layer video track. Its `.slots` are the layers (V1..Vn);
|
|
38
|
+
it has NO `.components`. Layers are PARALLEL, so each layer's
|
|
39
|
+
record position restarts at 0.
|
|
40
|
+
* Sequence — ordered `.components`, laid end to end.
|
|
41
|
+
* OperationGroup — effect wrapper. Its `.segments` are the effect INPUTS, and the
|
|
42
|
+
primary input is usually a nested Sequence (not a bare SourceClip).
|
|
43
|
+
* Selector — an enabled/disabled layer variant; the live one is `Selected`.
|
|
44
|
+
* ScopeReference — "show the NestedScope layer beneath me": real record time, no
|
|
45
|
+
clip of this layer's own. Treated as a gap, like Filler.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
import json
|
|
49
|
+
import os
|
|
50
|
+
import sys
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _fps_from_edit_rate(edit_rate):
|
|
54
|
+
try:
|
|
55
|
+
return round(float(edit_rate), 6)
|
|
56
|
+
except Exception:
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# How far to chase the mob reference chain looking for a named mob (see _source_name).
|
|
61
|
+
_MAX_MOB_CHASE = 8
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _usable_name(obj):
|
|
65
|
+
"""A real name, or None. pyaaf2 returns the CLASS NAME for an unset `.name`, so a
|
|
66
|
+
value equal to the object's type name means "absent", not a source called SourceClip."""
|
|
67
|
+
try:
|
|
68
|
+
v = getattr(obj, "name", None)
|
|
69
|
+
except Exception:
|
|
70
|
+
return None
|
|
71
|
+
if not v:
|
|
72
|
+
return None
|
|
73
|
+
text = str(v).strip()
|
|
74
|
+
if not text or text == type(obj).__name__:
|
|
75
|
+
return None
|
|
76
|
+
return text
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _find_source_clip(segment, depth=0):
|
|
80
|
+
"""First SourceClip inside an arbitrary segment tree (bounded)."""
|
|
81
|
+
if segment is None or depth > 8:
|
|
82
|
+
return None
|
|
83
|
+
cls = type(segment).__name__
|
|
84
|
+
if cls == "SourceClip":
|
|
85
|
+
return segment
|
|
86
|
+
if cls == "Selector":
|
|
87
|
+
return _find_source_clip(_selector_selected(segment), depth + 1)
|
|
88
|
+
if cls == "Sequence":
|
|
89
|
+
children = getattr(segment, "components", None) or []
|
|
90
|
+
elif cls == "OperationGroup":
|
|
91
|
+
children = getattr(segment, "segments", None) or []
|
|
92
|
+
elif cls == "NestedScope":
|
|
93
|
+
children = _nested_layers(segment)
|
|
94
|
+
else:
|
|
95
|
+
return None
|
|
96
|
+
for child in children:
|
|
97
|
+
found = _find_source_clip(child, depth + 1)
|
|
98
|
+
if found is not None:
|
|
99
|
+
return found
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _source_name(clip):
|
|
104
|
+
"""Best-effort human name for a SourceClip: the nearest NAMED mob it references.
|
|
105
|
+
|
|
106
|
+
Avid does not point a timeline SourceClip straight at a MasterMob. Subclips, group
|
|
107
|
+
clips and motion-effect sources go through one or more UNNAMED intermediate
|
|
108
|
+
CompositionMobs, so stopping at `clip.mob.name` yields nothing for the majority of
|
|
109
|
+
a real turnover's clips. Chase the reference chain — mob → its slot's SourceClip →
|
|
110
|
+
that clip's mob — until a mob actually carries a name (typically the MasterMob, e.g.
|
|
111
|
+
"A001C001_240101_AB01.new.01"). Bounded and cycle-guarded.
|
|
112
|
+
"""
|
|
113
|
+
current = clip
|
|
114
|
+
seen = set()
|
|
115
|
+
for _ in range(_MAX_MOB_CHASE):
|
|
116
|
+
try:
|
|
117
|
+
mob = getattr(current, "mob", None)
|
|
118
|
+
except Exception:
|
|
119
|
+
mob = None
|
|
120
|
+
if mob is None:
|
|
121
|
+
break
|
|
122
|
+
try:
|
|
123
|
+
key = str(getattr(mob, "mob_id", "") or id(mob))
|
|
124
|
+
except Exception:
|
|
125
|
+
key = str(id(mob))
|
|
126
|
+
if key in seen:
|
|
127
|
+
break # reference cycle — stop rather than spin
|
|
128
|
+
seen.add(key)
|
|
129
|
+
name = _usable_name(mob)
|
|
130
|
+
if name:
|
|
131
|
+
return name
|
|
132
|
+
nxt = None
|
|
133
|
+
for slot in getattr(mob, "slots", None) or []:
|
|
134
|
+
nxt = _find_source_clip(getattr(slot, "segment", None))
|
|
135
|
+
if nxt is not None:
|
|
136
|
+
break
|
|
137
|
+
if nxt is None:
|
|
138
|
+
break
|
|
139
|
+
current = nxt
|
|
140
|
+
# No named mob anywhere in the chain — fall back to the clip's own name.
|
|
141
|
+
return _usable_name(clip) or "UNKNOWN"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _emit_source_clip(clip, *, index, track, rec, fps, transition=None):
|
|
145
|
+
"""Turn a SourceClip into a normalized event. Returns (event, length)."""
|
|
146
|
+
try:
|
|
147
|
+
length = int(getattr(clip, "length", 0) or 0)
|
|
148
|
+
except Exception:
|
|
149
|
+
length = 0
|
|
150
|
+
try:
|
|
151
|
+
start = int(getattr(clip, "start", 0) or 0)
|
|
152
|
+
except Exception:
|
|
153
|
+
start = 0
|
|
154
|
+
event = {
|
|
155
|
+
"index": index,
|
|
156
|
+
"track": track,
|
|
157
|
+
"source": _source_name(clip),
|
|
158
|
+
"srcIn": start,
|
|
159
|
+
"srcOut": start + length,
|
|
160
|
+
"recIn": rec,
|
|
161
|
+
"recOut": rec + length,
|
|
162
|
+
"speed": 100,
|
|
163
|
+
"reverse": False,
|
|
164
|
+
"transition": transition,
|
|
165
|
+
"fps": fps,
|
|
166
|
+
}
|
|
167
|
+
return event, length
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# Depth guard: AAF nesting is a graph and a malformed file could cycle. Real Avid
|
|
171
|
+
# turnovers nest ~4 deep (NestedScope > Sequence > Selector > OperationGroup > Sequence).
|
|
172
|
+
_MAX_DEPTH = 24
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _length(obj):
|
|
176
|
+
try:
|
|
177
|
+
return int(getattr(obj, "length", 0) or 0)
|
|
178
|
+
except Exception:
|
|
179
|
+
return 0
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _note_unhandled(state, cls):
|
|
183
|
+
"""Record a component class we could not turn into events. See module docstring."""
|
|
184
|
+
state["unhandled"][cls] = state["unhandled"].get(cls, 0) + 1
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _nested_layers(scope):
|
|
188
|
+
"""The parallel layers of a NestedScope.
|
|
189
|
+
|
|
190
|
+
pyaaf2 hands back the layer Segments directly for this class, but tolerate a
|
|
191
|
+
slot-like wrapper (`.segment`) too so we work across pyaaf2 versions.
|
|
192
|
+
"""
|
|
193
|
+
layers = []
|
|
194
|
+
for item in getattr(scope, "slots", []) or []:
|
|
195
|
+
inner = getattr(item, "segment", None)
|
|
196
|
+
layers.append(inner if inner is not None else item)
|
|
197
|
+
return layers
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _selector_selected(comp):
|
|
201
|
+
"""The live variant of a Selector (Avid enabled/disabled layer variants).
|
|
202
|
+
|
|
203
|
+
This is the AAF `Selected` PROPERTY — pyaaf2 does not expose it as a `.selected`
|
|
204
|
+
python attribute, so read it via getvalue()/[] first and only then fall back.
|
|
205
|
+
"""
|
|
206
|
+
for getter in (
|
|
207
|
+
lambda c: c.getvalue("Selected"),
|
|
208
|
+
lambda c: c["Selected"].value,
|
|
209
|
+
lambda c: getattr(c, "selected", None),
|
|
210
|
+
):
|
|
211
|
+
try:
|
|
212
|
+
v = getter(comp)
|
|
213
|
+
if v is not None:
|
|
214
|
+
return v
|
|
215
|
+
except Exception:
|
|
216
|
+
pass
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _operation_name(comp):
|
|
221
|
+
try:
|
|
222
|
+
return str(getattr(getattr(comp, "operation", None), "name", "") or "")
|
|
223
|
+
except Exception:
|
|
224
|
+
return ""
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _walk_segment(segment, *, track, fps, rec, state, depth=0, transition=None):
|
|
228
|
+
"""
|
|
229
|
+
Emit normalized events for ONE segment placed at record position `rec`.
|
|
230
|
+
|
|
231
|
+
Appends to `state["events"]` (indices from the monotonic `state["idx"]`) and
|
|
232
|
+
counts anything it cannot model into `state["unhandled"]`.
|
|
233
|
+
|
|
234
|
+
Returns the number of RECORD frames this segment occupies, so a caller laying
|
|
235
|
+
components end to end can advance. Container classes return their own declared
|
|
236
|
+
length rather than the sum of what we managed to decode — a partial decode must
|
|
237
|
+
not silently slide every later clip earlier on the timeline.
|
|
238
|
+
"""
|
|
239
|
+
cls = type(segment).__name__
|
|
240
|
+
declared = _length(segment)
|
|
241
|
+
|
|
242
|
+
if depth > _MAX_DEPTH:
|
|
243
|
+
_note_unhandled(state, cls)
|
|
244
|
+
return declared
|
|
245
|
+
|
|
246
|
+
if cls == "SourceClip":
|
|
247
|
+
ev, length = _emit_source_clip(
|
|
248
|
+
segment, index=state["idx"], track=track, rec=rec, fps=fps, transition=transition
|
|
249
|
+
)
|
|
250
|
+
state["events"].append(ev)
|
|
251
|
+
state["idx"] += 1
|
|
252
|
+
return length
|
|
253
|
+
|
|
254
|
+
if cls in ("Filler", "ScopeReference"):
|
|
255
|
+
# Filler = a real gap. ScopeReference = "the NestedScope layer beneath shows
|
|
256
|
+
# through here" — real record time, but no clip of THIS layer's own.
|
|
257
|
+
return declared
|
|
258
|
+
|
|
259
|
+
if cls == "Sequence":
|
|
260
|
+
return _walk_components(segment, track=track, fps=fps, rec=rec, state=state, depth=depth + 1, transition=transition)
|
|
261
|
+
|
|
262
|
+
if cls == "NestedScope":
|
|
263
|
+
# Layers are parallel in time: every one starts at this segment's own rec.
|
|
264
|
+
for layer in _nested_layers(segment):
|
|
265
|
+
_walk_segment(layer, track=track, fps=fps, rec=rec, state=state, depth=depth + 1, transition=transition)
|
|
266
|
+
return declared
|
|
267
|
+
|
|
268
|
+
if cls == "Selector":
|
|
269
|
+
selected = _selector_selected(segment)
|
|
270
|
+
if selected is None:
|
|
271
|
+
_note_unhandled(state, cls)
|
|
272
|
+
else:
|
|
273
|
+
_walk_segment(selected, track=track, fps=fps, rec=rec, state=state, depth=depth + 1, transition=transition)
|
|
274
|
+
return declared
|
|
275
|
+
|
|
276
|
+
if cls == "OperationGroup":
|
|
277
|
+
# Effect wrapper (retime, paint, resize, blend, matte key...). Its `.segments`
|
|
278
|
+
# are the effect INPUTS, and the primary is usually a nested Sequence, NOT a
|
|
279
|
+
# bare SourceClip — only descending to a direct SourceClip finds nothing.
|
|
280
|
+
#
|
|
281
|
+
# EVERY input is walked, not just the primary: an SBlend B-side or a matte
|
|
282
|
+
# key's fill/key are real referenced media that a conform has to relink, and
|
|
283
|
+
# dropping them is the same data loss this walker exists to prevent. They share
|
|
284
|
+
# the primary's record span (that is what a blend IS), so they intentionally
|
|
285
|
+
# overlap it on the same track label. Callers already cannot assume events are
|
|
286
|
+
# non-overlapping — parallel NestedScope layers overlap by construction.
|
|
287
|
+
before = len(state["events"])
|
|
288
|
+
for inp in getattr(segment, "segments", None) or []:
|
|
289
|
+
_walk_segment(inp, track=track, fps=fps, rec=rec, state=state, depth=depth + 1, transition=transition)
|
|
290
|
+
op_name = _operation_name(segment)
|
|
291
|
+
if op_name and ("speed" in op_name.lower() or "motion" in op_name.lower()):
|
|
292
|
+
# We can detect that a retime is present but not reliably its ratio
|
|
293
|
+
# offline; flag it honestly rather than fake a speed number.
|
|
294
|
+
for ev in state["events"][before:]:
|
|
295
|
+
ev["effect"] = op_name
|
|
296
|
+
return declared
|
|
297
|
+
|
|
298
|
+
# Unknown component — advance by its declared length, don't fake an event, and
|
|
299
|
+
# make the miss loud so it cannot masquerade as an empty timeline.
|
|
300
|
+
_note_unhandled(state, cls)
|
|
301
|
+
return declared
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _walk_components(sequence, *, track, fps, rec, state, depth, transition=None):
|
|
305
|
+
"""Lay a Sequence's components end to end. Returns the record length consumed."""
|
|
306
|
+
start = rec
|
|
307
|
+
pending_transition = transition
|
|
308
|
+
components = getattr(sequence, "components", None)
|
|
309
|
+
if components is None:
|
|
310
|
+
components = [sequence]
|
|
311
|
+
for comp in components:
|
|
312
|
+
cls = type(comp).__name__
|
|
313
|
+
try:
|
|
314
|
+
if cls == "Transition":
|
|
315
|
+
# A transition overlaps its neighbours; it does not advance rec itself.
|
|
316
|
+
pending_transition = {"type": "dissolve", "duration": _length(comp)}
|
|
317
|
+
continue
|
|
318
|
+
rec += _walk_segment(
|
|
319
|
+
comp, track=track, fps=fps, rec=rec, state=state, depth=depth, transition=pending_transition
|
|
320
|
+
)
|
|
321
|
+
pending_transition = None
|
|
322
|
+
except Exception:
|
|
323
|
+
# Never let one bad component abort the whole sequence — but say so.
|
|
324
|
+
_note_unhandled(state, cls)
|
|
325
|
+
pending_transition = None
|
|
326
|
+
continue
|
|
327
|
+
return rec - start
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _walk_slot(segment, *, prefix, fps, state):
|
|
331
|
+
"""
|
|
332
|
+
Walk one mob slot's top-level segment into `state`.
|
|
333
|
+
|
|
334
|
+
A NestedScope slot is a multi-layer track: each layer gets its own numbered label
|
|
335
|
+
(V1..Vn / A1..An) and restarts at record 0, because layers are parallel, not
|
|
336
|
+
sequential. A plain (single-layer) slot keeps the flat "V"/"A" label.
|
|
337
|
+
"""
|
|
338
|
+
if type(segment).__name__ == "NestedScope":
|
|
339
|
+
for n, layer in enumerate(_nested_layers(segment), start=1):
|
|
340
|
+
_walk_segment(layer, track=f"{prefix}{n}", fps=fps, rec=0, state=state, depth=1)
|
|
341
|
+
return
|
|
342
|
+
_walk_segment(segment, track=prefix, fps=fps, rec=0, state=state, depth=0)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
# Slot media kinds that carry no editorial cuts. Matched on MEDIA KIND, not on the
|
|
346
|
+
# segment's class name: Avid wraps timecode slots in a Pulldown (segment class
|
|
347
|
+
# "Pulldown", media_kind "Timecode"), so a class-name-only skip let them through and
|
|
348
|
+
# they polluted both the events and the unhandled counter.
|
|
349
|
+
_NON_EDITORIAL_KINDS = frozenset(
|
|
350
|
+
{"timecode", "edgecode", "descriptivemetadata", "soundmastertrack"}
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _norm_kind(value):
|
|
355
|
+
try:
|
|
356
|
+
return "".join(str(value or "").split()).lower()
|
|
357
|
+
except Exception:
|
|
358
|
+
return ""
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _slot_media_kind(slot):
|
|
362
|
+
"""Media kind of a slot, falling back to its segment's."""
|
|
363
|
+
for owner in (slot, getattr(slot, "segment", None)):
|
|
364
|
+
kind = _norm_kind(getattr(owner, "media_kind", None))
|
|
365
|
+
if kind:
|
|
366
|
+
return kind
|
|
367
|
+
return ""
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _is_editorial_slot(slot):
|
|
371
|
+
return _slot_media_kind(slot) not in _NON_EDITORIAL_KINDS
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _media_kind_to_track(slot):
|
|
375
|
+
return "A" if _slot_media_kind(slot).startswith("sound") else "V"
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def probe(path):
|
|
379
|
+
import aaf2
|
|
380
|
+
|
|
381
|
+
sequences = []
|
|
382
|
+
with aaf2.open(path, "r") as f:
|
|
383
|
+
toplevel = list(f.content.toplevel())
|
|
384
|
+
# Fall back to all composition mobs if no explicit top-level usage is set.
|
|
385
|
+
if not toplevel:
|
|
386
|
+
try:
|
|
387
|
+
toplevel = [m for m in f.content.mobs if type(m).__name__ == "CompositionMob"]
|
|
388
|
+
except Exception:
|
|
389
|
+
toplevel = []
|
|
390
|
+
for mob in toplevel:
|
|
391
|
+
try:
|
|
392
|
+
mob_id = str(getattr(mob, "mob_id", "") or "")
|
|
393
|
+
except Exception:
|
|
394
|
+
mob_id = ""
|
|
395
|
+
name = None
|
|
396
|
+
try:
|
|
397
|
+
name = getattr(mob, "name", None)
|
|
398
|
+
except Exception:
|
|
399
|
+
name = None
|
|
400
|
+
# `idx` is monotonic across the WHOLE mob, every slot and every nested layer.
|
|
401
|
+
state = {"idx": 1, "events": [], "unhandled": {}}
|
|
402
|
+
for slot in getattr(mob, "slots", []) or []:
|
|
403
|
+
seg = getattr(slot, "segment", None)
|
|
404
|
+
if seg is None:
|
|
405
|
+
continue
|
|
406
|
+
# Skip non-editorial slots by MEDIA KIND (timecode/edgecode/descriptive
|
|
407
|
+
# metadata/sound master) — see _NON_EDITORIAL_KINDS.
|
|
408
|
+
if not _is_editorial_slot(slot):
|
|
409
|
+
continue
|
|
410
|
+
_walk_slot(
|
|
411
|
+
seg,
|
|
412
|
+
prefix=_media_kind_to_track(slot),
|
|
413
|
+
fps=_fps_from_edit_rate(getattr(slot, "edit_rate", None)),
|
|
414
|
+
state=state,
|
|
415
|
+
)
|
|
416
|
+
events = state["events"]
|
|
417
|
+
sequences.append(
|
|
418
|
+
{
|
|
419
|
+
"id": mob_id or (str(name) if name else f"seq{len(sequences) + 1}"),
|
|
420
|
+
"name": str(name) if name else f"Sequence {len(sequences) + 1}",
|
|
421
|
+
"eventCount": len(events),
|
|
422
|
+
# Component classes we could not model, by name+count. Empty {} means
|
|
423
|
+
# a structurally complete read; non-empty means events are INCOMPLETE.
|
|
424
|
+
"unhandled": dict(sorted(state["unhandled"].items())),
|
|
425
|
+
"events": events,
|
|
426
|
+
}
|
|
427
|
+
)
|
|
428
|
+
return sequences
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def main(argv):
|
|
432
|
+
if len(argv) < 2:
|
|
433
|
+
sys.stderr.write("AAF_PROBE_USAGE: aaf_probe.py <path.aaf>\n")
|
|
434
|
+
return 2
|
|
435
|
+
path = argv[1]
|
|
436
|
+
if not os.path.exists(path):
|
|
437
|
+
sys.stderr.write(f"AAF_PROBE_UNREADABLE: no such file: {path}\n")
|
|
438
|
+
return 4
|
|
439
|
+
try:
|
|
440
|
+
import aaf2 # noqa: F401
|
|
441
|
+
except Exception:
|
|
442
|
+
sys.stderr.write(
|
|
443
|
+
"AAF_PROBE_NO_PYAAF2: the pure-Python 'aaf2' package (pyaaf2) is not installed\n"
|
|
444
|
+
)
|
|
445
|
+
return 3
|
|
446
|
+
try:
|
|
447
|
+
sequences = probe(path)
|
|
448
|
+
except Exception as e: # unreadable / not an AAF / decode failure
|
|
449
|
+
sys.stderr.write(f"AAF_PROBE_UNREADABLE: {type(e).__name__}: {e}\n")
|
|
450
|
+
return 4
|
|
451
|
+
json.dump({"ok": True, "sequences": sequences}, sys.stdout)
|
|
452
|
+
sys.stdout.write("\n")
|
|
453
|
+
return 0
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
if __name__ == "__main__":
|
|
457
|
+
sys.exit(main(sys.argv))
|
package/src/granular/common.py
CHANGED
|
@@ -85,7 +85,7 @@ if not logging.getLogger().handlers:
|
|
|
85
85
|
handlers=[logging.StreamHandler()],
|
|
86
86
|
)
|
|
87
87
|
|
|
88
|
-
VERSION = "2.73.
|
|
88
|
+
VERSION = "2.73.2"
|
|
89
89
|
logger = logging.getLogger("davinci-resolve-mcp")
|
|
90
90
|
logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
|
|
91
91
|
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 341-tool granular server instead
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
|
-
VERSION = "2.73.
|
|
14
|
+
VERSION = "2.73.2"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -6048,9 +6048,23 @@ def _import_timeline_checked(proj, mp, p: Dict[str, Any]):
|
|
|
6048
6048
|
imported = t
|
|
6049
6049
|
if not imported:
|
|
6050
6050
|
if is_binary:
|
|
6051
|
+
# importSourceClips defaults to True, so Resolve tries to pull in the
|
|
6052
|
+
# sequence's source clips during import — and when those paths do not
|
|
6053
|
+
# resolve it fails the whole import rather than creating an offline
|
|
6054
|
+
# timeline. That is the normal case for a turnover (the paths belong
|
|
6055
|
+
# to the offline editor), so it is by far the most common reason a
|
|
6056
|
+
# valid AAF "creates no timeline", and the flag fixes it. Naming a
|
|
6057
|
+
# format conversion first sent people down a much longer road than
|
|
6058
|
+
# the one-flag retry that actually works. Note sourceClipsPath does
|
|
6059
|
+
# NOT rescue it: Resolve matches source clips by the filenames
|
|
6060
|
+
# recorded in the sequence, so an AAF referencing Avid MXF finds
|
|
6061
|
+
# nothing in a folder of differently-named finishing media.
|
|
6051
6062
|
remediation = (
|
|
6052
|
-
f"Resolve created no timeline from this {ext}.
|
|
6053
|
-
"
|
|
6063
|
+
f"Resolve created no timeline from this {ext}. Most often the source clips "
|
|
6064
|
+
"could not be resolved: retry with import_source_clips=false to land the "
|
|
6065
|
+
"timeline offline, then add the media to the media pool and relink. "
|
|
6066
|
+
"Otherwise verify it exports/opens in Resolve directly, or convert to "
|
|
6067
|
+
"FCP7 XML / FCPXML upstream and import that."
|
|
6054
6068
|
)
|
|
6055
6069
|
elif sanitize:
|
|
6056
6070
|
remediation = None
|
|
@@ -6583,6 +6597,7 @@ def _missing_media_diagnosis(missing_rows: List[Dict[str, Any]]) -> Dict[str, An
|
|
|
6583
6597
|
def _detect_missing_media_from_snapshot(snapshot: Dict[str, Any]):
|
|
6584
6598
|
missing = []
|
|
6585
6599
|
present = []
|
|
6600
|
+
unlinked = []
|
|
6586
6601
|
for track_type, type_payload in (snapshot.get("tracks") or {}).items():
|
|
6587
6602
|
for track in type_payload.get("tracks", []):
|
|
6588
6603
|
for item in track.get("items", []):
|
|
@@ -6603,13 +6618,40 @@ def _detect_missing_media_from_snapshot(snapshot: Dict[str, Any]):
|
|
|
6603
6618
|
}
|
|
6604
6619
|
if is_missing:
|
|
6605
6620
|
missing.append(row)
|
|
6621
|
+
elif not file_path and not item.get("media_pool_item_id"):
|
|
6622
|
+
# No path AND no media pool item: the timeline item has nothing
|
|
6623
|
+
# behind it at all. It is not "present" — we simply know nothing
|
|
6624
|
+
# about it — and counting it as present is how an entirely
|
|
6625
|
+
# offline timeline reported full coverage (an AAF imported with
|
|
6626
|
+
# importSourceClips=false yields 882 such items and used to
|
|
6627
|
+
# report present_count 882 / missing_count 0, while every one of
|
|
6628
|
+
# them returned None from GetMediaPoolItem()).
|
|
6629
|
+
#
|
|
6630
|
+
# Kept out of `missing` deliberately: those rows drive relink
|
|
6631
|
+
# plans keyed on media_pool_item_id, and there is no pool item
|
|
6632
|
+
# here to relink. This is a third state, so it gets its own.
|
|
6633
|
+
unlinked.append(row)
|
|
6606
6634
|
else:
|
|
6607
6635
|
present.append(row)
|
|
6608
6636
|
diagnosis = _missing_media_diagnosis(missing)
|
|
6637
|
+
if unlinked and not missing:
|
|
6638
|
+
# Nothing to relink, so the generic "no offline media detected" advice
|
|
6639
|
+
# would send the caller away satisfied from a timeline with no media.
|
|
6640
|
+
diagnosis = dict(diagnosis)
|
|
6641
|
+
diagnosis["primary_cause"] = "no_media_pool_items"
|
|
6642
|
+
diagnosis["recommended_next_step"] = (
|
|
6643
|
+
f"{len(unlinked)} timeline items have no media pool item at all — the timeline was "
|
|
6644
|
+
"imported without its source clips. Add the media to the media pool, then relink; "
|
|
6645
|
+
"there is nothing here for a path-based relink to act on."
|
|
6646
|
+
)
|
|
6609
6647
|
return {
|
|
6610
6648
|
"missing": missing,
|
|
6611
6649
|
"present_count": len(present),
|
|
6612
6650
|
"missing_count": len(missing),
|
|
6651
|
+
# Timeline items with neither a file path nor a media pool item. Never
|
|
6652
|
+
# folded into present_count — see above.
|
|
6653
|
+
"unlinked": unlinked,
|
|
6654
|
+
"unlinked_count": len(unlinked),
|
|
6613
6655
|
"diagnosis": diagnosis,
|
|
6614
6656
|
}
|
|
6615
6657
|
|