davinci-resolve-mcp 2.55.0 → 2.55.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md 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.55.2
6
+
7
+ Deep-QC P1 1b — required-param validation. Mutating/destructive actions that
8
+ hard-indexed required params (`p["name"]`, `p["index"]`, `p["color"]`, …) now
9
+ return a structured error instead of crashing with an unhandled `KeyError` (or,
10
+ for `set_clip_enabled`/`set_current`, coercing the value). Guarded actions:
11
+
12
+ - `timeline`: `set_current`, `set_name`, `set_start_timecode`, `add_track`
13
+ - `timeline_item`: `set_property`, `set_clip_enabled`
14
+ - `project_manager`: `create`, `load`, `import_project`, `export_project`,
15
+ `archive`, `restore`
16
+ - marker `delete_by_color` (clip / timeline / item — destructive)
17
+ - `layout_presets`: `save`, `export`; `project_settings`: `set_name`, `set_setting`
18
+
19
+ ## What's New in v2.55.1
20
+
21
+ Deep-QC P1 — settings/options whitelisting + DeleteProject reliability.
22
+
23
+ - **Fixed** options/settings dicts passed to several Resolve APIs are now
24
+ whitelisted to their documented keys, so a typo'd key is reported instead of
25
+ silently dropped (`_filter_to_keys` → `ignored_options`/`ignored_settings`):
26
+ `media_pool.import_timeline` (ImportTimelineFromFile), `timeline.import_into_timeline`
27
+ (ImportIntoTimeline), raw `render.set_settings` (SetRenderSettings) and
28
+ `render.quick_export`, and `set_voice_isolation_state` (track + item).
29
+ - **Fixed** `ProjectManager.DeleteProject` is flaky — it silently returns False
30
+ when the target is/was the current project. All callers (the safe and raw
31
+ `project_manager.delete` actions and the granular `delete_project`) now route
32
+ through `delete_project_safely` (switch-away + retry) and report `delete_detail`.
33
+ Added an `api_truth` entry.
34
+ - **Added** a destructive-registry drift guard (`tests/test_destructive_registry_drift.py`)
35
+ that asserts token-gated actions map to real handlers and locks in the media-pool
36
+ delete governance from v2.55.0. (It also surfaced a pre-existing registry
37
+ mis-keying issue now tracked for a dedicated audit.)
38
+
5
39
  ## What's New in v2.55.0
6
40
 
7
41
  Governance for catastrophic Media Pool deletes (exhaustive audit EX2/EX3).
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.55.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.55.2-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
@@ -35,7 +35,7 @@ from src.utils.update_check import (
35
35
 
36
36
  # ─── Version ──────────────────────────────────────────────────────────────────
37
37
 
38
- VERSION = "2.55.0"
38
+ VERSION = "2.55.2"
39
39
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
40
40
  # Resolve's scripting bridge loads into newer interpreters on recent builds
41
41
  # (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.55.0",
3
+ "version": "2.55.2",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -80,7 +80,7 @@ if not logging.getLogger().handlers:
80
80
  handlers=[logging.StreamHandler()],
81
81
  )
82
82
 
83
- VERSION = "2.55.0"
83
+ VERSION = "2.55.2"
84
84
  logger = logging.getLogger("davinci-resolve-mcp")
85
85
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
86
86
  logger.info(f"Detected platform: {get_platform()}")
@@ -786,8 +786,12 @@ def delete_project(project_name: str) -> Dict[str, Any]:
786
786
  if resolve is None:
787
787
  return {"error": "Not connected to DaVinci Resolve"}
788
788
  pm = resolve.GetProjectManager()
789
- result = pm.DeleteProject(project_name)
790
- return {"success": bool(result), "project_name": project_name}
789
+ # DeleteProject is flaky (silently returns False when the target is/was
790
+ # current, transient first-attempt failures); route through the retry+switch
791
+ # guard (#19).
792
+ from src.utils.project_cleanup import delete_project_safely
793
+ deleted = delete_project_safely(pm, project_name)
794
+ return {"success": bool(deleted.get("success")), "project_name": project_name, "delete_detail": deleted}
791
795
 
792
796
 
793
797
  @mcp.tool()
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.55.0"
14
+ VERSION = "2.55.2"
15
15
 
16
16
  import base64
17
17
  import os
@@ -7508,6 +7508,36 @@ def _safe_int(value: Any, default: int, *, minimum: Optional[int] = None, maximu
7508
7508
  return parsed
7509
7509
 
7510
7510
 
7511
+ def _filter_to_keys(settings: Any, allowed) -> Tuple[Dict[str, Any], list]:
7512
+ """Whitelist a settings/options dict to the keys a Resolve API documents.
7513
+
7514
+ Resolve silently ignores unrecognized keys in several options dicts (import
7515
+ options, render settings, voice-isolation state), so a typo'd key is dropped
7516
+ with no signal. This returns ``(filtered, ignored)`` — only known keys are
7517
+ forwarded; ``ignored`` lists the dropped keys so the caller can surface them
7518
+ in ``ignored_settings`` (P1 / deep-QC #4-6,23-25)."""
7519
+ if not isinstance(settings, dict):
7520
+ return {}, []
7521
+ allowed_set = set(allowed)
7522
+ filtered = {k: v for k, v in settings.items() if k in allowed_set}
7523
+ ignored = sorted(str(k) for k in settings if k not in allowed_set)
7524
+ return filtered, ignored
7525
+
7526
+
7527
+ # Documented option/state key sets (docs/reference/resolve_scripting_api.txt).
7528
+ _IMPORT_TIMELINE_OPTION_KEYS = frozenset({
7529
+ "timelineName", "importSourceClips", "sourceClipsPath",
7530
+ "sourceClipsFolders", "interlaceProcessing",
7531
+ })
7532
+ _IMPORT_INTO_TIMELINE_OPTION_KEYS = frozenset({
7533
+ "autoImportSourceClipsIntoMediaPool", "ignoreFileExtensionsWhenMatching",
7534
+ "linkToSourceCameraFiles", "useSizingInfo",
7535
+ "importMultiChannelAudioTracksAsLinkedGroups", "insertAdditionalTracks",
7536
+ "insertWithOffset", "sourceClipsPath", "sourceClipsFolders",
7537
+ })
7538
+ _VOICE_ISOLATION_STATE_KEYS = frozenset({"isEnabled", "amount"})
7539
+
7540
+
7511
7541
  def _setup_marker_limit(value: Any, default: int = 12) -> int:
7512
7542
  if isinstance(value, str) and _setup_text_key(value) in {"unlimited", "no_limit", "nolimit", "all"}:
7513
7543
  return 0
@@ -12931,12 +12961,20 @@ def layout_presets(action: str, params: Optional[Dict[str, Any]] = None) -> Dict
12931
12961
  return _err("Could not connect to DaVinci Resolve. It was not running and auto-launch failed. Check that Resolve Studio is installed.")
12932
12962
 
12933
12963
  if action == "save":
12964
+ if not p.get("name"):
12965
+ return _err("save requires name")
12934
12966
  return {"success": bool(r.SaveLayoutPreset(p["name"]))}
12935
12967
  elif action == "load":
12936
12968
  return {"success": bool(r.LoadLayoutPreset(p["name"]))}
12937
12969
  elif action == "update":
12938
12970
  return {"success": bool(r.UpdateLayoutPreset(p["name"]))}
12939
12971
  elif action == "export":
12972
+ err, _clean = _validate_params(p, {
12973
+ "name": {"type": str, "required": True, "non_empty": True},
12974
+ "path": {"type": str, "required": True, "non_empty": True},
12975
+ })
12976
+ if err:
12977
+ return _err(err)
12940
12978
  return {"success": bool(r.ExportLayoutPreset(p["name"], p["path"]))}
12941
12979
  elif action == "import_preset":
12942
12980
  if "name" in p:
@@ -13405,6 +13443,9 @@ def _safe_project_delete(pm, p: Dict[str, Any]) -> Dict[str, Any]:
13405
13443
  if p.get("dry_run"):
13406
13444
  return _ok(would_delete=True, name=name)
13407
13445
  current = pm.GetCurrentProject()
13446
+ # Route through delete_project_safely: DeleteProject silently returns False
13447
+ # when the target is/was current and is flaky on the first attempt (#19).
13448
+ from src.utils.project_cleanup import delete_project_safely
13408
13449
  current_name = current.GetName() if current and _has_method(current, "GetName") else None
13409
13450
  if current_name == name:
13410
13451
  if not p.get("close_current", False):
@@ -13413,11 +13454,13 @@ def _safe_project_delete(pm, p: Dict[str, Any]) -> Dict[str, Any]:
13413
13454
  closed = bool(pm.CloseProject(current))
13414
13455
  if not closed:
13415
13456
  return _err(f"Failed to close current project '{name}' before delete")
13416
- result = {"success": bool(pm.DeleteProject(name))}
13457
+ deleted = delete_project_safely(pm, name)
13458
+ result = {"success": bool(deleted.get("success")), "delete_detail": deleted}
13417
13459
  if saved is False:
13418
13460
  result["warning"] = "SaveProject returned False before close; unsaved changes may have been discarded"
13419
13461
  return result
13420
- return {"success": bool(pm.DeleteProject(name))}
13462
+ deleted = delete_project_safely(pm, name)
13463
+ return {"success": bool(deleted.get("success")), "delete_detail": deleted}
13421
13464
 
13422
13465
 
13423
13466
  def _database_capabilities(pm) -> Dict[str, Any]:
@@ -13947,6 +13990,8 @@ def project_manager(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
13947
13990
  proj = pm.GetCurrentProject()
13948
13991
  return {"name": proj.GetName(), "id": proj.GetUniqueId()} if proj else _err("No project open")
13949
13992
  elif action == "create":
13993
+ if not p.get("name"):
13994
+ return _err("create requires name")
13950
13995
  media_location_path = p.get("media_location_path") or p.get("mediaLocationPath")
13951
13996
  if media_location_path:
13952
13997
  version = r.GetVersion() or [0]
@@ -13955,6 +14000,8 @@ def project_manager(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
13955
14000
  proj = pm.CreateProject(p["name"], media_location_path) if media_location_path else pm.CreateProject(p["name"])
13956
14001
  return _ok(name=proj.GetName()) if proj else _err(f"Failed to create '{p['name']}'")
13957
14002
  elif action == "load":
14003
+ if not p.get("name"):
14004
+ return _err("load requires name")
13958
14005
  proj = pm.LoadProject(p["name"])
13959
14006
  return _ok() if proj else _err(f"Failed to load '{p['name']}'")
13960
14007
  elif action == "save":
@@ -13963,15 +14010,35 @@ def project_manager(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
13963
14010
  proj = pm.GetCurrentProject()
13964
14011
  return {"success": bool(pm.CloseProject(proj))} if proj else _err("No project open")
13965
14012
  elif action == "delete":
13966
- return {"success": bool(pm.DeleteProject(p["name"]))}
14013
+ if not p.get("name"):
14014
+ return _err("delete requires name")
14015
+ from src.utils.project_cleanup import delete_project_safely
14016
+ deleted = delete_project_safely(pm, p["name"])
14017
+ return {"success": bool(deleted.get("success")), "delete_detail": deleted}
13967
14018
  elif action == "import_project":
14019
+ if not p.get("path"):
14020
+ return _err("import_project requires path")
13968
14021
  return {"success": bool(pm.ImportProject(p["path"], p.get("name")))}
13969
14022
  elif action == "export_project":
14023
+ err, _clean = _validate_params(p, {
14024
+ "name": {"type": str, "required": True, "non_empty": True},
14025
+ "path": {"type": str, "required": True, "non_empty": True},
14026
+ })
14027
+ if err:
14028
+ return _err(err)
13970
14029
  return {"success": bool(pm.ExportProject(p["name"], p["path"], p.get("with_stills_and_luts", True)))}
13971
14030
  elif action == "archive":
14031
+ err, _clean = _validate_params(p, {
14032
+ "name": {"type": str, "required": True, "non_empty": True},
14033
+ "path": {"type": str, "required": True, "non_empty": True},
14034
+ })
14035
+ if err:
14036
+ return _err(err)
13972
14037
  return {"success": bool(pm.ArchiveProject(p["name"], p["path"],
13973
14038
  p.get("src_media", True), p.get("render_cache", True), p.get("proxy_media", False)))}
13974
14039
  elif action == "restore":
14040
+ if not p.get("path"):
14041
+ return _err("restore requires path")
13975
14042
  return {"success": bool(pm.RestoreProject(p["path"], p.get("name")))}
13976
14043
  return _unknown(action, ["list","get_current","create","load","save","close","delete","import_project","export_project","archive","restore","lint","diff_to_spec","plan_spec","apply_spec", *_PROJECT_KERNEL_ACTIONS])
13977
14044
 
@@ -14126,10 +14193,16 @@ def project_settings(action: str, params: Optional[Dict[str, Any]] = None) -> Di
14126
14193
  if action == "get_name":
14127
14194
  return {"name": proj.GetName()}
14128
14195
  elif action == "set_name":
14196
+ if not p.get("name"):
14197
+ return _err("set_name requires name")
14129
14198
  return {"success": bool(proj.SetName(p["name"]))}
14130
14199
  elif action == "get_setting":
14131
14200
  return {"settings": _ser(proj.GetSetting(p.get("name", "")))}
14132
14201
  elif action == "set_setting":
14202
+ if not p.get("name"):
14203
+ return _err("set_setting requires name")
14204
+ if "value" not in p:
14205
+ return _err("set_setting requires value")
14133
14206
  return {"success": bool(proj.SetSetting(p["name"], p["value"]))}
14134
14207
  elif action == "get_unique_id":
14135
14208
  return {"id": proj.GetUniqueId()}
@@ -14718,7 +14791,15 @@ def render(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
14718
14791
  return missing
14719
14792
  return {"settings": _ser(proj.GetRenderSettings())}
14720
14793
  elif action == "set_settings":
14721
- return {"success": bool(proj.SetRenderSettings(p["settings"]))}
14794
+ if "settings" not in p or not isinstance(p["settings"], dict):
14795
+ return _err("set_settings requires a settings object")
14796
+ # Whitelist to documented keys; SetRenderSettings silently ignores unknown
14797
+ # keys, so a typo'd setting would be dropped with no signal (P1 #6).
14798
+ settings, ignored_settings = _filter_to_keys(p["settings"], _RENDER_SETTING_KEYS)
14799
+ result = {"success": bool(proj.SetRenderSettings(settings))}
14800
+ if ignored_settings:
14801
+ result["ignored_settings"] = ignored_settings
14802
+ return result
14722
14803
  elif action == "list_presets":
14723
14804
  return {"presets": proj.GetRenderPresetList()}
14724
14805
  elif action == "load_preset":
@@ -14730,7 +14811,17 @@ def render(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
14730
14811
  elif action == "quick_export_presets":
14731
14812
  return {"presets": proj.GetQuickExportRenderPresets()}
14732
14813
  elif action == "quick_export":
14733
- return _ser(proj.RenderWithQuickExport(p["preset"], p.get("params", {})))
14814
+ if "preset" not in p:
14815
+ return _err("quick_export requires a preset")
14816
+ # Whitelist the documented quick-export params (unknown keys silently
14817
+ # ignored by Resolve) (P1 #23).
14818
+ qe_params, ignored_params = _filter_to_keys(
14819
+ p.get("params", {}), {"TargetDir", "CustomName", "VideoQuality", "EnableUpload"}
14820
+ )
14821
+ out = _ser(proj.RenderWithQuickExport(p["preset"], qe_params))
14822
+ if ignored_params and isinstance(out, dict):
14823
+ out["ignored_params"] = ignored_params
14824
+ return out
14734
14825
  elif action == "render_capabilities":
14735
14826
  return _render_capabilities(proj)
14736
14827
  elif action == "probe_render_matrix":
@@ -15036,9 +15127,17 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
15036
15127
  elif action == "setup_multicam_timeline":
15037
15128
  return _setup_multicam_timeline(proj, mp, p)
15038
15129
  elif action == "import_timeline":
15130
+ # Whitelist options to the documented keys; Resolve silently ignores
15131
+ # unknown keys, so a typo would be dropped with no signal (P1 #4).
15132
+ options, ignored_opts = _filter_to_keys(p.get("options", {}), _IMPORT_TIMELINE_OPTION_KEYS)
15039
15133
  with long_resolve_op("media_pool.import_timeline"):
15040
- tl = mp.ImportTimelineFromFile(p["path"], p.get("options", {}))
15041
- return _ok(name=tl.GetName()) if tl else _err("Failed to import timeline")
15134
+ tl = mp.ImportTimelineFromFile(p["path"], options)
15135
+ if not tl:
15136
+ return _err("Failed to import timeline")
15137
+ result = _ok(name=tl.GetName())
15138
+ if ignored_opts:
15139
+ result["ignored_options"] = ignored_opts
15140
+ return result
15042
15141
  elif action == "delete_timelines":
15043
15142
  count = proj.GetTimelineCount()
15044
15143
  timelines = []
@@ -15803,6 +15902,8 @@ def media_pool_item_markers(action: str, params: Optional[Dict[str, Any]] = None
15803
15902
  return frame_err
15804
15903
  return {"data": clip.GetMarkerCustomData(frame)}
15805
15904
  elif action == "delete_by_color":
15905
+ if not p.get("color"):
15906
+ return _err("delete_by_color requires color")
15806
15907
  return {"success": bool(clip.DeleteMarkersByColor(p["color"]))}
15807
15908
  elif action == "delete_at_frame":
15808
15909
  frame, frame_err = _marker_frame_from_params(p)
@@ -17861,8 +17962,11 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
17861
17962
  timelines.append({"name": tl.GetName(), "id": tl.GetUniqueId(), "index": i})
17862
17963
  return {"timelines": timelines}
17863
17964
  elif action == "set_current":
17864
- tl = proj.GetTimelineByIndex(p["index"])
17865
- return {"success": bool(proj.SetCurrentTimeline(tl))} if tl else _err(f"No timeline at index {p['index']}")
17965
+ err, clean = _validate_params(p, {"index": {"type": int, "required": True, "min": 1}})
17966
+ if err:
17967
+ return _err(err)
17968
+ tl = proj.GetTimelineByIndex(clean["index"])
17969
+ return {"success": bool(proj.SetCurrentTimeline(tl))} if tl else _err(f"No timeline at index {clean['index']}")
17866
17970
  elif action == "edit_kernel_capabilities":
17867
17971
  return _timeline_edit_kernel_capabilities()
17868
17972
  elif action == "conform_capabilities":
@@ -17899,7 +18003,10 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
17899
18003
  elif action == "get_name":
17900
18004
  return {"name": tl.GetName()}
17901
18005
  elif action == "set_name":
17902
- return {"success": bool(tl.SetName(p["name"]))}
18006
+ err, clean = _validate_params(p, {"name": {"type": str, "required": True, "non_empty": True}})
18007
+ if err:
18008
+ return _err(err)
18009
+ return {"success": bool(tl.SetName(clean["name"]))}
17903
18010
  elif action == "get_start_frame":
17904
18011
  return {"frame": tl.GetStartFrame()}
17905
18012
  elif action == "get_end_frame":
@@ -17907,10 +18014,16 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
17907
18014
  elif action == "get_start_timecode":
17908
18015
  return {"timecode": tl.GetStartTimecode()}
17909
18016
  elif action == "set_start_timecode":
17910
- return {"success": bool(tl.SetStartTimecode(p["timecode"]))}
18017
+ err, clean = _validate_params(p, {"timecode": {"type": str, "required": True, "non_empty": True}})
18018
+ if err:
18019
+ return _err(err)
18020
+ return {"success": bool(tl.SetStartTimecode(clean["timecode"]))}
17911
18021
  elif action == "get_track_count":
17912
18022
  return {"count": tl.GetTrackCount(p["track_type"])}
17913
18023
  elif action == "add_track":
18024
+ err, _clean = _validate_params(p, {"track_type": {"enum": ["video", "audio", "subtitle"], "required": True}})
18025
+ if err:
18026
+ return _err(err)
17914
18027
  opts_in = p.get("options") or {}
17915
18028
  new_track_options: Dict[str, Any] = {}
17916
18029
  if "audio_type" in opts_in:
@@ -18057,7 +18170,11 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
18057
18170
  result = tl.CreateFusionClip(found)
18058
18171
  return _ok() if result else _err("Failed to create Fusion clip")
18059
18172
  elif action == "import_into_timeline":
18060
- return {"success": bool(tl.ImportIntoTimeline(p["path"], p.get("options", {})))}
18173
+ options, ignored_opts = _filter_to_keys(p.get("options", {}), _IMPORT_INTO_TIMELINE_OPTION_KEYS)
18174
+ result = {"success": bool(tl.ImportIntoTimeline(p["path"], options))}
18175
+ if ignored_opts:
18176
+ result["ignored_options"] = ignored_opts
18177
+ return result
18061
18178
  elif action == "export":
18062
18179
  # Timeline.Export needs resolved resolve.EXPORT_* enum *values*, which a
18063
18180
  # JSON/MCP caller cannot pass — handing it a string silently fails. Resolve
@@ -18193,7 +18310,11 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
18193
18310
  missing = _requires_method(tl, "SetVoiceIsolationState", "20.1")
18194
18311
  if missing:
18195
18312
  return missing
18196
- return {"success": bool(tl.SetVoiceIsolationState(p["track_index"], p["state"]))}
18313
+ state, ignored_state = _filter_to_keys(p["state"], _VOICE_ISOLATION_STATE_KEYS)
18314
+ result = {"success": bool(tl.SetVoiceIsolationState(p["track_index"], state))}
18315
+ if ignored_state:
18316
+ result["ignored_state_keys"] = ignored_state
18317
+ return result
18197
18318
  elif action == "extract_source_frame_ranges":
18198
18319
  p = params or {}
18199
18320
  handles = int(p.get("handles", 24))
@@ -18442,6 +18563,8 @@ def timeline_markers(action: str, params: Optional[Dict[str, Any]] = None) -> An
18442
18563
  return frame_err
18443
18564
  return {"data": tl.GetMarkerCustomData(frame)}
18444
18565
  elif action == "delete_by_color":
18566
+ if not p.get("color"):
18567
+ return _err("delete_by_color requires color")
18445
18568
  return {"success": bool(tl.DeleteMarkersByColor(p["color"]))}
18446
18569
  elif action == "delete_at_frame":
18447
18570
  frame, frame_err = _marker_frame_from_params(p, tl=tl)
@@ -18609,6 +18732,11 @@ def timeline_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[
18609
18732
  elif action == "get_property":
18610
18733
  return {"properties": _ser(item.GetProperty(p.get("key", "")))}
18611
18734
  elif action == "set_property":
18735
+ err, _clean = _validate_params(p, {"key": {"type": str, "required": True, "non_empty": True}})
18736
+ if err:
18737
+ return _err(err)
18738
+ if "value" not in p:
18739
+ return _err("'value' is required")
18612
18740
  return {"success": bool(item.SetProperty(p["key"], p["value"]))}
18613
18741
  elif action == "get_duration":
18614
18742
  return {"duration": item.GetDuration()}
@@ -18629,7 +18757,10 @@ def timeline_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[
18629
18757
  elif action == "get_right_offset":
18630
18758
  return {"offset": item.GetRightOffset()}
18631
18759
  elif action == "set_clip_enabled":
18632
- return {"success": bool(item.SetClipEnabled(p["enabled"]))}
18760
+ err, clean = _validate_params(p, {"enabled": {"type": bool, "required": True}})
18761
+ if err:
18762
+ return _err(err)
18763
+ return {"success": bool(item.SetClipEnabled(clean["enabled"]))}
18633
18764
  elif action == "get_clip_enabled":
18634
18765
  return {"enabled": bool(item.GetClipEnabled())}
18635
18766
  elif action == "update_sidecar":
@@ -18670,7 +18801,11 @@ def timeline_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[
18670
18801
  missing = _requires_method(item, "SetVoiceIsolationState", "20.1")
18671
18802
  if missing:
18672
18803
  return missing
18673
- return {"success": bool(item.SetVoiceIsolationState(p["state"]))}
18804
+ state, ignored_state = _filter_to_keys(p["state"], _VOICE_ISOLATION_STATE_KEYS)
18805
+ result = {"success": bool(item.SetVoiceIsolationState(state))}
18806
+ if ignored_state:
18807
+ result["ignored_state_keys"] = ignored_state
18808
+ return result
18674
18809
 
18675
18810
  # ── Retime ──
18676
18811
  elif action == "get_retime":
@@ -18822,6 +18957,8 @@ def timeline_item_markers(action: str, params: Optional[Dict[str, Any]] = None)
18822
18957
  return frame_err
18823
18958
  return {"data": item.GetMarkerCustomData(frame)}
18824
18959
  elif action == "delete_by_color":
18960
+ if not p.get("color"):
18961
+ return _err("delete_by_color requires color")
18825
18962
  return {"success": bool(item.DeleteMarkersByColor(p["color"]))}
18826
18963
  elif action == "delete_at_frame":
18827
18964
  frame, frame_err = _marker_frame_from_params(p)
@@ -77,6 +77,18 @@ API_TRUTH: List[Dict[str, Any]] = [
77
77
  "tags": ["silent-failure", "timeline", "export", "enum"],
78
78
  "mitigation": ["_timeline_export_spec", "_timeline_export_value"],
79
79
  },
80
+ {
81
+ "symbol": "ProjectManager.DeleteProject",
82
+ "object": "ProjectManager",
83
+ "signature": "(projectName) -> bool",
84
+ "reality": "Returns False (no deletion) when the target project is, or "
85
+ "recently was, the current project, and is flaky on the first "
86
+ "attempt — so a single bool() call leaves the project undeleted "
87
+ "with no useful error.",
88
+ "recommended": "Load/close away from the target first, then retry; use "
89
+ "src/utils/project_cleanup.py:delete_project_safely.",
90
+ "tags": ["unreliable-return", "project", "flaky"],
91
+ },
80
92
  {
81
93
  "symbol": "Composition.Paste",
82
94
  "object": "Fusion Composition",