davinci-resolve-mcp 2.89.0 → 2.90.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,77 @@
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.90.0
6
+
7
+ AAF turnovers parsed by `editorial.parse_interchange` on the advanced server now
8
+ carry their animation curves, the transforms nobody had interpreted, and the
9
+ effects that occupy record time while emitting nothing. All three were losses a
10
+ consumer could not see, because the parse reported itself complete.
11
+
12
+ ### Added
13
+
14
+ - **Keyframe curves ship instead of the word "varying".** A `VaryingValue` was
15
+ read for its values alone, and only to answer "one number or more than one" —
16
+ `ControlPoint.time` was never asked for, so every animated reframe reached a
17
+ consumer as `"varying"`: enough to refuse the clip, never enough to rebuild
18
+ it. Transform stages now carry `keyframes[<AvidParamName>]` and retimes carry
19
+ `speedCurve.playRate` / `speedCurve.sourceOffset`, each with its interpolation
20
+ and its points as `{t, v, frame}`.
21
+ - **`domain` names which rule produced `frame`,** because the two curve families
22
+ do not share a time domain and a single conversion rule would have been wrong
23
+ by an effect's whole length on one of them. Measured over all 2047 control
24
+ points of an 878-event Avid picture turnover: transform params are normalized
25
+ over the effect span with the endpoint inclusive (`frame = t x (length - 1)`,
26
+ which lands 1243 of 1254 points on an integer frame against 278 under
27
+ `length`), while speed maps are already in frames. Keys outside `0..1` are
28
+ kept rather than clamped — 107 of 1254 sit before the first frame or past the
29
+ last, which is what Avid leaves when an animated clip is trimmed. A curve with
30
+ one unreadable point is refused whole; interpolating through a missing key
31
+ produces a confident wrong animation.
32
+ - **`passthrough` stages carry the four uninterpreted transform operations**
33
+ (SBlend_v2, Stabilize_2, MaskImage_2, 2DMatteKey_2) that were previously
34
+ discarded whole — 22 events on the fixture. Their numbers travel in
35
+ `rawParams`, deliberately not `params`: the same parameter *name* carries
36
+ different units on different operations (SBlend's `DVE_POS_X_U` runs to -315
37
+ where Stabilize's runs to -0.92 on the same show), so there is nothing to
38
+ normalize, and a 2DMatteKey `AFX_POS_X_U` of 500 promoted into `params` would
39
+ become a ~960px shift of a clip nobody repositioned.
40
+ - **`effectsWithoutEvents` closes a hole `unhandled` structurally cannot see.**
41
+ An effect can be modelled perfectly and still emit nothing — the group is
42
+ walked, its inputs are walked, and they contain no `SourceClip`. On the
43
+ fixture `unhandled` reads `{}` (a complete parse) while 29 SubCap titles
44
+ occupy real record time and reach the consumer as nothing whatsoever. Charged
45
+ once, to the innermost cause.
46
+ - **`speedRatioFromCurve`** — the rate the offset curve itself implies, emitted
47
+ only when that curve is straight, so no variable timewarp is ever averaged
48
+ into a single number.
49
+
50
+ ### Fixed
51
+
52
+ - **The declared AAF `SpeedRatio` rational is the source span truncated to whole
53
+ frames, and 7 of 18 constant retimes on the fixture disagree with their own
54
+ curve** — `201/112 = 1.794643` where the curve says `201.6/112 = 1.80`, and at
55
+ worst `31/19 = 1.631579` against a curve reading `1.70`, a 4% speed error in a
56
+ number an operator is handed to type in by hand. Every curve slope lands on a
57
+ rate an editor would actually dial (1.7, 1.8, 2.0, 0.75); every declared value
58
+ is that rate spoiled by rounding. Both ship under their own names and neither
59
+ is substituted for the other.
60
+ - **Variable timewarps are reconstructible.** The offset curve is dense (up to
61
+ 387 points at half-frame steps) and describes where every record frame reads
62
+ from, taking the 4 variable timewarps on the fixture from "flagged, rebuild
63
+ from nothing" to fully described — including one reverse ramp whose offset
64
+ runs 301.0 to -0.78. Reverse read off the curve agreed with the declared flag
65
+ 9 of 9, in both directions.
66
+
67
+ ### Validation
68
+
69
+ - Every pre-existing field is byte-identical on the fixture, verified by a
70
+ structural diff of the full 878-event parse against the previous output; event
71
+ count, `unhandled`, and all existing counters are unchanged.
72
+ - 10 new tests in `resolve-advanced/test/aaf-sequences.test.mjs`.
73
+ - No DaVinci Resolve scripting behavior changed; this is offline interchange
74
+ parsing, so no live Resolve validation was required.
75
+
5
76
  ## What's New in v2.89.0
6
77
 
7
78
  The build gates this server already enforced are now gates an agent can ask
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  English | [简体中文](README.zh-CN.md)
4
4
 
5
- [![Version](https://img.shields.io/badge/version-2.89.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.90.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-34%20(353%20full)-blue.svg)](#server-modes)
package/README.zh-CN.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [English](README.md) | 简体中文
4
4
 
5
- [![Version](https://img.shields.io/badge/version-2.89.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.90.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-34%20(353%20full)-blue.svg)](#服务器模式)
@@ -12,7 +12,7 @@
12
12
  [![Python](https://img.shields.io/badge/python-3.10+-green.svg)](https://www.python.org/downloads/)
13
13
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT)
14
14
 
15
- > 本翻译对应 v2.89.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v2.90.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
package/install.py CHANGED
@@ -36,7 +36,7 @@ from src.utils.update_check import (
36
36
 
37
37
  # ─── Version ──────────────────────────────────────────────────────────────────
38
38
 
39
- VERSION = "2.89.0"
39
+ VERSION = "2.90.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.89.0",
3
+ "version": "2.90.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -14,6 +14,7 @@ trusting, so the Node server shells out to this helper, which uses the pure-Pyth
14
14
  "startTimecode": <"HH:MM:SS:FF"|null>, "startFrame": <int|null>,
15
15
  "startTimecodeFps": <int|null>, "startTimecodeDrop": <bool|null>,
16
16
  "unhandled": { "<ComponentClass>": <int>, ... },
17
+ "effectsWithoutEvents": { "<OperationName>": <int>, ... },
17
18
  "events": [ {normalized-event}, ... ] }
18
19
  ]
19
20
  }
@@ -31,6 +32,25 @@ recoverable from the OperationGroup's parameters (see _retime_fields):
31
32
  For retimes, srcIn/srcOut are the SOURCE-side range while recIn/recOut span the
32
33
  OperationGroup's DECLARED (record) length — they differ by the ratio.
33
34
 
35
+ KEYFRAMES (U21). A VaryingValue is a curve, and it used to be read for its values
36
+ alone — enough to say "this animates", never enough to rebuild it. Curves now ship:
37
+ * transform stages → `geometry[i].keyframes[<AvidParamName>]`
38
+ * retimes → `speedCurve.playRate` / `speedCurve.sourceOffset`
39
+ each `{ interpolation, domain, effectLength, points: [{t, v, frame}] }`. 🚨 The two
40
+ families do NOT share a time domain — `domain` names which one applies:
41
+ * "effectSpan" → frame = t x (effectLength - 1) (transform parameters)
42
+ * "effectFrames" → frame = t (speed maps, already frames)
43
+ Keys outside 0..1 are real (a clip trimmed after it was animated) and are kept, and
44
+ a frame may be fractional. A curve with ONE unreadable point is refused whole.
45
+ Retimes also carry `"speedRatioFromCurve"` when the offset curve is straight — the
46
+ declared SpeedRatio rational is the source span truncated to WHOLE FRAMES and is
47
+ wrong by up to 4% on real material, so both numbers ship under their own names.
48
+
49
+ TRANSFORM OPS WE DO NOT INTERPRET (U22) get a stage marked `"passthrough": true`
50
+ whose numbers sit in `"rawParams"`, never `"params"` — the same parameter NAME
51
+ carries different units on different operations, so there is nothing to normalize
52
+ and nothing a consumer may safely compose. See _PASSTHROUGH_GEOMETRY_OPS.
53
+
34
54
  Honest-refuse discipline (no fake parses):
35
55
  * exit 3 → pyaaf2 not installed (stderr: AAF_PROBE_NO_PYAAF2)
36
56
  * exit 4 → file unreadable / not an AAF (stderr: AAF_PROBE_UNREADABLE: <detail>)
@@ -45,6 +65,13 @@ an genuinely empty sequence, and worse than an honest refusal because downstream
45
65
  consumers gate on `ok`. Every component we skip is now counted by class name and
46
66
  reported per sequence, so a miss is VISIBLE without changing the exit-code contract.
47
67
 
68
+ `effectsWithoutEvents` (U23) closes the hole `unhandled` structurally cannot see.
69
+ An effect can be modelled PERFECTLY and still produce nothing: an OperationGroup is
70
+ walked, its inputs are walked, and they contain no SourceClip. On the reference
71
+ turnover `unhandled` reads `{}` — a complete parse — while 29 SubCap title effects
72
+ occupy real record time and reach the consumer as nothing at all. Each such group is
73
+ counted once, against the innermost operation that caused it.
74
+
48
75
  Segment model (Avid Media Composer picture turnovers):
49
76
  * NestedScope — a multi-layer video track. Its `.slots` are the layers (V1..Vn);
50
77
  it has NO `.components`. Layers are PARALLEL, so each layer's
@@ -66,6 +93,7 @@ effect carry `"geometry"` (see _geometry_fields).
66
93
 
67
94
  import json
68
95
  import os
96
+ import re
69
97
  import sys
70
98
 
71
99
 
@@ -421,20 +449,154 @@ def _param_is(param, name, auid=None):
421
449
  return False
422
450
 
423
451
 
424
- def _pointlist_values(varying):
425
- """Control-point VALUES of a VaryingValue's point list, or None if unreadable."""
452
+ def _pointlist(varying):
453
+ """A VaryingValue's ControlPoint objects, or None if unreadable."""
426
454
  points = getattr(varying, "pointlist", None)
427
455
  if points is None:
428
456
  return None
429
457
  inner = getattr(points, "value", None)
430
458
  if inner is not None:
431
459
  points = inner
460
+ try:
461
+ return list(points)
462
+ except Exception:
463
+ return None
464
+
465
+
466
+ def _pointlist_values(varying):
467
+ """Control-point VALUES of a VaryingValue's point list, or None if unreadable."""
468
+ points = _pointlist(varying)
469
+ if points is None:
470
+ return None
432
471
  try:
433
472
  return [float(p.value) for p in points]
434
473
  except Exception:
435
474
  return None
436
475
 
437
476
 
477
+ # ── Keyframes (VaryingValue control points) ───────────────────────────────────
478
+ # A VaryingValue is a CURVE, and until now the probe read only its values, and only
479
+ # to answer "is this one number or more than one". The times were never asked for,
480
+ # so an animated reframe reached a consumer as the bare word "varying" — enough to
481
+ # refuse the clip, never enough to rebuild it.
482
+ #
483
+ # `ControlPoint.time` is readable. It is NOT one domain, and that is the whole trap
484
+ # here — measured over all 2047 control points of a real 878-event Avid turnover:
485
+ #
486
+ # * TRANSFORM parameters (AFX_*, DVE_*) are normalized over the effect span, with
487
+ # the endpoint INCLUSIVE: frame = time x (length - 1).
488
+ # 1243 of 1254 points land exactly on an integer frame under (length - 1); only
489
+ # 278 also do under (length), so the two are distinguishable and (length - 1)
490
+ # is the rule. The 11 that land on neither are ONE keyframe of one clip at
491
+ # frame 125.5 — a legitimate half-frame key, which (length - 1) renders exactly.
492
+ # * SPEED maps are already in effect FRAMES: PARAM_SPEED_OFFSET_MAP_U spans
493
+ # exactly 0..length on every one of the 11 retimes carrying it.
494
+ #
495
+ # So a single "convert to frames" rule would be wrong by the effect's whole length
496
+ # on one of the two families. The domain is therefore NAMED in the output and the
497
+ # raw stored `t` travels next to the derived `frame`, so a consumer that disagrees
498
+ # with our arithmetic can redo it. Same discipline as the raw AFX_* passthrough:
499
+ # a stored number keeps its own name until its semantics are measured.
500
+ #
501
+ # Keyframes outside 0..1 are REAL and are kept: 107 of 1254 sit before the effect's
502
+ # first frame or after its last, which is what Avid leaves behind when a clip is
503
+ # trimmed after it was animated. Clamping them would silently move the animation.
504
+
505
+ _DOMAIN_EFFECT_SPAN = "effectSpan" # frame = t x (length - 1)
506
+ _DOMAIN_EFFECT_FRAMES = "effectFrames" # frame = t
507
+
508
+
509
+ def _interpolation_name(varying):
510
+ try:
511
+ return str(varying.interpolationdef.name or "") or None
512
+ except Exception:
513
+ return None
514
+
515
+
516
+ def _keyframes(varying, *, length, domain):
517
+ """One VaryingValue as an explicit curve, or None if its points are unreadable.
518
+
519
+ Shape: {"interpolation", "domain", "effectLength", "points": [{"t","frame","v"}]}
520
+ `frame` is omitted when the effect length is unknown, because there is nothing
521
+ to normalize against and a fabricated frame number is worse than none.
522
+ """
523
+ points = _pointlist(varying)
524
+ if not points:
525
+ return None
526
+ span = None
527
+ if isinstance(length, int) and length > 1 and domain == _DOMAIN_EFFECT_SPAN:
528
+ span = length - 1
529
+ out = []
530
+ for p in points:
531
+ try:
532
+ t = float(p.time)
533
+ v = float(p.value)
534
+ except Exception:
535
+ # A point we cannot read makes the CURVE untrustworthy, not just the
536
+ # point — an animation with a hole in it would interpolate straight
537
+ # through the missing key. Refuse the whole parameter.
538
+ return None
539
+ point = {"t": round(t, 9), "v": round(v, 6)}
540
+ if domain == _DOMAIN_EFFECT_FRAMES:
541
+ point["frame"] = round(t, 6)
542
+ elif span:
543
+ point["frame"] = round(t * span, 6)
544
+ out.append(point)
545
+ curve = {"domain": domain, "points": out}
546
+ interp = _interpolation_name(varying)
547
+ if interp:
548
+ curve["interpolation"] = interp
549
+ if isinstance(length, int) and length > 0:
550
+ curve["effectLength"] = length
551
+ return curve
552
+
553
+
554
+ def _rate_from_offset_curve(curve):
555
+ """The play rate the source-offset curve ITSELF implies, or None.
556
+
557
+ Only returned when that curve is straight — every interior point on the line
558
+ through its endpoints, to within a tenth of a frame. A dense VARYING map is not
559
+ one rate and must not be averaged into one, so colinearity is the test that
560
+ separates the two rather than a flag we would have to trust.
561
+
562
+ 🚨 Why this exists: the declared `SpeedRatio` rational is the source span
563
+ TRUNCATED TO WHOLE FRAMES over the record length, and the curve is exact.
564
+ Measured on the fixture, 6 of 18 constant retimes disagree —
565
+
566
+ L=112 curve span 201.600 rate 1.80 declared 201/112 = 1.794643
567
+ L= 71 curve span 142.000 rate 2.00 declared 141/71 = 1.985915
568
+ L= 19 curve span 32.300 rate 1.70 declared 31/19 = 1.631579 ← 4%
569
+
570
+ — and every curve slope lands on a number an editor would actually dial (1.7,
571
+ 1.8, 2.0, 0.75) while every declared value is that number spoiled by a rounding.
572
+ A consumer handing 1.631579 to an operator to type in has handed them a visibly
573
+ wrong speed. Both numbers are reported under their own names; neither is
574
+ silently substituted for the other.
575
+ """
576
+ if not curve:
577
+ return None
578
+ points = curve.get("points") or []
579
+ if len(points) < 2:
580
+ return None
581
+ first, last = points[0], points[-1]
582
+ try:
583
+ run = float(last["frame"]) - float(first["frame"])
584
+ rise = float(last["v"]) - float(first["v"])
585
+ except (KeyError, TypeError, ValueError):
586
+ return None
587
+ if abs(run) < 1e-9:
588
+ return None
589
+ slope = rise / run
590
+ for p in points[1:-1]:
591
+ try:
592
+ expected = float(first["v"]) + (float(p["frame"]) - float(first["frame"])) * slope
593
+ except (KeyError, TypeError, ValueError):
594
+ return None
595
+ if abs(float(p["v"]) - expected) > 0.1:
596
+ return None # a real timewarp — no single rate describes it
597
+ return slope
598
+
599
+
438
600
  def _retime_fields(op_group):
439
601
  """Extra event fields recovered from a retime OperationGroup's parameters.
440
602
 
@@ -442,14 +604,34 @@ def _retime_fields(op_group):
442
604
  {"speedRatio": <play-rate float>, "speed": <int %>, "reverse": <bool>}
443
605
  {"speedVarying": True} — a variable-speed timewarp; no single honest number
444
606
  {} — nothing recoverable (flag-only, speed stays 100)
607
+
608
+ Any of those may additionally carry "speedCurve" — the retime's own keyframes.
609
+ A variable timewarp used to reach a consumer as the single word `speedVarying`,
610
+ which is enough to REFUSE the clip and never enough to rebuild it. Two curves
611
+ are emitted when present, under Avid's own parameter names:
612
+
613
+ playRate PARAM_SPEED_MAP_U — sparse play-rate keys (AvidCubicInterpolator
614
+ on every one measured), keys freely outside the trimmed range.
615
+ sourceOffset PARAM_SPEED_OFFSET_MAP_U — a DENSE source-position curve, linear,
616
+ spanning exactly 0..length (up to 387 points at half-frame steps
617
+ on the fixture). Its slope reproduces the constant ratios exactly
618
+ — 0.75 → 175.75 over 100 frames is the 1.75 the SpeedRatio
619
+ declares — so it is the same quantity measured a second way, and
620
+ for a VARYING timewarp it is the only complete description of
621
+ where each record frame reads from.
445
622
  """
446
623
  play = None
447
624
  speed_map = None
625
+ offset_map = None
626
+ length = _length(op_group)
448
627
  for param in _op_parameters(op_group):
449
628
  cls = type(param).__name__
450
629
  if cls == "VaryingValue":
451
- if _param_name(param) == "PARAM_SPEED_MAP_U":
630
+ name = _param_name(param)
631
+ if name == "PARAM_SPEED_MAP_U":
452
632
  speed_map = param
633
+ elif name == "PARAM_SPEED_OFFSET_MAP_U":
634
+ offset_map = param
453
635
  continue
454
636
  if cls != "ConstantValue":
455
637
  continue
@@ -469,22 +651,41 @@ def _retime_fields(op_group):
469
651
  continue
470
652
  if value:
471
653
  play = value # *_U family stores the play rate directly
654
+ curve = {}
655
+ for key, param in (("playRate", speed_map), ("sourceOffset", offset_map)):
656
+ if param is None:
657
+ continue
658
+ # Both live in the effect's own FRAME domain — measured, see _keyframes.
659
+ got = _keyframes(param, length=length, domain=_DOMAIN_EFFECT_FRAMES)
660
+ if got:
661
+ got["parameter"] = _param_name(param)
662
+ curve[key] = got
663
+ extra = {"speedCurve": curve} if curve else {}
664
+ measured = _rate_from_offset_curve(curve.get("sourceOffset"))
665
+ if measured is not None:
666
+ extra["speedRatioFromCurve"] = round(abs(measured), 6)
667
+ if measured < 0:
668
+ extra["reverseFromCurve"] = True
669
+
472
670
  if speed_map is not None:
473
671
  values = _pointlist_values(speed_map)
474
672
  if values is None:
475
673
  # A speed map we cannot read: we can neither call the speed constant
476
- # nor prove it varies — recover nothing rather than guess.
477
- return {}
674
+ # nor prove it varies — recover nothing rather than guess. The offset
675
+ # curve, if it read cleanly, still ships: it is an independent
676
+ # parameter and its unreadable sibling says nothing about it.
677
+ return extra
478
678
  if len(set(values)) > 1:
479
- return {"speedVarying": True}
679
+ return {"speedVarying": True, **extra}
480
680
  if play is None and values and values[0]:
481
681
  play = values[0] # a flat map's single value IS the constant play rate
482
682
  if not play:
483
- return {}
683
+ return extra
484
684
  return {
485
685
  "speedRatio": round(abs(play), 6),
486
686
  "speed": int(round(abs(play) * 100)),
487
687
  "reverse": play < 0,
688
+ **extra,
488
689
  }
489
690
 
490
691
 
@@ -518,6 +719,31 @@ def _retime_fields(op_group):
518
719
 
519
720
  _GEOMETRY_OPS = frozenset({"PaintResize_v2", "SpatialAdapter", "FlipHoriz_2"})
520
721
 
722
+ # ── Transform-bearing operations we do NOT interpret (U22) ────────────────────
723
+ # Widening _GEOMETRY_OPS is the wrong fix for these. Census of the same turnover:
724
+ #
725
+ # SBlend_v2 (18 groups over 12 clips) DVE_SCALE_X/Y_U 103, 123, 110→120 ·
726
+ # DVE_POS_X_U -60, -315 · DVE_ROT_Z_U 3, 1
727
+ # Stabilize_2 (5) DVE_SCALE_X/Y_U 102.52, 101.96 (a
728
+ # stabiliser's zoom-in) · DVE_POS_X_U 1.54
729
+ # MaskImage_2 (1), 2DMatteKey_2 (1) AFX_POS/AFX_SCALE
730
+ #
731
+ # 🚨 The same parameter NAME does not mean the same unit: SBlend's DVE_POS_X_U runs
732
+ # to -315 while Stabilize's runs to -0.92 on the same show. Two ops, one name, two
733
+ # units — so there is nothing here to normalize, and `scalePercent*` on these would
734
+ # be composed into the applied zoom by a consumer that has no way to know better.
735
+ #
736
+ # 🚨 And they cannot ride in `params` either: 2DMatteKey_2 carries AFX_POS_X_U 500.0,
737
+ # which is a name an existing consumer already has a CALIBRATED rule for — it would
738
+ # be silently applied as a ~960px reposition of a clip nobody repositioned. So the
739
+ # numbers travel under their own key, `rawParams`, which no consumer reads yet, next
740
+ # to `passthrough: true`. Reported, never silently dropped; never silently applied.
741
+ _PASSTHROUGH_GEOMETRY_OPS = frozenset({"SBlend_v2", "Stabilize_2", "MaskImage_2", "2DMatteKey_2"})
742
+
743
+ _PASSTHROUGH_PARAM_RE = re.compile(
744
+ r"(?:^|_)(?:POS|SCALE|CROP|ROT|SKEW|SIZE|OPACITY|ASPECT)(?:_|$)|CORNER_PIN|ENABLED"
745
+ )
746
+
521
747
  _GEOMETRY_SCALARS = frozenset(
522
748
  {
523
749
  "AFX_POS_X_U",
@@ -566,6 +792,52 @@ def _geometry_scalar(param):
566
792
  return _param_number(param), False
567
793
 
568
794
 
795
+ def _geometry_keyframes(param, length):
796
+ """The curve behind an ANIMATED geometry parameter, in the effect-span domain."""
797
+ return _keyframes(param, length=length, domain=_DOMAIN_EFFECT_SPAN)
798
+
799
+
800
+ def _passthrough_geometry(op_group, op_name):
801
+ """A transform op we do not interpret, reported rather than dropped (U22).
802
+
803
+ Everything geometry-shaped, under Avid's own names, in `rawParams` — plus the
804
+ curve for any of them that animates. No normalized field is emitted and no
805
+ `params` key is used, so this can be composed by nobody and misread by nobody.
806
+ Returns None when the group carries nothing geometry-shaped at all.
807
+ """
808
+ length = _length(op_group)
809
+ raw = {}
810
+ keyframes = {}
811
+ for param in _op_parameters(op_group):
812
+ name = _param_name(param)
813
+ if not name or not _PASSTHROUGH_PARAM_RE.search(name):
814
+ continue
815
+ if type(param).__name__ == "VaryingValue":
816
+ values = _pointlist_values(param)
817
+ if values is None:
818
+ continue
819
+ if len(set(values)) > 1:
820
+ curve = _keyframes(param, length=length, domain=_DOMAIN_EFFECT_SPAN)
821
+ if curve:
822
+ keyframes[name] = curve
823
+ continue
824
+ if values:
825
+ raw[name] = round(float(values[0]), 6)
826
+ continue
827
+ value = _param_number(param)
828
+ if value is not None:
829
+ raw[name] = value
830
+ if not raw and not keyframes:
831
+ return None
832
+ out = {"effect": op_name, "passthrough": True}
833
+ if raw:
834
+ out["rawParams"] = dict(sorted(raw.items()))
835
+ if keyframes:
836
+ out["keyframes"] = dict(sorted(keyframes.items()))
837
+ out["varying"] = sorted(keyframes)
838
+ return out
839
+
840
+
569
841
  def _geometry_fields(op_group, op_name):
570
842
  """Recovered geometry for one transform OperationGroup. See the census above."""
571
843
  scale = {}
@@ -573,6 +845,16 @@ def _geometry_fields(op_group, op_name):
573
845
  bools = {}
574
846
  rects = {}
575
847
  varying = set()
848
+ keyframes = {}
849
+ length = _length(op_group)
850
+
851
+ def note_varying(param, name):
852
+ """Name the animated parameter AND keep its curve."""
853
+ varying.add(name)
854
+ curve = _geometry_keyframes(param, length)
855
+ if curve:
856
+ keyframes[name] = curve
857
+
576
858
  for param in _op_parameters(op_group):
577
859
  name = _param_name(param)
578
860
  base, _, suffix = name.rpartition("_")
@@ -584,7 +866,7 @@ def _geometry_fields(op_group, op_name):
584
866
  if name in ("AFX_SCALE_X_U", "AFX_SCALE_Y_U"):
585
867
  value, is_varying = _geometry_scalar(param)
586
868
  if is_varying:
587
- varying.add(name)
869
+ note_varying(param, name)
588
870
  elif value is not None:
589
871
  scale[name] = value
590
872
  continue
@@ -596,7 +878,7 @@ def _geometry_fields(op_group, op_name):
596
878
  if name in _GEOMETRY_SCALARS:
597
879
  value, is_varying = _geometry_scalar(param)
598
880
  if is_varying:
599
- varying.add(name)
881
+ note_varying(param, name)
600
882
  elif value is not None:
601
883
  scalars[name] = value
602
884
  geometry = {"effect": op_name}
@@ -622,8 +904,12 @@ def _geometry_fields(op_group, op_name):
622
904
  if sizes.get(src) and sizes.get(framing) is not None:
623
905
  geometry[f"reformatScale{axis}"] = round(sizes[framing] / sizes[src], 6)
624
906
  if varying:
625
- # An animated transform. Named, never reduced to one number.
907
+ # An animated transform. Named, never reduced to one number — and, since
908
+ # U21, never reduced to the NAME either: `keyframes` carries the curve.
909
+ # `varying` stays exactly as it was, because consumers gate on it.
626
910
  geometry["varying"] = sorted(varying)
911
+ if keyframes:
912
+ geometry["keyframes"] = dict(sorted(keyframes.items()))
627
913
  params = dict(sorted({**scalars, **bools}.items()))
628
914
  if params:
629
915
  geometry["params"] = params
@@ -691,9 +977,21 @@ def _walk_segment(segment, *, track, fps, rec, state, depth=0, transition=None):
691
977
  # overlap it on the same track label. Callers already cannot assume events are
692
978
  # non-overlapping — parallel NestedScope layers overlap by construction.
693
979
  before = len(state["events"])
980
+ charges_before = state.get("_emptyEffectCharges", 0)
694
981
  for inp in getattr(segment, "segments", None) or []:
695
982
  _walk_segment(inp, track=track, fps=fps, rec=rec, state=state, depth=depth + 1, transition=transition)
696
983
  op_name = _operation_name(segment)
984
+
985
+ if op_name and declared > 0 and len(state["events"]) == before:
986
+ # Walked cleanly, emitted nothing, and still consumed record time. See the
987
+ # "effectsWithoutEvents" note in probe(). Only the INNERMOST such group is
988
+ # charged — an outer wrapper around an empty effect is empty for the same
989
+ # one reason, and charging both would report one title as two.
990
+ if state.get("_emptyEffectCharges", 0) == charges_before:
991
+ bucket = state.setdefault("effectsWithoutEvents", {})
992
+ bucket[op_name] = bucket.get(op_name, 0) + 1
993
+ state["_emptyEffectCharges"] = charges_before + 1
994
+
697
995
  if op_name in _GEOMETRY_OPS:
698
996
  # A transform effect. Clips are commonly wrapped in MORE than one (a
699
997
  # SpatialAdapter reformat inside a PaintResize, say), so geometry is a
@@ -703,6 +1001,14 @@ def _walk_segment(segment, *, track, fps, rec, state, depth=0, transition=None):
703
1001
  geometry = _geometry_fields(segment, op_name)
704
1002
  for ev in state["events"][before:]:
705
1003
  ev.setdefault("geometry", []).append(geometry)
1004
+ elif op_name in _PASSTHROUGH_GEOMETRY_OPS:
1005
+ # Carries a transform whose units we have not measured. It joins the same
1006
+ # ordered stack so its PLACE in the chain survives, marked passthrough so
1007
+ # no consumer composes it. See _PASSTHROUGH_GEOMETRY_OPS.
1008
+ geometry = _passthrough_geometry(segment, op_name)
1009
+ if geometry:
1010
+ for ev in state["events"][before:]:
1011
+ ev.setdefault("geometry", []).append(geometry)
706
1012
  if op_name and ("speed" in op_name.lower() or "motion" in op_name.lower()):
707
1013
  # A retime. Its ratio is recoverable from the group's PARAMETERS (see
708
1014
  # _retime_fields): a constant ratio updates speed/speedRatio so
@@ -991,7 +1297,7 @@ def probe(path):
991
1297
  except Exception:
992
1298
  name = None
993
1299
  # `idx` is monotonic across the WHOLE mob, every slot and every nested layer.
994
- state = {"idx": 1, "events": [], "unhandled": {}}
1300
+ state = {"idx": 1, "events": [], "unhandled": {}, "effectsWithoutEvents": {}}
995
1301
  edit_fps = None
996
1302
  for slot in getattr(mob, "slots", []) or []:
997
1303
  seg = getattr(slot, "segment", None)
@@ -1024,6 +1330,16 @@ def probe(path):
1024
1330
  # Component classes we could not model, by name+count. Empty {} means
1025
1331
  # a structurally complete read; non-empty means events are INCOMPLETE.
1026
1332
  "unhandled": dict(sorted(state["unhandled"].items())),
1333
+ # 🚨 Effects that OCCUPY RECORD TIME and produce no event (U23).
1334
+ # `unhandled` cannot see these: it counts component classes the
1335
+ # walker does not model, and these are modeled fine — an
1336
+ # OperationGroup is walked, its inputs are walked, and they simply
1337
+ # contain no SourceClip. On the reference turnover `unhandled` reads
1338
+ # {} — a structurally complete parse — while 29 SubCap title effects
1339
+ # occupy real record time and reach the consumer as nothing at all.
1340
+ # A conform that silently loses 29 titles looks exactly like a
1341
+ # conform that had none. Counted by operation name so it cannot.
1342
+ "effectsWithoutEvents": dict(sorted(state.get("effectsWithoutEvents", {}).items())),
1027
1343
  "events": events,
1028
1344
  }
1029
1345
  )
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "2.89.0"
90
+ VERSION = "2.90.0"
91
91
  logger = logging.getLogger("davinci-resolve-mcp")
92
92
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
93
93
  logger.info(f"Detected platform: {get_platform()}")
package/src/server.py CHANGED
@@ -11,7 +11,7 @@ Usage:
11
11
  python src/server.py --full # Start the 353-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.89.0"
14
+ VERSION = "2.90.0"
15
15
 
16
16
  import base64
17
17
  import os