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 +34 -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 +153 -16
- package/src/utils/api_truth.py +12 -0
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
|
-
[](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.55.
|
|
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
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.55.
|
|
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()}")
|
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.55.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"],
|
|
15041
|
-
|
|
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
|
-
|
|
17865
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
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",
|