davinci-resolve-mcp 2.54.5 → 2.55.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +43 -0
- package/README.md +1 -1
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/granular/common.py +1 -1
- package/src/granular/project.py +6 -2
- package/src/server.py +135 -14
- package/src/utils/api_truth.py +12 -0
- package/src/utils/destructive_hook.py +9 -10
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,49 @@
|
|
|
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.1
|
|
6
|
+
|
|
7
|
+
Deep-QC P1 — settings/options whitelisting + DeleteProject reliability.
|
|
8
|
+
|
|
9
|
+
- **Fixed** options/settings dicts passed to several Resolve APIs are now
|
|
10
|
+
whitelisted to their documented keys, so a typo'd key is reported instead of
|
|
11
|
+
silently dropped (`_filter_to_keys` → `ignored_options`/`ignored_settings`):
|
|
12
|
+
`media_pool.import_timeline` (ImportTimelineFromFile), `timeline.import_into_timeline`
|
|
13
|
+
(ImportIntoTimeline), raw `render.set_settings` (SetRenderSettings) and
|
|
14
|
+
`render.quick_export`, and `set_voice_isolation_state` (track + item).
|
|
15
|
+
- **Fixed** `ProjectManager.DeleteProject` is flaky — it silently returns False
|
|
16
|
+
when the target is/was the current project. All callers (the safe and raw
|
|
17
|
+
`project_manager.delete` actions and the granular `delete_project`) now route
|
|
18
|
+
through `delete_project_safely` (switch-away + retry) and report `delete_detail`.
|
|
19
|
+
Added an `api_truth` entry.
|
|
20
|
+
- **Added** a destructive-registry drift guard (`tests/test_destructive_registry_drift.py`)
|
|
21
|
+
that asserts token-gated actions map to real handlers and locks in the media-pool
|
|
22
|
+
delete governance from v2.55.0. (It also surfaced a pre-existing registry
|
|
23
|
+
mis-keying issue now tracked for a dedicated audit.)
|
|
24
|
+
|
|
25
|
+
## What's New in v2.55.0
|
|
26
|
+
|
|
27
|
+
Governance for catastrophic Media Pool deletes (exhaustive audit EX2/EX3).
|
|
28
|
+
|
|
29
|
+
**Behavior change:** `media_pool` `delete_clips`, `delete_folders`, and
|
|
30
|
+
`delete_timelines` now require a confirm token, the same two-step handshake
|
|
31
|
+
`timeline.delete_track` already uses — the first call returns
|
|
32
|
+
`status: "confirmation_required"` with a `confirm_token` and a preview (count +
|
|
33
|
+
names); re-call with `params.confirm_token` to execute. Callers that disable
|
|
34
|
+
gating via the `destructive.require_confirm_token=false` preference are
|
|
35
|
+
unaffected.
|
|
36
|
+
|
|
37
|
+
- **Fixed** the destructive-action registry listed granular function names
|
|
38
|
+
(`delete_media_pool_clips`, `move_media_pool_folders`, …) that the compound
|
|
39
|
+
`media_pool` tool never dispatches, so `is_destructive()` returned False and
|
|
40
|
+
these catastrophic deletes silently skipped version-on-mutate archiving and
|
|
41
|
+
change logging. The registry now uses the real compound action strings, so the
|
|
42
|
+
deletes are archived/logged as intended.
|
|
43
|
+
- **Added** confirm-token gating + previews to the three deletes (EX3). The
|
|
44
|
+
registry fix also re-enables the existing media-pool change log for them.
|
|
45
|
+
- Live-validated against DaVinci Resolve Studio 21.0.0 (non-destructively): the
|
|
46
|
+
no-token call returns `confirmation_required` and deletes nothing.
|
|
47
|
+
|
|
5
48
|
## What's New in v2.54.5
|
|
6
49
|
|
|
7
50
|
Reliability + security hardening from the exhaustive reliability audit (Wave A).
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# DaVinci Resolve MCP Server
|
|
2
2
|
|
|
3
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
4
4
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
5
5
|
[](docs/reference/api-coverage.md)
|
|
6
6
|
[-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.
|
|
38
|
+
VERSION = "2.55.1"
|
|
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
package/src/granular/common.py
CHANGED
|
@@ -80,7 +80,7 @@ if not logging.getLogger().handlers:
|
|
|
80
80
|
handlers=[logging.StreamHandler()],
|
|
81
81
|
)
|
|
82
82
|
|
|
83
|
-
VERSION = "2.
|
|
83
|
+
VERSION = "2.55.1"
|
|
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()}")
|
package/src/granular/project.py
CHANGED
|
@@ -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
|
-
|
|
790
|
-
|
|
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.
|
|
14
|
+
VERSION = "2.55.1"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -784,6 +784,11 @@ _destructive_hook.register_preference_provider(_destructive_preference_provider)
|
|
|
784
784
|
_TOKEN_GATED_DESTRUCTIVE_ACTIONS = frozenset({
|
|
785
785
|
("timeline", "delete_track"),
|
|
786
786
|
("timeline", "apply_cuts"),
|
|
787
|
+
# Catastrophic media-pool deletes (EX3): irreversibly destroy clips/folders/
|
|
788
|
+
# timelines. Gated like delete_track; also archive-on-mutate via the registry.
|
|
789
|
+
("media_pool", "delete_clips"),
|
|
790
|
+
("media_pool", "delete_folders"),
|
|
791
|
+
("media_pool", "delete_timelines"),
|
|
787
792
|
# Phase E edit-engine loops: plan → confirm → execute.
|
|
788
793
|
("edit_engine", "execute_selects"),
|
|
789
794
|
("edit_engine", "execute_tighten"),
|
|
@@ -7503,6 +7508,36 @@ def _safe_int(value: Any, default: int, *, minimum: Optional[int] = None, maximu
|
|
|
7503
7508
|
return parsed
|
|
7504
7509
|
|
|
7505
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
|
+
|
|
7506
7541
|
def _setup_marker_limit(value: Any, default: int = 12) -> int:
|
|
7507
7542
|
if isinstance(value, str) and _setup_text_key(value) in {"unlimited", "no_limit", "nolimit", "all"}:
|
|
7508
7543
|
return 0
|
|
@@ -13400,6 +13435,9 @@ def _safe_project_delete(pm, p: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
13400
13435
|
if p.get("dry_run"):
|
|
13401
13436
|
return _ok(would_delete=True, name=name)
|
|
13402
13437
|
current = pm.GetCurrentProject()
|
|
13438
|
+
# Route through delete_project_safely: DeleteProject silently returns False
|
|
13439
|
+
# when the target is/was current and is flaky on the first attempt (#19).
|
|
13440
|
+
from src.utils.project_cleanup import delete_project_safely
|
|
13403
13441
|
current_name = current.GetName() if current and _has_method(current, "GetName") else None
|
|
13404
13442
|
if current_name == name:
|
|
13405
13443
|
if not p.get("close_current", False):
|
|
@@ -13408,11 +13446,13 @@ def _safe_project_delete(pm, p: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
13408
13446
|
closed = bool(pm.CloseProject(current))
|
|
13409
13447
|
if not closed:
|
|
13410
13448
|
return _err(f"Failed to close current project '{name}' before delete")
|
|
13411
|
-
|
|
13449
|
+
deleted = delete_project_safely(pm, name)
|
|
13450
|
+
result = {"success": bool(deleted.get("success")), "delete_detail": deleted}
|
|
13412
13451
|
if saved is False:
|
|
13413
13452
|
result["warning"] = "SaveProject returned False before close; unsaved changes may have been discarded"
|
|
13414
13453
|
return result
|
|
13415
|
-
|
|
13454
|
+
deleted = delete_project_safely(pm, name)
|
|
13455
|
+
return {"success": bool(deleted.get("success")), "delete_detail": deleted}
|
|
13416
13456
|
|
|
13417
13457
|
|
|
13418
13458
|
def _database_capabilities(pm) -> Dict[str, Any]:
|
|
@@ -13958,7 +13998,11 @@ def project_manager(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
13958
13998
|
proj = pm.GetCurrentProject()
|
|
13959
13999
|
return {"success": bool(pm.CloseProject(proj))} if proj else _err("No project open")
|
|
13960
14000
|
elif action == "delete":
|
|
13961
|
-
|
|
14001
|
+
if not p.get("name"):
|
|
14002
|
+
return _err("delete requires name")
|
|
14003
|
+
from src.utils.project_cleanup import delete_project_safely
|
|
14004
|
+
deleted = delete_project_safely(pm, p["name"])
|
|
14005
|
+
return {"success": bool(deleted.get("success")), "delete_detail": deleted}
|
|
13962
14006
|
elif action == "import_project":
|
|
13963
14007
|
return {"success": bool(pm.ImportProject(p["path"], p.get("name")))}
|
|
13964
14008
|
elif action == "export_project":
|
|
@@ -14713,7 +14757,15 @@ def render(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
|
|
|
14713
14757
|
return missing
|
|
14714
14758
|
return {"settings": _ser(proj.GetRenderSettings())}
|
|
14715
14759
|
elif action == "set_settings":
|
|
14716
|
-
|
|
14760
|
+
if "settings" not in p or not isinstance(p["settings"], dict):
|
|
14761
|
+
return _err("set_settings requires a settings object")
|
|
14762
|
+
# Whitelist to documented keys; SetRenderSettings silently ignores unknown
|
|
14763
|
+
# keys, so a typo'd setting would be dropped with no signal (P1 #6).
|
|
14764
|
+
settings, ignored_settings = _filter_to_keys(p["settings"], _RENDER_SETTING_KEYS)
|
|
14765
|
+
result = {"success": bool(proj.SetRenderSettings(settings))}
|
|
14766
|
+
if ignored_settings:
|
|
14767
|
+
result["ignored_settings"] = ignored_settings
|
|
14768
|
+
return result
|
|
14717
14769
|
elif action == "list_presets":
|
|
14718
14770
|
return {"presets": proj.GetRenderPresetList()}
|
|
14719
14771
|
elif action == "load_preset":
|
|
@@ -14725,7 +14777,17 @@ def render(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
|
|
|
14725
14777
|
elif action == "quick_export_presets":
|
|
14726
14778
|
return {"presets": proj.GetQuickExportRenderPresets()}
|
|
14727
14779
|
elif action == "quick_export":
|
|
14728
|
-
|
|
14780
|
+
if "preset" not in p:
|
|
14781
|
+
return _err("quick_export requires a preset")
|
|
14782
|
+
# Whitelist the documented quick-export params (unknown keys silently
|
|
14783
|
+
# ignored by Resolve) (P1 #23).
|
|
14784
|
+
qe_params, ignored_params = _filter_to_keys(
|
|
14785
|
+
p.get("params", {}), {"TargetDir", "CustomName", "VideoQuality", "EnableUpload"}
|
|
14786
|
+
)
|
|
14787
|
+
out = _ser(proj.RenderWithQuickExport(p["preset"], qe_params))
|
|
14788
|
+
if ignored_params and isinstance(out, dict):
|
|
14789
|
+
out["ignored_params"] = ignored_params
|
|
14790
|
+
return out
|
|
14729
14791
|
elif action == "render_capabilities":
|
|
14730
14792
|
return _render_capabilities(proj)
|
|
14731
14793
|
elif action == "probe_render_matrix":
|
|
@@ -14935,7 +14997,20 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
|
|
|
14935
14997
|
for sub in (root.GetSubFolderList() or []):
|
|
14936
14998
|
if sub.GetUniqueId() == fid:
|
|
14937
14999
|
folders.append(sub)
|
|
14938
|
-
|
|
15000
|
+
if not folders:
|
|
15001
|
+
return _err("No folders found")
|
|
15002
|
+
if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required():
|
|
15003
|
+
return _issue_confirm_token(
|
|
15004
|
+
action="media_pool.delete_folders", params=p,
|
|
15005
|
+
preview={"operation": "media_pool.delete_folders",
|
|
15006
|
+
"warning": "Permanently deletes folders AND every clip they contain.",
|
|
15007
|
+
"folders_lost": len(folders),
|
|
15008
|
+
"names": [_clip_name(f) for f in folders][:25]},
|
|
15009
|
+
)
|
|
15010
|
+
blocked = _consume_confirm_token(action="media_pool.delete_folders", params=p)
|
|
15011
|
+
if blocked:
|
|
15012
|
+
return blocked
|
|
15013
|
+
return {"success": bool(mp.DeleteFolders(folders))}
|
|
14939
15014
|
elif action == "move_folders":
|
|
14940
15015
|
target = _navigate_folder(mp, p["target_path"])
|
|
14941
15016
|
if not target:
|
|
@@ -15018,9 +15093,17 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
|
|
|
15018
15093
|
elif action == "setup_multicam_timeline":
|
|
15019
15094
|
return _setup_multicam_timeline(proj, mp, p)
|
|
15020
15095
|
elif action == "import_timeline":
|
|
15096
|
+
# Whitelist options to the documented keys; Resolve silently ignores
|
|
15097
|
+
# unknown keys, so a typo would be dropped with no signal (P1 #4).
|
|
15098
|
+
options, ignored_opts = _filter_to_keys(p.get("options", {}), _IMPORT_TIMELINE_OPTION_KEYS)
|
|
15021
15099
|
with long_resolve_op("media_pool.import_timeline"):
|
|
15022
|
-
tl = mp.ImportTimelineFromFile(p["path"],
|
|
15023
|
-
|
|
15100
|
+
tl = mp.ImportTimelineFromFile(p["path"], options)
|
|
15101
|
+
if not tl:
|
|
15102
|
+
return _err("Failed to import timeline")
|
|
15103
|
+
result = _ok(name=tl.GetName())
|
|
15104
|
+
if ignored_opts:
|
|
15105
|
+
result["ignored_options"] = ignored_opts
|
|
15106
|
+
return result
|
|
15024
15107
|
elif action == "delete_timelines":
|
|
15025
15108
|
count = proj.GetTimelineCount()
|
|
15026
15109
|
timelines = []
|
|
@@ -15028,7 +15111,20 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
|
|
|
15028
15111
|
tl = proj.GetTimelineByIndex(i)
|
|
15029
15112
|
if tl and tl.GetUniqueId() in p["timeline_ids"]:
|
|
15030
15113
|
timelines.append(tl)
|
|
15031
|
-
|
|
15114
|
+
if not timelines:
|
|
15115
|
+
return _err("No timelines found")
|
|
15116
|
+
if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required():
|
|
15117
|
+
return _issue_confirm_token(
|
|
15118
|
+
action="media_pool.delete_timelines", params=p,
|
|
15119
|
+
preview={"operation": "media_pool.delete_timelines",
|
|
15120
|
+
"warning": "Permanently deletes timelines.",
|
|
15121
|
+
"timelines_lost": len(timelines),
|
|
15122
|
+
"names": [t.GetName() for t in timelines][:25]},
|
|
15123
|
+
)
|
|
15124
|
+
blocked = _consume_confirm_token(action="media_pool.delete_timelines", params=p)
|
|
15125
|
+
if blocked:
|
|
15126
|
+
return blocked
|
|
15127
|
+
return {"success": bool(mp.DeleteTimelines(timelines))}
|
|
15032
15128
|
elif action == "append_to_timeline":
|
|
15033
15129
|
if p.get("clip_infos") is not None:
|
|
15034
15130
|
raw = p["clip_infos"]
|
|
@@ -15115,7 +15211,20 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
|
|
|
15115
15211
|
elif action == "delete_clips":
|
|
15116
15212
|
clips = [_find_clip(root, cid) for cid in p["clip_ids"]]
|
|
15117
15213
|
clips = [c for c in clips if c]
|
|
15118
|
-
|
|
15214
|
+
if not clips:
|
|
15215
|
+
return _err("No clips found")
|
|
15216
|
+
if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required():
|
|
15217
|
+
return _issue_confirm_token(
|
|
15218
|
+
action="media_pool.delete_clips", params=p,
|
|
15219
|
+
preview={"operation": "media_pool.delete_clips",
|
|
15220
|
+
"warning": "Permanently deletes Media Pool clips.",
|
|
15221
|
+
"clips_lost": len(clips),
|
|
15222
|
+
"names": [_clip_name(c) for c in clips][:25]},
|
|
15223
|
+
)
|
|
15224
|
+
blocked = _consume_confirm_token(action="media_pool.delete_clips", params=p)
|
|
15225
|
+
if blocked:
|
|
15226
|
+
return blocked
|
|
15227
|
+
return {"success": bool(mp.DeleteClips(clips))}
|
|
15119
15228
|
elif action == "move_clips":
|
|
15120
15229
|
target = _navigate_folder(mp, p["target_path"])
|
|
15121
15230
|
if not target:
|
|
@@ -18013,7 +18122,11 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
|
|
|
18013
18122
|
result = tl.CreateFusionClip(found)
|
|
18014
18123
|
return _ok() if result else _err("Failed to create Fusion clip")
|
|
18015
18124
|
elif action == "import_into_timeline":
|
|
18016
|
-
|
|
18125
|
+
options, ignored_opts = _filter_to_keys(p.get("options", {}), _IMPORT_INTO_TIMELINE_OPTION_KEYS)
|
|
18126
|
+
result = {"success": bool(tl.ImportIntoTimeline(p["path"], options))}
|
|
18127
|
+
if ignored_opts:
|
|
18128
|
+
result["ignored_options"] = ignored_opts
|
|
18129
|
+
return result
|
|
18017
18130
|
elif action == "export":
|
|
18018
18131
|
# Timeline.Export needs resolved resolve.EXPORT_* enum *values*, which a
|
|
18019
18132
|
# JSON/MCP caller cannot pass — handing it a string silently fails. Resolve
|
|
@@ -18149,7 +18262,11 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
|
|
|
18149
18262
|
missing = _requires_method(tl, "SetVoiceIsolationState", "20.1")
|
|
18150
18263
|
if missing:
|
|
18151
18264
|
return missing
|
|
18152
|
-
|
|
18265
|
+
state, ignored_state = _filter_to_keys(p["state"], _VOICE_ISOLATION_STATE_KEYS)
|
|
18266
|
+
result = {"success": bool(tl.SetVoiceIsolationState(p["track_index"], state))}
|
|
18267
|
+
if ignored_state:
|
|
18268
|
+
result["ignored_state_keys"] = ignored_state
|
|
18269
|
+
return result
|
|
18153
18270
|
elif action == "extract_source_frame_ranges":
|
|
18154
18271
|
p = params or {}
|
|
18155
18272
|
handles = int(p.get("handles", 24))
|
|
@@ -18626,7 +18743,11 @@ def timeline_item(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[
|
|
|
18626
18743
|
missing = _requires_method(item, "SetVoiceIsolationState", "20.1")
|
|
18627
18744
|
if missing:
|
|
18628
18745
|
return missing
|
|
18629
|
-
|
|
18746
|
+
state, ignored_state = _filter_to_keys(p["state"], _VOICE_ISOLATION_STATE_KEYS)
|
|
18747
|
+
result = {"success": bool(item.SetVoiceIsolationState(state))}
|
|
18748
|
+
if ignored_state:
|
|
18749
|
+
result["ignored_state_keys"] = ignored_state
|
|
18750
|
+
return result
|
|
18630
18751
|
|
|
18631
18752
|
# ── Retime ──
|
|
18632
18753
|
elif action == "get_retime":
|
package/src/utils/api_truth.py
CHANGED
|
@@ -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",
|
|
@@ -41,18 +41,17 @@ logger = logging.getLogger("resolve-mcp.destructive-hook")
|
|
|
41
41
|
# default metric.
|
|
42
42
|
|
|
43
43
|
DESTRUCTIVE_ACTIONS_BY_TOOL: Dict[str, FrozenSet[str]] = {
|
|
44
|
+
# Real COMPOUND media_pool action strings (the @destructive_op("media_pool")
|
|
45
|
+
# wrapper queries is_destructive("media_pool", <compound action>)). The prior
|
|
46
|
+
# entries used granular function names (delete_media_pool_clips, …) and
|
|
47
|
+
# media_pool_item names that the compound tool never dispatches, so catastrophic
|
|
48
|
+
# deletes silently bypassed version-on-mutate archiving (EX2). media_pool_item
|
|
49
|
+
# link/replace actions are governed separately (its own tool is not yet wrapped).
|
|
44
50
|
"media_pool": frozenset({
|
|
45
|
-
"
|
|
46
|
-
"
|
|
51
|
+
"delete_clips",
|
|
52
|
+
"delete_folders",
|
|
47
53
|
"move_clips",
|
|
48
|
-
"
|
|
49
|
-
"replace_clip",
|
|
50
|
-
"replace_clip_preserve_sub_clip",
|
|
51
|
-
"link_clip_proxy_media",
|
|
52
|
-
"unlink_clip_proxy_media",
|
|
53
|
-
"link_proxy_media",
|
|
54
|
-
"unlink_proxy_media",
|
|
55
|
-
"link_clip_full_resolution_media",
|
|
54
|
+
"move_folders",
|
|
56
55
|
"delete_clip_mattes",
|
|
57
56
|
"delete_timelines",
|
|
58
57
|
}),
|