davinci-resolve-mcp 2.72.1 → 2.73.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,81 @@
2
2
 
3
3
  Release history for the DaVinci Resolve MCP Server. The latest release is summarized in the root README; older entries live here to keep the README focused.
4
4
 
5
+ ## What's New in v2.73.1
6
+
7
+ Packaging fix. The npm package shipped the AAF reader's Node half without its
8
+ Python half, so offline AAF preview could never have worked from an npm install.
9
+
10
+ ### Fixed
11
+
12
+ - **`aaf_probe.py` was missing from the published npm package.** The `files`
13
+ allowlist was written in v2.58.0 as `resolve-advanced/server/**/*.mjs`, and
14
+ when `aaf_probe.py` landed in v2.59.0 the allowlist was not extended. `aaf.mjs`
15
+ shipped and shelled out to a file that did not exist on disk, so every
16
+ `parse_interchange` / `list_sequences` call against a `.aaf` failed for
17
+ npm-installed users. Repository clones were unaffected, which is why it
18
+ survived a year of releases unnoticed.
19
+
20
+ The failure was at least loud rather than a fake parse — but its remediation
21
+ was actively misleading. The probe exited 2 (`can't open file …aaf_probe.py`),
22
+ which fell through to the generic branch and appended "Install the offline AAF
23
+ reader (`pip install pyaaf2`)". Installing pyaaf2 cannot fix a file that was
24
+ never packaged, so the message sent anyone who hit it down a dead end.
25
+
26
+ This means the v2.73.0 multi-layer AAF fix did not reach npm users at all;
27
+ 2.73.1 is what actually delivers it.
28
+
29
+ ## What's New in v2.73.0
30
+
31
+ The offline AAF reader could not read a multi-layer Avid timeline, and said so
32
+ in the worst possible way: `ok: true` with an empty event list, indistinguishable
33
+ from an empty timeline. Any caller gating on a successful parse would proceed to
34
+ conform nothing.
35
+
36
+ ### Fixed
37
+
38
+ - **AAF: multi-layer timelines returned zero events while reporting success.**
39
+ Avid exports a multi-layer video timeline as a `NestedScope` segment, which the
40
+ offline reader never traversed — `NestedScope` carries `.slots`, not
41
+ `.components`, so the whole timeline fell through the walker's lone-`SourceClip`
42
+ fallback and emitted nothing. Two further drops sat on the same path and had to
43
+ be fixed with it: `OperationGroup.segments` holds a nested `Sequence` rather than
44
+ a direct `SourceClip`, so effect-wrapped clips — the majority of any real
45
+ turnover — were dropped even once `NestedScope` was traversed; and source-name
46
+ resolution stopped one mob hop short of the MasterMob, because Avid routes
47
+ timeline clips through an unnamed intermediate `CompositionMob`, resolving most
48
+ clips to `UNKNOWN`. `Selector` segments are now followed (via the AAF `Selected`
49
+ property — pyaaf2 does not expose it as an attribute), and non-editorial slots
50
+ are skipped by media kind rather than segment class, so `Pulldown`-wrapped
51
+ timecode tracks no longer leak through as editorial.
52
+
53
+ Verified against a real 83-minute Avid picture turnover: **0 → 878 events**
54
+ across 5 layers, **0 `UNKNOWN` sources** (779 distinct camera rolls), no
55
+ unhandled component classes, ~1.5 s.
56
+
57
+ ### Added
58
+
59
+ - AAF probe and `listAafSequences` now report an `unhandled` map (component class
60
+ → count) per sequence. A structural miss is visible instead of masquerading as
61
+ an empty timeline.
62
+
63
+ ### Known limitations, stated deliberately
64
+
65
+ - **Motion Control retimes are flagged, not quantified.** They carry
66
+ `effect: "Motion Control"` with `speed: 100`, because the ratio is not
67
+ recoverable offline. A consumer reading `speed` alone will treat them as full
68
+ speed. Pre-existing behaviour, preserved on purpose — flag over fabricate.
69
+ - **Effect-only layers correctly produce no events.** Layers that wrap
70
+ `ScopeReference` (subtitle burns, blends, mattes) apply to what shows through
71
+ from below and reference no media of their own. OTIO reports such layers as
72
+ tracks of gaps, so its track count can exceed the number of layers with media;
73
+ "6 layers" is not "6 layers with media". Events were not fabricated to make the
74
+ counts match.
75
+ - **Only `NestedScope` layers are numbered `V1..Vn`.** Non-nested slots keep the
76
+ flat `V`/`A` label, a deliberate scope limit that keeps the blast radius off
77
+ simple AAFs — notably `editorial.mjs`'s `track === 'A'` audio-follows-video
78
+ heuristic, which reads that label.
79
+
5
80
  ## What's New in v2.72.1
6
81
 
7
82
  Documentation only. The API coverage page stated its method counts in four
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # DaVinci Resolve MCP Server
2
2
 
3
- [![Version](https://img.shields.io/badge/version-2.72.1-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.73.1-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
4
4
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
5
5
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
6
6
  [![Tools](https://img.shields.io/badge/MCP%20Tools-34%20(341%20full)-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.72.1"
39
+ VERSION = "2.73.1"
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.72.1",
3
+ "version": "2.73.1",
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",
@@ -173,6 +173,25 @@ status + install hints):
173
173
  Missing features fail with a clear, actionable message rather than crashing; the server logs a one-line
174
174
  "needs setup" summary to stderr at startup.
175
175
 
176
+ > **Pinning the AAF interpreter.** `aaf_probe.py` runs under `AAF_PROBE_PYTHON`, falling back to
177
+ > `PYTHON` and then plain `python3` on PATH. `install.py` sets that pin **only inside the `mcpServers`
178
+ > JSON entry it writes**, so a host that spawns this advanced server some other way must pin it itself —
179
+ > otherwise the reader resolves a `python3` without `pyaaf2` and honest-refuses every AAF.
180
+
181
+ ### AAF multi-layer reading — what it does and does not tell you
182
+ Avid exports a multi-layer video timeline as a `NestedScope` segment holding one nested `Sequence` per
183
+ layer; those layers are traversed and numbered `V1..Vn`. Each parsed sequence also carries an
184
+ `unhandled` map (component class → count) so a structural miss is visible rather than arriving as a
185
+ plausible-looking short event list. Three limits are deliberate:
186
+
187
+ - **Motion Control retimes are flagged, not quantified** — `effect: "Motion Control"` with `speed: 100`,
188
+ because the ratio is not recoverable offline. Reading `speed` alone reports them as full speed.
189
+ - **Effect-only layers produce no events.** A layer wrapping `ScopeReference` (subtitle burns, blends,
190
+ mattes) applies to what shows through from below and references no media of its own. OTIO materializes
191
+ such layers as tracks of gaps, so its track count can exceed the number of layers with media.
192
+ - **Only `NestedScope` layers are numbered.** Non-nested slots keep the flat `V`/`A` label, which is what
193
+ `editorial.mjs`'s `track === 'A'` audio-follows-video heuristic reads.
194
+
176
195
  ## Provenance & license
177
196
  Vendored libraries are clean offline format-interop and deterministic compute code: no secrets, no
178
197
  external service coupling, no network calls, no LLM dependency. Where a feature can take a second
@@ -94,11 +94,21 @@ export async function parseAAF(contentOrPath) {
94
94
 
95
95
  /**
96
96
  * Enumerate the sequences inside an AAF for the picker.
97
+ *
98
+ * `unhandled` is carried through deliberately: a structural miss in the probe
99
+ * (a component class it cannot walk) otherwise looks identical to a genuinely
100
+ * empty timeline, and the caller would gate on a successful parse and conform
101
+ * nothing. A NestedScope — Avid's multi-layer video stack — used to land in
102
+ * exactly that hole: eventCount 0 alongside ok:true. Absent means clean.
97
103
  * @param {string} contentOrPath absolute .aaf path
98
- * @returns {Promise<Array<{id:string,name:string,eventCount:number}>>}
104
+ * @returns {Promise<Array<{id:string,name:string,eventCount:number,unhandled?:Object}>>}
99
105
  */
100
106
  export async function listAafSequences(contentOrPath) {
101
107
  const aafPath = resolveAafPath(contentOrPath);
102
108
  const { sequences } = await runProbe(aafPath);
103
- return (sequences || []).map((s) => ({ id: String(s.id), name: String(s.name), eventCount: Number(s.eventCount || 0) }));
109
+ return (sequences || []).map((s) => {
110
+ const out = { id: String(s.id), name: String(s.name), eventCount: Number(s.eventCount || 0) };
111
+ if (s.unhandled && Object.keys(s.unhandled).length) out.unhandled = s.unhandled;
112
+ return out;
113
+ });
104
114
  }
@@ -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))
@@ -85,7 +85,7 @@ if not logging.getLogger().handlers:
85
85
  handlers=[logging.StreamHandler()],
86
86
  )
87
87
 
88
- VERSION = "2.72.1"
88
+ VERSION = "2.73.1"
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.72.1"
14
+ VERSION = "2.73.1"
15
15
 
16
16
  import base64
17
17
  import os