davinci-resolve-mcp 2.54.4 → 2.54.5
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 +26 -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 +84 -29
- 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/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,32 @@
|
|
|
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.54.5
|
|
6
|
+
|
|
7
|
+
Reliability + security hardening from the exhaustive reliability audit (Wave A).
|
|
8
|
+
|
|
9
|
+
- **Security** `apply_spec` hooks no longer run with `shell=True`. A spec's hook
|
|
10
|
+
command is now `shlex`-split and executed without a shell, so a hook string
|
|
11
|
+
can't inject arbitrary shell (`; rm -rf …`, pipes, expansion). Hooks needing
|
|
12
|
+
shell features must invoke an interpreter explicitly (e.g. `bash -c "…"`).
|
|
13
|
+
- **Fixed** the confirm-token table (`_CONFIRM_TOKENS`) is now guarded by a lock.
|
|
14
|
+
The control panel is threaded, so issue/consume/GC ran concurrently; validate-
|
|
15
|
+
then-pop is now atomic and GC can't race a write.
|
|
16
|
+
- **Fixed** negative item indices are rejected instead of silently returning the
|
|
17
|
+
wrong item (Python reverse-indexing): `_get_item`, the audio item resolver, and
|
|
18
|
+
the two timeline-matte helpers now require `0 <= index < len`.
|
|
19
|
+
- **Fixed** history queries clamp `limit` to `[1, 1000]`. SQLite treats a negative
|
|
20
|
+
`LIMIT` as "no limit", so a negative value could silently fetch the entire table
|
|
21
|
+
(`get_brain_edit_history`, `list_runs`, `get_media_pool_change_history`).
|
|
22
|
+
`timeline_versioning` version/limit/keep_n params are now validated (no unhandled
|
|
23
|
+
`ValueError`; `rollback` rejects negative versions before archiving). `max_files`
|
|
24
|
+
/`max_seconds` in the relink file search are clamped positive.
|
|
25
|
+
- **Fixed** the server-reachable ffmpeg probes (render, audio, review) now pass
|
|
26
|
+
`timeout=120` so a hung ffmpeg can't block the server indefinitely.
|
|
27
|
+
|
|
28
|
+
(Deferred to a live-validated follow-up: confirm-token gating + archiving for
|
|
29
|
+
catastrophic media-pool/take/fusion deletes, and temp-directory lifecycle cleanup.)
|
|
30
|
+
|
|
5
31
|
## What's New in v2.54.4
|
|
6
32
|
|
|
7
33
|
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.54.
|
|
38
|
+
VERSION = "2.54.5"
|
|
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.54.
|
|
83
|
+
VERSION = "2.54.5"
|
|
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.54.
|
|
14
|
+
VERSION = "2.54.5"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -1006,6 +1006,10 @@ import uuid as _uuid
|
|
|
1006
1006
|
|
|
1007
1007
|
|
|
1008
1008
|
_CONFIRM_TOKENS: Dict[str, Dict[str, Any]] = {}
|
|
1009
|
+
# The control panel runs on a threaded HTTP server, so issue/consume/gc of the
|
|
1010
|
+
# token table run on concurrent threads. Guard every access so a GC iteration
|
|
1011
|
+
# can't race a write and validate-then-pop stays atomic (EX4).
|
|
1012
|
+
_CONFIRM_TOKENS_LOCK = threading.RLock()
|
|
1009
1013
|
_CONFIRM_TTL_SECONDS = 300
|
|
1010
1014
|
|
|
1011
1015
|
|
|
@@ -1023,11 +1027,13 @@ def _confirm_token_fingerprint(action: str, params: Optional[Dict[str, Any]]) ->
|
|
|
1023
1027
|
|
|
1024
1028
|
|
|
1025
1029
|
def _confirm_token_gc():
|
|
1026
|
-
"""Drop expired tokens; called on every issue/validate.
|
|
1030
|
+
"""Drop expired tokens; called on every issue/validate. Caller may already
|
|
1031
|
+
hold _CONFIRM_TOKENS_LOCK (RLock makes re-entry safe)."""
|
|
1027
1032
|
now = _time.time()
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1033
|
+
with _CONFIRM_TOKENS_LOCK:
|
|
1034
|
+
expired = [t for t, rec in _CONFIRM_TOKENS.items() if rec.get("expires_at", 0) < now]
|
|
1035
|
+
for t in expired:
|
|
1036
|
+
_CONFIRM_TOKENS.pop(t, None)
|
|
1031
1037
|
|
|
1032
1038
|
|
|
1033
1039
|
def _confirm_token_required() -> bool:
|
|
@@ -1045,16 +1051,17 @@ def _confirm_token_required() -> bool:
|
|
|
1045
1051
|
|
|
1046
1052
|
def _issue_confirm_token(*, action: str, params: Optional[Dict[str, Any]], preview: Dict[str, Any]) -> Dict[str, Any]:
|
|
1047
1053
|
"""Mint a token. Returns the pending_user_decision response shape."""
|
|
1048
|
-
_confirm_token_gc()
|
|
1049
1054
|
token = _uuid.uuid4().hex
|
|
1050
1055
|
fp = _confirm_token_fingerprint(action, params)
|
|
1051
1056
|
expires_at = _time.time() + _CONFIRM_TTL_SECONDS
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1057
|
+
with _CONFIRM_TOKENS_LOCK:
|
|
1058
|
+
_confirm_token_gc()
|
|
1059
|
+
_CONFIRM_TOKENS[token] = {
|
|
1060
|
+
"action": action,
|
|
1061
|
+
"fingerprint": fp,
|
|
1062
|
+
"expires_at": expires_at,
|
|
1063
|
+
"issued_at": _time.time(),
|
|
1064
|
+
}
|
|
1058
1065
|
body = _err(
|
|
1059
1066
|
f"This action is destructive. Re-call with confirm_token to proceed.",
|
|
1060
1067
|
code="CONFIRMATION_REQUIRED",
|
|
@@ -1082,8 +1089,9 @@ def _consume_confirm_token(*, action: str, params: Optional[Dict[str, Any]]) ->
|
|
|
1082
1089
|
token = (params or {}).get("confirm_token") or (params or {}).get("confirmToken")
|
|
1083
1090
|
if not token:
|
|
1084
1091
|
return None # Caller is expected to call _issue_confirm_token in this case.
|
|
1085
|
-
|
|
1086
|
-
|
|
1092
|
+
with _CONFIRM_TOKENS_LOCK:
|
|
1093
|
+
_confirm_token_gc()
|
|
1094
|
+
rec = _CONFIRM_TOKENS.pop(token, None) # one-time use, atomic with gc
|
|
1087
1095
|
if rec is None:
|
|
1088
1096
|
return _err(
|
|
1089
1097
|
"confirm_token is invalid, expired, or was issued by a different "
|
|
@@ -1855,7 +1863,9 @@ def _get_item(p):
|
|
|
1855
1863
|
track_index = p.get("track_index", 1)
|
|
1856
1864
|
item_index = p.get("item_index", 0)
|
|
1857
1865
|
items = tl.GetItemListInTrack(track_type, track_index)
|
|
1858
|
-
|
|
1866
|
+
# Reject negatives explicitly: Python's reverse-indexing would otherwise
|
|
1867
|
+
# silently return the wrong item for item_index < 0 (EX5).
|
|
1868
|
+
if not items or not isinstance(item_index, int) or item_index < 0 or item_index >= len(items):
|
|
1859
1869
|
return tl, None, _err(
|
|
1860
1870
|
f"No item at index {item_index} on {track_type} track {track_index}",
|
|
1861
1871
|
code="ITEM_INDEX_OUT_OF_RANGE", category="invalid_input",
|
|
@@ -5494,8 +5504,10 @@ def _bounded_basename_matches(
|
|
|
5494
5504
|
p: Dict[str, Any],
|
|
5495
5505
|
) -> Tuple[List[str], Dict[str, Any]]:
|
|
5496
5506
|
started = time.monotonic()
|
|
5497
|
-
|
|
5498
|
-
|
|
5507
|
+
# Clamp to positive: a negative max_seconds/max_files would trip the
|
|
5508
|
+
# >= guards on the first iteration and silently return no results (EX8).
|
|
5509
|
+
max_seconds = max(0.1, float(p.get("max_seconds", p.get("timeout_seconds", 20)) or 20))
|
|
5510
|
+
max_files = max(1, int(p.get("max_files_scanned", p.get("maxFilesScanned", 50000)) or 50000))
|
|
5499
5511
|
max_depth_raw = p.get("max_depth", p.get("maxDepth"))
|
|
5500
5512
|
max_depth = int(max_depth_raw) if max_depth_raw is not None else None
|
|
5501
5513
|
all_matches = bool(p.get("all_matches", False))
|
|
@@ -5704,7 +5716,7 @@ def _audio_item_from_params(tl, p: Dict[str, Any]):
|
|
|
5704
5716
|
track_index = int(p.get("track_index", 1))
|
|
5705
5717
|
item_index = int(p.get("item_index", 0))
|
|
5706
5718
|
items = tl.GetItemListInTrack(track_type, track_index) or []
|
|
5707
|
-
if item_index >= len(items):
|
|
5719
|
+
if item_index < 0 or item_index >= len(items): # reject negatives (EX5)
|
|
5708
5720
|
return None, _err(f"No item at index {item_index} on {track_type} track {track_index}")
|
|
5709
5721
|
return items[item_index], None
|
|
5710
5722
|
|
|
@@ -7474,6 +7486,23 @@ def _setup_positive_int(value: Any, default: int, min_value: int = 0, max_value:
|
|
|
7474
7486
|
return max(min_value, min(parsed, max_value))
|
|
7475
7487
|
|
|
7476
7488
|
|
|
7489
|
+
def _safe_int(value: Any, default: int, *, minimum: Optional[int] = None, maximum: Optional[int] = None) -> int:
|
|
7490
|
+
"""Coerce a tool param to int without throwing; clamp to [minimum, maximum].
|
|
7491
|
+
|
|
7492
|
+
Used by action handlers so a non-numeric or out-of-range numeric param yields
|
|
7493
|
+
a sane value instead of an unhandled ValueError or a SQLite "negative LIMIT =
|
|
7494
|
+
no limit" full-table fetch (EX8)."""
|
|
7495
|
+
try:
|
|
7496
|
+
parsed = int(value)
|
|
7497
|
+
except (TypeError, ValueError):
|
|
7498
|
+
parsed = default
|
|
7499
|
+
if minimum is not None:
|
|
7500
|
+
parsed = max(minimum, parsed)
|
|
7501
|
+
if maximum is not None:
|
|
7502
|
+
parsed = min(parsed, maximum)
|
|
7503
|
+
return parsed
|
|
7504
|
+
|
|
7505
|
+
|
|
7477
7506
|
def _setup_marker_limit(value: Any, default: int = 12) -> int:
|
|
7478
7507
|
if isinstance(value, str) and _setup_text_key(value) in {"unlimited", "no_limit", "nolimit", "all"}:
|
|
7479
7508
|
return 0
|
|
@@ -13673,14 +13702,29 @@ class _SpecLiveExecutor:
|
|
|
13673
13702
|
|
|
13674
13703
|
|
|
13675
13704
|
def _make_spec_hook_runner(timeout: float = 120.0):
|
|
13676
|
-
"""Return a callable that runs a Hook's
|
|
13705
|
+
"""Return a callable that runs a Hook's command (opt-in only).
|
|
13706
|
+
|
|
13707
|
+
The command is split with shlex and run WITHOUT a shell, so a spec's hook
|
|
13708
|
+
string cannot inject arbitrary shell (`; rm -rf …`, pipes, expansion). A
|
|
13709
|
+
hook that needs shell features must invoke an explicit interpreter
|
|
13710
|
+
(e.g. ``bash -c "…"``) as its own argv, making the intent visible (EX1).
|
|
13711
|
+
"""
|
|
13712
|
+
import shlex
|
|
13677
13713
|
import subprocess
|
|
13678
13714
|
|
|
13679
13715
|
def _run(hook) -> bool:
|
|
13716
|
+
try:
|
|
13717
|
+
argv = shlex.split(hook.command or "")
|
|
13718
|
+
except ValueError as exc:
|
|
13719
|
+
logger.warning("spec hook '%s' has an unparseable command: %s", hook.name or hook.command, exc)
|
|
13720
|
+
return False
|
|
13721
|
+
if not argv:
|
|
13722
|
+
logger.warning("spec hook '%s' has an empty command", hook.name or "")
|
|
13723
|
+
return False
|
|
13680
13724
|
try:
|
|
13681
13725
|
proc = subprocess.run(
|
|
13682
|
-
|
|
13683
|
-
shell=
|
|
13726
|
+
argv,
|
|
13727
|
+
shell=False,
|
|
13684
13728
|
timeout=timeout,
|
|
13685
13729
|
stdin=subprocess.DEVNULL,
|
|
13686
13730
|
capture_output=True,
|
|
@@ -15965,7 +16009,7 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
|
|
|
15965
16009
|
_r, _proj, project_root, _name = vctx
|
|
15966
16010
|
session_only = bool(p.get("session_only", False))
|
|
15967
16011
|
session_id = _AI_LEDGER_SESSION_ID if session_only else None
|
|
15968
|
-
limit =
|
|
16012
|
+
limit = _safe_int(p.get("limit"), 50, minimum=1, maximum=1000)
|
|
15969
16013
|
return {
|
|
15970
16014
|
"success": True,
|
|
15971
16015
|
"session_id": _AI_LEDGER_SESSION_ID,
|
|
@@ -16821,7 +16865,7 @@ def timeline_versioning(action: str, params: Optional[Dict[str, Any]] = None) ->
|
|
|
16821
16865
|
if action == "list_runs":
|
|
16822
16866
|
return {
|
|
16823
16867
|
"success": True,
|
|
16824
|
-
"runs": _analysis_runs.list_runs(project_root, limit=
|
|
16868
|
+
"runs": _analysis_runs.list_runs(project_root, limit=_safe_int(p.get("limit"), 50, minimum=1, maximum=1000)),
|
|
16825
16869
|
}
|
|
16826
16870
|
if action == "archive_current":
|
|
16827
16871
|
return _timeline_versioning.archive_current_timeline(
|
|
@@ -16842,13 +16886,18 @@ def timeline_versioning(action: str, params: Optional[Dict[str, Any]] = None) ->
|
|
|
16842
16886
|
if action == "diff_versions":
|
|
16843
16887
|
if not p.get("timeline_name") or "from_version" not in p or "to_version" not in p:
|
|
16844
16888
|
return _err("timeline_name, from_version, to_version required")
|
|
16889
|
+
try:
|
|
16890
|
+
from_version = int(p["from_version"])
|
|
16891
|
+
to_version = int(p["to_version"])
|
|
16892
|
+
except (TypeError, ValueError):
|
|
16893
|
+
return _err("from_version and to_version must be integers")
|
|
16845
16894
|
return {
|
|
16846
16895
|
"success": True,
|
|
16847
16896
|
**_timeline_versioning.diff_versions(
|
|
16848
16897
|
project_root=project_root,
|
|
16849
16898
|
timeline_name=str(p["timeline_name"]),
|
|
16850
|
-
from_version=
|
|
16851
|
-
to_version=
|
|
16899
|
+
from_version=from_version,
|
|
16900
|
+
to_version=to_version,
|
|
16852
16901
|
),
|
|
16853
16902
|
}
|
|
16854
16903
|
if action == "diff_timelines":
|
|
@@ -16864,18 +16913,24 @@ def timeline_versioning(action: str, params: Optional[Dict[str, Any]] = None) ->
|
|
|
16864
16913
|
project_root=project_root,
|
|
16865
16914
|
timeline_name=p.get("timeline_name"),
|
|
16866
16915
|
analysis_run_id=p.get("analysis_run_id"),
|
|
16867
|
-
limit=
|
|
16916
|
+
limit=_safe_int(p.get("limit"), 50, minimum=1, maximum=1000),
|
|
16868
16917
|
)
|
|
16869
16918
|
return {"success": True, "edits": rows}
|
|
16870
16919
|
if action == "rollback":
|
|
16871
16920
|
if not p.get("timeline_name") or "version" not in p:
|
|
16872
16921
|
return _err("timeline_name and version required")
|
|
16922
|
+
try:
|
|
16923
|
+
version = int(p["version"])
|
|
16924
|
+
except (TypeError, ValueError):
|
|
16925
|
+
return _err("version must be an integer")
|
|
16926
|
+
if version < 0:
|
|
16927
|
+
return _err("version must be >= 0")
|
|
16873
16928
|
return _timeline_versioning.rollback_to_version(
|
|
16874
16929
|
resolve=resolve_h,
|
|
16875
16930
|
project=project_h,
|
|
16876
16931
|
project_root=project_root,
|
|
16877
16932
|
timeline_name=str(p["timeline_name"]),
|
|
16878
|
-
version=
|
|
16933
|
+
version=version,
|
|
16879
16934
|
analysis_run_id=p.get("analysis_run_id"),
|
|
16880
16935
|
)
|
|
16881
16936
|
if action == "prune":
|
|
@@ -16886,7 +16941,7 @@ def timeline_versioning(action: str, params: Optional[Dict[str, Any]] = None) ->
|
|
|
16886
16941
|
project=project_h,
|
|
16887
16942
|
project_root=project_root,
|
|
16888
16943
|
timeline_name=str(p["timeline_name"]),
|
|
16889
|
-
keep_n=
|
|
16944
|
+
keep_n=_safe_int(p.get("keep_n"), 10, minimum=1, maximum=1000),
|
|
16890
16945
|
)
|
|
16891
16946
|
if action == "registry":
|
|
16892
16947
|
return {"success": True, **_brain_edits.read_brain_edits_registry(project_root)}
|
|
@@ -16897,7 +16952,7 @@ def timeline_versioning(action: str, params: Optional[Dict[str, Any]] = None) ->
|
|
|
16897
16952
|
project_root=project_root,
|
|
16898
16953
|
analysis_run_id=p.get("analysis_run_id"),
|
|
16899
16954
|
action=p.get("media_pool_action"),
|
|
16900
|
-
limit=
|
|
16955
|
+
limit=_safe_int(p.get("limit"), 50, minimum=1, maximum=1000),
|
|
16901
16956
|
),
|
|
16902
16957
|
}
|
|
16903
16958
|
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] = []
|
|
@@ -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:
|