davinci-resolve-mcp 2.54.3 → 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 +52 -0
- package/README.md +1 -1
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/analysis_dashboard.py +14 -8
- 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 +209 -48
- package/src/utils/analysis_memory.py +10 -1
- 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/fusion_group_settings.py +13 -2
- 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,58 @@
|
|
|
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
|
+
|
|
31
|
+
## What's New in v2.54.4
|
|
32
|
+
|
|
33
|
+
Persistence-safety hardening (generalizes issue #71 to the analysis state
|
|
34
|
+
stores). A multi-agent audit found several state files whose **readers** swallow a
|
|
35
|
+
`JSONDecodeError` and reset to an empty default — so a later read-modify-write
|
|
36
|
+
writes back only the new field, silently wiping prior data. Atomic writes don't
|
|
37
|
+
help, because the loss happens at *read* time.
|
|
38
|
+
|
|
39
|
+
- **Fixed** read-modify-write paths now read strictly and **refuse to overwrite**
|
|
40
|
+
a corrupt existing file (via a shared `ConfigParseError` + `_read_json_strict`),
|
|
41
|
+
rather than clobbering it:
|
|
42
|
+
- media-analysis **preferences** — 5 write paths (set-defaults, timed-marker
|
|
43
|
+
default, sampling-mode default, AI governance, caps preset)
|
|
44
|
+
- **update-state** — configure/clear update settings (3 paths), now also written
|
|
45
|
+
atomically (temp + `os.replace`)
|
|
46
|
+
- per-clip **corrections** (human edit history — the highest-value store) —
|
|
47
|
+
update and revert paths
|
|
48
|
+
- **Fixed** non-atomic writes that a crash mid-write could truncate: the bin
|
|
49
|
+
summary markdown and Fusion `.setting` files now write to a temp file and
|
|
50
|
+
`os.replace`.
|
|
51
|
+
- **Fixed** a read-modify-write race on `analysis.json` under the threaded control
|
|
52
|
+
panel: transcript regeneration is now serialized under the existing state lock,
|
|
53
|
+
so a concurrent regen/batch write can't drop the other's updates.
|
|
54
|
+
- Read-only callers keep their forgiving behavior (they fall back to defaults and
|
|
55
|
+
never write). New tests in `tests/test_persistence_safety.py`.
|
|
56
|
+
|
|
5
57
|
## What's New in v2.54.3
|
|
6
58
|
|
|
7
59
|
Follow-up to the v2.54.2 config-merge fix (#71): the JSONC sanitizer's
|
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
|
@@ -15176,14 +15176,20 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
15176
15176
|
self._json({"success": False, "error": "Transcript regeneration is loopback-only."}, HTTPStatus.FORBIDDEN)
|
|
15177
15177
|
return
|
|
15178
15178
|
clip_id = path.split("/")[3]
|
|
15179
|
-
|
|
15180
|
-
|
|
15181
|
-
|
|
15182
|
-
|
|
15183
|
-
|
|
15184
|
-
|
|
15185
|
-
|
|
15186
|
-
|
|
15179
|
+
# Serialize the analysis.json read-modify-write: this server is
|
|
15180
|
+
# threaded, so concurrent regenerations (or a regen racing a batch
|
|
15181
|
+
# job's report write) would interleave and the last writer would drop
|
|
15182
|
+
# the other's updates (PS9).
|
|
15183
|
+
with self.state.lock:
|
|
15184
|
+
result = regenerate_clip_transcript(
|
|
15185
|
+
self.state.project_root,
|
|
15186
|
+
clip_id,
|
|
15187
|
+
with_words=bool(body.get("with_words", True)),
|
|
15188
|
+
backend=body.get("backend") or None,
|
|
15189
|
+
language=body.get("language") or None,
|
|
15190
|
+
model=body.get("model") or None,
|
|
15191
|
+
)
|
|
15192
|
+
self._json(result)
|
|
15187
15193
|
return
|
|
15188
15194
|
if path.startswith("/api/clips/") and path.endswith("/transcript/corrections"):
|
|
15189
15195
|
if not _request_is_loopback(self):
|
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
|
|
|
@@ -7273,6 +7285,39 @@ def _media_analysis_preferences_path() -> str:
|
|
|
7273
7285
|
return os.path.join(project_dir, "logs", "media-analysis-preferences.json")
|
|
7274
7286
|
|
|
7275
7287
|
|
|
7288
|
+
class ConfigParseError(Exception):
|
|
7289
|
+
"""A persisted state/config file exists but its contents could not be read.
|
|
7290
|
+
|
|
7291
|
+
Read-modify-write callers MUST refuse to overwrite when this is raised — a
|
|
7292
|
+
transient/corrupt read returning a default, followed by a write, silently
|
|
7293
|
+
wipes the user's prior data. This is the issue-#71 bug class generalized to
|
|
7294
|
+
the analysis state stores (see local gameplan Phase 5 / PS1–PS3).
|
|
7295
|
+
"""
|
|
7296
|
+
|
|
7297
|
+
|
|
7298
|
+
def _read_json_strict(path: str) -> Dict[str, Any]:
|
|
7299
|
+
"""Read JSON for a read-modify-write cycle.
|
|
7300
|
+
|
|
7301
|
+
Returns ``{}`` only when the file is absent or empty (safe to create). Raises
|
|
7302
|
+
:class:`ConfigParseError` when the file exists but cannot be read/parsed, so
|
|
7303
|
+
the caller can refuse to overwrite and avoid clobbering prior contents.
|
|
7304
|
+
"""
|
|
7305
|
+
try:
|
|
7306
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
7307
|
+
raw = handle.read()
|
|
7308
|
+
except FileNotFoundError:
|
|
7309
|
+
return {}
|
|
7310
|
+
except OSError as exc:
|
|
7311
|
+
raise ConfigParseError(f"{path}: {exc}") from exc
|
|
7312
|
+
if not raw.strip():
|
|
7313
|
+
return {}
|
|
7314
|
+
try:
|
|
7315
|
+
payload = json.loads(raw)
|
|
7316
|
+
except json.JSONDecodeError as exc:
|
|
7317
|
+
raise ConfigParseError(f"{path}: {exc}") from exc
|
|
7318
|
+
return payload if isinstance(payload, dict) else {}
|
|
7319
|
+
|
|
7320
|
+
|
|
7276
7321
|
def _read_media_analysis_preferences() -> Dict[str, Any]:
|
|
7277
7322
|
path = _media_analysis_preferences_path()
|
|
7278
7323
|
try:
|
|
@@ -7283,6 +7328,12 @@ def _read_media_analysis_preferences() -> Dict[str, Any]:
|
|
|
7283
7328
|
return {}
|
|
7284
7329
|
|
|
7285
7330
|
|
|
7331
|
+
def _read_media_analysis_preferences_strict() -> Dict[str, Any]:
|
|
7332
|
+
"""Strict prefs read for read-modify-write paths — raises ConfigParseError on
|
|
7333
|
+
a corrupt existing file so the caller refuses to overwrite (PS1)."""
|
|
7334
|
+
return _read_json_strict(_media_analysis_preferences_path())
|
|
7335
|
+
|
|
7336
|
+
|
|
7286
7337
|
def _write_media_analysis_preferences(preferences: Dict[str, Any]) -> None:
|
|
7287
7338
|
# Atomic replace: a crash mid-write must not truncate the file, because the
|
|
7288
7339
|
# reader resets to {} on corruption and the next save would then wipe every
|
|
@@ -7435,6 +7486,23 @@ def _setup_positive_int(value: Any, default: int, min_value: int = 0, max_value:
|
|
|
7435
7486
|
return max(min_value, min(parsed, max_value))
|
|
7436
7487
|
|
|
7437
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
|
+
|
|
7438
7506
|
def _setup_marker_limit(value: Any, default: int = 12) -> int:
|
|
7439
7507
|
if isinstance(value, str) and _setup_text_key(value) in {"unlimited", "no_limit", "nolimit", "all"}:
|
|
7440
7508
|
return 0
|
|
@@ -7669,6 +7737,12 @@ def _media_analysis_timed_marker_decision(p: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
7669
7737
|
|
|
7670
7738
|
if choice in {"default_yes", "default_no"}:
|
|
7671
7739
|
saved = "yes" if choice == "default_yes" else "no"
|
|
7740
|
+
# Strict re-read before writing: a corrupt prefs file must refuse, not
|
|
7741
|
+
# clobber every saved preference (PS1).
|
|
7742
|
+
try:
|
|
7743
|
+
preferences = _read_media_analysis_preferences_strict()
|
|
7744
|
+
except ConfigParseError as exc:
|
|
7745
|
+
return _err(f"Refusing to save timed-marker default: {exc}. The preferences file exists but is unparseable; fix or delete it to avoid wiping saved settings.")
|
|
7672
7746
|
preferences["timed_markers_default"] = saved
|
|
7673
7747
|
preferences["timed_markers_default_updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
7674
7748
|
_write_media_analysis_preferences(preferences)
|
|
@@ -7833,6 +7907,10 @@ def _media_analysis_sampling_mode_decision(p: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
7833
7907
|
source = "explicit"
|
|
7834
7908
|
if save_default:
|
|
7835
7909
|
saved = mode
|
|
7910
|
+
try:
|
|
7911
|
+
preferences = _read_media_analysis_preferences_strict()
|
|
7912
|
+
except ConfigParseError as exc:
|
|
7913
|
+
return _err(f"Refusing to save sampling-mode default: {exc}. The preferences file exists but is unparseable; fix or delete it to avoid wiping saved settings.")
|
|
7836
7914
|
preferences["sampling_mode_default"] = mode
|
|
7837
7915
|
preferences["sampling_mode_default_updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
7838
7916
|
_write_media_analysis_preferences(preferences)
|
|
@@ -11040,6 +11118,31 @@ def _setup_update_state() -> Dict[str, Any]:
|
|
|
11040
11118
|
return {}
|
|
11041
11119
|
|
|
11042
11120
|
|
|
11121
|
+
def _setup_update_state_strict() -> Dict[str, Any]:
|
|
11122
|
+
"""Strict update-state read for read-modify-write paths — raises
|
|
11123
|
+
ConfigParseError on a corrupt existing file so the caller refuses to
|
|
11124
|
+
overwrite and wipe saved update prefs (PS2)."""
|
|
11125
|
+
return _read_json_strict(str(update_state_path(project_dir)))
|
|
11126
|
+
|
|
11127
|
+
|
|
11128
|
+
def _write_setup_update_state(state: Dict[str, Any]) -> None:
|
|
11129
|
+
"""Atomically persist update state (temp + os.replace), so a crash mid-write
|
|
11130
|
+
can't truncate the file that _setup_update_state then resets to {} (PS2)."""
|
|
11131
|
+
path = str(update_state_path(project_dir))
|
|
11132
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
11133
|
+
tmp_path = f"{path}.tmp-{os.getpid()}-{threading.get_ident()}-{time.time_ns()}"
|
|
11134
|
+
try:
|
|
11135
|
+
with open(tmp_path, "w", encoding="utf-8") as handle:
|
|
11136
|
+
json.dump(state, handle, indent=2, sort_keys=True)
|
|
11137
|
+
handle.write("\n")
|
|
11138
|
+
os.replace(tmp_path, path)
|
|
11139
|
+
finally:
|
|
11140
|
+
try:
|
|
11141
|
+
os.remove(tmp_path)
|
|
11142
|
+
except OSError:
|
|
11143
|
+
pass
|
|
11144
|
+
|
|
11145
|
+
|
|
11043
11146
|
def _setup_update_env() -> Dict[str, str]:
|
|
11044
11147
|
env = dict(os.environ)
|
|
11045
11148
|
state = _setup_update_state()
|
|
@@ -11263,7 +11366,13 @@ def _setup_set_media_analysis_defaults(media_defaults: Dict[str, Any], dry_run:
|
|
|
11263
11366
|
if not requested:
|
|
11264
11367
|
return {"changed": False, "recognized": False}
|
|
11265
11368
|
|
|
11266
|
-
|
|
11369
|
+
# Strict read: this is a read-modify-write of the prefs file, so a corrupt
|
|
11370
|
+
# existing file must refuse rather than seed the write from {} and wipe every
|
|
11371
|
+
# saved preference (PS1).
|
|
11372
|
+
try:
|
|
11373
|
+
preferences = _read_media_analysis_preferences_strict()
|
|
11374
|
+
except ConfigParseError as exc:
|
|
11375
|
+
return _err(f"Refusing to update media-analysis defaults: {exc}. The preferences file exists but is unparseable; fix or delete it to avoid wiping saved settings.")
|
|
11267
11376
|
before = _media_analysis_effective_preferences()
|
|
11268
11377
|
next_preferences = dict(preferences)
|
|
11269
11378
|
updates: Dict[str, Dict[str, Any]] = {}
|
|
@@ -11519,9 +11628,18 @@ def _setup_set_updates_defaults(update_defaults: Dict[str, Any], dry_run: bool)
|
|
|
11519
11628
|
if dry_run:
|
|
11520
11629
|
return {"changed": True, "recognized": True, "updates": updates, "before": before, "dry_run": True}
|
|
11521
11630
|
|
|
11631
|
+
# Strict re-read before mutating: a corrupt update-state file must refuse,
|
|
11632
|
+
# not seed the write from {} and wipe saved update prefs (PS2).
|
|
11633
|
+
try:
|
|
11634
|
+
state = _setup_update_state_strict()
|
|
11635
|
+
except ConfigParseError as exc:
|
|
11636
|
+
return _err(f"Refusing to update settings: {exc}. The update-state file exists but is unparseable; fix or delete it to avoid wiping saved update preferences.")
|
|
11522
11637
|
if "mode" in updates:
|
|
11523
11638
|
set_update_mode(project_dir, updates["mode"]["after"], env=_setup_update_env())
|
|
11524
|
-
|
|
11639
|
+
try:
|
|
11640
|
+
state = _setup_update_state_strict()
|
|
11641
|
+
except ConfigParseError as exc:
|
|
11642
|
+
return _err(f"Refusing to update settings: {exc}. The update-state file became unparseable; fix or delete it to avoid wiping saved update preferences.")
|
|
11525
11643
|
updated_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
11526
11644
|
if "check_interval_hours" in updates:
|
|
11527
11645
|
state["check_interval_hours"] = updates["check_interval_hours"]["after"]
|
|
@@ -11529,9 +11647,7 @@ def _setup_set_updates_defaults(update_defaults: Dict[str, Any], dry_run: bool)
|
|
|
11529
11647
|
state["snooze_hours"] = updates["snooze_hours"]["after"]
|
|
11530
11648
|
if updates:
|
|
11531
11649
|
state["setup_defaults_updated_at"] = updated_at
|
|
11532
|
-
|
|
11533
|
-
json.dump(state, handle, indent=2, sort_keys=True)
|
|
11534
|
-
handle.write("\n")
|
|
11650
|
+
_write_setup_update_state(state)
|
|
11535
11651
|
return {
|
|
11536
11652
|
"changed": bool(updates),
|
|
11537
11653
|
"recognized": True,
|
|
@@ -11602,26 +11718,28 @@ def _setup_clear_defaults(keys: Any, dry_run: bool) -> Dict[str, Any]:
|
|
|
11602
11718
|
result["cleared"].append("updates.mode")
|
|
11603
11719
|
|
|
11604
11720
|
if clear_all or normalized_keys & {"updates.check_interval_hours", "check_interval_hours", "update_interval_hours"}:
|
|
11605
|
-
state = _setup_update_state()
|
|
11606
11721
|
if dry_run:
|
|
11607
11722
|
result["update_check_interval_hours"] = {"changed": True, "dry_run": True}
|
|
11608
11723
|
else:
|
|
11724
|
+
try:
|
|
11725
|
+
state = _setup_update_state_strict()
|
|
11726
|
+
except ConfigParseError as exc:
|
|
11727
|
+
return _err(f"Refusing to clear update interval: {exc}. The update-state file exists but is unparseable; fix or delete it to avoid wiping saved update preferences.")
|
|
11609
11728
|
state.pop("check_interval_hours", None)
|
|
11610
|
-
|
|
11611
|
-
json.dump(state, handle, indent=2, sort_keys=True)
|
|
11612
|
-
handle.write("\n")
|
|
11729
|
+
_write_setup_update_state(state)
|
|
11613
11730
|
result["update_check_interval_hours"] = {"changed": True, "state_path": str(update_state_path(project_dir))}
|
|
11614
11731
|
result["cleared"].append("updates.check_interval_hours")
|
|
11615
11732
|
|
|
11616
11733
|
if clear_all or normalized_keys & {"updates.snooze_hours", "snooze_hours", "update_snooze_hours"}:
|
|
11617
|
-
state = _setup_update_state()
|
|
11618
11734
|
if dry_run:
|
|
11619
11735
|
result["update_snooze_hours"] = {"changed": True, "dry_run": True}
|
|
11620
11736
|
else:
|
|
11737
|
+
try:
|
|
11738
|
+
state = _setup_update_state_strict()
|
|
11739
|
+
except ConfigParseError as exc:
|
|
11740
|
+
return _err(f"Refusing to clear snooze: {exc}. The update-state file exists but is unparseable; fix or delete it to avoid wiping saved update preferences.")
|
|
11621
11741
|
state.pop("snooze_hours", None)
|
|
11622
|
-
|
|
11623
|
-
json.dump(state, handle, indent=2, sort_keys=True)
|
|
11624
|
-
handle.write("\n")
|
|
11742
|
+
_write_setup_update_state(state)
|
|
11625
11743
|
result["update_snooze_hours"] = {"changed": True, "state_path": str(update_state_path(project_dir))}
|
|
11626
11744
|
result["cleared"].append("updates.snooze_hours")
|
|
11627
11745
|
|
|
@@ -11988,7 +12106,7 @@ def _v2_corrections_path_for_clip(project_root: str, clip_dir: Optional[str], cl
|
|
|
11988
12106
|
return None
|
|
11989
12107
|
|
|
11990
12108
|
|
|
11991
|
-
def _v2_read_corrections(path: str) -> Dict[str, Any]:
|
|
12109
|
+
def _v2_read_corrections(path: str, *, strict: bool = False) -> Dict[str, Any]:
|
|
11992
12110
|
if not os.path.isfile(path):
|
|
11993
12111
|
return {"schema_version": "2.0", "current": {}, "changelog": []}
|
|
11994
12112
|
try:
|
|
@@ -12001,7 +12119,12 @@ def _v2_read_corrections(path: str) -> Dict[str, Any]:
|
|
|
12001
12119
|
data.setdefault("current", {})
|
|
12002
12120
|
data.setdefault("changelog", [])
|
|
12003
12121
|
return data
|
|
12004
|
-
except (OSError, json.JSONDecodeError):
|
|
12122
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
12123
|
+
# strict=True is for read-modify-write callers: an existing-but-corrupt
|
|
12124
|
+
# corrections file must refuse, not reset to empty and then erase the
|
|
12125
|
+
# user's human edit history on the next write (PS3, issue #71 class).
|
|
12126
|
+
if strict:
|
|
12127
|
+
raise ConfigParseError(f"{path}: {exc}") from exc
|
|
12005
12128
|
return {"schema_version": "2.0", "current": {}, "changelog": []}
|
|
12006
12129
|
|
|
12007
12130
|
|
|
@@ -12049,7 +12172,10 @@ def _v2_update_field(project_root: str, p: Dict[str, Any], *, entity_type: str)
|
|
|
12049
12172
|
if not path:
|
|
12050
12173
|
return _err(f"Could not locate clip directory for clip_id={clip_id} under {project_root}/clips/. Pass clip_dir explicitly if the clip directory is non-standard.")
|
|
12051
12174
|
|
|
12052
|
-
|
|
12175
|
+
try:
|
|
12176
|
+
data = _v2_read_corrections(path, strict=True)
|
|
12177
|
+
except ConfigParseError as exc:
|
|
12178
|
+
return _err(f"Refusing to write correction: {exc}. The corrections file exists but is unparseable; fix or remove it to avoid wiping the human edit history.")
|
|
12053
12179
|
if clip_id and "clip_id" not in data:
|
|
12054
12180
|
data["clip_id"] = str(clip_id)
|
|
12055
12181
|
|
|
@@ -12149,7 +12275,10 @@ def _v2_revert_field(project_root: str, p: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
12149
12275
|
path = _v2_corrections_path_for_clip(project_root, clip_dir, clip_id)
|
|
12150
12276
|
if not path or not os.path.isfile(path):
|
|
12151
12277
|
return _err("No corrections file exists for this clip; nothing to revert.")
|
|
12152
|
-
|
|
12278
|
+
try:
|
|
12279
|
+
data = _v2_read_corrections(path, strict=True)
|
|
12280
|
+
except ConfigParseError as exc:
|
|
12281
|
+
return _err(f"Refusing to revert: {exc}. The corrections file exists but is unparseable; fix or remove it to avoid wiping the human edit history.")
|
|
12153
12282
|
key = f"{entity_type}:{entity_uuid}:{field_path}"
|
|
12154
12283
|
if key not in data.get("current", {}):
|
|
12155
12284
|
return _err(f"No current correction for {key}; nothing to revert.")
|
|
@@ -13573,14 +13702,29 @@ class _SpecLiveExecutor:
|
|
|
13573
13702
|
|
|
13574
13703
|
|
|
13575
13704
|
def _make_spec_hook_runner(timeout: float = 120.0):
|
|
13576
|
-
"""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
|
|
13577
13713
|
import subprocess
|
|
13578
13714
|
|
|
13579
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
|
|
13580
13724
|
try:
|
|
13581
13725
|
proc = subprocess.run(
|
|
13582
|
-
|
|
13583
|
-
shell=
|
|
13726
|
+
argv,
|
|
13727
|
+
shell=False,
|
|
13584
13728
|
timeout=timeout,
|
|
13585
13729
|
stdin=subprocess.DEVNULL,
|
|
13586
13730
|
capture_output=True,
|
|
@@ -15865,7 +16009,7 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
|
|
|
15865
16009
|
_r, _proj, project_root, _name = vctx
|
|
15866
16010
|
session_only = bool(p.get("session_only", False))
|
|
15867
16011
|
session_id = _AI_LEDGER_SESSION_ID if session_only else None
|
|
15868
|
-
limit =
|
|
16012
|
+
limit = _safe_int(p.get("limit"), 50, minimum=1, maximum=1000)
|
|
15869
16013
|
return {
|
|
15870
16014
|
"success": True,
|
|
15871
16015
|
"session_id": _AI_LEDGER_SESSION_ID,
|
|
@@ -15896,7 +16040,10 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
|
|
|
15896
16040
|
return _err(f"unknown tier '{preset}'. Valid: {sorted(_resolve_ai_governance.VALID_TIERS)}")
|
|
15897
16041
|
if mode and mode not in _AI_GOVERNANCE_MODES:
|
|
15898
16042
|
return _err(f"unknown mode '{mode}'. Valid: {list(_AI_GOVERNANCE_MODES)}")
|
|
15899
|
-
|
|
16043
|
+
try:
|
|
16044
|
+
prefs = _read_media_analysis_preferences_strict()
|
|
16045
|
+
except ConfigParseError as exc:
|
|
16046
|
+
return _err(f"Refusing to save AI governance: {exc}. The preferences file exists but is unparseable; fix or delete it to avoid wiping saved settings.")
|
|
15900
16047
|
if preset:
|
|
15901
16048
|
prefs["resolve_ai_governance_preset"] = preset
|
|
15902
16049
|
if mode:
|
|
@@ -15916,7 +16063,10 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
|
|
|
15916
16063
|
if preset not in _ac.VALID_PRESETS:
|
|
15917
16064
|
return _err(f"unknown preset '{preset}'. Valid: {sorted(_ac.VALID_PRESETS)}")
|
|
15918
16065
|
# Update preference file directly (same pattern as set_defaults).
|
|
15919
|
-
|
|
16066
|
+
try:
|
|
16067
|
+
prefs = _read_media_analysis_preferences_strict()
|
|
16068
|
+
except ConfigParseError as exc:
|
|
16069
|
+
return _err(f"Refusing to save caps preset: {exc}. The preferences file exists but is unparseable; fix or delete it to avoid wiping saved settings.")
|
|
15920
16070
|
prefs["analysis_caps_preset"] = preset
|
|
15921
16071
|
if isinstance(p.get("overrides"), dict):
|
|
15922
16072
|
prefs["analysis_caps_overrides"] = p["overrides"]
|
|
@@ -16715,7 +16865,7 @@ def timeline_versioning(action: str, params: Optional[Dict[str, Any]] = None) ->
|
|
|
16715
16865
|
if action == "list_runs":
|
|
16716
16866
|
return {
|
|
16717
16867
|
"success": True,
|
|
16718
|
-
"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)),
|
|
16719
16869
|
}
|
|
16720
16870
|
if action == "archive_current":
|
|
16721
16871
|
return _timeline_versioning.archive_current_timeline(
|
|
@@ -16736,13 +16886,18 @@ def timeline_versioning(action: str, params: Optional[Dict[str, Any]] = None) ->
|
|
|
16736
16886
|
if action == "diff_versions":
|
|
16737
16887
|
if not p.get("timeline_name") or "from_version" not in p or "to_version" not in p:
|
|
16738
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")
|
|
16739
16894
|
return {
|
|
16740
16895
|
"success": True,
|
|
16741
16896
|
**_timeline_versioning.diff_versions(
|
|
16742
16897
|
project_root=project_root,
|
|
16743
16898
|
timeline_name=str(p["timeline_name"]),
|
|
16744
|
-
from_version=
|
|
16745
|
-
to_version=
|
|
16899
|
+
from_version=from_version,
|
|
16900
|
+
to_version=to_version,
|
|
16746
16901
|
),
|
|
16747
16902
|
}
|
|
16748
16903
|
if action == "diff_timelines":
|
|
@@ -16758,18 +16913,24 @@ def timeline_versioning(action: str, params: Optional[Dict[str, Any]] = None) ->
|
|
|
16758
16913
|
project_root=project_root,
|
|
16759
16914
|
timeline_name=p.get("timeline_name"),
|
|
16760
16915
|
analysis_run_id=p.get("analysis_run_id"),
|
|
16761
|
-
limit=
|
|
16916
|
+
limit=_safe_int(p.get("limit"), 50, minimum=1, maximum=1000),
|
|
16762
16917
|
)
|
|
16763
16918
|
return {"success": True, "edits": rows}
|
|
16764
16919
|
if action == "rollback":
|
|
16765
16920
|
if not p.get("timeline_name") or "version" not in p:
|
|
16766
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")
|
|
16767
16928
|
return _timeline_versioning.rollback_to_version(
|
|
16768
16929
|
resolve=resolve_h,
|
|
16769
16930
|
project=project_h,
|
|
16770
16931
|
project_root=project_root,
|
|
16771
16932
|
timeline_name=str(p["timeline_name"]),
|
|
16772
|
-
version=
|
|
16933
|
+
version=version,
|
|
16773
16934
|
analysis_run_id=p.get("analysis_run_id"),
|
|
16774
16935
|
)
|
|
16775
16936
|
if action == "prune":
|
|
@@ -16780,7 +16941,7 @@ def timeline_versioning(action: str, params: Optional[Dict[str, Any]] = None) ->
|
|
|
16780
16941
|
project=project_h,
|
|
16781
16942
|
project_root=project_root,
|
|
16782
16943
|
timeline_name=str(p["timeline_name"]),
|
|
16783
|
-
keep_n=
|
|
16944
|
+
keep_n=_safe_int(p.get("keep_n"), 10, minimum=1, maximum=1000),
|
|
16784
16945
|
)
|
|
16785
16946
|
if action == "registry":
|
|
16786
16947
|
return {"success": True, **_brain_edits.read_brain_edits_registry(project_root)}
|
|
@@ -16791,7 +16952,7 @@ def timeline_versioning(action: str, params: Optional[Dict[str, Any]] = None) ->
|
|
|
16791
16952
|
project_root=project_root,
|
|
16792
16953
|
analysis_run_id=p.get("analysis_run_id"),
|
|
16793
16954
|
action=p.get("media_pool_action"),
|
|
16794
|
-
limit=
|
|
16955
|
+
limit=_safe_int(p.get("limit"), 50, minimum=1, maximum=1000),
|
|
16795
16956
|
),
|
|
16796
16957
|
}
|
|
16797
16958
|
return _err(f"Unknown action: {action}")
|
|
@@ -358,9 +358,13 @@ def regenerate_bin_summary_from_manifest(
|
|
|
358
358
|
lines.append("\n")
|
|
359
359
|
|
|
360
360
|
path = bin_summary_path(project_root)
|
|
361
|
+
# Atomic write (temp + os.replace): a crash mid-write must not truncate the
|
|
362
|
+
# bin summary that session_start_context reads at startup (PS4).
|
|
363
|
+
tmp_path = path + ".tmp"
|
|
361
364
|
try:
|
|
362
|
-
with open(
|
|
365
|
+
with open(tmp_path, "w", encoding="utf-8") as handle:
|
|
363
366
|
handle.writelines(lines)
|
|
367
|
+
os.replace(tmp_path, path)
|
|
364
368
|
return {
|
|
365
369
|
"success": True,
|
|
366
370
|
"path": path,
|
|
@@ -376,6 +380,11 @@ def regenerate_bin_summary_from_manifest(
|
|
|
376
380
|
},
|
|
377
381
|
}
|
|
378
382
|
except OSError as exc:
|
|
383
|
+
if os.path.isfile(tmp_path):
|
|
384
|
+
try:
|
|
385
|
+
os.remove(tmp_path)
|
|
386
|
+
except OSError:
|
|
387
|
+
pass
|
|
379
388
|
return {"success": False, "error": f"{type(exc).__name__}: {exc}"}
|
|
380
389
|
|
|
381
390
|
|
|
@@ -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] = []
|
|
@@ -304,8 +304,19 @@ def splice_inputs_block(
|
|
|
304
304
|
after_inputs = parse_instance_input_block(new_inner)
|
|
305
305
|
|
|
306
306
|
os.makedirs(os.path.dirname(os.path.abspath(dest_path)) or ".", exist_ok=True)
|
|
307
|
-
|
|
308
|
-
|
|
307
|
+
# Atomic write (temp + os.replace): a crash mid-write must not truncate the
|
|
308
|
+
# Fusion .setting file and leave the GroupOperator config uneditable (PS5).
|
|
309
|
+
tmp_path = f"{dest_path}.tmp-{os.getpid()}"
|
|
310
|
+
try:
|
|
311
|
+
with open(tmp_path, "w", encoding="utf-8", newline="\n") as handle:
|
|
312
|
+
handle.write(new_content)
|
|
313
|
+
os.replace(tmp_path, dest_path)
|
|
314
|
+
finally:
|
|
315
|
+
if os.path.isfile(tmp_path):
|
|
316
|
+
try:
|
|
317
|
+
os.remove(tmp_path)
|
|
318
|
+
except OSError:
|
|
319
|
+
pass
|
|
309
320
|
|
|
310
321
|
return {
|
|
311
322
|
"source_path": os.path.abspath(source_path),
|
|
@@ -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:
|