davinci-resolve-mcp 2.82.0 → 2.86.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/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.82.0"
14
+ VERSION = "2.86.1"
15
15
 
16
16
  import base64
17
17
  import os
@@ -46,6 +46,8 @@ for p in [current_dir, project_dir]:
46
46
  from src.utils.cdl import normalize_cdl_payload
47
47
  from src.utils.mcp_stdio import run_fastmcp_stdio
48
48
  from src.utils.api_truth import lookup_api_truth, VERIFIED_ON as _API_TRUTH_VERIFIED_ON
49
+ from src.utils import clip_colors as _clip_colors
50
+ from src.utils import resolve_versions as _resolve_versions
49
51
  from src.utils.contracts import validate as _validate_params
50
52
  from src.utils.cut_ir import build_cut_list as _build_cut_list
51
53
  from src.utils.page_lock import (
@@ -179,9 +181,10 @@ mcp = FastMCP(
179
181
  "if one is already running they never launch a second. "
180
182
  "On a connection error, read the error's own remediation field — it names the "
181
183
  "fix that applies. External scripting is Studio-only, but the free edition is "
182
- "reachable via the in-app bridge (Workspace > Scripts > resolve_bridge, with "
183
- "DAVINCI_RESOLVE_BRIDGE=1), so a connection error does NOT mean the free "
184
- "edition is unsupported."
184
+ "reachable via the in-app bridge (Workspace > Scripts > resolve_bridge — it is "
185
+ "used automatically when external scripting is unavailable; "
186
+ "DAVINCI_RESOLVE_BRIDGE=1 only forces it), so a connection error does NOT mean "
187
+ "the free edition is unsupported."
185
188
  ),
186
189
  )
187
190
 
@@ -936,8 +939,8 @@ def get_resolve():
936
939
  "DaVinci Resolve is already running but is not answering the scripting API, "
937
940
  "so it will NOT be launched again. Either enable Preferences > General > "
938
941
  "'External scripting using' = Local (Studio only), or, on the free edition, "
939
- "use the in-app bridge: install it, run Workspace > Scripts > resolve_bridge, "
940
- "and set DAVINCI_RESOLVE_BRIDGE=1."
942
+ "use the in-app bridge: install it and run Workspace > Scripts > resolve_bridge "
943
+ "— once running it is used automatically, no environment variable needed."
941
944
  )
942
945
  return None
943
946
  if already_running is None:
@@ -990,8 +993,10 @@ def _not_connected_error():
990
993
  reason="External scripting is a Studio feature; the free edition refuses it "
991
994
  "regardless of the preference. Resolve was NOT launched again.",
992
995
  remediation="On Studio: Preferences > General > 'External scripting using' = Local. "
993
- "On the free edition: install the in-app bridge, run "
994
- "Workspace > Scripts > resolve_bridge, and set DAVINCI_RESOLVE_BRIDGE=1.",
996
+ "On the free edition: install the in-app bridge and run "
997
+ "Workspace > Scripts > resolve_bridge — once it is running it is used "
998
+ "automatically, no environment variable needed "
999
+ "(DAVINCI_RESOLVE_BRIDGE=1 only forces it and disables this fallback).",
995
1000
  # Which Resolve is refusing matters to the reader: a headless render
996
1001
  # worker and the editor the user is looking at warrant different
997
1002
  # responses, and the in-app bridge is not an option for the former.
@@ -3541,7 +3546,9 @@ def _coerce_item_list(value):
3541
3546
 
3542
3547
  def _get_selected_timeline_items(tl):
3543
3548
  warnings = []
3544
- for method_name in ("GetSelectedTimelineItems", "GetSelectedItems", "GetSelectedClips"):
3549
+ # GetSelectedClips is the documented name since Resolve 21.0.4; the other two
3550
+ # are legacy speculative probes kept for older/renamed builds.
3551
+ for method_name in ("GetSelectedClips", "GetSelectedTimelineItems", "GetSelectedItems"):
3545
3552
  method = getattr(tl, method_name, None)
3546
3553
  if not callable(method):
3547
3554
  continue
@@ -7731,6 +7738,7 @@ _MEDIA_POOL_ITEM_METHODS = [
7731
7738
  "GetMarkInOut",
7732
7739
  "SetMarkInOut",
7733
7740
  "ClearMarkInOut",
7741
+ "GetTimeline",
7734
7742
  ]
7735
7743
 
7736
7744
  _MEDIA_POOL_METHODS = [
@@ -9006,6 +9014,66 @@ def _safe_int(value: Any, default: int, *, minimum: Optional[int] = None, maximu
9006
9014
  return parsed
9007
9015
 
9008
9016
 
9017
+ def _set_clip_color_checked(obj, color, *, kind: str):
9018
+ """SetClipColor, with the bare bool turned into something a caller can act on.
9019
+
9020
+ Two measured failures hide behind that bool (issue #124, enumerated live on
9021
+ Studio 19.1.3.7):
9022
+
9023
+ - A name outside the 16-name Edit-page palette is refused with `False` and
9024
+ nothing else. The marker constants are the only colour vocabulary the
9025
+ scripting reference enumerates, so they are what an agent reaches for, and
9026
+ most of them are refused.
9027
+ - On generator and title items the call returns `True` and the colour does
9028
+ not persist — `GetClipColor` still reads empty afterwards. Media-backed
9029
+ items on the same timeline persist correctly, so the bool is honest for
9030
+ some items and a lie for others.
9031
+
9032
+ So the return value is never trusted on its own: the colour is read back and
9033
+ the response reports what actually stuck.
9034
+ """
9035
+ returned = bool(obj.SetClipColor(color))
9036
+ readback = None
9037
+ if _has_method(obj, "GetClipColor"):
9038
+ try:
9039
+ readback = obj.GetClipColor()
9040
+ except Exception: # pragma: no cover - defensive; getter is documented
9041
+ readback = None
9042
+ persisted = (readback == color) if readback is not None else None
9043
+
9044
+ if not returned:
9045
+ refusal = _clip_colors.clip_color_refusal(color)
9046
+ return _err(
9047
+ f"SetClipColor refused '{color}' on this {kind}",
9048
+ code="CLIP_COLOR_REJECTED",
9049
+ category="invalid_input",
9050
+ reason=refusal["reason"],
9051
+ remediation=refusal["remediation"],
9052
+ state=refusal["state"],
9053
+ )
9054
+ if persisted is False:
9055
+ # The generator/title case. Reporting success here is the silent lie.
9056
+ return {
9057
+ "success": False,
9058
+ "color": color,
9059
+ "readback": readback,
9060
+ "warnings": [{
9061
+ "code": "CLIP_COLOR_NOT_PERSISTED",
9062
+ "message": (
9063
+ f"SetClipColor returned True on this {kind} but the colour did "
9064
+ f"not persist — GetClipColor reads {readback!r}. Measured on "
9065
+ "generator and title items, which take the call and drop it; "
9066
+ "media-backed items on the same timeline persist correctly."
9067
+ ),
9068
+ "remediation": (
9069
+ "Colour a media-backed item, or mark the generator another way "
9070
+ "(a timeline marker at its start reads back reliably)."
9071
+ ),
9072
+ }],
9073
+ }
9074
+ return {"success": True, "color": color, "readback": readback}
9075
+
9076
+
9009
9077
  def _filter_to_keys(settings: Any, allowed) -> Tuple[Dict[str, Any], list]:
9010
9078
  """Whitelist a settings/options dict to the keys a Resolve API documents.
9011
9079
 
@@ -13486,8 +13554,18 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
13486
13554
  ignore_mcp_update() -> {success, version, update, decision}
13487
13555
  snooze_mcp_update(hours?) -> {success, version, update, decision}
13488
13556
  clear_mcp_update_preferences() -> {success, version, update, decision}
13489
- api_truth(query?) -> {verified_on, count, facts} — look up behaviorally-verified
13490
- facts about quirky/unreliable Resolve API behavior (no connection needed).
13557
+ api_truth(query?, resolve_version?) -> {verified_on, count, facts, live_version?}
13558
+ — look up behaviorally-verified facts about quirky/unreliable Resolve API
13559
+ behavior (no connection needed; never connects). When a build is already
13560
+ connected or resolve_version is passed, each fact carries a
13561
+ version_context saying whether it was measured on an older, newer or
13562
+ identical build — the API changes per PATCH release, so a fact from
13563
+ another build is a prior, not a finding.
13564
+ check_version_support(symbol?, resolve_version?) -> {live_version, support|unavailable_on_this_build}
13565
+ — does THIS build have that method? Answers "unknown" unless a gate is
13566
+ recorded, because most of the API has never been version-bisected and a
13567
+ false "available" is how an agent insists a missing method exists.
13568
+ Omit symbol for every recorded gate this build does not clear.
13491
13569
  verification_stats() -> {stats} — readback-verification tally
13492
13570
  (verified/contradicted/unverified) since server start (no connection needed).
13493
13571
  job_status(job_id) -> {id, label, status, result?, error?, started_at, ended_at}
@@ -13515,10 +13593,72 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
13515
13593
  """
13516
13594
  p = _params(params)
13517
13595
 
13518
- # api_truth is a static knowledge lookup — no Resolve connection needed.
13596
+ # api_truth is a static knowledge lookup — no Resolve connection needed, and
13597
+ # that property is worth keeping: it is the one call that still answers when
13598
+ # Resolve is down or unreachable. So the live build is used only if one is
13599
+ # ALREADY connected, or if the caller names it. Never connect for this.
13519
13600
  if action == "api_truth":
13520
13601
  facts = lookup_api_truth(p.get("query"))
13521
- return {"verified_on": _API_TRUTH_VERIFIED_ON, "count": len(facts), "facts": facts}
13602
+ live_version = p.get("resolve_version")
13603
+ if not live_version and resolve is not None:
13604
+ try:
13605
+ live_version = resolve.GetVersionString()
13606
+ except Exception:
13607
+ live_version = None
13608
+ result = {
13609
+ "verified_on": _API_TRUTH_VERIFIED_ON,
13610
+ "count": len(facts),
13611
+ "facts": _resolve_versions.annotate_facts(
13612
+ facts, live_version, ledger_verified_on=_API_TRUTH_VERIFIED_ON
13613
+ ),
13614
+ }
13615
+ if live_version:
13616
+ result["live_version"] = str(live_version)
13617
+ unavailable = _resolve_versions.gates_unavailable_on(live_version)
13618
+ if unavailable:
13619
+ result["unavailable_on_this_build"] = unavailable
13620
+ else:
13621
+ result["version_context"] = (
13622
+ "No live build known, so facts are unannotated. The scripting API "
13623
+ "changes per PATCH release, so a fact measured on another build is a "
13624
+ "prior rather than a finding. Pass resolve_version, or call "
13625
+ "get_version first."
13626
+ )
13627
+ return result
13628
+ if action == "check_version_support":
13629
+ # "Does THIS build have it?" asked directly, instead of inferred from
13630
+ # prose. Answers `unknown` unless a gate is recorded — see
13631
+ # utils/resolve_versions.py for why that default is the load-bearing one.
13632
+ live_version = p.get("resolve_version")
13633
+ if not live_version and resolve is not None:
13634
+ try:
13635
+ live_version = resolve.GetVersionString()
13636
+ except Exception:
13637
+ live_version = None
13638
+ if not live_version:
13639
+ return _err(
13640
+ "No Resolve version available",
13641
+ code="VERSION_UNKNOWN",
13642
+ category="invalid_input",
13643
+ reason="Nothing is connected and no resolve_version was passed.",
13644
+ remediation="Call get_version first, or pass resolve_version='21.0.4.5'.",
13645
+ )
13646
+ symbol = p.get("symbol")
13647
+ if symbol:
13648
+ return {
13649
+ "live_version": str(live_version),
13650
+ "support": _resolve_versions.availability(symbol, live_version),
13651
+ }
13652
+ return {
13653
+ "live_version": str(live_version),
13654
+ "unavailable_on_this_build": _resolve_versions.gates_unavailable_on(live_version),
13655
+ "known_gates": len(_resolve_versions.VERSION_GATES),
13656
+ "note": (
13657
+ "Only surfaces with recorded evidence appear here. Most of the "
13658
+ "scripting API has never been version-bisected, so an absence from "
13659
+ "this list is not a promise that a method exists."
13660
+ ),
13661
+ }
13522
13662
  if action == "verification_stats":
13523
13663
  # Process-level readback-verification tally — no connection needed.
13524
13664
  stats = _verification_stats()
@@ -13676,7 +13816,7 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
13676
13816
  return missing
13677
13817
  r.DisableBackgroundTasksForCurrentResolveSession()
13678
13818
  return _ok()
13679
- return _unknown(action, ["launch","runtime_mode","get_version","api_truth","verification_stats","job_status","list_jobs","mcp_update_status","set_mcp_update_policy","ignore_mcp_update","snooze_mcp_update","clear_mcp_update_preferences","get_page","open_page","get_keyframe_mode","set_keyframe_mode","quit","get_fairlight_presets","set_high_priority","disable_background_tasks_for_current_session","open_control_panel","control_panel_status","close_control_panel","save_state","restore_state"])
13819
+ return _unknown(action, ["launch","runtime_mode","get_version","api_truth","check_version_support","verification_stats","job_status","list_jobs","mcp_update_status","set_mcp_update_policy","ignore_mcp_update","snooze_mcp_update","clear_mcp_update_preferences","get_page","open_page","get_keyframe_mode","set_keyframe_mode","quit","get_fairlight_presets","set_high_priority","disable_background_tasks_for_current_session","open_control_panel","control_panel_status","close_control_panel","save_state","restore_state"])
13680
13820
 
13681
13821
 
13682
13822
  # ─── V2 C4: Per-field corrections with provenance + changelog ────────────────
@@ -15967,6 +16107,9 @@ _RENDER_SETTING_KEYS = [
15967
16107
  "ReplaceExistingFilesInPlace",
15968
16108
  "ExportSubtitle",
15969
16109
  "SubtitleFormat",
16110
+ "UseFullExtents",
16111
+ "AddFrameHandles",
16112
+ "DataBurnIn",
15970
16113
  ]
15971
16114
 
15972
16115
  _RENDER_KERNEL_ACTIONS = [
@@ -16186,6 +16329,24 @@ def _render_preset_pin(proj, preset_name: str):
16186
16329
  return {"preset": preset_name, "loaded": True}, None
16187
16330
 
16188
16331
 
16332
+ def _render_settings_warnings(settings: Dict[str, Any]):
16333
+ """Inter-key combinations Resolve accepts and then silently ignores.
16334
+
16335
+ SetRenderSettings returns True for these, so nothing downstream would ever
16336
+ tell the caller the key did nothing — the same silent-no-op class as the
16337
+ unknown-key drop that `_filter_to_keys` covers on the set path.
16338
+ """
16339
+ warnings = []
16340
+ if not isinstance(settings, dict):
16341
+ return warnings
16342
+ if settings.get("UseFullExtents") is True and isinstance(settings.get("AddFrameHandles"), int) and settings["AddFrameHandles"] > 0:
16343
+ warnings.append(
16344
+ "AddFrameHandles is ignored while UseFullExtents is true: Resolve renders the "
16345
+ "clip's full extents and the handle count silently does nothing. Drop one of the two."
16346
+ )
16347
+ return warnings
16348
+
16349
+
16189
16350
  def _validate_render_settings_payload(settings: Dict[str, Any], *, require_temp_target: bool = False):
16190
16351
  if not isinstance(settings, dict) or not settings:
16191
16352
  return None, _err("settings must be a non-empty object")
@@ -16201,15 +16362,20 @@ def _validate_render_settings_payload(settings: Dict[str, Any], *, require_temp_
16201
16362
  errors.append("TargetDir must be under the system temp directory for this safe operation")
16202
16363
  elif require_temp_target:
16203
16364
  errors.append("TargetDir is required for this safe operation")
16204
- for key in ("FormatWidth", "FormatHeight", "MarkIn", "MarkOut", "AudioBitDepth", "AudioSampleRate"):
16365
+ for key in ("FormatWidth", "FormatHeight", "MarkIn", "MarkOut", "AudioBitDepth", "AudioSampleRate", "AddFrameHandles"):
16205
16366
  if key in settings and not isinstance(settings[key], int):
16206
16367
  errors.append(f"{key} must be an integer")
16207
- for key in ("SelectAllFrames", "ExportVideo", "ExportAudio", "ExportAlpha", "MultiPassEncode", "NetworkOptimization", "ReplaceExistingFilesInPlace", "ExportSubtitle"):
16368
+ for key in ("SelectAllFrames", "ExportVideo", "ExportAudio", "ExportAlpha", "MultiPassEncode", "NetworkOptimization", "ReplaceExistingFilesInPlace", "ExportSubtitle", "UseFullExtents"):
16208
16369
  if key in settings and not isinstance(settings[key], bool):
16209
16370
  errors.append(f"{key} must be a boolean")
16371
+ if "AddFrameHandles" in settings and isinstance(settings["AddFrameHandles"], int) and settings["AddFrameHandles"] < 0:
16372
+ errors.append("AddFrameHandles must be >= 0")
16373
+ if "DataBurnIn" in settings and not isinstance(settings["DataBurnIn"], str):
16374
+ errors.append("DataBurnIn must be a string (a burn-in preset name, 'Same as project', or 'None')")
16210
16375
  if "MarkIn" in settings and "MarkOut" in settings and settings["MarkOut"] < settings["MarkIn"]:
16211
16376
  errors.append("MarkOut must be greater than or equal to MarkIn")
16212
- result = {"valid": not errors, "unknown_keys": unknown, "errors": errors, "settings": dict(settings)}
16377
+ result = {"valid": not errors, "unknown_keys": unknown, "errors": errors,
16378
+ "warnings": _render_settings_warnings(settings), "settings": dict(settings)}
16213
16379
  return result, None
16214
16380
 
16215
16381
 
@@ -16660,7 +16826,12 @@ def render(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
16660
16826
  set_mode(mode) -> {success}
16661
16827
  get_resolutions(format, codec) -> {resolutions}
16662
16828
  get_settings() -> {settings} (alias for set_render_settings with get)
16663
- set_settings(settings) -> {success}
16829
+ set_settings(settings) -> {success, ignored_settings?, warnings?}
16830
+ Resolve 21.0.4+ settings keys: UseFullExtents (bool),
16831
+ AddFrameHandles (int >= 0), DataBurnIn (burn-in preset name,
16832
+ "Same as project", or "None"). AddFrameHandles is ignored while
16833
+ UseFullExtents is true — the call still succeeds, so that pairing
16834
+ comes back in warnings rather than silently doing nothing.
16664
16835
  list_presets() -> {presets}
16665
16836
  load_preset(name) -> {success}
16666
16837
  save_preset(name) -> {success}
@@ -16784,6 +16955,11 @@ def render(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
16784
16955
  result = {"success": bool(proj.SetRenderSettings(settings))}
16785
16956
  if ignored_settings:
16786
16957
  result["ignored_settings"] = ignored_settings
16958
+ # Accepted-then-ignored key combinations (21.0.4 AddFrameHandles under
16959
+ # UseFullExtents) read as a clean success without this.
16960
+ warnings = _render_settings_warnings(settings)
16961
+ if warnings:
16962
+ result["warnings"] = warnings
16787
16963
  return result
16788
16964
  elif action == "list_presets":
16789
16965
  return {"presets": proj.GetRenderPresetList()}
@@ -17611,6 +17787,9 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
17611
17787
  get_mark_in_out(clip_id) -> {mark}
17612
17788
  set_mark_in_out(clip_id, mark_in, mark_out, type?) -> {success}
17613
17789
  clear_mark_in_out(clip_id, type?) -> {success}
17790
+ get_timeline(clip_id) -> {is_timeline, timeline: {name, unique_id?, start_frame?, end_frame?}}
17791
+ — Resolve 21.0.4+. Resolves a Media Pool timeline entry to its timeline
17792
+ object summary; is_timeline=false for ordinary clips.
17614
17793
  open_in_viewer(clip_id, page?, mark_in_seconds?, mark_out_seconds?, clear_marks?) -> {success, clip_id, clip_name, folder_name, page, mark_set}
17615
17794
  — Switches to Media page (default) and selects the clip in the bin.
17616
17795
  Resolve auto-loads the selected clip into the source viewer on Media page.
@@ -17783,7 +17962,7 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
17783
17962
  elif action == "get_clip_color":
17784
17963
  return {"color": clip.GetClipColor()}
17785
17964
  elif action == "set_clip_color":
17786
- return {"success": bool(clip.SetClipColor(p["color"]))}
17965
+ return _set_clip_color_checked(clip, p["color"], kind="media pool item")
17787
17966
  elif action == "clear_clip_color":
17788
17967
  return {"success": bool(clip.ClearClipColor())}
17789
17968
  elif action == "link_proxy":
@@ -17939,7 +18118,28 @@ def media_pool_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
17939
18118
  return {"success": bool(clip.SetMarkInOut(clean["mark_in"], clean["mark_out"], p.get("type", "all")))}
17940
18119
  elif action == "clear_mark_in_out":
17941
18120
  return {"success": bool(clip.ClearMarkInOut(p.get("type", "all")))}
17942
- return _unknown(action, ["get_name","get_metadata","set_metadata","get_third_party_metadata","set_third_party_metadata","get_media_id","get_clip_property","set_clip_property","get_clip_color","set_clip_color","clear_clip_color","link_proxy","unlink_proxy","replace_clip","set_name","link_full_resolution_media","monitor_growing_file","replace_clip_preserve_sub_clip","get_unique_id","transcribe_audio","clear_transcription","get_transcription","extract_frames","perform_audio_classification","clear_audio_classification","analyze_for_intellisearch","analyze_for_slate","remove_motion_blur","get_audio_mapping","get_mark_in_out","set_mark_in_out","clear_mark_in_out","open_in_viewer"])
18121
+ elif action == "get_timeline":
18122
+ missing = _requires_method(clip, "GetTimeline", "21.0.4")
18123
+ if missing:
18124
+ return missing
18125
+ try:
18126
+ tl_obj = clip.GetTimeline()
18127
+ except Exception as exc:
18128
+ return _err(f"GetTimeline failed: {exc}")
18129
+ if not tl_obj:
18130
+ return {"is_timeline": False, "timeline": None,
18131
+ "note": "This media pool item is not a timeline entry."}
18132
+ summary = {}
18133
+ for getter, key in (("GetName", "name"), ("GetUniqueId", "unique_id"),
18134
+ ("GetStartFrame", "start_frame"), ("GetEndFrame", "end_frame")):
18135
+ if _has_method(tl_obj, getter):
18136
+ try:
18137
+ summary[key] = getattr(tl_obj, getter)()
18138
+ except Exception:
18139
+ pass
18140
+ return {"is_timeline": True, "timeline": summary,
18141
+ "note": "Address this timeline by name via the timeline tool."}
18142
+ return _unknown(action, ["get_name","get_metadata","set_metadata","get_third_party_metadata","set_third_party_metadata","get_media_id","get_clip_property","set_clip_property","get_clip_color","set_clip_color","clear_clip_color","link_proxy","unlink_proxy","replace_clip","set_name","link_full_resolution_media","monitor_growing_file","replace_clip_preserve_sub_clip","get_unique_id","transcribe_audio","clear_transcription","get_transcription","extract_frames","perform_audio_classification","clear_audio_classification","analyze_for_intellisearch","analyze_for_slate","remove_motion_blur","get_audio_mapping","get_mark_in_out","set_mark_in_out","clear_mark_in_out","open_in_viewer","get_timeline"])
17943
18143
 
17944
18144
 
17945
18145
  # ═══════════════════════════════════════════════════════════════════════════════
@@ -22251,7 +22451,7 @@ def timeline_item_markers(action: str, params: Optional[Dict[str, Any]] = None)
22251
22451
  elif action == "get_clip_color":
22252
22452
  return {"color": item.GetClipColor()}
22253
22453
  elif action == "set_clip_color":
22254
- return {"success": bool(item.SetClipColor(p["color"]))}
22454
+ return _set_clip_color_checked(item, p["color"], kind="timeline item")
22255
22455
  elif action == "clear_clip_color":
22256
22456
  return {"success": bool(item.ClearClipColor())}
22257
22457
  return _unknown(action, ["add","get_all","get_by_custom_data","update_custom_data","get_custom_data","delete_by_color","delete_at_frame","delete_by_custom_data","add_flag","get_flags","clear_flags","get_clip_color","set_clip_color","clear_clip_color"])
@@ -863,7 +863,10 @@ API_TRUTH: List[Dict[str, Any]] = [
863
863
  "mix CAN be applied wholesale via "
864
864
  "Project.ApplyFairlightPresetToCurrentTimeline(name), with the "
865
865
  "available names from Resolve.GetFairlightPresets() — so "
866
- "'no Fairlight write path exists' would be too strong.",
866
+ "'no Fairlight write path exists' would be too strong. Both "
867
+ "methods require Resolve 20.2.2+; on 19.1.3 they are absent "
868
+ "(confirmed live 2026-08-06), so on older builds the "
869
+ "per-parameter gap really is the whole story.",
867
870
  "recommended": "To reapply a known mix, save it once as a Fairlight preset "
868
871
  "in the UI and apply it per-timeline with "
869
872
  "ApplyFairlightPresetToCurrentTimeline (exposed as "
@@ -1005,7 +1008,8 @@ API_TRUTH: List[Dict[str, Any]] = [
1005
1008
  "a normalised position vector (param 17). Exposed as "
1006
1009
  "project_db list_subtitle_styles / set_subtitle_style "
1007
1010
  "(font family/size/weight/italic + position). Confirmed "
1008
- "live on 21.0 (2026-08-06): Resolve opens a patched track "
1011
+ "live on BOTH editions 2026-08-06 — Studio 19.1.3 and "
1012
+ "free 21.0.3, identical behaviour: Resolve opens a patched track "
1009
1013
  "and re-serialises it back to its own zstd form with the "
1010
1014
  "patched values intact, so it genuinely parses the write. "
1011
1015
  "Caveats: whole-TRACK style not per-caption, project must "
@@ -1067,13 +1071,29 @@ API_TRUTH: List[Dict[str, Any]] = [
1067
1071
  "NOT refute the 21.0.0 record: if the bridge resolves any name "
1068
1072
  "known to the RemoteObject method table rather than literally any "
1069
1073
  "string, an invented name is correctly rejected on both builds and "
1070
- "the probe never exercised the failing case. Re-running the probe "
1071
- "with those five real names is what would settle it; until then, "
1072
- "assume fabrication is possible.",
1074
+ "the probe never exercised the failing case. "
1075
+ "PARTLY SETTLED 2026-08-06 on Studio 19.1.3.7, DIRECT connection "
1076
+ "(not the bridge): the five real borrowed names were re-run as the "
1077
+ "entry asked, across Resolve, ProjectManager, Project, MediaPool, "
1078
+ "Timeline, Folder and TimelineItem — 42 checks. Every one returned "
1079
+ "getattr-callable False and dir()-absent, agreeing in all 42 cases, "
1080
+ "exactly like the invented control. So NO fabrication on the direct "
1081
+ "path on this build, and the borrowed-name case the 21.0.2.4 probe "
1082
+ "missed is now covered there. What the same run DID establish is "
1083
+ "narrower and worse than expected: bare hasattr() returns True for "
1084
+ "EVERY name on every object — real, borrowed or invented — while "
1085
+ "getattr returns None. hasattr alone is therefore not a weak probe, "
1086
+ "it is a constant True and carries no information at all. "
1087
+ "STILL OPEN: the 21.0.0 record was against the BRIDGE, which this "
1088
+ "run did not exercise, so bridge-side fabrication remains untested "
1089
+ "and the recommendation below stands unchanged.",
1073
1090
  "recommended": "Use dir(obj) membership for capability probes. It is correct "
1074
1091
  "on every build measured, and it is the only form not affected "
1075
- "by whichever way this resolves. server._has_method uses "
1076
- "hasattr/getattr and so may over-report on builds where "
1092
+ "by whichever way this resolves. NEVER use bare hasattr(): it "
1093
+ "is a constant True on Resolve objects and cannot distinguish "
1094
+ "anything. server._has_method uses callable(getattr(...)), "
1095
+ "which agreed with dir() in all 42 direct-path checks on "
1096
+ "19.1.3.7 but may still over-report on a bridge build where "
1077
1097
  "fabrication is live — that is the case _requires_method gates "
1078
1098
  "guard, so it matters most exactly where it is least tested. "
1079
1099
  "Calling a fabricated method typically returns None/False with "
@@ -1421,6 +1441,65 @@ API_TRUTH: List[Dict[str, Any]] = [
1421
1441
  "issue": 59,
1422
1442
  "mitigation": ["_render_format_id", "_render_codec_id"],
1423
1443
  },
1444
+ {
1445
+ "symbol": "SetClipColor (undocumented value space; marker constants are a decoy)",
1446
+ "object": "TimelineItem / MediaPoolItem",
1447
+ "signature": "(colorName) -> bool",
1448
+ "reality": "SetClipColor accepts exactly 16 names — the Edit-page clip-colour "
1449
+ "palette — and refuses everything else with a bare False, no "
1450
+ "exception and no other signal. Enumerated live on Studio "
1451
+ "19.1.3.7 (2026-08-06) against both objects; the accepted set is "
1452
+ "IDENTICAL on TimelineItem and MediaPoolItem: Orange, Apricot, "
1453
+ "Yellow, Lime, Olive, Green, Teal, Navy, Blue, Purple, Violet, "
1454
+ "Pink, Tan, Beige, Brown, Chocolate. The empty string is refused "
1455
+ "too. The trap is that the scripting reference documents "
1456
+ "colorName as a bare string with no enumerated values, while "
1457
+ "exporting the MARKER palette as constants (resolve.MARKER_ROSE, "
1458
+ "resolve.MARKER_FUCHSIA, ...) — so the only colour vocabulary "
1459
+ "reachable from the API surface is the wrong one. Cyan, Red, "
1460
+ "Fuchsia, Rose, Lavender, Sky, Mint, Lemon, Sand, Cocoa and Cream "
1461
+ "are all marker-only and all refused. Five names overlap both "
1462
+ "palettes (Blue, Green, Yellow, Pink, Purple), which is why the "
1463
+ "decoy survives: reasoning from the marker constants scores 5 of "
1464
+ "16 and looks like the right vocabulary with a few gaps. The "
1465
+ "reporter's field experience sharpens this — the trap is not that "
1466
+ "a decoy vocabulary exists, it is that the decoy HALF-WORKS. "
1467
+ "Intermittent successes read as an unreliable API rather than as "
1468
+ "a wrong vocabulary, so the wrong conclusion is the natural one; "
1469
+ "a clean 0-for-8 would have exposed the mechanism immediately.",
1470
+ "recommended": "Pass only the 16 clip-colour names; on False, treat it as an "
1471
+ "invalid name rather than an item/lock/page problem. The set "
1472
+ "is pinned in utils/clip_colors.py and named in the refusal "
1473
+ "remediation, but deliberately NOT enforced — it was measured "
1474
+ "on one build and a later Resolve could extend it.",
1475
+ "tags": ["timeline-item", "media-pool", "silent-failure", "undocumented-enum"],
1476
+ "submit": "missing",
1477
+ "issue": 124,
1478
+ "mitigation": ["_set_clip_color_checked", "ti_set_clip_color"],
1479
+ },
1480
+ {
1481
+ "symbol": "TimelineItem.SetClipColor on generator/title items",
1482
+ "object": "TimelineItem",
1483
+ "signature": "(colorName) -> bool",
1484
+ "reality": "On a generator or title item SetClipColor returns True with a "
1485
+ "VALID colour name and the colour does not persist — GetClipColor "
1486
+ "still reads '' immediately afterwards, and ClearClipColor "
1487
+ "changes nothing because there is nothing to clear. Measured on "
1488
+ "Studio 19.1.3.7 (2026-08-06) on a Solid Color generator. A "
1489
+ "media-backed item on the SAME timeline in the same session "
1490
+ "persists correctly (SetClipColor('Teal') -> True, GetClipColor "
1491
+ "-> 'Teal'), so the bool is honest for some items and a lie for "
1492
+ "others, with nothing in the return value to tell them apart. A "
1493
+ "timeline item's colour is also independent of its backing "
1494
+ "MediaPoolItem's: colouring the item leaves the pool clip at ''.",
1495
+ "recommended": "Never trust the bool alone — read GetClipColor back and "
1496
+ "compare. To mark a generator or title, use a timeline marker "
1497
+ "at its start instead; markers read back reliably.",
1498
+ "tags": ["timeline-item", "silent-failure", "generator", "readback-lies"],
1499
+ "submit": "bug",
1500
+ "issue": 124,
1501
+ "mitigation": ["_set_clip_color_checked", "ti_set_clip_color"],
1502
+ },
1424
1503
  {
1425
1504
  "symbol": "Project.SetRenderSettings (inherits the loaded preset)",
1426
1505
  "object": "Project",
@@ -0,0 +1,94 @@
1
+ """The value space of SetClipColor, and an honest refusal when a name misses.
2
+
3
+ `SetClipColor(colorName) --> Bool` is documented with `colorName` as a bare
4
+ string and no enumerated values anywhere in the scripting reference. The same
5
+ reference *does* export a discoverable colour vocabulary — the marker constants
6
+ (`resolve.MARKER_ROSE`, `resolve.MARKER_FUCHSIA`, …) — so the only colour names
7
+ reachable from the API surface come from the marker palette, and those are
8
+ largely the ones `SetClipColor` refuses. Issue #124 named this a decoy
9
+ vocabulary, which is exactly right.
10
+
11
+ Enumerated live on **DaVinci Resolve Studio 19.1.3.7 (macOS, 2026-08-06)**
12
+ against both objects that expose the method. Both accept the same 16 names —
13
+ the Edit-page clip-colour palette — and refuse everything else with a bare
14
+ `False`, including every marker-only name and the empty string.
15
+
16
+ The five names that overlap the two palettes (Blue, Green, Yellow, Pink,
17
+ Purple) are what makes this trap so durable: an agent reasoning from the marker
18
+ constants gets five hits out of sixteen and concludes the vocabulary is right.
19
+
20
+ This set is deliberately NOT enforced. It was measured on one build, and a
21
+ future Resolve could extend it; hard-rejecting an unlisted name would turn a
22
+ working call into a failure on a build we have not seen. Callers pass the name
23
+ through, and when Resolve refuses, the remediation names the measured-valid set.
24
+ """
25
+
26
+ VERIFIED_ON = "DaVinci Resolve Studio 19.1.3.7 (macOS, 2026-08-06)"
27
+
28
+ # Accepted by BOTH TimelineItem.SetClipColor and MediaPoolItem.SetClipColor.
29
+ CLIP_COLORS = (
30
+ "Orange", "Apricot", "Yellow", "Lime", "Olive", "Green", "Teal", "Navy",
31
+ "Blue", "Purple", "Violet", "Pink", "Tan", "Beige", "Brown", "Chocolate",
32
+ )
33
+
34
+ # Measured refused. Kept explicit because these are the names an agent actually
35
+ # reaches for: every one is either a marker constant or a common colour word.
36
+ REFUSED_NAMES = (
37
+ "Cyan", "Red", "Fuchsia", "Rose", "Lavender", "Sky", "Mint", "Lemon",
38
+ "Sand", "Cocoa", "Cream", "Apple", "Magenta", "White", "Black", "Gray",
39
+ "Grey", "",
40
+ )
41
+
42
+ # The marker-palette names that are NOT clip colours — the decoy set proper.
43
+ MARKER_ONLY_NAMES = (
44
+ "Cyan", "Red", "Fuchsia", "Rose", "Lavender", "Sky", "Mint", "Lemon",
45
+ "Sand", "Cocoa", "Cream",
46
+ )
47
+
48
+
49
+ def is_known_clip_color(color) -> bool:
50
+ """True if `color` is in the measured-valid set. Case-sensitive, as Resolve is."""
51
+ return isinstance(color, str) and color in CLIP_COLORS
52
+
53
+
54
+ def clip_color_refusal(color) -> dict:
55
+ """Error payload for a SetClipColor that came back False.
56
+
57
+ Separates 'you used a marker name' from 'Resolve refused for another
58
+ reason', because the caller cannot tell them apart from the bare bool.
59
+ """
60
+ marker_decoy = isinstance(color, str) and color in MARKER_ONLY_NAMES
61
+ if marker_decoy:
62
+ reason = (
63
+ f"'{color}' is a MARKER colour, not a clip colour. The scripting "
64
+ "reference enumerates the marker constants and nothing else, so it "
65
+ "is the vocabulary an agent finds first — and SetClipColor refuses "
66
+ "most of it with a bare False."
67
+ )
68
+ elif is_known_clip_color(color):
69
+ reason = (
70
+ f"'{color}' is a valid clip colour on {VERIFIED_ON}, so this refusal "
71
+ "is about the item or the session, not the name. Note that "
72
+ "SetClipColor returns True WITHOUT persisting on generator and title "
73
+ "items — check GetClipColor rather than the bool."
74
+ )
75
+ else:
76
+ reason = (
77
+ f"'{color}' is not in the clip-colour palette. SetClipColor refuses "
78
+ "undocumented names with a bare False and no other signal."
79
+ )
80
+ return {
81
+ "reason": reason,
82
+ "remediation": (
83
+ "Use one of the 16 Edit-page clip colours: "
84
+ + ", ".join(CLIP_COLORS)
85
+ + f". Measured on {VERIFIED_ON}; the set is not enforced here because "
86
+ "a later build could extend it."
87
+ ),
88
+ "state": {
89
+ "requested_color": color,
90
+ "valid_colors": list(CLIP_COLORS),
91
+ "is_marker_only_name": marker_decoy,
92
+ "verified_on": VERIFIED_ON,
93
+ },
94
+ }
@@ -1742,6 +1742,12 @@ def plan_transcript_tighten(
1742
1742
  plan.get("keep_ranges") or [],
1743
1743
  source_duration_seconds=float(duration) if isinstance(duration, (int, float)) and duration else None,
1744
1744
  )
1745
+ # The class this planner is structurally blind to. Its false-start heuristic
1746
+ # only sees restarts the transcript actually reports; an immediate re-read is
1747
+ # emitted ONCE with the pause and second take absorbed into one word, so the
1748
+ # plan cannot propose removing what it was never told happened (issue #125).
1749
+ from src.utils import swallowed_retakes as _swallowed
1750
+ plan["possible_swallowed_retakes"] = _swallowed.swallowed_retake_report(words)
1745
1751
  return plan
1746
1752
 
1747
1753
 
@@ -55,8 +55,10 @@ logger = logging.getLogger("resolve-mcp.bridge-client")
55
55
  DEFAULT_CONFIG_PATH = Path.home() / ".config/davinci-resolve-mcp/bridge.json"
56
56
  #: Env override, so a caller can point at a non-default bridge without editing code.
57
57
  ENV_CONFIG_PATH = "DAVINCI_RESOLVE_BRIDGE_CONFIG"
58
- #: Opt-in. Absent means "do not try the bridge", so nothing changes for existing
59
- #: installs until someone asks for it.
58
+ #: Forces the bridge: it becomes the only transport tried, so its faults surface
59
+ #: directly. Absent does NOT mean "never try the bridge" — `connect_resolve` falls
60
+ #: back to it when a direct transport yields nothing, which is what lets the free
61
+ #: edition work with no configuration beyond starting the in-Resolve script.
60
62
  ENV_ENABLE = "DAVINCI_RESOLVE_BRIDGE"
61
63
 
62
64
  #: Operations this client cannot work without. The in-Resolve script is a *copy*
@@ -390,7 +392,8 @@ def connect(*, timeout: float = DEFAULT_TIMEOUT_SECONDS, require_enabled: bool =
390
392
  """
391
393
  if require_enabled and not bridge_enabled():
392
394
  raise BridgeUnavailable(
393
- f"The in-app bridge is opt-in: set {ENV_ENABLE}=1 to use it."
395
+ f"This caller required an explicitly-enabled bridge: set {ENV_ENABLE}=1, "
396
+ "or call connect(require_enabled=False) to use the bridge as a fallback."
394
397
  )
395
398
  path = config_path()
396
399
  try: