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.
@@ -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())
@@ -80,7 +80,7 @@ if not logging.getLogger().handlers:
80
80
  handlers=[logging.StreamHandler()],
81
81
  )
82
82
 
83
- VERSION = "2.25.0"
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()}")