davinci-resolve-mcp 2.27.1 → 2.28.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 +90 -0
- package/README.md +1 -1
- package/docs/SKILL.md +29 -2
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/analysis_dashboard.py +100 -26
- package/src/batch_cli.py +81 -0
- package/src/granular/common.py +1 -1
- package/src/server.py +332 -4
- package/src/utils/clip_query.py +85 -0
- package/src/utils/media_analysis.py +214 -5
- package/src/utils/project_lint.py +122 -0
- package/src/utils/project_spec.py +428 -0
- package/src/utils/structural_diff.py +175 -0
- package/src/utils/timeline_versioning.py +31 -9
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,96 @@
|
|
|
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.28.0
|
|
6
|
+
|
|
7
|
+
This release adds a structural timeline-diff engine, a declarative project spec
|
|
8
|
+
you can `apply` like infrastructure-as-code, a project health `lint`, a clip
|
|
9
|
+
query DSL, and a machine-readable `state` field on error responses.
|
|
10
|
+
|
|
11
|
+
**Timeline version diff — see exactly what an edit changed.** Comparing two
|
|
12
|
+
archived timeline versions now reports clips that were **added, removed, moved,
|
|
13
|
+
and trimmed**, plus summary counts and before/after clip totals. A new reusable
|
|
14
|
+
diff engine aligns clips by a rename-stable identity (so a reordered or renamed
|
|
15
|
+
clip reads as a move/change, not a delete-and-re-add).
|
|
16
|
+
|
|
17
|
+
- `timeline_versioning(action="diff_versions", timeline_name, from_version, to_version)`
|
|
18
|
+
now returns `{added, removed, moved, trimmed, summary}` (the previous
|
|
19
|
+
`added`/`removed`/`moved` keys are unchanged).
|
|
20
|
+
- Dashboard endpoint `GET /api/timeline_versions/diff?timeline_name=&from_version=&to_version=`
|
|
21
|
+
exposes the same diff to the control panel.
|
|
22
|
+
|
|
23
|
+
**Declarative project spec + `apply` — reproducible project setup.** Describe a
|
|
24
|
+
project's desired settings, color preset, timelines, and markers in a
|
|
25
|
+
`project.dvr.yaml` (or `.json`), then reconcile the live project toward it. Runs
|
|
26
|
+
are **idempotent** — applying twice is a no-op — and a dry run previews every
|
|
27
|
+
change before anything is touched.
|
|
28
|
+
|
|
29
|
+
- New MCP actions on `project_manager`:
|
|
30
|
+
- `diff_to_spec(spec_path | spec)` — preview drift without mutating.
|
|
31
|
+
- `plan_spec(spec_path | spec)` — the ordered action list (dry run).
|
|
32
|
+
- `apply_spec(spec_path | spec, dry_run?, run_hooks?, continue_on_error?)` —
|
|
33
|
+
reconcile. Color/HDR settings apply in dependency order; markers are only
|
|
34
|
+
added when absent; an explicit `color_preset` can be overridden by explicit
|
|
35
|
+
`settings`. Failures can abort on first error or accumulate.
|
|
36
|
+
- New headless CLI commands: `davinci-resolve-mcp batch plan-spec SPEC` and
|
|
37
|
+
`davinci-resolve-mcp batch apply SPEC [--dry-run] [--run-hooks] [--continue-on-error]`.
|
|
38
|
+
Exit codes follow the existing convention (`0` ok, `2` partial, `3` fatal).
|
|
39
|
+
- Optional before/after shell **hooks** in the spec run only when `run_hooks` is
|
|
40
|
+
passed (opt-in).
|
|
41
|
+
|
|
42
|
+
**Project health `lint` — a pre-flight before editing.** `project_manager(action="lint")`
|
|
43
|
+
returns a graded issue list (errors / warnings / info) covering: no project, no
|
|
44
|
+
current timeline, mixed frame rates across timelines, empty timelines, unset
|
|
45
|
+
render format, unmanaged color science, offline media, and unanalyzed clips.
|
|
46
|
+
|
|
47
|
+
**Clip query DSL — find clips in one call.** `timeline(action="clip_where", ...)`
|
|
48
|
+
returns the clips on the current timeline matching named filters (AND), instead
|
|
49
|
+
of enumerating tracks by hand. Live filters: `track_type`, `track_index`,
|
|
50
|
+
`name_contains`, `duration_lt`, `duration_gt`. A typo'd filter name is rejected
|
|
51
|
+
rather than silently matching everything.
|
|
52
|
+
|
|
53
|
+
**Machine-readable error context.** Structured error responses can now carry an
|
|
54
|
+
optional `state` object — a snapshot of the relevant values at failure time
|
|
55
|
+
(e.g. which filter was unknown, which spec failed and where) — so an agent can
|
|
56
|
+
react without parsing prose. Existing error fields are unchanged.
|
|
57
|
+
|
|
58
|
+
## What's New in v2.27.2
|
|
59
|
+
|
|
60
|
+
**Control panel under-counted analyzed clips after a Media Pool rename (issue
|
|
61
|
+
#51)** — with every clip analyzed (e.g. 303/303 reports on disk), the overview
|
|
62
|
+
and Media tab could report something like "108 / 303 analyzed". The panel only
|
|
63
|
+
recognized a report when a folder's name exactly matched the clip's *current*
|
|
64
|
+
display name, so renaming clips after analysis hid their existing reports even
|
|
65
|
+
though the underlying media was unchanged.
|
|
66
|
+
|
|
67
|
+
Root cause and fix:
|
|
68
|
+
|
|
69
|
+
- **Lookups are keyed by a rename-stable hash, not the display-name folder.**
|
|
70
|
+
Report folders are named `<display-slug>-<hash>`; the count now matches on the
|
|
71
|
+
trailing hash (and the ids inside each report), so a renamed clip still
|
|
72
|
+
resolves to its existing folder. Both the disk path and the jobs-DB fallback
|
|
73
|
+
were corrected.
|
|
74
|
+
- **The hash is now anchored to the normalized file path (canonical basis).**
|
|
75
|
+
Previously the basis was a `clip_id`-first cascade, so the same media hashed
|
|
76
|
+
differently depending on which fields a record carried — Resolve inventory
|
|
77
|
+
(clip_id) vs path-based batch jobs (file path) disagreed on the same clip.
|
|
78
|
+
Anchoring to the file path removes that cross-basis mismatch. Legacy folders
|
|
79
|
+
(clip_id-based, or raw-path-based) still resolve via a migration-safe set of
|
|
80
|
+
candidate hashes, so **no on-disk migration is required**.
|
|
81
|
+
- **Writes reuse an existing report folder** (matched by canonical or legacy
|
|
82
|
+
hash) instead of minting a new `<newslug>-<hash>` directory, eliminating
|
|
83
|
+
orphaned duplicate folders when a renamed clip is re-analyzed.
|
|
84
|
+
- **A persisted clip index (`clips/index.json`)** maps every stable id found in
|
|
85
|
+
a report (normalized + raw file path, clip_id, media_id) to its folder, so the
|
|
86
|
+
count can still match a clip by any id it carries — including an offline clip
|
|
87
|
+
that no longer reports a file path but retains its clip_id. The index is
|
|
88
|
+
refreshed only when a report is added, removed, or rewritten (cheap signature
|
|
89
|
+
check), so the recurring poll stays inexpensive.
|
|
90
|
+
|
|
91
|
+
No public MCP tool surface changed. Adds regression tests in
|
|
92
|
+
`tests/test_media_analysis.py` covering rename, cross-basis, legacy-folder reuse,
|
|
93
|
+
the jobs-DB fallback, and the offline/no-path case.
|
|
94
|
+
|
|
5
95
|
## What's New in v2.27.1
|
|
6
96
|
|
|
7
97
|
**Faster control-panel startup with network source media (issue #50)** — on
|
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)
|
package/docs/SKILL.md
CHANGED
|
@@ -355,6 +355,26 @@ lifecycle, settings, database, preset, and archive boundary helpers:
|
|
|
355
355
|
- `preset_lifecycle_probe`
|
|
356
356
|
- `project_boundary_report`
|
|
357
357
|
|
|
358
|
+
Health check and declarative spec (v2.28.0+):
|
|
359
|
+
|
|
360
|
+
- `lint` — graded project health pre-flight returning `{ok, counts, issues}`.
|
|
361
|
+
Issues (error / warning / info) cover: no project, no current timeline, mixed
|
|
362
|
+
frame rates across timelines, empty timeline, render format unset, color
|
|
363
|
+
science unmanaged, offline media, and unanalyzed clips. Composed from existing
|
|
364
|
+
probes; safe read-only.
|
|
365
|
+
- `diff_to_spec(spec_path | spec)` — preview drift between a declarative spec and
|
|
366
|
+
the live project WITHOUT mutating. Returns `{actions, diff, change_count}`.
|
|
367
|
+
- `plan_spec(spec_path | spec)` — the ordered action list as a dry run.
|
|
368
|
+
- `apply_spec(spec_path | spec, dry_run?, run_hooks?, continue_on_error?)` —
|
|
369
|
+
reconcile the project toward the spec. Idempotent (re-runs are no-ops); color/
|
|
370
|
+
HDR settings apply in dependency order; markers added only when absent; explicit
|
|
371
|
+
`settings` override a named `color_preset`; before/after shell hooks run only
|
|
372
|
+
with `run_hooks=true`. The spec is YAML or JSON:
|
|
373
|
+
`{project, color_preset?, settings?, timelines:[{name, fps?, settings?, markers?}], hooks?}`.
|
|
374
|
+
Note: `apply_spec` reconciles the **currently open or already-existing** project;
|
|
375
|
+
creating a brand-new project from a spec depends on Resolve's `CreateProject`
|
|
376
|
+
succeeding (it can return None when an unsaved project blocks the switch).
|
|
377
|
+
|
|
358
378
|
Safe project actions require `_mcp_` names and temp paths by default. Database
|
|
359
379
|
switching dry-runs by default because Resolve closes open projects when
|
|
360
380
|
switching databases. Archive source media/cache/proxy flags are rejected unless
|
|
@@ -584,8 +604,10 @@ Key actions:
|
|
|
584
604
|
`initiator`, `thumbnail_path`, and `drt_export_path` (set when the version
|
|
585
605
|
was retention-collapsed to disk).
|
|
586
606
|
- `diff_versions(timeline_name, from_version, to_version)` — structural diff
|
|
587
|
-
between two snapshots: `{added, removed, moved
|
|
588
|
-
|
|
607
|
+
between two snapshots: `{added, removed, moved, trimmed, summary}`. `trimmed`
|
|
608
|
+
lists clips kept in place but re-trimmed (carries `out_frame_before`); `summary`
|
|
609
|
+
has per-bucket counts plus `before_clip_count`/`after_clip_count`. Clips are
|
|
610
|
+
keyed by media_pool_item_id and timeline position.
|
|
589
611
|
- `get_history(timeline_name?, analysis_run_id?, limit?)` — brain-edit rows
|
|
590
612
|
with `edit_type`, `target_metric`, `before_value`, `after_value`, `delta`,
|
|
591
613
|
`rationale`, and `initiator`. Filter by timeline or run; defaults to 50.
|
|
@@ -674,6 +696,11 @@ Key actions:
|
|
|
674
696
|
- `get_track_count(track_type)` — track_type: `"video"`, `"audio"`, `"subtitle"`
|
|
675
697
|
- `add_track(track_type, sub_type?)` / `delete_track(track_type, index)`
|
|
676
698
|
- `get_items(track_type, index)` — items on a track
|
|
699
|
+
- `clip_where(track_type?, track_index?, name_contains?, duration_lt?, duration_gt?)` —
|
|
700
|
+
(v2.28.0+) return clips on the current timeline matching named filters (AND),
|
|
701
|
+
instead of walking tracks by hand. Filters may be passed inline or as a
|
|
702
|
+
`filters` dict; a mistyped filter name is rejected rather than silently
|
|
703
|
+
matching everything. Returns `{clips, match_count, total_clips}`.
|
|
677
704
|
- `delete_clips(clip_ids, ripple?)` — IDs are unique IDs from `get_items`
|
|
678
705
|
- `duplicate_clips(clip_ids?, selected?, target_track_index?, track_offset?, placement?, record_frame?, record_frame_offset?, copy_properties?, include_linked?)` —
|
|
679
706
|
duplicate existing video timeline items by re-appending the same Media Pool
|
package/install.py
CHANGED
|
@@ -35,7 +35,7 @@ from src.utils.update_check import (
|
|
|
35
35
|
|
|
36
36
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
37
37
|
|
|
38
|
-
VERSION = "2.
|
|
38
|
+
VERSION = "2.28.0"
|
|
39
39
|
# Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
|
|
40
40
|
# Resolve's scripting bridge loads into newer interpreters on recent builds
|
|
41
41
|
# (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
|
package/package.json
CHANGED
|
@@ -27,7 +27,10 @@ from src.utils.media_analysis import (
|
|
|
27
27
|
detect_capabilities,
|
|
28
28
|
query_analysis_index,
|
|
29
29
|
resolve_output_root,
|
|
30
|
+
clip_directory_hash,
|
|
31
|
+
load_clip_index,
|
|
30
32
|
stable_clip_directory,
|
|
33
|
+
stable_clip_match_hashes,
|
|
31
34
|
)
|
|
32
35
|
from src.utils.media_analysis_jobs import (
|
|
33
36
|
MEDIA_EXTENSIONS,
|
|
@@ -10912,11 +10915,32 @@ def _analysis_status_by_clip(project_root: str, records: List[Dict[str, Any]]) -
|
|
|
10912
10915
|
status_by_key: Dict[str, Dict[str, Any]] = {}
|
|
10913
10916
|
if not keys:
|
|
10914
10917
|
return status_by_key
|
|
10918
|
+
|
|
10919
|
+
# Resolve each clip to its report via the persisted clip index, which maps
|
|
10920
|
+
# every stable id found in a report (normalized + raw file path, clip_id,
|
|
10921
|
+
# media_id) to its folder. This survives a Media Pool rename, a legacy hash
|
|
10922
|
+
# basis, AND an offline clip that no longer reports a file path but still
|
|
10923
|
+
# carries its clip_id — none of which a folder-name scan can match. See #51.
|
|
10924
|
+
clips_root = os.path.join(project_root, "clips")
|
|
10925
|
+
hash_to_folder = load_clip_index(project_root).get("hash_to_folder") or {}
|
|
10926
|
+
|
|
10915
10927
|
for record in records:
|
|
10916
10928
|
clip_key = record.get("clip_key")
|
|
10917
10929
|
if not clip_key:
|
|
10918
10930
|
continue
|
|
10919
10931
|
report_path = os.path.join(project_root, "clips", str(clip_key), "analysis.json")
|
|
10932
|
+
if not os.path.isfile(report_path):
|
|
10933
|
+
# Renamed/legacy/offline clip: the recomputed clip_key no longer
|
|
10934
|
+
# matches the folder on disk. Fall back to any of the clip's stable
|
|
10935
|
+
# hashes via the index.
|
|
10936
|
+
for clip_hash in stable_clip_match_hashes(record):
|
|
10937
|
+
folder = hash_to_folder.get(clip_hash)
|
|
10938
|
+
if not folder:
|
|
10939
|
+
continue
|
|
10940
|
+
candidate = os.path.join(clips_root, folder, "analysis.json")
|
|
10941
|
+
if os.path.isfile(candidate):
|
|
10942
|
+
report_path = candidate
|
|
10943
|
+
break
|
|
10920
10944
|
if os.path.isfile(report_path):
|
|
10921
10945
|
status_by_key[str(clip_key)] = {
|
|
10922
10946
|
"analysis_status": "analyzed",
|
|
@@ -10926,32 +10950,24 @@ def _analysis_status_by_clip(project_root: str, records: List[Dict[str, Any]]) -
|
|
|
10926
10950
|
db_path = os.path.join(project_root, "jobs.sqlite")
|
|
10927
10951
|
if not os.path.isfile(db_path):
|
|
10928
10952
|
return status_by_key
|
|
10929
|
-
|
|
10930
|
-
|
|
10931
|
-
|
|
10932
|
-
|
|
10933
|
-
|
|
10934
|
-
|
|
10935
|
-
|
|
10936
|
-
|
|
10937
|
-
|
|
10938
|
-
|
|
10939
|
-
|
|
10940
|
-
ORDER BY jc.updated_at DESC
|
|
10941
|
-
""",
|
|
10942
|
-
keys,
|
|
10943
|
-
).fetchall()
|
|
10944
|
-
except Exception:
|
|
10945
|
-
return status_by_key
|
|
10946
|
-
finally:
|
|
10947
|
-
try:
|
|
10948
|
-
conn.close()
|
|
10949
|
-
except Exception:
|
|
10950
|
-
pass
|
|
10951
|
-
for row in rows:
|
|
10952
|
-
clip_key = str(row["clip_key"])
|
|
10953
|
-
if clip_key in status_by_key and status_by_key[clip_key].get("analysis_status") == "analyzed":
|
|
10953
|
+
|
|
10954
|
+
# The jobs DB stores each clip under the clip_key it had when analyzed. A
|
|
10955
|
+
# clip renamed afterwards produces a new clip_key, so an exact-key match
|
|
10956
|
+
# misses its job row. Index unresolved records by their rename-stable hash
|
|
10957
|
+
# so a DB row recorded under the old name (e.g. a reused batch report living
|
|
10958
|
+
# outside the local clips/ dir) still maps back to the current clip. #51.
|
|
10959
|
+
key_set = {str(k) for k in keys}
|
|
10960
|
+
pending_hash_to_key: Dict[str, str] = {}
|
|
10961
|
+
for record in records:
|
|
10962
|
+
clip_key = record.get("clip_key")
|
|
10963
|
+
if not clip_key or str(clip_key) in status_by_key:
|
|
10954
10964
|
continue
|
|
10965
|
+
for folder_hash in stable_clip_match_hashes(record):
|
|
10966
|
+
pending_hash_to_key.setdefault(folder_hash, str(clip_key))
|
|
10967
|
+
|
|
10968
|
+
def _apply_row(row: sqlite3.Row, target_key: str) -> None:
|
|
10969
|
+
if status_by_key.get(target_key, {}).get("analysis_status") == "analyzed":
|
|
10970
|
+
return
|
|
10955
10971
|
db_status = row["status"]
|
|
10956
10972
|
report_path = row["report_path"]
|
|
10957
10973
|
# In media_analysis_jobs, 'succeeded' = fresh analysis written this run,
|
|
@@ -10969,7 +10985,7 @@ def _analysis_status_by_clip(project_root: str, records: List[Dict[str, Any]]) -
|
|
|
10969
10985
|
normalized = db_status
|
|
10970
10986
|
if db_status in ("succeeded", "skipped") and report_resolves:
|
|
10971
10987
|
normalized = "analyzed"
|
|
10972
|
-
status_by_key[
|
|
10988
|
+
status_by_key[target_key] = {
|
|
10973
10989
|
"analysis_status": normalized,
|
|
10974
10990
|
"job_status": db_status,
|
|
10975
10991
|
"cache_status": row["cache_status"],
|
|
@@ -10979,6 +10995,45 @@ def _analysis_status_by_clip(project_root: str, records: List[Dict[str, Any]]) -
|
|
|
10979
10995
|
"job_name": row["job_name"],
|
|
10980
10996
|
"job_updated_at": row["updated_at"],
|
|
10981
10997
|
}
|
|
10998
|
+
|
|
10999
|
+
select_cols = (
|
|
11000
|
+
"SELECT jc.clip_key, jc.status, jc.cache_status, jc.report_path, jc.error, "
|
|
11001
|
+
"j.job_id, j.name AS job_name, j.updated_at "
|
|
11002
|
+
"FROM job_clips jc JOIN jobs j ON j.job_id = jc.job_id"
|
|
11003
|
+
)
|
|
11004
|
+
placeholders = ",".join("?" for _ in keys)
|
|
11005
|
+
try:
|
|
11006
|
+
conn = sqlite3.connect(db_path)
|
|
11007
|
+
conn.row_factory = sqlite3.Row
|
|
11008
|
+
rows = conn.execute(
|
|
11009
|
+
f"{select_cols} WHERE jc.clip_key IN ({placeholders}) ORDER BY jc.updated_at DESC",
|
|
11010
|
+
keys,
|
|
11011
|
+
).fetchall()
|
|
11012
|
+
for row in rows:
|
|
11013
|
+
_apply_row(row, str(row["clip_key"]))
|
|
11014
|
+
# Only pay for the unfiltered scan when a rename actually left a record
|
|
11015
|
+
# unresolved by the disk pass and exact-key match above.
|
|
11016
|
+
unresolved = {
|
|
11017
|
+
h: k for h, k in pending_hash_to_key.items() if k not in status_by_key
|
|
11018
|
+
}
|
|
11019
|
+
if unresolved:
|
|
11020
|
+
for row in conn.execute(
|
|
11021
|
+
f"{select_cols} ORDER BY jc.updated_at DESC"
|
|
11022
|
+
).fetchall():
|
|
11023
|
+
raw_key = str(row["clip_key"])
|
|
11024
|
+
if raw_key in key_set:
|
|
11025
|
+
continue
|
|
11026
|
+
row_hash = clip_directory_hash(raw_key)
|
|
11027
|
+
target_key = unresolved.get(row_hash) if row_hash else None
|
|
11028
|
+
if target_key:
|
|
11029
|
+
_apply_row(row, target_key)
|
|
11030
|
+
except Exception:
|
|
11031
|
+
return status_by_key
|
|
11032
|
+
finally:
|
|
11033
|
+
try:
|
|
11034
|
+
conn.close()
|
|
11035
|
+
except Exception:
|
|
11036
|
+
pass
|
|
10982
11037
|
return status_by_key
|
|
10983
11038
|
|
|
10984
11039
|
|
|
@@ -13426,6 +13481,25 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
13426
13481
|
if path == "/api/timeline_versions":
|
|
13427
13482
|
self._json(list_timelines_with_versions(self.state.project_root))
|
|
13428
13483
|
return
|
|
13484
|
+
if path == "/api/timeline_versions/diff":
|
|
13485
|
+
timeline_name = (query.get("timeline_name") or [""])[0]
|
|
13486
|
+
try:
|
|
13487
|
+
from_version = int((query.get("from_version") or [""])[0])
|
|
13488
|
+
to_version = int((query.get("to_version") or [""])[0])
|
|
13489
|
+
except (ValueError, TypeError):
|
|
13490
|
+
self._json({"success": False, "error": "from_version and to_version (ints) required"},
|
|
13491
|
+
HTTPStatus.BAD_REQUEST)
|
|
13492
|
+
return
|
|
13493
|
+
if not timeline_name:
|
|
13494
|
+
self._json({"success": False, "error": "timeline_name required"}, HTTPStatus.BAD_REQUEST)
|
|
13495
|
+
return
|
|
13496
|
+
self._json({"success": True, **_timeline_versioning.diff_versions(
|
|
13497
|
+
project_root=self.state.project_root,
|
|
13498
|
+
timeline_name=timeline_name,
|
|
13499
|
+
from_version=from_version,
|
|
13500
|
+
to_version=to_version,
|
|
13501
|
+
)})
|
|
13502
|
+
return
|
|
13429
13503
|
if path.startswith("/api/timeline_versions/"):
|
|
13430
13504
|
timeline_name = unquote(path[len("/api/timeline_versions/"):])
|
|
13431
13505
|
if not timeline_name:
|
package/src/batch_cli.py
CHANGED
|
@@ -318,6 +318,65 @@ def _cmd_cancel(args: argparse.Namespace) -> int:
|
|
|
318
318
|
return EXIT_OK if payload.get("success") else EXIT_FATAL
|
|
319
319
|
|
|
320
320
|
|
|
321
|
+
def _run_spec_action(action: str, params: Dict[str, Any]):
|
|
322
|
+
"""Connect to Resolve (auto-launch) and run a project_manager spec action.
|
|
323
|
+
|
|
324
|
+
Imported lazily so the analysis commands stay free of the MCP/Resolve import.
|
|
325
|
+
"""
|
|
326
|
+
from src.server import get_resolve, _spec_action # lazy: pulls mcp + resolve
|
|
327
|
+
|
|
328
|
+
r = get_resolve()
|
|
329
|
+
if r is None:
|
|
330
|
+
return None
|
|
331
|
+
return _spec_action(r, r.GetProjectManager(), action, params)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _emit_spec_result(result, *, json_mode: bool) -> int:
|
|
335
|
+
if result is None:
|
|
336
|
+
_emit("Could not connect to DaVinci Resolve.",
|
|
337
|
+
json_mode=json_mode,
|
|
338
|
+
payload={"success": False, "error": "not_connected"})
|
|
339
|
+
return EXIT_FATAL
|
|
340
|
+
if json_mode:
|
|
341
|
+
_emit("", json_mode=True, payload=result)
|
|
342
|
+
err = result.get("error")
|
|
343
|
+
if err:
|
|
344
|
+
_emit(f"Spec error: {err.get('message')}", json_mode=False)
|
|
345
|
+
return EXIT_FATAL
|
|
346
|
+
if "actions" in result: # plan / diff
|
|
347
|
+
changed = result.get("change_count", 0)
|
|
348
|
+
_emit(f"Project : {result.get('project')}", json_mode=False)
|
|
349
|
+
_emit(f"Changes : {changed}", json_mode=False)
|
|
350
|
+
for a in result.get("actions", []):
|
|
351
|
+
if a.get("op") != "noop":
|
|
352
|
+
_emit(f" [{a['op']:>6}] {a['target']} {a.get('detail', '')}", json_mode=False)
|
|
353
|
+
return EXIT_OK
|
|
354
|
+
# apply
|
|
355
|
+
failures = result.get("failures") or []
|
|
356
|
+
_emit(f"Applied : {result.get('applied_count', 0)}", json_mode=False)
|
|
357
|
+
if failures:
|
|
358
|
+
for f in failures:
|
|
359
|
+
_emit(f" [failed] {f.get('target')}", json_mode=False)
|
|
360
|
+
return EXIT_PARTIAL
|
|
361
|
+
_emit("Done: spec applied", json_mode=False)
|
|
362
|
+
return EXIT_OK
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _cmd_plan_spec(args: argparse.Namespace) -> int:
|
|
366
|
+
result = _run_spec_action("diff_to_spec", {"spec_path": args.spec})
|
|
367
|
+
return _emit_spec_result(result, json_mode=args.json)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _cmd_apply(args: argparse.Namespace) -> int:
|
|
371
|
+
result = _run_spec_action("apply_spec", {
|
|
372
|
+
"spec_path": args.spec,
|
|
373
|
+
"dry_run": args.dry_run,
|
|
374
|
+
"run_hooks": args.run_hooks,
|
|
375
|
+
"continue_on_error": args.continue_on_error,
|
|
376
|
+
})
|
|
377
|
+
return _emit_spec_result(result, json_mode=args.json)
|
|
378
|
+
|
|
379
|
+
|
|
321
380
|
def _add_run_args(parser: argparse.ArgumentParser) -> None:
|
|
322
381
|
parser.add_argument("paths", nargs="+", help="Files or directories to analyze")
|
|
323
382
|
parser.add_argument(
|
|
@@ -431,6 +490,26 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
431
490
|
p_cancel.add_argument("job_id")
|
|
432
491
|
p_cancel.add_argument("--project-root", required=True)
|
|
433
492
|
|
|
493
|
+
p_plan_spec = sub.add_parser(
|
|
494
|
+
"plan-spec",
|
|
495
|
+
help="Preview drift between a declarative project spec and live Resolve",
|
|
496
|
+
parents=[common],
|
|
497
|
+
)
|
|
498
|
+
p_plan_spec.add_argument("spec", help="Path to project.dvr.yaml (or .json)")
|
|
499
|
+
|
|
500
|
+
p_apply = sub.add_parser(
|
|
501
|
+
"apply",
|
|
502
|
+
help="Reconcile the Resolve project toward a declarative spec (idempotent)",
|
|
503
|
+
parents=[common],
|
|
504
|
+
)
|
|
505
|
+
p_apply.add_argument("spec", help="Path to project.dvr.yaml (or .json)")
|
|
506
|
+
p_apply.add_argument("--dry-run", action="store_true",
|
|
507
|
+
help="Compute the plan without mutating")
|
|
508
|
+
p_apply.add_argument("--run-hooks", action="store_true",
|
|
509
|
+
help="Execute the spec's before/after shell hooks (opt-in)")
|
|
510
|
+
p_apply.add_argument("--continue-on-error", action="store_true",
|
|
511
|
+
help="Accumulate failures instead of aborting on the first")
|
|
512
|
+
|
|
434
513
|
return parser
|
|
435
514
|
|
|
436
515
|
|
|
@@ -441,6 +520,8 @@ _HANDLERS = {
|
|
|
441
520
|
"list": _cmd_list,
|
|
442
521
|
"resume": _cmd_resume,
|
|
443
522
|
"cancel": _cmd_cancel,
|
|
523
|
+
"plan-spec": _cmd_plan_spec,
|
|
524
|
+
"apply": _cmd_apply,
|
|
444
525
|
}
|
|
445
526
|
|
|
446
527
|
|
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.28.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()}")
|