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.
@@ -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
+ ]