davinci-resolve-mcp 2.25.0 → 2.26.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 CHANGED
@@ -2,6 +2,44 @@
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.26.0
6
+
7
+ **Fusion group-settings helpers** — Three new `fusion_comp` actions for
8
+ authoring and patching `GroupOperator` macros without leaving the kernel.
9
+ `group_settings_export` writes a live group to a `.setting` file and returns a
10
+ parsed `published_inputs` summary using a balanced-brace walker so nested
11
+ `UserControls` / `ControlGroup` tables are bounded correctly (the original
12
+ flat-regex parser truncated `InstanceInput` bodies at the first inner `}`).
13
+ `group_settings_splice_inputs` replaces the `Inputs = ordered() { ... }` block
14
+ of one `.setting` with the matching block from another, preserving the source's
15
+ outer structure and inner `Tools`. `group_settings_load` applies a `.setting`
16
+ to a live group with an automatic timestamped backup, wrapped in
17
+ `StartUndo`/`Lock`/`LoadSettings`/`Unlock`/`EndUndo(True)` so Fusion's Ctrl+Z
18
+ reverses the change — verified live via direct BMD API.
19
+
20
+ **bulk_set_expressions** — Companion to the existing `bulk_set_inputs`. Batch
21
+ attach Fusion expressions across many timeline-item comps in one call. Each op
22
+ requires timeline scope plus `tool_name`, `input_name`, `expression`. Returns
23
+ per-op `success`/`error` rows + `op_count`, matching the bulk-inputs contract.
24
+ Useful for animating many controls at once (`time/30`, etc.) under a single
25
+ chat turn.
26
+
27
+ **Headless batch-runner CLI** — New
28
+ `davinci-resolve-mcp batch <plan|run|status|list|resume|cancel>` subcommand
29
+ drives `src/utils/media_analysis_jobs` from outside an MCP/chat client so long
30
+ analysis batches can run via cron, CI, or terminal without holding a chat turn
31
+ open. The orchestration loop and durable state stay in the existing jobs
32
+ engine; the CLI only handles argv, progress streaming, and exit codes
33
+ (`0` ok / `2` partial / `3` fatal / `130` Ctrl+C). JSON mode (`--json`)
34
+ emits one record per progress event for log scraping. Closes #42.
35
+
36
+ **Adapted from PR #40** — Group-settings work originated as a contribution
37
+ from @RaincloudTheDragon; PR #43 retains the keepable parts (parser, exporter,
38
+ splicer, loader, `bulk_set_expressions`) with a balanced-brace fix on the
39
+ parser and an undo+lock wrap on `group_settings_load`. The two AMZ-specific
40
+ templates and the static checklist from #40 were dropped as out-of-scope for a
41
+ general kernel.
42
+
5
43
  ## What's New in v2.25.0
6
44
 
7
45
  **Agentic flow improvements** — A second-pass review against the Claude
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # DaVinci Resolve MCP Server
2
2
 
3
- [![Version](https://img.shields.io/badge/version-2.25.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.26.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
4
4
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
5
5
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
6
6
  [![Tools](https://img.shields.io/badge/MCP%20Tools-32%20(329%20full)-blue.svg)](#server-modes)
@@ -41,6 +41,7 @@ Usage:
41
41
  davinci-resolve-mcp doctor [install.py options]
42
42
  davinci-resolve-mcp server [server.py options]
43
43
  davinci-resolve-mcp control-panel [control panel options]
44
+ davinci-resolve-mcp batch <plan|run|status|list|resume|cancel> [options]
44
45
  davinci-resolve-mcp --version
45
46
  davinci-resolve-mcp --help
46
47
 
@@ -48,6 +49,8 @@ Examples:
48
49
  npx davinci-resolve-mcp setup
49
50
  npx davinci-resolve-mcp setup --clients cursor,claude-desktop
50
51
  npx davinci-resolve-mcp doctor
52
+ npx davinci-resolve-mcp batch run /path/to/footage --depth standard
53
+ npx davinci-resolve-mcp batch run /path/to/footage --json > progress.log
51
54
 
52
55
  Environment:
53
56
  DAVINCI_RESOLVE_MCP_INSTALL_ROOT Override the managed install directory.
@@ -335,6 +338,13 @@ function commandControlPanel(args) {
335
338
  run(command, commandArgs, { cwd: root });
336
339
  }
337
340
 
341
+ function commandBatch(args) {
342
+ const root = syncManagedInstall(installRoot());
343
+ const python = venvPython(root) || findSupportedPython();
344
+ const [command, ...commandArgs] = pythonCommandLine(python, ["-m", "src.batch_cli", ...args]);
345
+ run(command, commandArgs, { cwd: root });
346
+ }
347
+
338
348
  function main() {
339
349
  const argv = process.argv.slice(2);
340
350
  // No args → run the MCP stdio server. Anything printed to stdout would
@@ -366,6 +376,10 @@ function main() {
366
376
  commandControlPanel(args);
367
377
  return;
368
378
  }
379
+ if (command === "batch") {
380
+ commandBatch(args);
381
+ return;
382
+ }
369
383
 
370
384
  console.error(`Unknown command: ${command}\n`);
371
385
  console.error(usage());
package/docs/SKILL.md CHANGED
@@ -923,6 +923,18 @@ Key actions:
923
923
  - `start_undo(name?)` / `end_undo(keep?)`
924
924
  - `bulk_set_inputs(ops)` — batch set inputs across multiple timeline item comps in
925
925
  one call; each op requires timeline scope plus `tool_name`, `input_name`, `value`
926
+ - `bulk_set_expressions(ops)` — batch attach expressions across multiple timeline
927
+ item comps in one call; each op requires timeline scope plus `tool_name`,
928
+ `input_name`, `expression`
929
+ - `group_settings_export(group_name, path, include_advisory?)` — write a
930
+ `GroupOperator` to disk and return a parsed published-input summary
931
+ - `group_settings_splice_inputs(source_path, template_path, dest_path?, source_group_name?, template_group_name?)` —
932
+ replace one `.setting` file's `Inputs = ordered() { ... }` block with the
933
+ matching block from another, preserving inner tools and outer structure;
934
+ balanced-brace parser handles nested `UserControls`
935
+ - `group_settings_load(group_name, settings_path, backup_path?, undo_name?)` —
936
+ apply a `.setting` to a live group with an auto backup and an undo wrap so
937
+ Ctrl+Z reverses the change
926
938
 
927
939
  Fusion Composition kernel actions (v2.12.0+) add safer graph inspection and
928
940
  mutation wrappers around the raw Fusion API:
@@ -30,10 +30,40 @@ All actions are exposed through `fusion_comp`.
30
30
  | `safe_set_inputs` | Batch write inputs on one tool with optional readback classification. |
31
31
  | `safe_connect_tools` | Validate source/target tools before connecting a source output to a target input. |
32
32
  | `fusion_boundary_report` | Return graph capabilities plus a composition snapshot for the selected comp scope. |
33
+ | `bulk_set_expressions` | Batch `SetExpression` across scoped timeline-item Fusion comps. Each op needs `tool_name`, `input_name`, `expression`, plus a timeline scope. Wraps each op in `StartUndo`/`EndUndo` + `comp.Lock`. |
34
+ | `group_settings_export` | Save a named `GroupOperator`'s settings to a `.setting` file via `SaveSettings`, returning a parsed published-input summary. |
35
+ | `group_settings_splice_inputs` | Replace the `Inputs = ordered() { ... }` block of `source_path` with the matching block from `template_path` and write `dest_path`. Read-only against Resolve; pure file operation. |
36
+ | `group_settings_load` | Backup the current group state, then `LoadSettings` from a `.setting` file. Wrapped in `StartUndo`/`EndUndo` + `comp.Lock` so Fusion's Ctrl+Z can reverse it. Backup path is returned alongside any error. |
37
+ | `probe_group_published_inputs` | Read live published `Input1..InputN` slots off a `GroupOperator`, optionally cross-referenced with a `.setting` file summary. |
33
38
 
34
39
  The pre-existing `bulk_set_inputs` action remains the batch path for applying
35
40
  input writes across multiple explicitly scoped timeline-item Fusion comps.
36
41
 
42
+ ### `group_settings_splice_inputs` notes
43
+
44
+ The Fusion `.setting` format is a Lua-like nested structure: an InstanceInput
45
+ commonly contains `UserControls = ordered() { Custom = { ... } }` tables, so any
46
+ parsing that uses a flat regex will truncate bodies at the first inner `}`. This
47
+ kernel uses balanced-brace scanning end-to-end. Practical implications:
48
+
49
+ - The action only swaps the published `Inputs = ordered() { ... }` block. The
50
+ group's outer name, inner `Tools = ordered() { ... }` section, and surrounding
51
+ structure are preserved byte-for-byte.
52
+ - You must provide the *new* layout as a real `.setting` file (typically
53
+ exported from a known-good group via `group_settings_export`). The kernel does
54
+ not ship hardcoded templates.
55
+ - `template_group_name` is optional when the template file contains a single
56
+ `GroupOperator`; pass it when the template file contains multiple groups and
57
+ you want a specific one.
58
+
59
+ ### `group_settings_load` Edit-page caveat
60
+
61
+ `LoadSettings` may update inner tool wiring but not refresh Edit-page
62
+ `InstanceInput` order until the group is selected in Fusion and reloaded via UI.
63
+ This is a Resolve quirk, not a kernel bug. The action always backs up the group
64
+ to a timestamped sibling of `settings_path` first; the backup path is returned
65
+ in success and in error responses.
66
+
37
67
  ## Scope Matrix
38
68
 
39
69
  | Scope | Probe Support | Mutation Support | Notes |
package/install.py CHANGED
@@ -35,7 +35,7 @@ from src.utils.update_check import (
35
35
 
36
36
  # ─── Version ──────────────────────────────────────────────────────────────────
37
37
 
38
- VERSION = "2.25.0"
38
+ VERSION = "2.26.0"
39
39
  SUPPORTED_PYTHON_MIN = (3, 10)
40
40
  SUPPORTED_PYTHON_MAX = (3, 12)
41
41
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.25.0",
3
+ "version": "2.26.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -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.0"
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()}")
package/src/server.py CHANGED
@@ -11,7 +11,7 @@ Usage:
11
11
  python src/server.py --full # Start the 329-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.25.0"
14
+ VERSION = "2.26.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -93,6 +93,13 @@ from src.utils.timeline_title_text import (
93
93
  timeline_item_get_property_map as _timeline_item_get_property_map,
94
94
  )
95
95
  from src.utils.multicam import build_multicam_setup_plan
96
+ from src.utils.fusion_group_settings import (
97
+ FUSION_COMMIT_CHECKLIST,
98
+ FUSION_GROUP_GUARDRAILS,
99
+ default_backup_path,
100
+ parse_setting_file,
101
+ splice_inputs_block,
102
+ )
96
103
  from src.utils import analysis_runs as _analysis_runs
97
104
  from src.utils import brain_edits as _brain_edits
98
105
  from src.utils import media_pool_changes as _media_pool_changes
@@ -16864,6 +16871,304 @@ def color_group(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
16864
16871
  # TOOL 27: fusion_comp
16865
16872
  # ═══════════════════════════════════════════════════════════════════════════════
16866
16873
 
16874
+ _FUSION_GROUP_KERNEL_ACTIONS = [
16875
+ "group_settings_export",
16876
+ "group_settings_splice_inputs",
16877
+ "group_settings_load",
16878
+ "bulk_set_expressions",
16879
+ "probe_group_published_inputs",
16880
+ ]
16881
+
16882
+
16883
+ def _find_fusion_group(comp, group_name: str):
16884
+ tool = comp.FindTool(group_name)
16885
+ if tool:
16886
+ attrs = tool.GetAttrs() or {}
16887
+ if attrs.get("TOOLS_RegID") == "GroupOperator":
16888
+ return tool
16889
+ tool_list = comp.GetToolList(False, "GroupOperator") or {}
16890
+ for idx in tool_list:
16891
+ candidate = tool_list[idx]
16892
+ attrs = candidate.GetAttrs() or {}
16893
+ if attrs.get("TOOLS_Name") == group_name:
16894
+ return candidate
16895
+ return None
16896
+
16897
+
16898
+ def _resolve_setting_path(p: Dict[str, Any], key: str = "path") -> Tuple[Optional[str], Optional[Dict[str, Any]]]:
16899
+ raw = p.get(key)
16900
+ if not raw or not str(raw).strip():
16901
+ return None, _err(f"{key} is required")
16902
+ return os.path.abspath(str(raw)), None
16903
+
16904
+
16905
+ def _fusion_group_advisory(include: bool) -> Dict[str, Any]:
16906
+ if not include:
16907
+ return {}
16908
+ return {
16909
+ "guardrails": list(FUSION_GROUP_GUARDRAILS),
16910
+ "commit_checklist": list(FUSION_COMMIT_CHECKLIST),
16911
+ }
16912
+
16913
+
16914
+ def _fusion_group_settings_export(comp, p: Dict[str, Any]) -> Dict[str, Any]:
16915
+ group_name = p.get("group_name")
16916
+ if not group_name:
16917
+ return _err("group_name is required")
16918
+ path, path_err = _resolve_setting_path(p)
16919
+ if path_err:
16920
+ return path_err
16921
+ group = _find_fusion_group(comp, str(group_name))
16922
+ if not group:
16923
+ return _err(f"GroupOperator {group_name!r} not found in comp")
16924
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
16925
+ try:
16926
+ group.SaveSettings(path)
16927
+ except Exception as exc:
16928
+ return _err(f"SaveSettings failed: {exc}")
16929
+ if not os.path.isfile(path):
16930
+ return _err(f"SaveSettings did not create file at {path!r}")
16931
+ try:
16932
+ parsed = parse_setting_file(path, group_name=str(group_name))
16933
+ except Exception as exc:
16934
+ return {
16935
+ "success": True,
16936
+ "path": path,
16937
+ "group_name": group_name,
16938
+ "parse_warning": f"saved, but summary parse failed: {exc}",
16939
+ **_fusion_group_advisory(p.get("include_advisory", False)),
16940
+ }
16941
+ return {
16942
+ "success": True,
16943
+ "path": path,
16944
+ "group_name": group_name,
16945
+ "published_inputs": parsed["published_inputs"],
16946
+ "input_count": parsed["input_count"],
16947
+ **_fusion_group_advisory(p.get("include_advisory", False)),
16948
+ }
16949
+
16950
+
16951
+ def _fusion_group_settings_splice_inputs(p: Dict[str, Any]) -> Dict[str, Any]:
16952
+ source_path, src_err = _resolve_setting_path(p, "source_path")
16953
+ if src_err:
16954
+ return src_err
16955
+ if not os.path.isfile(source_path):
16956
+ return _err(f"source_path not found: {source_path}")
16957
+ template_path, tpl_err = _resolve_setting_path(p, "template_path")
16958
+ if tpl_err:
16959
+ return tpl_err
16960
+ if not os.path.isfile(template_path):
16961
+ return _err(f"template_path not found: {template_path}")
16962
+ dest_path = p.get("dest_path")
16963
+ if dest_path:
16964
+ dest_path = os.path.abspath(str(dest_path))
16965
+ else:
16966
+ root, ext = os.path.splitext(source_path)
16967
+ dest_path = f"{root}.patched{ext or '.setting'}"
16968
+ try:
16969
+ summary = splice_inputs_block(
16970
+ source_path,
16971
+ template_path,
16972
+ dest_path,
16973
+ source_group_name=p.get("source_group_name") or p.get("group_name"),
16974
+ template_group_name=p.get("template_group_name"),
16975
+ )
16976
+ except Exception as exc:
16977
+ return _err(f"splice failed: {exc}")
16978
+ return {
16979
+ "success": True,
16980
+ "dest_path": summary["dest_path"],
16981
+ "summary": summary,
16982
+ **_fusion_group_advisory(p.get("include_advisory", False)),
16983
+ }
16984
+
16985
+
16986
+ def _fusion_group_settings_load(comp, p: Dict[str, Any]) -> Dict[str, Any]:
16987
+ group_name = p.get("group_name")
16988
+ if not group_name:
16989
+ return _err("group_name is required")
16990
+ settings_path, path_err = _resolve_setting_path(p, "settings_path")
16991
+ if path_err:
16992
+ return path_err
16993
+ if not os.path.isfile(settings_path):
16994
+ return _err(f"settings_path not found: {settings_path}")
16995
+ group = _find_fusion_group(comp, str(group_name))
16996
+ if not group:
16997
+ return _err(f"GroupOperator {group_name!r} not found in comp")
16998
+ backup_path = p.get("backup_path")
16999
+ if backup_path:
17000
+ backup_path = os.path.abspath(str(backup_path))
17001
+ else:
17002
+ backup_path = default_backup_path(settings_path)
17003
+ try:
17004
+ os.makedirs(os.path.dirname(backup_path) or ".", exist_ok=True)
17005
+ group.SaveSettings(backup_path)
17006
+ except Exception as exc:
17007
+ return _err(f"backup SaveSettings failed: {exc}")
17008
+
17009
+ undo_name = p.get("undo_name", f"MCP group_settings_load {group_name}")
17010
+ undo_started = False
17011
+ keep_undo = False
17012
+ error_message: Optional[str] = None
17013
+ try:
17014
+ try:
17015
+ comp.StartUndo(undo_name)
17016
+ undo_started = True
17017
+ except Exception:
17018
+ undo_started = False
17019
+ comp.Lock()
17020
+ try:
17021
+ group.LoadSettings(settings_path)
17022
+ keep_undo = True
17023
+ finally:
17024
+ comp.Unlock()
17025
+ except Exception as exc:
17026
+ error_message = str(exc)
17027
+ finally:
17028
+ if undo_started:
17029
+ try:
17030
+ comp.EndUndo(keep_undo)
17031
+ except Exception:
17032
+ pass
17033
+
17034
+ if error_message is not None:
17035
+ return _err(
17036
+ f"LoadSettings failed: {error_message}",
17037
+ remediation=f"Group state preserved by backup at {backup_path}",
17038
+ )
17039
+ return {
17040
+ "success": True,
17041
+ "group_name": group_name,
17042
+ "settings_path": settings_path,
17043
+ "backup_path": backup_path,
17044
+ "warning": (
17045
+ "Group preserved. If Edit Controls don't refresh, select the group in "
17046
+ "Fusion and use UI Load Settings to remap InstanceInput order."
17047
+ ),
17048
+ **_fusion_group_advisory(p.get("include_advisory", False)),
17049
+ }
17050
+
17051
+
17052
+ def _fusion_comp_bulk_set_expressions(p: Dict[str, Any]) -> Dict[str, Any]:
17053
+ ops = p.get("ops")
17054
+ if not isinstance(ops, list) or not ops:
17055
+ return _err(
17056
+ "bulk_set_expressions requires params.ops: non-empty list of objects. "
17057
+ "Each op must include tool_name, input_name, expression, and a timeline scope: "
17058
+ "clip_id, timeline_item_id, or timeline_item={track_type, track_index, item_index}. "
17059
+ "Optional per-op: comp_name, comp_index, time, undo_name."
17060
+ )
17061
+ results: List[Dict[str, Any]] = []
17062
+ for index, op in enumerate(ops):
17063
+ if not isinstance(op, dict):
17064
+ results.append({"index": index, "error": "op must be an object"})
17065
+ continue
17066
+ if not _has_fusion_timeline_scope(op):
17067
+ results.append({"index": index, "error": "timeline scope is required for bulk_set_expressions"})
17068
+ continue
17069
+ missing = [key for key in ("tool_name", "input_name", "expression") if key not in op]
17070
+ if missing:
17071
+ results.append({"index": index, "error": f"missing required field(s): {', '.join(missing)}"})
17072
+ continue
17073
+ comp, comp_err = _resolve_fusion_comp(op, require_timeline_scope=True)
17074
+ if comp_err:
17075
+ results.append({"index": index, "error": comp_err.get("error", str(comp_err))})
17076
+ continue
17077
+ tool = comp.FindTool(op["tool_name"])
17078
+ if not tool:
17079
+ results.append({"index": index, "error": f"Tool {op['tool_name']!r} not found"})
17080
+ continue
17081
+ undo_name = op.get("undo_name", f"MCP bulk_set_expressions #{index}")
17082
+ undo_started = False
17083
+ keep_undo = False
17084
+ error_message: Optional[str] = None
17085
+ try:
17086
+ try:
17087
+ comp.StartUndo(undo_name)
17088
+ undo_started = True
17089
+ except Exception:
17090
+ undo_started = False
17091
+ comp.Lock()
17092
+ try:
17093
+ time = op.get("time", 0)
17094
+ inp = tool[op["input_name"]]
17095
+ if not inp:
17096
+ raise ValueError(f"Input {op['input_name']!r} not found on {op['tool_name']!r}")
17097
+ inp.SetExpression(str(op["expression"]), time)
17098
+ keep_undo = True
17099
+ finally:
17100
+ comp.Unlock()
17101
+ except Exception as exc:
17102
+ error_message = str(exc)
17103
+ finally:
17104
+ if undo_started:
17105
+ try:
17106
+ comp.EndUndo(keep_undo)
17107
+ except Exception:
17108
+ pass
17109
+ if error_message is not None:
17110
+ results.append({"index": index, "error": error_message})
17111
+ elif keep_undo:
17112
+ results.append({"index": index, "success": True, "expression": op["expression"]})
17113
+ return {"results": results, "op_count": len(ops)}
17114
+
17115
+
17116
+ def _fusion_probe_group_published_inputs(comp, p: Dict[str, Any]) -> Dict[str, Any]:
17117
+ group_name = p.get("group_name")
17118
+ if not group_name:
17119
+ return _err("group_name is required")
17120
+ group = _find_fusion_group(comp, str(group_name))
17121
+ if not group:
17122
+ return _err(f"GroupOperator {group_name!r} not found in comp")
17123
+ max_inputs = p.get("max_inputs", 32)
17124
+ try:
17125
+ max_inputs = int(max_inputs)
17126
+ except (TypeError, ValueError):
17127
+ max_inputs = 32
17128
+ time = p.get("time", 0)
17129
+ live_inputs: List[Dict[str, Any]] = []
17130
+ for index in range(1, max_inputs + 1):
17131
+ slot = f"Input{index}"
17132
+ try:
17133
+ inp = group[slot]
17134
+ except Exception:
17135
+ break
17136
+ if not inp:
17137
+ break
17138
+ row: Dict[str, Any] = {"slot": slot}
17139
+ try:
17140
+ attrs = inp.GetAttrs() or {}
17141
+ row["name"] = attrs.get("INPS_Name", "")
17142
+ row["type"] = attrs.get("INPS_DataType", "")
17143
+ except Exception:
17144
+ pass
17145
+ try:
17146
+ row["value"] = _ser(inp[time])
17147
+ except Exception as exc:
17148
+ row["value_error"] = str(exc)
17149
+ try:
17150
+ row["expression"] = inp.GetExpression(time)
17151
+ except Exception:
17152
+ row["expression"] = None
17153
+ live_inputs.append(row)
17154
+
17155
+ file_summary: Optional[Dict[str, Any]] = None
17156
+ settings_path = p.get("settings_path")
17157
+ if settings_path and os.path.isfile(str(settings_path)):
17158
+ try:
17159
+ file_summary = parse_setting_file(str(settings_path), group_name=str(group_name))
17160
+ except Exception as exc:
17161
+ file_summary = {"error": str(exc)}
17162
+
17163
+ return {
17164
+ "group_name": group_name,
17165
+ "live_inputs": live_inputs,
17166
+ "live_input_count": len(live_inputs),
17167
+ "file_summary": file_summary,
17168
+ **_fusion_group_advisory(p.get("include_advisory", False)),
17169
+ }
17170
+
17171
+
16867
17172
  def _fusion_comp_bulk_set_inputs(p: Dict[str, Any]) -> Dict[str, Any]:
16868
17173
  """Apply set_input across many explicitly scoped timeline-item Fusion comps."""
16869
17174
  ops = p.get("ops")
@@ -17177,6 +17482,14 @@ def fusion_comp(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
17177
17482
  start_undo(name?) -> {success}
17178
17483
  end_undo(keep?) -> {success}
17179
17484
  bulk_set_inputs(ops) -> {results, op_count} — each op requires timeline scope plus tool_name, input_name, value
17485
+ bulk_set_expressions(ops) -> {results, op_count} — each op requires timeline scope plus tool_name, input_name, expression
17486
+ group_settings_export(group_name, path, include_advisory?) -> {path, published_inputs, input_count}
17487
+ group_settings_splice_inputs(source_path, template_path, dest_path?, source_group_name?, template_group_name?) -> {dest_path, summary}
17488
+ Replace a source .setting's `Inputs = ordered() { ... }` block with the matching block from template_path.
17489
+ Both files are read; neither GroupOperator is loaded into Resolve. Inner tools and outer structure are preserved.
17490
+ group_settings_load(group_name, settings_path, backup_path?, undo_name?) -> {settings_path, backup_path}
17491
+ Wraps the LoadSettings call in StartUndo/EndUndo + comp.Lock for reversibility.
17492
+ probe_group_published_inputs(group_name, settings_path?, max_inputs?, time?) -> {live_inputs, file_summary?}
17180
17493
  fusion_graph_capabilities(...) -> {supported, boundaries, common_tools}
17181
17494
  probe_fusion_comp(include_io?, max_tools?) -> {name, tool_count, tools}
17182
17495
  probe_fusion_tool(tool_name, include_io?) -> {found, tool}
@@ -17193,11 +17506,22 @@ def fusion_comp(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
17193
17506
 
17194
17507
  if action == "bulk_set_inputs":
17195
17508
  return _fusion_comp_bulk_set_inputs(p)
17509
+ if action == "bulk_set_expressions":
17510
+ return _fusion_comp_bulk_set_expressions(p)
17511
+ if action == "group_settings_splice_inputs":
17512
+ return _fusion_group_settings_splice_inputs(p)
17196
17513
 
17197
17514
  comp, comp_err = _resolve_fusion_comp(p)
17198
17515
  if comp_err:
17199
17516
  return comp_err
17200
17517
 
17518
+ if action == "group_settings_export":
17519
+ return _fusion_group_settings_export(comp, p)
17520
+ if action == "group_settings_load":
17521
+ return _fusion_group_settings_load(comp, p)
17522
+ if action == "probe_group_published_inputs":
17523
+ return _fusion_probe_group_published_inputs(comp, p)
17524
+
17201
17525
  if action == "fusion_graph_capabilities":
17202
17526
  return _fusion_graph_capabilities(comp)
17203
17527
  elif action == "probe_fusion_comp":
@@ -17449,6 +17773,8 @@ def fusion_comp(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
17449
17773
  "get_comp_info","set_frame_range","render",
17450
17774
  "start_undo","end_undo",
17451
17775
  "bulk_set_inputs",
17776
+ "bulk_set_expressions",
17777
+ *_FUSION_GROUP_KERNEL_ACTIONS,
17452
17778
  *_FUSION_KERNEL_ACTIONS,
17453
17779
  ])
17454
17780
 
@@ -0,0 +1,323 @@
1
+ """Parse and patch Fusion GroupOperator .setting files.
2
+
3
+ Fusion's .setting format is a Lua-like nested structure. Real-world group
4
+ exports routinely contain InstanceInput blocks with nested UserControls /
5
+ ControlGroup tables (>=2 levels of braces), so we cannot parse them with a
6
+ flat regex. All structural scans here use balanced-brace matching.
7
+
8
+ Public surface:
9
+ parse_setting_file(path, group_name=None) -> dict
10
+ Inspect a .setting file and return the published-input summary for a
11
+ named (or first) GroupOperator.
12
+
13
+ splice_inputs_block(source_path, template_path, dest_path,
14
+ group_name=None) -> dict
15
+ Replace the `Inputs = ordered() { ... }` block of source_path with
16
+ the matching block from template_path and write to dest_path.
17
+ Returns a before/after diff summary.
18
+
19
+ default_backup_path(path) -> str
20
+ Generate a timestamped backup filename next to a target path.
21
+
22
+ FUSION_GROUP_GUARDRAILS, FUSION_COMMIT_CHECKLIST
23
+ Advisory text. Other modules may surface these on demand; this module
24
+ does not jam them into every return.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import os
30
+ import re
31
+ from dataclasses import dataclass, field
32
+ from datetime import datetime, timezone
33
+ from typing import Any, Dict, List, Optional, Tuple
34
+
35
+
36
+ FUSION_COMMIT_CHECKLIST: Tuple[str, ...] = (
37
+ "Open the Fusion page for the modified timeline item (or comp scope).",
38
+ "Select the group/macro node and nudge one published control so Resolve refreshes bindings.",
39
+ "Verify Edit-page controls respond (Text, Text Size, wrap, padding, corners).",
40
+ "Save the project (Ctrl+S / Cmd+S).",
41
+ "If InstanceInput order changed, confirm Edit Controls order in UI; LoadSettings may require manual UI 'Load Settings' on the group.",
42
+ )
43
+
44
+ FUSION_GROUP_GUARDRAILS: Tuple[str, ...] = (
45
+ "Do not call project_manager.load or switch projects mid Fusion edit — comp scope and undo stacks are lost.",
46
+ "Batch graph mutations: prefer bulk_set_inputs / bulk_set_expressions over many single-action calls.",
47
+ "InstanceInput remapping via LoadSettings may not refresh Edit-page control order until the group is selected and settings reloaded in Fusion UI.",
48
+ "Never delete a group via automation; group_settings_load only patches published inputs and inner settings.",
49
+ "After mutating a timeline-item Fusion comp, follow the commit checklist before expecting Edit-page updates.",
50
+ )
51
+
52
+
53
+ @dataclass
54
+ class InstanceInputSummary:
55
+ slot: str
56
+ source_op: Optional[str] = None
57
+ source: Optional[str] = None
58
+ name: Optional[str] = None
59
+ max_scale: Optional[float] = None
60
+ default: Optional[str] = None
61
+ control_group: Optional[int] = None
62
+ raw_fields: Dict[str, Any] = field(default_factory=dict)
63
+
64
+
65
+ # Used only to read shallow fields out of an InstanceInput body that we have
66
+ # already isolated with balanced-brace scanning. We never use it to FIND the
67
+ # bounds of a block.
68
+ _FIELD_RE = re.compile(r'(\w+)\s*=\s*(?:"([^"]*)"|([\d.eE+-]+))')
69
+
70
+ _INPUT_HEAD_RE = re.compile(r"(Input\d+)\s*=\s*InstanceInput\s*\{")
71
+
72
+
73
+ def _find_balanced_brace(text: str, open_index: int) -> int:
74
+ """Return the index of the `}` that closes the `{` at open_index."""
75
+ depth = 0
76
+ for idx in range(open_index, len(text)):
77
+ ch = text[idx]
78
+ if ch == "{":
79
+ depth += 1
80
+ elif ch == "}":
81
+ depth -= 1
82
+ if depth == 0:
83
+ return idx
84
+ raise ValueError("Unbalanced braces")
85
+
86
+
87
+ def _iter_instance_input_blocks(inputs_inner: str) -> List[Tuple[str, str]]:
88
+ """Yield (slot, body) pairs by walking InstanceInput heads + balanced braces."""
89
+ blocks: List[Tuple[str, str]] = []
90
+ pos = 0
91
+ while True:
92
+ match = _INPUT_HEAD_RE.search(inputs_inner, pos)
93
+ if not match:
94
+ break
95
+ slot = match.group(1)
96
+ open_brace = match.end() - 1
97
+ try:
98
+ close_brace = _find_balanced_brace(inputs_inner, open_brace)
99
+ except ValueError:
100
+ break
101
+ body = inputs_inner[open_brace + 1 : close_brace]
102
+ blocks.append((slot, body))
103
+ pos = close_brace + 1
104
+ return blocks
105
+
106
+
107
+ def _shallow_fields(body: str) -> Dict[str, Any]:
108
+ """Extract top-level `key = value` fields, skipping nested braces."""
109
+ fields: Dict[str, Any] = {}
110
+ i = 0
111
+ n = len(body)
112
+ while i < n:
113
+ ch = body[i]
114
+ if ch == "{":
115
+ try:
116
+ close = _find_balanced_brace(body, i)
117
+ except ValueError:
118
+ break
119
+ i = close + 1
120
+ continue
121
+ match = _FIELD_RE.match(body, i)
122
+ if match:
123
+ key, str_val, num_val = match.group(1), match.group(2), match.group(3)
124
+ if str_val is not None:
125
+ fields[key] = str_val
126
+ elif num_val is not None:
127
+ try:
128
+ fields[key] = (
129
+ float(num_val) if "." in num_val or "e" in num_val.lower() else int(num_val)
130
+ )
131
+ except ValueError:
132
+ fields[key] = num_val
133
+ i = match.end()
134
+ else:
135
+ i += 1
136
+ return fields
137
+
138
+
139
+ def parse_instance_input_block(inputs_inner: str) -> List[InstanceInputSummary]:
140
+ summaries: List[InstanceInputSummary] = []
141
+ for slot, body in _iter_instance_input_blocks(inputs_inner):
142
+ fields = _shallow_fields(body)
143
+ max_scale = fields.get("MaxScale")
144
+ if isinstance(max_scale, (int, float)):
145
+ max_scale_f: Optional[float] = float(max_scale)
146
+ else:
147
+ max_scale_f = None
148
+ cg = fields.get("ControlGroup")
149
+ summaries.append(
150
+ InstanceInputSummary(
151
+ slot=slot,
152
+ source_op=fields.get("SourceOp"),
153
+ source=fields.get("Source"),
154
+ name=fields.get("Name"),
155
+ max_scale=max_scale_f,
156
+ default=str(fields["Default"]) if "Default" in fields else None,
157
+ control_group=int(cg) if isinstance(cg, int) else None,
158
+ raw_fields=fields,
159
+ )
160
+ )
161
+ summaries.sort(key=lambda row: _slot_key(row.slot))
162
+ return summaries
163
+
164
+
165
+ def _slot_key(slot: str) -> int:
166
+ try:
167
+ return int(slot.replace("Input", ""))
168
+ except ValueError:
169
+ return 9999
170
+
171
+
172
+ def _group_inputs_span(
173
+ content: str, group_name: Optional[str] = None
174
+ ) -> Tuple[int, int, str]:
175
+ """Return absolute (start, end_exclusive, inner) for the Inputs ordered block."""
176
+ if group_name:
177
+ pattern = re.compile(
178
+ rf"{re.escape(group_name)}\s*=\s*GroupOperator\s*\{{",
179
+ re.MULTILINE,
180
+ )
181
+ match = pattern.search(content)
182
+ if not match:
183
+ raise ValueError(f"GroupOperator {group_name!r} not found in .setting content")
184
+ group_open = match.end() - 1
185
+ else:
186
+ match = re.search(r"=\s*GroupOperator\s*\{", content)
187
+ if not match:
188
+ raise ValueError("No GroupOperator found in .setting content")
189
+ group_open = match.end() - 1
190
+
191
+ group_close = _find_balanced_brace(content, group_open)
192
+ group_body = content[group_open + 1 : group_close]
193
+ inputs_marker = re.search(r"Inputs\s*=\s*ordered\s*\(\s*\)\s*\{", group_body)
194
+ if not inputs_marker:
195
+ raise ValueError("GroupOperator has no Inputs = ordered() block")
196
+ abs_start = group_open + 1 + inputs_marker.start()
197
+ open_brace = group_open + 1 + inputs_marker.end() - 1
198
+ close_brace = _find_balanced_brace(content, open_brace)
199
+ replace_end = close_brace + 1
200
+ if replace_end < len(content) and content[replace_end] == ",":
201
+ replace_end += 1
202
+ return abs_start, replace_end, content[open_brace + 1 : close_brace]
203
+
204
+
205
+ def parse_setting_file(path: str, group_name: Optional[str] = None) -> Dict[str, Any]:
206
+ with open(path, encoding="utf-8", errors="replace") as handle:
207
+ content = handle.read()
208
+ _, _, inner = _group_inputs_span(content, group_name=group_name)
209
+ inputs = parse_instance_input_block(inner)
210
+ return {
211
+ "path": os.path.abspath(path),
212
+ "published_inputs": [
213
+ {
214
+ "slot": row.slot,
215
+ "name": row.name,
216
+ "source_op": row.source_op,
217
+ "source": row.source,
218
+ "max_scale": row.max_scale,
219
+ "default": row.default,
220
+ "control_group": row.control_group,
221
+ }
222
+ for row in inputs
223
+ ],
224
+ "input_count": len(inputs),
225
+ }
226
+
227
+
228
+ def _read_template_inputs_block(
229
+ template_path: str, group_name: Optional[str] = None
230
+ ) -> str:
231
+ """Pull the full `Inputs = ordered() { ... },` block from a .setting file."""
232
+ with open(template_path, encoding="utf-8", errors="replace") as handle:
233
+ content = handle.read()
234
+ start, end, _ = _group_inputs_span(content, group_name=group_name)
235
+ return content[start:end]
236
+
237
+
238
+ def _summary_dict(row: Optional[InstanceInputSummary]) -> Optional[Dict[str, Any]]:
239
+ if row is None:
240
+ return None
241
+ return {
242
+ "source_op": row.source_op,
243
+ "source": row.source,
244
+ "name": row.name,
245
+ "max_scale": row.max_scale,
246
+ "control_group": row.control_group,
247
+ }
248
+
249
+
250
+ def _diff_inputs(
251
+ before: List[InstanceInputSummary], after: List[InstanceInputSummary]
252
+ ) -> List[Dict[str, Any]]:
253
+ before_by_slot = {row.slot: row for row in before}
254
+ after_by_slot = {row.slot: row for row in after}
255
+ all_slots = sorted(set(before_by_slot) | set(after_by_slot), key=_slot_key)
256
+ diff: List[Dict[str, Any]] = []
257
+ for slot in all_slots:
258
+ b = before_by_slot.get(slot)
259
+ a = after_by_slot.get(slot)
260
+ if b is None:
261
+ diff.append({"slot": slot, "change": "added", "after": _summary_dict(a)})
262
+ elif a is None:
263
+ diff.append({"slot": slot, "change": "removed", "before": _summary_dict(b)})
264
+ elif _summary_dict(b) != _summary_dict(a):
265
+ diff.append(
266
+ {
267
+ "slot": slot,
268
+ "change": "modified",
269
+ "before": _summary_dict(b),
270
+ "after": _summary_dict(a),
271
+ }
272
+ )
273
+ return diff
274
+
275
+
276
+ def splice_inputs_block(
277
+ source_path: str,
278
+ template_path: str,
279
+ dest_path: str,
280
+ *,
281
+ source_group_name: Optional[str] = None,
282
+ template_group_name: Optional[str] = None,
283
+ ) -> Dict[str, Any]:
284
+ """Replace source's `Inputs = ordered() { ... }` with template's, write dest.
285
+
286
+ source_path: .setting whose published inputs you want to replace.
287
+ template_path: .setting whose Inputs block defines the desired layout.
288
+ dest_path: where to write the spliced result.
289
+
290
+ Returns a summary with input counts before/after and a per-slot diff.
291
+ """
292
+ with open(source_path, encoding="utf-8", errors="replace") as handle:
293
+ source_content = handle.read()
294
+ src_start, src_end, src_inner = _group_inputs_span(
295
+ source_content, group_name=source_group_name
296
+ )
297
+ before_inputs = parse_instance_input_block(src_inner)
298
+ template_block = _read_template_inputs_block(
299
+ template_path, group_name=template_group_name
300
+ )
301
+
302
+ new_content = source_content[:src_start] + template_block + source_content[src_end:]
303
+ _, _, new_inner = _group_inputs_span(new_content, group_name=source_group_name)
304
+ after_inputs = parse_instance_input_block(new_inner)
305
+
306
+ os.makedirs(os.path.dirname(os.path.abspath(dest_path)) or ".", exist_ok=True)
307
+ with open(dest_path, "w", encoding="utf-8", newline="\n") as handle:
308
+ handle.write(new_content)
309
+
310
+ return {
311
+ "source_path": os.path.abspath(source_path),
312
+ "template_path": os.path.abspath(template_path),
313
+ "dest_path": os.path.abspath(dest_path),
314
+ "before_input_count": len(before_inputs),
315
+ "after_input_count": len(after_inputs),
316
+ "diff": _diff_inputs(before_inputs, after_inputs),
317
+ }
318
+
319
+
320
+ def default_backup_path(base_path: str) -> str:
321
+ stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
322
+ root, ext = os.path.splitext(base_path)
323
+ return f"{root}.backup_{stamp}{ext or '.setting'}"