davinci-resolve-mcp 2.54.4 → 2.55.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +49 -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/media_pool.py +1 -1
- package/src/granular/media_storage.py +1 -1
- package/src/server.py +131 -32
- package/src/utils/analysis_runs.py +5 -0
- package/src/utils/audio_fairlight_live_probe.py +1 -1
- package/src/utils/brain_edits.py +6 -0
- package/src/utils/destructive_hook.py +9 -10
- package/src/utils/media_pool_changes.py +5 -0
- package/src/utils/render_deliver_live_probe.py +1 -1
- package/src/utils/review_annotation_live_probe.py +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,55 @@
|
|
|
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.0
|
|
6
|
+
|
|
7
|
+
Governance for catastrophic Media Pool deletes (exhaustive audit EX2/EX3).
|
|
8
|
+
|
|
9
|
+
**Behavior change:** `media_pool` `delete_clips`, `delete_folders`, and
|
|
10
|
+
`delete_timelines` now require a confirm token, the same two-step handshake
|
|
11
|
+
`timeline.delete_track` already uses — the first call returns
|
|
12
|
+
`status: "confirmation_required"` with a `confirm_token` and a preview (count +
|
|
13
|
+
names); re-call with `params.confirm_token` to execute. Callers that disable
|
|
14
|
+
gating via the `destructive.require_confirm_token=false` preference are
|
|
15
|
+
unaffected.
|
|
16
|
+
|
|
17
|
+
- **Fixed** the destructive-action registry listed granular function names
|
|
18
|
+
(`delete_media_pool_clips`, `move_media_pool_folders`, …) that the compound
|
|
19
|
+
`media_pool` tool never dispatches, so `is_destructive()` returned False and
|
|
20
|
+
these catastrophic deletes silently skipped version-on-mutate archiving and
|
|
21
|
+
change logging. The registry now uses the real compound action strings, so the
|
|
22
|
+
deletes are archived/logged as intended.
|
|
23
|
+
- **Added** confirm-token gating + previews to the three deletes (EX3). The
|
|
24
|
+
registry fix also re-enables the existing media-pool change log for them.
|
|
25
|
+
- Live-validated against DaVinci Resolve Studio 21.0.0 (non-destructively): the
|
|
26
|
+
no-token call returns `confirmation_required` and deletes nothing.
|
|
27
|
+
|
|
28
|
+
## What's New in v2.54.5
|
|
29
|
+
|
|
30
|
+
Reliability + security hardening from the exhaustive reliability audit (Wave A).
|
|
31
|
+
|
|
32
|
+
- **Security** `apply_spec` hooks no longer run with `shell=True`. A spec's hook
|
|
33
|
+
command is now `shlex`-split and executed without a shell, so a hook string
|
|
34
|
+
can't inject arbitrary shell (`; rm -rf …`, pipes, expansion). Hooks needing
|
|
35
|
+
shell features must invoke an interpreter explicitly (e.g. `bash -c "…"`).
|
|
36
|
+
- **Fixed** the confirm-token table (`_CONFIRM_TOKENS`) is now guarded by a lock.
|
|
37
|
+
The control panel is threaded, so issue/consume/GC ran concurrently; validate-
|
|
38
|
+
then-pop is now atomic and GC can't race a write.
|
|
39
|
+
- **Fixed** negative item indices are rejected instead of silently returning the
|
|
40
|
+
wrong item (Python reverse-indexing): `_get_item`, the audio item resolver, and
|
|
41
|
+
the two timeline-matte helpers now require `0 <= index < len`.
|
|
42
|
+
- **Fixed** history queries clamp `limit` to `[1, 1000]`. SQLite treats a negative
|
|
43
|
+
`LIMIT` as "no limit", so a negative value could silently fetch the entire table
|
|
44
|
+
(`get_brain_edit_history`, `list_runs`, `get_media_pool_change_history`).
|
|
45
|
+
`timeline_versioning` version/limit/keep_n params are now validated (no unhandled
|
|
46
|
+
`ValueError`; `rollback` rejects negative versions before archiving). `max_files`
|
|
47
|
+
/`max_seconds` in the relink file search are clamped positive.
|
|
48
|
+
- **Fixed** the server-reachable ffmpeg probes (render, audio, review) now pass
|
|
49
|
+
`timeout=120` so a hung ffmpeg can't block the server indefinitely.
|
|
50
|
+
|
|
51
|
+
(Deferred to a live-validated follow-up: confirm-token gating + archiving for
|
|
52
|
+
catastrophic media-pool/take/fusion deletes, and temp-directory lifecycle cleanup.)
|
|
53
|
+
|
|
5
54
|
## What's New in v2.54.4
|
|
6
55
|
|
|
7
56
|
Persistence-safety hardening (generalizes issue #71 to the analysis state
|
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.0"
|
|
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.0"
|
|
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()}")
|
|
@@ -574,7 +574,7 @@ def get_timeline_matte_list(item_index: int = 0, track_type: str = "video", trac
|
|
|
574
574
|
if not tl:
|
|
575
575
|
return {"error": "No current timeline"}
|
|
576
576
|
items = tl.GetItemListInTrack(track_type, track_index)
|
|
577
|
-
if not items or item_index >= len(items):
|
|
577
|
+
if not items or item_index < 0 or item_index >= len(items): # reject negatives (EX5)
|
|
578
578
|
return {"error": f"No item at index {item_index}"}
|
|
579
579
|
mattes = mp.GetTimelineMatteList(items[item_index])
|
|
580
580
|
return {"mattes": mattes if mattes else []}
|
|
@@ -171,7 +171,7 @@ def add_timeline_mattes_to_media_pool(timeline_item_index: int, matte_paths: Lis
|
|
|
171
171
|
return {"error": "No current timeline"}
|
|
172
172
|
|
|
173
173
|
items = timeline.GetItemListInTrack(track_type, track_index)
|
|
174
|
-
if not items or timeline_item_index >= len(items):
|
|
174
|
+
if not items or timeline_item_index < 0 or timeline_item_index >= len(items): # reject negatives (EX5)
|
|
175
175
|
return {"error": f"Timeline item at index {timeline_item_index} not found"}
|
|
176
176
|
|
|
177
177
|
item = items[timeline_item_index]
|
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.0"
|
|
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"),
|
|
@@ -1006,6 +1011,10 @@ import uuid as _uuid
|
|
|
1006
1011
|
|
|
1007
1012
|
|
|
1008
1013
|
_CONFIRM_TOKENS: Dict[str, Dict[str, Any]] = {}
|
|
1014
|
+
# The control panel runs on a threaded HTTP server, so issue/consume/gc of the
|
|
1015
|
+
# token table run on concurrent threads. Guard every access so a GC iteration
|
|
1016
|
+
# can't race a write and validate-then-pop stays atomic (EX4).
|
|
1017
|
+
_CONFIRM_TOKENS_LOCK = threading.RLock()
|
|
1009
1018
|
_CONFIRM_TTL_SECONDS = 300
|
|
1010
1019
|
|
|
1011
1020
|
|
|
@@ -1023,11 +1032,13 @@ def _confirm_token_fingerprint(action: str, params: Optional[Dict[str, Any]]) ->
|
|
|
1023
1032
|
|
|
1024
1033
|
|
|
1025
1034
|
def _confirm_token_gc():
|
|
1026
|
-
"""Drop expired tokens; called on every issue/validate.
|
|
1035
|
+
"""Drop expired tokens; called on every issue/validate. Caller may already
|
|
1036
|
+
hold _CONFIRM_TOKENS_LOCK (RLock makes re-entry safe)."""
|
|
1027
1037
|
now = _time.time()
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1038
|
+
with _CONFIRM_TOKENS_LOCK:
|
|
1039
|
+
expired = [t for t, rec in _CONFIRM_TOKENS.items() if rec.get("expires_at", 0) < now]
|
|
1040
|
+
for t in expired:
|
|
1041
|
+
_CONFIRM_TOKENS.pop(t, None)
|
|
1031
1042
|
|
|
1032
1043
|
|
|
1033
1044
|
def _confirm_token_required() -> bool:
|
|
@@ -1045,16 +1056,17 @@ def _confirm_token_required() -> bool:
|
|
|
1045
1056
|
|
|
1046
1057
|
def _issue_confirm_token(*, action: str, params: Optional[Dict[str, Any]], preview: Dict[str, Any]) -> Dict[str, Any]:
|
|
1047
1058
|
"""Mint a token. Returns the pending_user_decision response shape."""
|
|
1048
|
-
_confirm_token_gc()
|
|
1049
1059
|
token = _uuid.uuid4().hex
|
|
1050
1060
|
fp = _confirm_token_fingerprint(action, params)
|
|
1051
1061
|
expires_at = _time.time() + _CONFIRM_TTL_SECONDS
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1062
|
+
with _CONFIRM_TOKENS_LOCK:
|
|
1063
|
+
_confirm_token_gc()
|
|
1064
|
+
_CONFIRM_TOKENS[token] = {
|
|
1065
|
+
"action": action,
|
|
1066
|
+
"fingerprint": fp,
|
|
1067
|
+
"expires_at": expires_at,
|
|
1068
|
+
"issued_at": _time.time(),
|
|
1069
|
+
}
|
|
1058
1070
|
body = _err(
|
|
1059
1071
|
f"This action is destructive. Re-call with confirm_token to proceed.",
|
|
1060
1072
|
code="CONFIRMATION_REQUIRED",
|
|
@@ -1082,8 +1094,9 @@ def _consume_confirm_token(*, action: str, params: Optional[Dict[str, Any]]) ->
|
|
|
1082
1094
|
token = (params or {}).get("confirm_token") or (params or {}).get("confirmToken")
|
|
1083
1095
|
if not token:
|
|
1084
1096
|
return None # Caller is expected to call _issue_confirm_token in this case.
|
|
1085
|
-
|
|
1086
|
-
|
|
1097
|
+
with _CONFIRM_TOKENS_LOCK:
|
|
1098
|
+
_confirm_token_gc()
|
|
1099
|
+
rec = _CONFIRM_TOKENS.pop(token, None) # one-time use, atomic with gc
|
|
1087
1100
|
if rec is None:
|
|
1088
1101
|
return _err(
|
|
1089
1102
|
"confirm_token is invalid, expired, or was issued by a different "
|
|
@@ -1855,7 +1868,9 @@ def _get_item(p):
|
|
|
1855
1868
|
track_index = p.get("track_index", 1)
|
|
1856
1869
|
item_index = p.get("item_index", 0)
|
|
1857
1870
|
items = tl.GetItemListInTrack(track_type, track_index)
|
|
1858
|
-
|
|
1871
|
+
# Reject negatives explicitly: Python's reverse-indexing would otherwise
|
|
1872
|
+
# silently return the wrong item for item_index < 0 (EX5).
|
|
1873
|
+
if not items or not isinstance(item_index, int) or item_index < 0 or item_index >= len(items):
|
|
1859
1874
|
return tl, None, _err(
|
|
1860
1875
|
f"No item at index {item_index} on {track_type} track {track_index}",
|
|
1861
1876
|
code="ITEM_INDEX_OUT_OF_RANGE", category="invalid_input",
|
|
@@ -5494,8 +5509,10 @@ def _bounded_basename_matches(
|
|
|
5494
5509
|
p: Dict[str, Any],
|
|
5495
5510
|
) -> Tuple[List[str], Dict[str, Any]]:
|
|
5496
5511
|
started = time.monotonic()
|
|
5497
|
-
|
|
5498
|
-
|
|
5512
|
+
# Clamp to positive: a negative max_seconds/max_files would trip the
|
|
5513
|
+
# >= guards on the first iteration and silently return no results (EX8).
|
|
5514
|
+
max_seconds = max(0.1, float(p.get("max_seconds", p.get("timeout_seconds", 20)) or 20))
|
|
5515
|
+
max_files = max(1, int(p.get("max_files_scanned", p.get("maxFilesScanned", 50000)) or 50000))
|
|
5499
5516
|
max_depth_raw = p.get("max_depth", p.get("maxDepth"))
|
|
5500
5517
|
max_depth = int(max_depth_raw) if max_depth_raw is not None else None
|
|
5501
5518
|
all_matches = bool(p.get("all_matches", False))
|
|
@@ -5704,7 +5721,7 @@ def _audio_item_from_params(tl, p: Dict[str, Any]):
|
|
|
5704
5721
|
track_index = int(p.get("track_index", 1))
|
|
5705
5722
|
item_index = int(p.get("item_index", 0))
|
|
5706
5723
|
items = tl.GetItemListInTrack(track_type, track_index) or []
|
|
5707
|
-
if item_index >= len(items):
|
|
5724
|
+
if item_index < 0 or item_index >= len(items): # reject negatives (EX5)
|
|
5708
5725
|
return None, _err(f"No item at index {item_index} on {track_type} track {track_index}")
|
|
5709
5726
|
return items[item_index], None
|
|
5710
5727
|
|
|
@@ -7474,6 +7491,23 @@ def _setup_positive_int(value: Any, default: int, min_value: int = 0, max_value:
|
|
|
7474
7491
|
return max(min_value, min(parsed, max_value))
|
|
7475
7492
|
|
|
7476
7493
|
|
|
7494
|
+
def _safe_int(value: Any, default: int, *, minimum: Optional[int] = None, maximum: Optional[int] = None) -> int:
|
|
7495
|
+
"""Coerce a tool param to int without throwing; clamp to [minimum, maximum].
|
|
7496
|
+
|
|
7497
|
+
Used by action handlers so a non-numeric or out-of-range numeric param yields
|
|
7498
|
+
a sane value instead of an unhandled ValueError or a SQLite "negative LIMIT =
|
|
7499
|
+
no limit" full-table fetch (EX8)."""
|
|
7500
|
+
try:
|
|
7501
|
+
parsed = int(value)
|
|
7502
|
+
except (TypeError, ValueError):
|
|
7503
|
+
parsed = default
|
|
7504
|
+
if minimum is not None:
|
|
7505
|
+
parsed = max(minimum, parsed)
|
|
7506
|
+
if maximum is not None:
|
|
7507
|
+
parsed = min(parsed, maximum)
|
|
7508
|
+
return parsed
|
|
7509
|
+
|
|
7510
|
+
|
|
7477
7511
|
def _setup_marker_limit(value: Any, default: int = 12) -> int:
|
|
7478
7512
|
if isinstance(value, str) and _setup_text_key(value) in {"unlimited", "no_limit", "nolimit", "all"}:
|
|
7479
7513
|
return 0
|
|
@@ -13673,14 +13707,29 @@ class _SpecLiveExecutor:
|
|
|
13673
13707
|
|
|
13674
13708
|
|
|
13675
13709
|
def _make_spec_hook_runner(timeout: float = 120.0):
|
|
13676
|
-
"""Return a callable that runs a Hook's
|
|
13710
|
+
"""Return a callable that runs a Hook's command (opt-in only).
|
|
13711
|
+
|
|
13712
|
+
The command is split with shlex and run WITHOUT a shell, so a spec's hook
|
|
13713
|
+
string cannot inject arbitrary shell (`; rm -rf …`, pipes, expansion). A
|
|
13714
|
+
hook that needs shell features must invoke an explicit interpreter
|
|
13715
|
+
(e.g. ``bash -c "…"``) as its own argv, making the intent visible (EX1).
|
|
13716
|
+
"""
|
|
13717
|
+
import shlex
|
|
13677
13718
|
import subprocess
|
|
13678
13719
|
|
|
13679
13720
|
def _run(hook) -> bool:
|
|
13721
|
+
try:
|
|
13722
|
+
argv = shlex.split(hook.command or "")
|
|
13723
|
+
except ValueError as exc:
|
|
13724
|
+
logger.warning("spec hook '%s' has an unparseable command: %s", hook.name or hook.command, exc)
|
|
13725
|
+
return False
|
|
13726
|
+
if not argv:
|
|
13727
|
+
logger.warning("spec hook '%s' has an empty command", hook.name or "")
|
|
13728
|
+
return False
|
|
13680
13729
|
try:
|
|
13681
13730
|
proc = subprocess.run(
|
|
13682
|
-
|
|
13683
|
-
shell=
|
|
13731
|
+
argv,
|
|
13732
|
+
shell=False,
|
|
13684
13733
|
timeout=timeout,
|
|
13685
13734
|
stdin=subprocess.DEVNULL,
|
|
13686
13735
|
capture_output=True,
|
|
@@ -14891,7 +14940,20 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
|
|
|
14891
14940
|
for sub in (root.GetSubFolderList() or []):
|
|
14892
14941
|
if sub.GetUniqueId() == fid:
|
|
14893
14942
|
folders.append(sub)
|
|
14894
|
-
|
|
14943
|
+
if not folders:
|
|
14944
|
+
return _err("No folders found")
|
|
14945
|
+
if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required():
|
|
14946
|
+
return _issue_confirm_token(
|
|
14947
|
+
action="media_pool.delete_folders", params=p,
|
|
14948
|
+
preview={"operation": "media_pool.delete_folders",
|
|
14949
|
+
"warning": "Permanently deletes folders AND every clip they contain.",
|
|
14950
|
+
"folders_lost": len(folders),
|
|
14951
|
+
"names": [_clip_name(f) for f in folders][:25]},
|
|
14952
|
+
)
|
|
14953
|
+
blocked = _consume_confirm_token(action="media_pool.delete_folders", params=p)
|
|
14954
|
+
if blocked:
|
|
14955
|
+
return blocked
|
|
14956
|
+
return {"success": bool(mp.DeleteFolders(folders))}
|
|
14895
14957
|
elif action == "move_folders":
|
|
14896
14958
|
target = _navigate_folder(mp, p["target_path"])
|
|
14897
14959
|
if not target:
|
|
@@ -14984,7 +15046,20 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
|
|
|
14984
15046
|
tl = proj.GetTimelineByIndex(i)
|
|
14985
15047
|
if tl and tl.GetUniqueId() in p["timeline_ids"]:
|
|
14986
15048
|
timelines.append(tl)
|
|
14987
|
-
|
|
15049
|
+
if not timelines:
|
|
15050
|
+
return _err("No timelines found")
|
|
15051
|
+
if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required():
|
|
15052
|
+
return _issue_confirm_token(
|
|
15053
|
+
action="media_pool.delete_timelines", params=p,
|
|
15054
|
+
preview={"operation": "media_pool.delete_timelines",
|
|
15055
|
+
"warning": "Permanently deletes timelines.",
|
|
15056
|
+
"timelines_lost": len(timelines),
|
|
15057
|
+
"names": [t.GetName() for t in timelines][:25]},
|
|
15058
|
+
)
|
|
15059
|
+
blocked = _consume_confirm_token(action="media_pool.delete_timelines", params=p)
|
|
15060
|
+
if blocked:
|
|
15061
|
+
return blocked
|
|
15062
|
+
return {"success": bool(mp.DeleteTimelines(timelines))}
|
|
14988
15063
|
elif action == "append_to_timeline":
|
|
14989
15064
|
if p.get("clip_infos") is not None:
|
|
14990
15065
|
raw = p["clip_infos"]
|
|
@@ -15071,7 +15146,20 @@ def media_pool(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str
|
|
|
15071
15146
|
elif action == "delete_clips":
|
|
15072
15147
|
clips = [_find_clip(root, cid) for cid in p["clip_ids"]]
|
|
15073
15148
|
clips = [c for c in clips if c]
|
|
15074
|
-
|
|
15149
|
+
if not clips:
|
|
15150
|
+
return _err("No clips found")
|
|
15151
|
+
if "confirm_token" not in p and "confirmToken" not in p and _confirm_token_required():
|
|
15152
|
+
return _issue_confirm_token(
|
|
15153
|
+
action="media_pool.delete_clips", params=p,
|
|
15154
|
+
preview={"operation": "media_pool.delete_clips",
|
|
15155
|
+
"warning": "Permanently deletes Media Pool clips.",
|
|
15156
|
+
"clips_lost": len(clips),
|
|
15157
|
+
"names": [_clip_name(c) for c in clips][:25]},
|
|
15158
|
+
)
|
|
15159
|
+
blocked = _consume_confirm_token(action="media_pool.delete_clips", params=p)
|
|
15160
|
+
if blocked:
|
|
15161
|
+
return blocked
|
|
15162
|
+
return {"success": bool(mp.DeleteClips(clips))}
|
|
15075
15163
|
elif action == "move_clips":
|
|
15076
15164
|
target = _navigate_folder(mp, p["target_path"])
|
|
15077
15165
|
if not target:
|
|
@@ -15965,7 +16053,7 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
|
|
|
15965
16053
|
_r, _proj, project_root, _name = vctx
|
|
15966
16054
|
session_only = bool(p.get("session_only", False))
|
|
15967
16055
|
session_id = _AI_LEDGER_SESSION_ID if session_only else None
|
|
15968
|
-
limit =
|
|
16056
|
+
limit = _safe_int(p.get("limit"), 50, minimum=1, maximum=1000)
|
|
15969
16057
|
return {
|
|
15970
16058
|
"success": True,
|
|
15971
16059
|
"session_id": _AI_LEDGER_SESSION_ID,
|
|
@@ -16821,7 +16909,7 @@ def timeline_versioning(action: str, params: Optional[Dict[str, Any]] = None) ->
|
|
|
16821
16909
|
if action == "list_runs":
|
|
16822
16910
|
return {
|
|
16823
16911
|
"success": True,
|
|
16824
|
-
"runs": _analysis_runs.list_runs(project_root, limit=
|
|
16912
|
+
"runs": _analysis_runs.list_runs(project_root, limit=_safe_int(p.get("limit"), 50, minimum=1, maximum=1000)),
|
|
16825
16913
|
}
|
|
16826
16914
|
if action == "archive_current":
|
|
16827
16915
|
return _timeline_versioning.archive_current_timeline(
|
|
@@ -16842,13 +16930,18 @@ def timeline_versioning(action: str, params: Optional[Dict[str, Any]] = None) ->
|
|
|
16842
16930
|
if action == "diff_versions":
|
|
16843
16931
|
if not p.get("timeline_name") or "from_version" not in p or "to_version" not in p:
|
|
16844
16932
|
return _err("timeline_name, from_version, to_version required")
|
|
16933
|
+
try:
|
|
16934
|
+
from_version = int(p["from_version"])
|
|
16935
|
+
to_version = int(p["to_version"])
|
|
16936
|
+
except (TypeError, ValueError):
|
|
16937
|
+
return _err("from_version and to_version must be integers")
|
|
16845
16938
|
return {
|
|
16846
16939
|
"success": True,
|
|
16847
16940
|
**_timeline_versioning.diff_versions(
|
|
16848
16941
|
project_root=project_root,
|
|
16849
16942
|
timeline_name=str(p["timeline_name"]),
|
|
16850
|
-
from_version=
|
|
16851
|
-
to_version=
|
|
16943
|
+
from_version=from_version,
|
|
16944
|
+
to_version=to_version,
|
|
16852
16945
|
),
|
|
16853
16946
|
}
|
|
16854
16947
|
if action == "diff_timelines":
|
|
@@ -16864,18 +16957,24 @@ def timeline_versioning(action: str, params: Optional[Dict[str, Any]] = None) ->
|
|
|
16864
16957
|
project_root=project_root,
|
|
16865
16958
|
timeline_name=p.get("timeline_name"),
|
|
16866
16959
|
analysis_run_id=p.get("analysis_run_id"),
|
|
16867
|
-
limit=
|
|
16960
|
+
limit=_safe_int(p.get("limit"), 50, minimum=1, maximum=1000),
|
|
16868
16961
|
)
|
|
16869
16962
|
return {"success": True, "edits": rows}
|
|
16870
16963
|
if action == "rollback":
|
|
16871
16964
|
if not p.get("timeline_name") or "version" not in p:
|
|
16872
16965
|
return _err("timeline_name and version required")
|
|
16966
|
+
try:
|
|
16967
|
+
version = int(p["version"])
|
|
16968
|
+
except (TypeError, ValueError):
|
|
16969
|
+
return _err("version must be an integer")
|
|
16970
|
+
if version < 0:
|
|
16971
|
+
return _err("version must be >= 0")
|
|
16873
16972
|
return _timeline_versioning.rollback_to_version(
|
|
16874
16973
|
resolve=resolve_h,
|
|
16875
16974
|
project=project_h,
|
|
16876
16975
|
project_root=project_root,
|
|
16877
16976
|
timeline_name=str(p["timeline_name"]),
|
|
16878
|
-
version=
|
|
16977
|
+
version=version,
|
|
16879
16978
|
analysis_run_id=p.get("analysis_run_id"),
|
|
16880
16979
|
)
|
|
16881
16980
|
if action == "prune":
|
|
@@ -16886,7 +16985,7 @@ def timeline_versioning(action: str, params: Optional[Dict[str, Any]] = None) ->
|
|
|
16886
16985
|
project=project_h,
|
|
16887
16986
|
project_root=project_root,
|
|
16888
16987
|
timeline_name=str(p["timeline_name"]),
|
|
16889
|
-
keep_n=
|
|
16988
|
+
keep_n=_safe_int(p.get("keep_n"), 10, minimum=1, maximum=1000),
|
|
16890
16989
|
)
|
|
16891
16990
|
if action == "registry":
|
|
16892
16991
|
return {"success": True, **_brain_edits.read_brain_edits_registry(project_root)}
|
|
@@ -16897,7 +16996,7 @@ def timeline_versioning(action: str, params: Optional[Dict[str, Any]] = None) ->
|
|
|
16897
16996
|
project_root=project_root,
|
|
16898
16997
|
analysis_run_id=p.get("analysis_run_id"),
|
|
16899
16998
|
action=p.get("media_pool_action"),
|
|
16900
|
-
limit=
|
|
16999
|
+
limit=_safe_int(p.get("limit"), 50, minimum=1, maximum=1000),
|
|
16901
17000
|
),
|
|
16902
17001
|
}
|
|
16903
17002
|
return _err(f"Unknown action: {action}")
|
|
@@ -234,6 +234,11 @@ def get_run(project_root: str, analysis_run_id: str) -> Optional[Dict[str, Any]]
|
|
|
234
234
|
|
|
235
235
|
|
|
236
236
|
def list_runs(project_root: str, *, limit: int = 50) -> List[Dict[str, Any]]:
|
|
237
|
+
# SQLite treats a negative LIMIT as "no limit"; clamp (EX8).
|
|
238
|
+
try:
|
|
239
|
+
limit = max(1, min(1000, int(limit)))
|
|
240
|
+
except (TypeError, ValueError):
|
|
241
|
+
limit = 50
|
|
237
242
|
conn = timeline_brain_db.connect(project_root)
|
|
238
243
|
rows = conn.execute(
|
|
239
244
|
"""
|
|
@@ -47,7 +47,7 @@ def _record_tool_result(
|
|
|
47
47
|
|
|
48
48
|
|
|
49
49
|
def _run_ffmpeg(args: list[str]) -> None:
|
|
50
|
-
subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", *args], check=True)
|
|
50
|
+
subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", *args], check=True, timeout=120)
|
|
51
51
|
|
|
52
52
|
|
|
53
53
|
def _make_synthetic_media(work_dir: Path) -> Dict[str, Path]:
|
package/src/utils/brain_edits.py
CHANGED
|
@@ -278,6 +278,12 @@ def get_brain_edit_history(
|
|
|
278
278
|
limit: int = 50,
|
|
279
279
|
) -> List[Dict[str, Any]]:
|
|
280
280
|
"""Return brain_edit rows, optionally filtered by timeline or run, newest first."""
|
|
281
|
+
# SQLite treats a negative LIMIT as "no limit"; clamp so a negative/huge
|
|
282
|
+
# limit can't silently fetch the whole table (EX8).
|
|
283
|
+
try:
|
|
284
|
+
limit = max(1, min(1000, int(limit)))
|
|
285
|
+
except (TypeError, ValueError):
|
|
286
|
+
limit = 50
|
|
281
287
|
conn = timeline_brain_db.connect(project_root)
|
|
282
288
|
clauses: List[str] = []
|
|
283
289
|
args: List[Any] = []
|
|
@@ -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
|
}),
|
|
@@ -84,6 +84,11 @@ def get_media_pool_change_history(
|
|
|
84
84
|
action: Optional[str] = None,
|
|
85
85
|
limit: int = 50,
|
|
86
86
|
) -> List[Dict[str, Any]]:
|
|
87
|
+
# SQLite treats a negative LIMIT as "no limit"; clamp (EX8).
|
|
88
|
+
try:
|
|
89
|
+
limit = max(1, min(1000, int(limit)))
|
|
90
|
+
except (TypeError, ValueError):
|
|
91
|
+
limit = 50
|
|
87
92
|
conn = timeline_brain_db.connect(project_root)
|
|
88
93
|
clauses: List[str] = []
|
|
89
94
|
args: List[Any] = []
|
|
@@ -59,7 +59,7 @@ def _record_tool_result(
|
|
|
59
59
|
|
|
60
60
|
|
|
61
61
|
def _run_ffmpeg(args: list[str]) -> None:
|
|
62
|
-
subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", *args], check=True)
|
|
62
|
+
subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", *args], check=True, timeout=120)
|
|
63
63
|
|
|
64
64
|
|
|
65
65
|
def _make_synthetic_video(work_dir: Path) -> Path:
|
|
@@ -59,7 +59,7 @@ def _record_tool_result(
|
|
|
59
59
|
|
|
60
60
|
|
|
61
61
|
def _run_ffmpeg(args: list[str]) -> None:
|
|
62
|
-
subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", *args], check=True)
|
|
62
|
+
subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", *args], check=True, timeout=120)
|
|
63
63
|
|
|
64
64
|
|
|
65
65
|
def _make_synthetic_video(work_dir: Path) -> Path:
|