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 +38 -0
- package/README.md +1 -1
- package/bin/davinci-resolve-mcp.mjs +14 -0
- package/docs/SKILL.md +12 -0
- package/docs/kernels/fusion-composition-kernel.md +30 -0
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/batch_cli.py +456 -0
- package/src/granular/common.py +1 -1
- package/src/server.py +327 -1
- package/src/utils/fusion_group_settings.py +323 -0
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
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
4
4
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
5
5
|
[](docs/reference/api-coverage.md)
|
|
6
6
|
[-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
package/package.json
CHANGED
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.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.
|
|
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'}"
|