davinci-resolve-mcp 2.61.1 → 2.62.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 +64 -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/server.py +516 -189
- package/src/utils/background_jobs.py +108 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""In-process registry for long DaVinci Resolve operations run off-thread.
|
|
2
|
+
|
|
3
|
+
Some Resolve calls (transcription, subtitle generation, scene-cut and Dolby
|
|
4
|
+
analysis, timeline export/import) block for minutes — longer than an MCP
|
|
5
|
+
client's tool-window timeout. start_job runs such a call on a daemon thread and
|
|
6
|
+
returns a short id immediately; the caller polls job_status until the job
|
|
7
|
+
reports "done" (with its result) or "error" (with the message).
|
|
8
|
+
|
|
9
|
+
The worker runs fn inside resolve_busy.long_resolve_op(label) so another
|
|
10
|
+
Resolve-touching tool call meets the advisory "busy" answer instead of colliding
|
|
11
|
+
on the single-threaded scripting bridge (job_status / list_jobs are
|
|
12
|
+
connection-free and never contend). Serialization comes from that preflight, not
|
|
13
|
+
from the bridge itself; the busy record tracks a single owner, so two jobs
|
|
14
|
+
started in quick succession have a brief window before the first's record shows.
|
|
15
|
+
|
|
16
|
+
Finished jobs are pruned once older than MAX_JOB_AGE_SECONDS on any access, so
|
|
17
|
+
the registry cannot grow without bound.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
import uuid
|
|
24
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
25
|
+
|
|
26
|
+
from src.utils.resolve_busy import long_resolve_op
|
|
27
|
+
|
|
28
|
+
# A finished job is dropped from the registry this long after it ended.
|
|
29
|
+
MAX_JOB_AGE_SECONDS = 60 * 60
|
|
30
|
+
|
|
31
|
+
_lock = threading.Lock()
|
|
32
|
+
_jobs: Dict[str, Dict[str, Any]] = {}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _prune_locked(now: float) -> None:
|
|
36
|
+
stale = [
|
|
37
|
+
job_id
|
|
38
|
+
for job_id, job in _jobs.items()
|
|
39
|
+
if job["status"] != "running"
|
|
40
|
+
and job["ended_at"] is not None
|
|
41
|
+
and now - job["ended_at"] > MAX_JOB_AGE_SECONDS
|
|
42
|
+
]
|
|
43
|
+
for job_id in stale:
|
|
44
|
+
del _jobs[job_id]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _run(job_id: str, label: str, fn: Callable[[], Any]) -> None:
|
|
48
|
+
try:
|
|
49
|
+
with long_resolve_op(label):
|
|
50
|
+
result = fn()
|
|
51
|
+
except Exception as exc:
|
|
52
|
+
with _lock:
|
|
53
|
+
job = _jobs.get(job_id)
|
|
54
|
+
if job is not None:
|
|
55
|
+
job["status"] = "error"
|
|
56
|
+
job["error"] = f"{type(exc).__name__}: {exc}"
|
|
57
|
+
job["ended_at"] = time.time()
|
|
58
|
+
return
|
|
59
|
+
with _lock:
|
|
60
|
+
job = _jobs.get(job_id)
|
|
61
|
+
if job is not None:
|
|
62
|
+
job["status"] = "done"
|
|
63
|
+
job["result"] = result
|
|
64
|
+
job["ended_at"] = time.time()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def start_job(label: str, fn: Callable[[], Any]) -> str:
|
|
68
|
+
"""Run fn on a daemon thread; return a short job id immediately."""
|
|
69
|
+
now = time.time()
|
|
70
|
+
with _lock:
|
|
71
|
+
_prune_locked(now)
|
|
72
|
+
job_id = uuid.uuid4().hex[:8]
|
|
73
|
+
while job_id in _jobs:
|
|
74
|
+
job_id = uuid.uuid4().hex[:8]
|
|
75
|
+
_jobs[job_id] = {
|
|
76
|
+
"id": job_id,
|
|
77
|
+
"label": str(label),
|
|
78
|
+
"status": "running",
|
|
79
|
+
"result": None,
|
|
80
|
+
"error": None,
|
|
81
|
+
"started_at": now,
|
|
82
|
+
"ended_at": None,
|
|
83
|
+
}
|
|
84
|
+
threading.Thread(
|
|
85
|
+
target=_run,
|
|
86
|
+
args=(job_id, str(label), fn),
|
|
87
|
+
name=f"bgjob-{job_id}",
|
|
88
|
+
daemon=True,
|
|
89
|
+
).start()
|
|
90
|
+
return job_id
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def job_status(job_id: str) -> Optional[Dict[str, Any]]:
|
|
94
|
+
"""Snapshot of one job, or None when the id is unknown."""
|
|
95
|
+
with _lock:
|
|
96
|
+
_prune_locked(time.time())
|
|
97
|
+
job = _jobs.get(job_id)
|
|
98
|
+
return dict(job) if job is not None else None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def list_jobs() -> List[Dict[str, Any]]:
|
|
102
|
+
"""Compact status of every known job."""
|
|
103
|
+
with _lock:
|
|
104
|
+
_prune_locked(time.time())
|
|
105
|
+
return [
|
|
106
|
+
{k: job[k] for k in ("id", "label", "status", "started_at", "ended_at")}
|
|
107
|
+
for job in _jobs.values()
|
|
108
|
+
]
|