davinci-resolve-mcp 2.77.0 → 2.78.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,40 @@
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.78.0
6
+
7
+ The AAF probe reports where in the *source* an event actually lives, not where it
8
+ lives in the consolidated fragment the AAF happens to reference.
9
+
10
+ ### Fixed
11
+
12
+ - **`srcIn`/`srcOut` were handle offsets, not positions in the take.** A
13
+ `SourceClip`'s `start` is an offset into whatever mob it references *directly*,
14
+ and for consolidated media that mob is a per-cut fragment carrying handles — not
15
+ the take. On a real turnover 774 of 878 events reported `srcIn <= 45`, and one
16
+ take used thirteen times reported `srcIn 40` all thirteen times; two cuts of one
17
+ take cannot both begin at frame 42 of it. A consumer placing those numbers
18
+ against camera originals lands on the right cut of the right take showing the
19
+ **wrong moment**, and the number fits inside the file, so no range check catches
20
+ it. The consumer measured 2 of 526 cuts matching its picture reference before
21
+ this.
22
+
23
+ ### Added
24
+
25
+ - **Physical source position and timecode per event** — `srcPos`, `srcTcFrame`,
26
+ `srcTc`, `srcTcFps`, `srcTcDrop`. The chase sums `start` down the mob chain and
27
+ reads the nearest mob's timecode slot, so a consumer that links camera originals
28
+ can place `sourceTc − fileStartTc` instead of a fragment-relative number. Emitted
29
+ only when actually read: a chain that adds nothing emits nothing rather than
30
+ restating `srcIn`, and per-sequence `sourcePositionCoverage` counters let a
31
+ consumer distinguish "this AAF carries none" from "this probe is too old to emit
32
+ it." `srcIn`/`srcOut` are unchanged — this is additive.
33
+
34
+ Verified on the fixture: 869/878 events carry `srcPos`, 876 carry source timecode,
35
+ and the take used thirteen times separates into thirteen distinct positions.
36
+ Frame-exact against an independent witness — at the frame the turnover's own
37
+ picture reference burned 21:19:28:21, the probe says 21:19:28:21.
38
+
5
39
  ## What's New in v2.77.0
6
40
 
7
41
  Folder addressing fails loud instead of quietly answering about whichever bin the
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.77.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.78.0-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.77.0"
39
+ VERSION = "2.78.0"
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.77.0",
3
+ "version": "2.78.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -160,6 +160,133 @@ def _source_name(clip):
160
160
  return _usable_name(clip) or "UNKNOWN"
161
161
 
162
162
 
163
+ # ── Physical source position + timecode (the "which frames" question) ─────────
164
+ #
165
+ # MEASURED 2026-08-05 on a consolidated Avid turnover: a SourceClip's own
166
+ # `start` is the offset into whatever mob it references DIRECTLY, and for
167
+ # consolidated media that is a per-cut fragment with ~40-frame handles — so 774
168
+ # of 878 events reported srcIn <= 45, and takes used several times all reported
169
+ # the SAME srcIn with different lengths. Two different cuts of one take cannot
170
+ # both begin at frame 42 of that take; those 42s were handles on separate
171
+ # fragments.
172
+ #
173
+ # The real position is the SUM of the `start` offsets down the mob chain, and
174
+ # the anchor that survives relinking to different media is the physical source
175
+ # TIMECODE: the referenced mob's timecode start plus that accumulated offset.
176
+ # A consumer that links camera originals can then place
177
+ # `sourceTc - fileStartTc` instead of a fragment-relative number that merely
178
+ # FITS inside the file.
179
+ #
180
+ # Emitted per event as srcPos / srcTcFrame / srcTc / srcTcFps / srcTcDrop, and
181
+ # only when actually found — a consumer must be able to tell "this AAF carries
182
+ # no source timecode" from "this probe is too old to emit it", which is what
183
+ # the per-sequence sourceTimecodeCoverage counters are for.
184
+
185
+
186
+ def _mob_timecode(mob, _cache={}):
187
+ """(startFrame, rate, drop) from a mob's timecode slot, or None."""
188
+ try:
189
+ key = str(getattr(mob, "mob_id", "") or id(mob))
190
+ except Exception:
191
+ key = str(id(mob))
192
+ if key in _cache:
193
+ return _cache[key]
194
+ found = None
195
+ for slot in getattr(mob, "slots", None) or []:
196
+ try:
197
+ if _slot_media_kind(slot) != _TIMECODE_KIND:
198
+ continue
199
+ except Exception:
200
+ continue
201
+ tc = _timecode_component(getattr(slot, "segment", None))
202
+ if tc is None:
203
+ continue
204
+ try:
205
+ start = int(getattr(tc, "start"))
206
+ rate = int(round(float(getattr(tc, "fps", 0) or 0)))
207
+ drop = bool(getattr(tc, "drop", False))
208
+ except Exception:
209
+ continue
210
+ if rate > 0 and start >= 0:
211
+ found = (start, rate, drop)
212
+ break
213
+ _cache[key] = found
214
+ return found
215
+
216
+
217
+ def _chase_source_position(clip):
218
+ """Physical source position + timecode for a SourceClip's FIRST frame.
219
+
220
+ Walks clip -> referenced mob -> that mob's own SourceClip, accumulating each
221
+ hop's `start`. Returns (position, timecode_or_None) where `timecode` is
222
+ (tc_start, rate, drop, position_at_that_mob) for the NEAREST mob carrying a
223
+ timecode slot. Bounded and cycle-guarded like _source_name.
224
+ """
225
+ current = clip
226
+ position = 0
227
+ timecode = None
228
+ seen = set()
229
+ for _ in range(_MAX_MOB_CHASE):
230
+ try:
231
+ position += int(getattr(current, "start", 0) or 0)
232
+ except Exception:
233
+ pass
234
+ try:
235
+ mob = getattr(current, "mob", None)
236
+ except Exception:
237
+ mob = None
238
+ if mob is None:
239
+ break
240
+ try:
241
+ key = str(getattr(mob, "mob_id", "") or id(mob))
242
+ except Exception:
243
+ key = str(id(mob))
244
+ if key in seen:
245
+ break # reference cycle — stop rather than spin
246
+ seen.add(key)
247
+ if timecode is None:
248
+ tc = _mob_timecode(mob)
249
+ if tc is not None:
250
+ timecode = (tc[0], tc[1], tc[2], position)
251
+ nxt = None
252
+ for slot in getattr(mob, "slots", None) or []:
253
+ nxt = _find_source_clip(getattr(slot, "segment", None))
254
+ if nxt is not None:
255
+ break
256
+ if nxt is None:
257
+ break
258
+ current = nxt
259
+ return position, timecode
260
+
261
+
262
+ def _source_position_fields(clip):
263
+ """The srcPos/srcTc* keys for an event. Empty dict when nothing is knowable."""
264
+ try:
265
+ position, timecode = _chase_source_position(clip)
266
+ except Exception:
267
+ return {}
268
+ fields = {}
269
+ try:
270
+ own_start = int(getattr(clip, "start", 0) or 0)
271
+ except Exception:
272
+ own_start = 0
273
+ if position != own_start:
274
+ # Only meaningful when the chain actually went deeper than the clip's
275
+ # own reference — otherwise srcPos would just restate srcIn.
276
+ fields["srcPos"] = position
277
+ if timecode is not None:
278
+ tc_start, rate, drop, at = timecode
279
+ fields.update(
280
+ {
281
+ "srcTcFrame": tc_start + at,
282
+ "srcTc": _frames_to_timecode(tc_start + at, rate, drop),
283
+ "srcTcFps": rate,
284
+ "srcTcDrop": drop,
285
+ }
286
+ )
287
+ return fields
288
+
289
+
163
290
  def _emit_source_clip(clip, *, index, track, rec, fps, transition=None):
164
291
  """Turn a SourceClip into a normalized event. Returns (event, length)."""
165
292
  try:
@@ -183,6 +310,7 @@ def _emit_source_clip(clip, *, index, track, rec, fps, transition=None):
183
310
  "transition": transition,
184
311
  "fps": fps,
185
312
  }
313
+ event.update(_source_position_fields(clip))
186
314
  return event, length
187
315
 
188
316
 
@@ -884,6 +1012,15 @@ def probe(path):
884
1012
  "name": str(name) if name else f"Sequence {len(sequences) + 1}",
885
1013
  "eventCount": len(events),
886
1014
  **_sequence_start_timecode(mob, edit_fps),
1015
+ # How much of this sequence carries a physical source position
1016
+ # / timecode. Always present, so a consumer can tell "this AAF
1017
+ # has none" (zeros) from "this probe is too old to emit it"
1018
+ # (key absent) — the same reasoning as _NO_START_TIMECODE.
1019
+ "sourcePositionCoverage": {
1020
+ "events": len(events),
1021
+ "withSourcePosition": sum(1 for e in events if "srcPos" in e),
1022
+ "withSourceTimecode": sum(1 for e in events if "srcTcFrame" in e),
1023
+ },
887
1024
  # Component classes we could not model, by name+count. Empty {} means
888
1025
  # a structurally complete read; non-empty means events are INCOMPLETE.
889
1026
  "unhandled": dict(sorted(state["unhandled"].items())),
@@ -85,7 +85,7 @@ if not logging.getLogger().handlers:
85
85
  handlers=[logging.StreamHandler()],
86
86
  )
87
87
 
88
- VERSION = "2.77.0"
88
+ VERSION = "2.78.0"
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.77.0"
14
+ VERSION = "2.78.0"
15
15
 
16
16
  import base64
17
17
  import os