davinci-resolve-mcp 2.25.0 → 2.26.1
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/AGENTS.md +3 -1
- package/CHANGELOG.md +52 -0
- package/README.md +3 -3
- package/bin/davinci-resolve-mcp.mjs +78 -7
- package/docs/SKILL.md +19 -3
- package/docs/install.md +10 -1
- package/docs/kernels/fusion-composition-kernel.md +30 -0
- package/install.py +73 -8
- package/package.json +1 -1
- package/src/batch_cli.py +456 -0
- package/src/granular/common.py +1 -1
- package/src/server.py +331 -4
- package/src/utils/fusion_group_settings.py +323 -0
package/src/batch_cli.py
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
"""Headless batch-runner CLI for source-safe media analysis.
|
|
2
|
+
|
|
3
|
+
Drives src.utils.media_analysis_jobs from outside an MCP/chat client so users
|
|
4
|
+
can run long batches via cron, CI, or terminal without holding a chat turn
|
|
5
|
+
open. The orchestration loop and durable state live in the jobs engine; this
|
|
6
|
+
module only handles argv, progress streaming, and exit codes.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import signal
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
from typing import Any, Dict, List, Optional
|
|
16
|
+
|
|
17
|
+
from src.utils.media_analysis import (
|
|
18
|
+
build_plan,
|
|
19
|
+
detect_capabilities,
|
|
20
|
+
)
|
|
21
|
+
from src.utils.media_analysis_jobs import (
|
|
22
|
+
batch_job_status,
|
|
23
|
+
cancel_batch_job,
|
|
24
|
+
create_batch_job_from_paths,
|
|
25
|
+
list_batch_jobs,
|
|
26
|
+
records_from_paths,
|
|
27
|
+
resume_batch_job,
|
|
28
|
+
run_batch_job_slice,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
EXIT_OK = 0
|
|
33
|
+
EXIT_PARTIAL = 2
|
|
34
|
+
EXIT_FATAL = 3
|
|
35
|
+
EXIT_CANCELED = 130
|
|
36
|
+
|
|
37
|
+
_TERMINAL_STATUSES = {"completed", "completed_with_errors", "canceled"}
|
|
38
|
+
|
|
39
|
+
_canceled = False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _on_sigint(signum, frame): # noqa: ARG001 - signal handler signature
|
|
43
|
+
global _canceled
|
|
44
|
+
_canceled = True
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _emit(message: str, *, json_mode: bool, payload: Optional[Dict[str, Any]] = None) -> None:
|
|
48
|
+
if json_mode:
|
|
49
|
+
sys.stdout.write(json.dumps(payload or {}, ensure_ascii=False) + "\n")
|
|
50
|
+
else:
|
|
51
|
+
sys.stdout.write(message + "\n")
|
|
52
|
+
sys.stdout.flush()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _build_params(args: argparse.Namespace) -> Dict[str, Any]:
|
|
56
|
+
params: Dict[str, Any] = {}
|
|
57
|
+
if getattr(args, "depth", None):
|
|
58
|
+
params["depth"] = args.depth
|
|
59
|
+
if getattr(args, "source_trust", None):
|
|
60
|
+
params["source_trust"] = args.source_trust
|
|
61
|
+
if getattr(args, "summary_style", None):
|
|
62
|
+
params["vision"] = {"summary_style": args.summary_style}
|
|
63
|
+
params["analysis_summary_style"] = args.summary_style
|
|
64
|
+
return params
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _exit_for_status(status: Optional[str]) -> int:
|
|
68
|
+
if status == "completed":
|
|
69
|
+
return EXIT_OK
|
|
70
|
+
if status == "completed_with_errors":
|
|
71
|
+
return EXIT_PARTIAL
|
|
72
|
+
if status == "canceled":
|
|
73
|
+
return EXIT_CANCELED
|
|
74
|
+
return EXIT_FATAL
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _drive_to_completion(
|
|
78
|
+
project_root: str,
|
|
79
|
+
job_id: str,
|
|
80
|
+
*,
|
|
81
|
+
max_clips: int,
|
|
82
|
+
max_seconds: Optional[float],
|
|
83
|
+
json_mode: bool,
|
|
84
|
+
) -> int:
|
|
85
|
+
signal.signal(signal.SIGINT, _on_sigint)
|
|
86
|
+
while True:
|
|
87
|
+
if _canceled:
|
|
88
|
+
cancel_batch_job(project_root, job_id)
|
|
89
|
+
_emit(
|
|
90
|
+
"Interrupted — job canceled",
|
|
91
|
+
json_mode=json_mode,
|
|
92
|
+
payload={"event": "canceled", "job_id": job_id},
|
|
93
|
+
)
|
|
94
|
+
return EXIT_CANCELED
|
|
95
|
+
slice_result = run_batch_job_slice(
|
|
96
|
+
project_root,
|
|
97
|
+
job_id,
|
|
98
|
+
max_clips=max_clips,
|
|
99
|
+
max_seconds=max_seconds,
|
|
100
|
+
)
|
|
101
|
+
if not slice_result.get("success"):
|
|
102
|
+
_emit(
|
|
103
|
+
f"Slice failed: {slice_result.get('error')}",
|
|
104
|
+
json_mode=json_mode,
|
|
105
|
+
payload={"event": "slice_failed", **slice_result},
|
|
106
|
+
)
|
|
107
|
+
return EXIT_FATAL
|
|
108
|
+
for clip in slice_result.get("processed") or []:
|
|
109
|
+
_emit(
|
|
110
|
+
f" [{(clip.get('status') or '?'):>9}] #{(clip.get('position') or 0) + 1}"
|
|
111
|
+
+ (f" — {clip.get('error')}" if clip.get("error") else ""),
|
|
112
|
+
json_mode=json_mode,
|
|
113
|
+
payload={"event": "clip_done", **clip},
|
|
114
|
+
)
|
|
115
|
+
job_state = slice_result.get("job") or {}
|
|
116
|
+
status = job_state.get("status")
|
|
117
|
+
processed_count = int(slice_result.get("processed_count") or 0)
|
|
118
|
+
if status in _TERMINAL_STATUSES or processed_count == 0:
|
|
119
|
+
final = batch_job_status(
|
|
120
|
+
project_root, job_id, include_clips=False, include_events=False
|
|
121
|
+
)
|
|
122
|
+
counts = final.get("progress", {})
|
|
123
|
+
status = final.get("status")
|
|
124
|
+
_emit(
|
|
125
|
+
f"Done: {status} ({counts.get('done_clips', 0)}/{counts.get('total_clips', 0)})",
|
|
126
|
+
json_mode=json_mode,
|
|
127
|
+
payload={
|
|
128
|
+
"event": "job_done",
|
|
129
|
+
"job_id": job_id,
|
|
130
|
+
"status": status,
|
|
131
|
+
"progress": counts,
|
|
132
|
+
"succeeded_clips": final.get("succeeded_clips"),
|
|
133
|
+
"failed_clips": final.get("failed_clips"),
|
|
134
|
+
"skipped_clips": final.get("skipped_clips"),
|
|
135
|
+
},
|
|
136
|
+
)
|
|
137
|
+
return _exit_for_status(status)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _cmd_plan(args: argparse.Namespace) -> int:
|
|
141
|
+
records, warnings = records_from_paths(args.paths, recursive=args.recursive)
|
|
142
|
+
if not records:
|
|
143
|
+
_emit(
|
|
144
|
+
"No analyzable media files found",
|
|
145
|
+
json_mode=args.json,
|
|
146
|
+
payload={"success": False, "error": "no_media", "warnings": warnings},
|
|
147
|
+
)
|
|
148
|
+
return EXIT_FATAL
|
|
149
|
+
params = _build_params(args)
|
|
150
|
+
if args.analysis_root:
|
|
151
|
+
params["analysis_root"] = args.analysis_root
|
|
152
|
+
target = {
|
|
153
|
+
"type": "paths",
|
|
154
|
+
"paths": [r["file_path"] for r in records],
|
|
155
|
+
"recursive": args.recursive,
|
|
156
|
+
}
|
|
157
|
+
plan = build_plan(
|
|
158
|
+
project_name=args.project_name,
|
|
159
|
+
project_id=None,
|
|
160
|
+
records=records,
|
|
161
|
+
target=target,
|
|
162
|
+
params=params,
|
|
163
|
+
capabilities=detect_capabilities(),
|
|
164
|
+
)
|
|
165
|
+
if not plan.get("success"):
|
|
166
|
+
_emit(
|
|
167
|
+
f"Plan failed: {plan.get('error')}",
|
|
168
|
+
json_mode=args.json,
|
|
169
|
+
payload=plan,
|
|
170
|
+
)
|
|
171
|
+
return EXIT_FATAL
|
|
172
|
+
if args.json:
|
|
173
|
+
plan_payload = dict(plan)
|
|
174
|
+
if warnings:
|
|
175
|
+
plan_payload["warnings"] = warnings
|
|
176
|
+
_emit("", json_mode=True, payload=plan_payload)
|
|
177
|
+
else:
|
|
178
|
+
root = (plan.get("output_root") or {}).get("project_root", "?")
|
|
179
|
+
_emit(f"Project root : {root}", json_mode=False)
|
|
180
|
+
_emit(f"Depth : {plan.get('depth', '?')}", json_mode=False)
|
|
181
|
+
_emit(
|
|
182
|
+
f"Clips : {plan.get('clip_count', 0)}"
|
|
183
|
+
f" ({plan.get('reusable_clip_count', 0)} reusable)",
|
|
184
|
+
json_mode=False,
|
|
185
|
+
)
|
|
186
|
+
_emit(
|
|
187
|
+
f"Est. seconds : {plan.get('estimated_seconds_after_reuse', '?')}",
|
|
188
|
+
json_mode=False,
|
|
189
|
+
)
|
|
190
|
+
if plan.get("capability_gaps"):
|
|
191
|
+
_emit(f"Missing tools: {plan['capability_gaps']}", json_mode=False)
|
|
192
|
+
for w in warnings:
|
|
193
|
+
_emit(f" warning: {w}", json_mode=False)
|
|
194
|
+
return EXIT_OK
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _cmd_run(args: argparse.Namespace) -> int:
|
|
198
|
+
params = _build_params(args)
|
|
199
|
+
create = create_batch_job_from_paths(
|
|
200
|
+
project_name=args.project_name,
|
|
201
|
+
paths=args.paths,
|
|
202
|
+
analysis_root=args.analysis_root,
|
|
203
|
+
recursive=args.recursive,
|
|
204
|
+
params=params,
|
|
205
|
+
name=args.name,
|
|
206
|
+
)
|
|
207
|
+
if not create.get("success"):
|
|
208
|
+
_emit(
|
|
209
|
+
f"Job creation failed: {create.get('error') or create.get('status')}",
|
|
210
|
+
json_mode=args.json,
|
|
211
|
+
payload=create,
|
|
212
|
+
)
|
|
213
|
+
return EXIT_FATAL
|
|
214
|
+
job = create["job"]
|
|
215
|
+
job_id = job["job_id"]
|
|
216
|
+
project_root = job["project_root"]
|
|
217
|
+
total = (job.get("progress") or {}).get("total_clips", 0)
|
|
218
|
+
_emit(
|
|
219
|
+
f"Created job {job_id} ({total} clips, root: {project_root})",
|
|
220
|
+
json_mode=args.json,
|
|
221
|
+
payload={
|
|
222
|
+
"event": "job_created",
|
|
223
|
+
"job_id": job_id,
|
|
224
|
+
"project_root": project_root,
|
|
225
|
+
"total_clips": total,
|
|
226
|
+
},
|
|
227
|
+
)
|
|
228
|
+
if args.no_follow:
|
|
229
|
+
return EXIT_OK
|
|
230
|
+
return _drive_to_completion(
|
|
231
|
+
project_root,
|
|
232
|
+
job_id,
|
|
233
|
+
max_clips=args.max_clips,
|
|
234
|
+
max_seconds=args.max_seconds,
|
|
235
|
+
json_mode=args.json,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _emit_status(payload: Dict[str, Any], *, json_mode: bool) -> None:
|
|
240
|
+
if json_mode:
|
|
241
|
+
_emit("", json_mode=True, payload=payload)
|
|
242
|
+
return
|
|
243
|
+
if not payload.get("success"):
|
|
244
|
+
_emit(f"Error: {payload.get('error')}", json_mode=False)
|
|
245
|
+
return
|
|
246
|
+
counts = payload.get("progress", {})
|
|
247
|
+
_emit(f"Job : {payload.get('job_id')}", json_mode=False)
|
|
248
|
+
_emit(
|
|
249
|
+
f"Status : {payload.get('status')} ({payload.get('phase')})",
|
|
250
|
+
json_mode=False,
|
|
251
|
+
)
|
|
252
|
+
_emit(
|
|
253
|
+
f"Progress : {counts.get('done_clips', 0)}/{counts.get('total_clips', 0)} ({counts.get('percent', 0)}%)",
|
|
254
|
+
json_mode=False,
|
|
255
|
+
)
|
|
256
|
+
_emit(f"Succeeded : {payload.get('succeeded_clips')}", json_mode=False)
|
|
257
|
+
_emit(f"Failed : {payload.get('failed_clips')}", json_mode=False)
|
|
258
|
+
_emit(f"Skipped : {payload.get('skipped_clips')}", json_mode=False)
|
|
259
|
+
if payload.get("last_error"):
|
|
260
|
+
_emit(f"Last error : {payload['last_error']}", json_mode=False)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _cmd_status(args: argparse.Namespace) -> int:
|
|
264
|
+
payload = batch_job_status(args.project_root, args.job_id)
|
|
265
|
+
_emit_status(payload, json_mode=args.json)
|
|
266
|
+
return EXIT_OK if payload.get("success") else EXIT_FATAL
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _cmd_list(args: argparse.Namespace) -> int:
|
|
270
|
+
payload = list_batch_jobs(args.project_root, limit=args.limit)
|
|
271
|
+
if args.json:
|
|
272
|
+
_emit("", json_mode=True, payload=payload)
|
|
273
|
+
return EXIT_OK if payload.get("success") else EXIT_FATAL
|
|
274
|
+
if not payload.get("success"):
|
|
275
|
+
_emit(f"Error: {payload.get('error')}", json_mode=False)
|
|
276
|
+
return EXIT_FATAL
|
|
277
|
+
jobs = payload.get("jobs") or []
|
|
278
|
+
if not jobs:
|
|
279
|
+
_emit("No jobs found", json_mode=False)
|
|
280
|
+
return EXIT_OK
|
|
281
|
+
_emit(f"{'JOB ID':<28} {'STATUS':<22} {'CLIPS':>10} NAME", json_mode=False)
|
|
282
|
+
for job in jobs:
|
|
283
|
+
counts = job.get("progress", {})
|
|
284
|
+
clip_col = f"{counts.get('done_clips', 0)}/{counts.get('total_clips', 0)}"
|
|
285
|
+
_emit(
|
|
286
|
+
f"{job['job_id']:<28} {job['status']:<22} {clip_col:>10} {job.get('name', '')}",
|
|
287
|
+
json_mode=False,
|
|
288
|
+
)
|
|
289
|
+
return EXIT_OK
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _cmd_resume(args: argparse.Namespace) -> int:
|
|
293
|
+
resumed = resume_batch_job(args.project_root, args.job_id)
|
|
294
|
+
if not resumed.get("success"):
|
|
295
|
+
_emit(
|
|
296
|
+
f"Resume failed: {resumed.get('error')}",
|
|
297
|
+
json_mode=args.json,
|
|
298
|
+
payload=resumed,
|
|
299
|
+
)
|
|
300
|
+
return EXIT_FATAL
|
|
301
|
+
_emit(
|
|
302
|
+
f"Resumed {args.job_id}",
|
|
303
|
+
json_mode=args.json,
|
|
304
|
+
payload={"event": "resumed", "job_id": args.job_id},
|
|
305
|
+
)
|
|
306
|
+
return _drive_to_completion(
|
|
307
|
+
args.project_root,
|
|
308
|
+
args.job_id,
|
|
309
|
+
max_clips=args.max_clips,
|
|
310
|
+
max_seconds=args.max_seconds,
|
|
311
|
+
json_mode=args.json,
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _cmd_cancel(args: argparse.Namespace) -> int:
|
|
316
|
+
payload = cancel_batch_job(args.project_root, args.job_id)
|
|
317
|
+
_emit_status(payload, json_mode=args.json)
|
|
318
|
+
return EXIT_OK if payload.get("success") else EXIT_FATAL
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _add_run_args(parser: argparse.ArgumentParser) -> None:
|
|
322
|
+
parser.add_argument("paths", nargs="+", help="Files or directories to analyze")
|
|
323
|
+
parser.add_argument(
|
|
324
|
+
"--no-recursive",
|
|
325
|
+
dest="recursive",
|
|
326
|
+
action="store_false",
|
|
327
|
+
default=True,
|
|
328
|
+
help="Do not descend into subdirectories (default: recurse)",
|
|
329
|
+
)
|
|
330
|
+
parser.add_argument(
|
|
331
|
+
"--analysis-root",
|
|
332
|
+
help="Override analysis output root (default ~/Documents/DaVinci Resolve MCP/analysis)",
|
|
333
|
+
)
|
|
334
|
+
parser.add_argument(
|
|
335
|
+
"--project-name",
|
|
336
|
+
default=f"CLI batch {time.strftime('%Y-%m-%d')}",
|
|
337
|
+
help="Project name written into the analysis root layout",
|
|
338
|
+
)
|
|
339
|
+
parser.add_argument(
|
|
340
|
+
"--depth",
|
|
341
|
+
choices=["quick", "standard", "deep", "custom"],
|
|
342
|
+
help="Analysis depth (default: standard)",
|
|
343
|
+
)
|
|
344
|
+
parser.add_argument(
|
|
345
|
+
"--source-trust",
|
|
346
|
+
choices=["auto", "filename", "low", "medium", "high"],
|
|
347
|
+
help="Trust tier hint for the vision pass",
|
|
348
|
+
)
|
|
349
|
+
parser.add_argument(
|
|
350
|
+
"--summary-style",
|
|
351
|
+
choices=["full", "concise", "creative", "technical"],
|
|
352
|
+
help="Narrative tone for clip_summary / shot descriptions",
|
|
353
|
+
)
|
|
354
|
+
parser.add_argument("--name", help="Job display name")
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
358
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
359
|
+
# SUPPRESS keeps the subparser from overwriting a --json passed before the
|
|
360
|
+
# subcommand (its default would otherwise win over the top-level parser).
|
|
361
|
+
# Post-processed in main() so handlers always see a real bool.
|
|
362
|
+
common.add_argument(
|
|
363
|
+
"--json",
|
|
364
|
+
action="store_true",
|
|
365
|
+
default=argparse.SUPPRESS,
|
|
366
|
+
help="Emit one JSON object per progress event instead of human-readable lines",
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
parser = argparse.ArgumentParser(
|
|
370
|
+
prog="davinci-resolve-mcp batch",
|
|
371
|
+
description=(
|
|
372
|
+
"Headless runner for source-safe media analysis. Wraps the same engine "
|
|
373
|
+
"the MCP server uses; durable state lives in <project-root>/jobs.sqlite."
|
|
374
|
+
),
|
|
375
|
+
parents=[common],
|
|
376
|
+
)
|
|
377
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
378
|
+
|
|
379
|
+
p_plan = sub.add_parser(
|
|
380
|
+
"plan",
|
|
381
|
+
help="Dry-run: print what would be analyzed without creating a job",
|
|
382
|
+
parents=[common],
|
|
383
|
+
)
|
|
384
|
+
_add_run_args(p_plan)
|
|
385
|
+
|
|
386
|
+
p_run = sub.add_parser(
|
|
387
|
+
"run",
|
|
388
|
+
help="Create a batch job and drive it to completion",
|
|
389
|
+
parents=[common],
|
|
390
|
+
)
|
|
391
|
+
_add_run_args(p_run)
|
|
392
|
+
p_run.add_argument(
|
|
393
|
+
"--max-clips",
|
|
394
|
+
type=int,
|
|
395
|
+
default=1,
|
|
396
|
+
help="Clips processed per slice (default 1, max 25)",
|
|
397
|
+
)
|
|
398
|
+
p_run.add_argument(
|
|
399
|
+
"--max-seconds",
|
|
400
|
+
type=float,
|
|
401
|
+
default=None,
|
|
402
|
+
help="Optional wall-clock budget per slice (seconds)",
|
|
403
|
+
)
|
|
404
|
+
p_run.add_argument(
|
|
405
|
+
"--no-follow",
|
|
406
|
+
action="store_true",
|
|
407
|
+
help="Create the job and exit immediately instead of looping until done",
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
p_status = sub.add_parser("status", help="Inspect a job", parents=[common])
|
|
411
|
+
p_status.add_argument("job_id")
|
|
412
|
+
p_status.add_argument("--project-root", required=True)
|
|
413
|
+
|
|
414
|
+
p_list = sub.add_parser(
|
|
415
|
+
"list", help="List jobs under a project root", parents=[common]
|
|
416
|
+
)
|
|
417
|
+
p_list.add_argument("--project-root", required=True)
|
|
418
|
+
p_list.add_argument("--limit", type=int, default=50)
|
|
419
|
+
|
|
420
|
+
p_resume = sub.add_parser(
|
|
421
|
+
"resume", help="Resume a queued / canceled job", parents=[common]
|
|
422
|
+
)
|
|
423
|
+
p_resume.add_argument("job_id")
|
|
424
|
+
p_resume.add_argument("--project-root", required=True)
|
|
425
|
+
p_resume.add_argument("--max-clips", type=int, default=1)
|
|
426
|
+
p_resume.add_argument("--max-seconds", type=float, default=None)
|
|
427
|
+
|
|
428
|
+
p_cancel = sub.add_parser(
|
|
429
|
+
"cancel", help="Cancel a running job", parents=[common]
|
|
430
|
+
)
|
|
431
|
+
p_cancel.add_argument("job_id")
|
|
432
|
+
p_cancel.add_argument("--project-root", required=True)
|
|
433
|
+
|
|
434
|
+
return parser
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
_HANDLERS = {
|
|
438
|
+
"plan": _cmd_plan,
|
|
439
|
+
"run": _cmd_run,
|
|
440
|
+
"status": _cmd_status,
|
|
441
|
+
"list": _cmd_list,
|
|
442
|
+
"resume": _cmd_resume,
|
|
443
|
+
"cancel": _cmd_cancel,
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
448
|
+
parser = _build_parser()
|
|
449
|
+
args = parser.parse_args(argv)
|
|
450
|
+
if not hasattr(args, "json"):
|
|
451
|
+
args.json = False
|
|
452
|
+
return _HANDLERS[args.cmd](args)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
if __name__ == "__main__":
|
|
456
|
+
sys.exit(main())
|
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.26.1"
|
|
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()}")
|