davinci-resolve-mcp 2.27.2 → 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 +53 -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 +19 -0
- 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/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/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.28.0"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -104,6 +104,9 @@ from src.utils import analysis_runs as _analysis_runs
|
|
|
104
104
|
from src.utils import brain_edits as _brain_edits
|
|
105
105
|
from src.utils import media_pool_changes as _media_pool_changes
|
|
106
106
|
from src.utils import timeline_versioning as _timeline_versioning
|
|
107
|
+
from src.utils import project_spec as _project_spec
|
|
108
|
+
from src.utils import project_lint as _project_lint
|
|
109
|
+
from src.utils import clip_query as _clip_query
|
|
107
110
|
from src.utils import destructive_hook as _destructive_hook
|
|
108
111
|
from src.utils.destructive_hook import destructive_op as _destructive_op
|
|
109
112
|
|
|
@@ -774,7 +777,7 @@ _RETRYABLE_UNSET = object()
|
|
|
774
777
|
|
|
775
778
|
|
|
776
779
|
def _err(message, *, code=None, category=None, retryable=_RETRYABLE_UNSET,
|
|
777
|
-
remediation=None, reason=None):
|
|
780
|
+
remediation=None, reason=None, state=None):
|
|
778
781
|
"""Return a structured error envelope.
|
|
779
782
|
|
|
780
783
|
Callers may pass just a message string for back-compat with the legacy shape;
|
|
@@ -787,9 +790,15 @@ def _err(message, *, code=None, category=None, retryable=_RETRYABLE_UNSET,
|
|
|
787
790
|
- Pass True/False explicitly to override the default (rare; usually the
|
|
788
791
|
default is correct).
|
|
789
792
|
|
|
793
|
+
`state`:
|
|
794
|
+
- Optional dict snapshot of the relevant values at failure time
|
|
795
|
+
(e.g. {"queue_size": 0, "format": "mov"}). Machine-readable context so
|
|
796
|
+
the agent doesn't have to parse `reason` prose. Omitted when empty.
|
|
797
|
+
|
|
790
798
|
Shape:
|
|
791
799
|
{"error": {"message": str, "code": str, "category": str,
|
|
792
|
-
"retryable": bool, "reason": str?, "remediation": str
|
|
800
|
+
"retryable": bool, "reason": str?, "remediation": str?,
|
|
801
|
+
"state": dict?}}
|
|
793
802
|
"""
|
|
794
803
|
cat = category if category in ERROR_CATEGORIES else "resolve_api_failed"
|
|
795
804
|
if retryable is _RETRYABLE_UNSET:
|
|
@@ -806,6 +815,8 @@ def _err(message, *, code=None, category=None, retryable=_RETRYABLE_UNSET,
|
|
|
806
815
|
body["reason"] = str(reason)
|
|
807
816
|
if remediation:
|
|
808
817
|
body["remediation"] = str(remediation)
|
|
818
|
+
if state:
|
|
819
|
+
body["state"] = state
|
|
809
820
|
return {"error": body}
|
|
810
821
|
|
|
811
822
|
def _ok(**kw):
|
|
@@ -2051,6 +2062,57 @@ def _timeline_item_summary(item, track_info=None):
|
|
|
2051
2062
|
return summary
|
|
2052
2063
|
|
|
2053
2064
|
|
|
2065
|
+
# Filters the live timeline adapter can populate from a timeline-item summary.
|
|
2066
|
+
# Analysis-aware filters in clip_query (analyzed/has_transcription/shot_type/
|
|
2067
|
+
# marker_color) require an analysis-DB join not yet wired here; reject them at
|
|
2068
|
+
# the boundary rather than return silently-wrong (empty) matches.
|
|
2069
|
+
_LIVE_CLIP_WHERE_FILTERS = {
|
|
2070
|
+
"track_type", "track_index", "name_contains", "duration_lt", "duration_gt",
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
|
|
2074
|
+
def _timeline_clip_where(tl, p: Dict[str, Any]) -> Dict[str, Any]:
|
|
2075
|
+
"""clip_where action: return timeline clips matching named filters (AND).
|
|
2076
|
+
|
|
2077
|
+
Filters may be passed as a `filters` dict or as top-level params. Only the
|
|
2078
|
+
structural filters in `_LIVE_CLIP_WHERE_FILTERS` are supported live.
|
|
2079
|
+
"""
|
|
2080
|
+
filters = p.get("filters")
|
|
2081
|
+
if not isinstance(filters, dict):
|
|
2082
|
+
# Top-level convenience: treat every param as a filter and validate it,
|
|
2083
|
+
# so a typo'd key is rejected rather than silently matching everything.
|
|
2084
|
+
filters = {k: v for k, v in p.items() if k != "filters"}
|
|
2085
|
+
ok, unknown = _clip_query.validate_filters(filters)
|
|
2086
|
+
if not ok:
|
|
2087
|
+
return _err(
|
|
2088
|
+
f"Unknown clip_where filter(s): {unknown}",
|
|
2089
|
+
code="UNKNOWN_FILTER", category="invalid_input",
|
|
2090
|
+
state={"unknown": unknown, "supported": sorted(_clip_query.SUPPORTED_FILTERS)},
|
|
2091
|
+
)
|
|
2092
|
+
not_live = [k for k in filters if k not in _LIVE_CLIP_WHERE_FILTERS]
|
|
2093
|
+
if not_live:
|
|
2094
|
+
return _err(
|
|
2095
|
+
f"Filter(s) not yet supported on a live timeline: {not_live}",
|
|
2096
|
+
code="FILTER_NOT_LIVE", category="unsupported",
|
|
2097
|
+
remediation="Use structural filters (track_type, track_index, name_contains, duration_lt, duration_gt).",
|
|
2098
|
+
state={"not_live": not_live, "live_supported": sorted(_LIVE_CLIP_WHERE_FILTERS)},
|
|
2099
|
+
)
|
|
2100
|
+
clips: List[Dict[str, Any]] = []
|
|
2101
|
+
for tt in ("video", "audio", "subtitle"):
|
|
2102
|
+
try:
|
|
2103
|
+
count = int(tl.GetTrackCount(tt) or 0)
|
|
2104
|
+
except Exception:
|
|
2105
|
+
continue
|
|
2106
|
+
for ti in range(1, count + 1):
|
|
2107
|
+
for item in (tl.GetItemListInTrack(tt, ti) or []):
|
|
2108
|
+
summary = _timeline_item_summary(item, (tt, ti))
|
|
2109
|
+
if summary:
|
|
2110
|
+
clips.append(summary)
|
|
2111
|
+
matches = _clip_query.filter_clips(clips, filters)
|
|
2112
|
+
return _ok(filters=filters, match_count=len(matches),
|
|
2113
|
+
total_clips=len(clips), clips=matches)
|
|
2114
|
+
|
|
2115
|
+
|
|
2054
2116
|
def _serialize_appended_timeline_item(item, index: int, *, allow_empty_timeline_item_id: bool = False):
|
|
2055
2117
|
if not item:
|
|
2056
2118
|
return None, _err(f"Failed to append clip_infos to timeline: missing timeline item at index {index}")
|
|
@@ -11766,6 +11828,252 @@ def _project_boundary_report(resolve_obj, pm, project, p: Dict[str, Any]) -> Dic
|
|
|
11766
11828
|
}
|
|
11767
11829
|
|
|
11768
11830
|
|
|
11831
|
+
def _find_project_timeline(project, name: str):
|
|
11832
|
+
"""Return the timeline named `name` in `project`, or None."""
|
|
11833
|
+
try:
|
|
11834
|
+
count = int(project.GetTimelineCount() or 0)
|
|
11835
|
+
except Exception:
|
|
11836
|
+
return None
|
|
11837
|
+
for i in range(1, count + 1):
|
|
11838
|
+
tl = project.GetTimelineByIndex(i)
|
|
11839
|
+
try:
|
|
11840
|
+
if tl and tl.GetName() == name:
|
|
11841
|
+
return tl
|
|
11842
|
+
except Exception:
|
|
11843
|
+
continue
|
|
11844
|
+
return None
|
|
11845
|
+
|
|
11846
|
+
|
|
11847
|
+
class _SpecLiveExecutor:
|
|
11848
|
+
"""Live executor for project_spec.apply_spec — adapts a Resolve project to
|
|
11849
|
+
the duck-typed executor contract. Spec-aware so live_state() only reads the
|
|
11850
|
+
setting keys the spec cares about."""
|
|
11851
|
+
|
|
11852
|
+
def __init__(self, r, pm, spec):
|
|
11853
|
+
self._r = r
|
|
11854
|
+
self._pm = pm
|
|
11855
|
+
self._spec = spec
|
|
11856
|
+
self._proj = pm.GetCurrentProject()
|
|
11857
|
+
|
|
11858
|
+
def live_state(self) -> Dict[str, Any]:
|
|
11859
|
+
proj = self._proj
|
|
11860
|
+
projects = list(self._pm.GetProjectListInCurrentFolder() or [])
|
|
11861
|
+
settings: Dict[str, Any] = {}
|
|
11862
|
+
if proj:
|
|
11863
|
+
for k in _project_spec.effective_settings(self._spec):
|
|
11864
|
+
try:
|
|
11865
|
+
v = proj.GetSetting(k)
|
|
11866
|
+
except Exception:
|
|
11867
|
+
v = None
|
|
11868
|
+
if v is not None:
|
|
11869
|
+
settings[k] = v
|
|
11870
|
+
spec_names = {t.name for t in self._spec.timelines}
|
|
11871
|
+
timelines: List[Dict[str, Any]] = []
|
|
11872
|
+
if proj:
|
|
11873
|
+
count = int(proj.GetTimelineCount() or 0)
|
|
11874
|
+
for i in range(1, count + 1):
|
|
11875
|
+
tl = proj.GetTimelineByIndex(i)
|
|
11876
|
+
if not tl:
|
|
11877
|
+
continue
|
|
11878
|
+
name = tl.GetName()
|
|
11879
|
+
if name not in spec_names:
|
|
11880
|
+
timelines.append({"name": name})
|
|
11881
|
+
continue
|
|
11882
|
+
tspec = next((t for t in self._spec.timelines if t.name == name), None)
|
|
11883
|
+
keys = set((tspec.settings if tspec else {})) | {"timelineFrameRate"}
|
|
11884
|
+
tl_settings: Dict[str, Any] = {}
|
|
11885
|
+
for k in keys:
|
|
11886
|
+
try:
|
|
11887
|
+
v = tl.GetSetting(k)
|
|
11888
|
+
except Exception:
|
|
11889
|
+
v = None
|
|
11890
|
+
if v is not None:
|
|
11891
|
+
tl_settings[k] = v
|
|
11892
|
+
markers: List[Dict[str, Any]] = []
|
|
11893
|
+
try:
|
|
11894
|
+
for frame, m in (tl.GetMarkers() or {}).items():
|
|
11895
|
+
entry = {"frame": int(frame)}
|
|
11896
|
+
if isinstance(m, dict):
|
|
11897
|
+
entry.update({kk: m.get(kk) for kk in
|
|
11898
|
+
("color", "name", "note", "duration", "customData")})
|
|
11899
|
+
markers.append(entry)
|
|
11900
|
+
except Exception:
|
|
11901
|
+
pass
|
|
11902
|
+
timelines.append({"name": name, "settings": tl_settings, "markers": markers})
|
|
11903
|
+
return {
|
|
11904
|
+
"project": proj.GetName() if proj else None,
|
|
11905
|
+
"projects": projects,
|
|
11906
|
+
"settings": settings,
|
|
11907
|
+
"timelines": timelines,
|
|
11908
|
+
}
|
|
11909
|
+
|
|
11910
|
+
def ensure_project(self, name: str) -> bool:
|
|
11911
|
+
if self._proj and self._proj.GetName() == name:
|
|
11912
|
+
return True
|
|
11913
|
+
projects = list(self._pm.GetProjectListInCurrentFolder() or [])
|
|
11914
|
+
proj = self._pm.LoadProject(name) if name in projects else self._pm.CreateProject(name)
|
|
11915
|
+
if proj:
|
|
11916
|
+
self._proj = proj
|
|
11917
|
+
return True
|
|
11918
|
+
return False
|
|
11919
|
+
|
|
11920
|
+
def set_project_setting(self, key: str, value: Any) -> bool:
|
|
11921
|
+
if not self._proj:
|
|
11922
|
+
return False
|
|
11923
|
+
try:
|
|
11924
|
+
return bool(self._proj.SetSetting(key, str(value)))
|
|
11925
|
+
except Exception:
|
|
11926
|
+
return False
|
|
11927
|
+
|
|
11928
|
+
def ensure_timeline(self, name: str, fps: Optional[float]) -> bool:
|
|
11929
|
+
if not self._proj:
|
|
11930
|
+
return False
|
|
11931
|
+
tl = _find_project_timeline(self._proj, name)
|
|
11932
|
+
if tl is None:
|
|
11933
|
+
mp = self._proj.GetMediaPool()
|
|
11934
|
+
if mp is None:
|
|
11935
|
+
return False
|
|
11936
|
+
tl = mp.CreateEmptyTimeline(name)
|
|
11937
|
+
if tl is None:
|
|
11938
|
+
return False
|
|
11939
|
+
if fps is not None:
|
|
11940
|
+
try:
|
|
11941
|
+
tl.SetSetting("timelineFrameRate", str(fps))
|
|
11942
|
+
except Exception:
|
|
11943
|
+
pass
|
|
11944
|
+
return True
|
|
11945
|
+
|
|
11946
|
+
def set_timeline_setting(self, tl_name: str, key: str, value: Any) -> bool:
|
|
11947
|
+
tl = _find_project_timeline(self._proj, tl_name) if self._proj else None
|
|
11948
|
+
if tl is None:
|
|
11949
|
+
return False
|
|
11950
|
+
try:
|
|
11951
|
+
return bool(tl.SetSetting(key, str(value)))
|
|
11952
|
+
except Exception:
|
|
11953
|
+
return False
|
|
11954
|
+
|
|
11955
|
+
def add_marker(self, tl_name: str, marker: Dict[str, Any]) -> bool:
|
|
11956
|
+
tl = _find_project_timeline(self._proj, tl_name) if self._proj else None
|
|
11957
|
+
if tl is None:
|
|
11958
|
+
return False
|
|
11959
|
+
try:
|
|
11960
|
+
return bool(tl.AddMarker(
|
|
11961
|
+
int(marker.get("frame", 0)),
|
|
11962
|
+
marker.get("color", "Blue"),
|
|
11963
|
+
marker.get("name", ""),
|
|
11964
|
+
marker.get("note", ""),
|
|
11965
|
+
int(marker.get("duration", 1)),
|
|
11966
|
+
marker.get("customData", marker.get("custom_data", "")),
|
|
11967
|
+
))
|
|
11968
|
+
except Exception:
|
|
11969
|
+
return False
|
|
11970
|
+
|
|
11971
|
+
|
|
11972
|
+
def _make_spec_hook_runner(timeout: float = 120.0):
|
|
11973
|
+
"""Return a callable that runs a Hook's shell command (opt-in only)."""
|
|
11974
|
+
import subprocess
|
|
11975
|
+
|
|
11976
|
+
def _run(hook) -> bool:
|
|
11977
|
+
try:
|
|
11978
|
+
proc = subprocess.run(hook.command, shell=True, timeout=timeout)
|
|
11979
|
+
return proc.returncode == 0
|
|
11980
|
+
except Exception as exc:
|
|
11981
|
+
logger.warning("spec hook '%s' failed: %s", hook.name or hook.command, exc)
|
|
11982
|
+
return False
|
|
11983
|
+
|
|
11984
|
+
return _run
|
|
11985
|
+
|
|
11986
|
+
|
|
11987
|
+
def _spec_action(r, pm, action: str, p: Dict[str, Any]) -> Dict[str, Any]:
|
|
11988
|
+
"""plan_spec / apply_spec / diff_to_spec — declarative project reconcile."""
|
|
11989
|
+
spec_path = p.get("spec_path") or p.get("path")
|
|
11990
|
+
try:
|
|
11991
|
+
if spec_path:
|
|
11992
|
+
spec = _project_spec.load_spec(str(spec_path))
|
|
11993
|
+
elif isinstance(p.get("spec"), dict):
|
|
11994
|
+
spec = _project_spec.spec_from_dict(p["spec"])
|
|
11995
|
+
else:
|
|
11996
|
+
return _err("Provide spec_path (file) or an inline spec dict.",
|
|
11997
|
+
code="NO_SPEC", category="invalid_input")
|
|
11998
|
+
except _project_spec.SpecError as exc:
|
|
11999
|
+
return _err(str(exc), code="SPEC_INVALID", category="invalid_input", state=exc.state)
|
|
12000
|
+
|
|
12001
|
+
executor = _SpecLiveExecutor(r, pm, spec)
|
|
12002
|
+
if action == "diff_to_spec":
|
|
12003
|
+
return _ok(project=spec.project, **_project_spec.plan_spec(spec, executor.live_state()))
|
|
12004
|
+
if action == "plan_spec":
|
|
12005
|
+
return _ok(project=spec.project,
|
|
12006
|
+
**_project_spec.apply_spec(spec, executor, dry_run=True))
|
|
12007
|
+
# apply_spec
|
|
12008
|
+
run_hooks = bool(p.get("run_hooks", False))
|
|
12009
|
+
try:
|
|
12010
|
+
result = _project_spec.apply_spec(
|
|
12011
|
+
spec, executor,
|
|
12012
|
+
dry_run=bool(p.get("dry_run", False)),
|
|
12013
|
+
run_hooks=run_hooks,
|
|
12014
|
+
continue_on_error=bool(p.get("continue_on_error", False)),
|
|
12015
|
+
run_hook=_make_spec_hook_runner() if run_hooks else None,
|
|
12016
|
+
)
|
|
12017
|
+
except _project_spec.SpecError as exc:
|
|
12018
|
+
return _err(str(exc), code="SPEC_APPLY_FAILED",
|
|
12019
|
+
category="batch_partial", state=exc.state)
|
|
12020
|
+
return _ok(**result) if result.get("success") else {"success": False, **result}
|
|
12021
|
+
|
|
12022
|
+
|
|
12023
|
+
def _project_lint_live(r, pm) -> Dict[str, Any]:
|
|
12024
|
+
"""Gather live project state and run the lint health-check."""
|
|
12025
|
+
proj = pm.GetCurrentProject()
|
|
12026
|
+
if not proj:
|
|
12027
|
+
return _ok(**_project_lint.lint_report({"project": None}))
|
|
12028
|
+
state: Dict[str, Any] = {"project": proj.GetName()}
|
|
12029
|
+
try:
|
|
12030
|
+
cur = proj.GetCurrentTimeline()
|
|
12031
|
+
state["current_timeline"] = cur.GetName() if cur else None
|
|
12032
|
+
except Exception:
|
|
12033
|
+
state["current_timeline"] = None
|
|
12034
|
+
timelines: List[Dict[str, Any]] = []
|
|
12035
|
+
try:
|
|
12036
|
+
count = int(proj.GetTimelineCount() or 0)
|
|
12037
|
+
except Exception:
|
|
12038
|
+
count = 0
|
|
12039
|
+
for i in range(1, count + 1):
|
|
12040
|
+
tl = proj.GetTimelineByIndex(i)
|
|
12041
|
+
if not tl:
|
|
12042
|
+
continue
|
|
12043
|
+
fps = None
|
|
12044
|
+
try:
|
|
12045
|
+
fps = float(tl.GetSetting("timelineFrameRate"))
|
|
12046
|
+
except Exception:
|
|
12047
|
+
pass
|
|
12048
|
+
item_count = 0
|
|
12049
|
+
try:
|
|
12050
|
+
vc = int(tl.GetTrackCount("video") or 0)
|
|
12051
|
+
for ti in range(1, vc + 1):
|
|
12052
|
+
item_count += len(tl.GetItemListInTrack("video", ti) or [])
|
|
12053
|
+
except Exception:
|
|
12054
|
+
pass
|
|
12055
|
+
timelines.append({"name": tl.GetName(), "fps": fps, "item_count": item_count})
|
|
12056
|
+
state["timelines"] = timelines
|
|
12057
|
+
settings: Dict[str, Any] = {}
|
|
12058
|
+
try:
|
|
12059
|
+
csm = proj.GetSetting("colorScienceMode")
|
|
12060
|
+
if csm is not None:
|
|
12061
|
+
settings["colorScienceMode"] = csm
|
|
12062
|
+
except Exception:
|
|
12063
|
+
pass
|
|
12064
|
+
state["settings"] = settings
|
|
12065
|
+
render: Dict[str, Any] = {}
|
|
12066
|
+
try:
|
|
12067
|
+
rf = proj.GetCurrentRenderFormatAndCodec() or {}
|
|
12068
|
+
if rf.get("format"):
|
|
12069
|
+
render["format"] = rf.get("format")
|
|
12070
|
+
render["codec"] = rf.get("codec")
|
|
12071
|
+
except Exception:
|
|
12072
|
+
pass
|
|
12073
|
+
state["render"] = render
|
|
12074
|
+
return _ok(**_project_lint.lint_report(state))
|
|
12075
|
+
|
|
12076
|
+
|
|
11769
12077
|
@mcp.tool()
|
|
11770
12078
|
def project_manager(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
11771
12079
|
"""Manage DaVinci Resolve projects.
|
|
@@ -11797,6 +12105,16 @@ def project_manager(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
11797
12105
|
safe_set_current_database(db_info, dry_run?, allow_switch?) -> {success}
|
|
11798
12106
|
preset_lifecycle_probe() -> {project_presets, render_presets, layout_presets, ...}
|
|
11799
12107
|
project_boundary_report() -> {capabilities, project_manager, settings, database, presets, cloud}
|
|
12108
|
+
lint() -> {ok, counts, issues} — graded project health pre-flight (no project, no
|
|
12109
|
+
current timeline, mixed fps, empty timeline, render/color-science unset, offline media).
|
|
12110
|
+
diff_to_spec(spec_path|spec) -> {actions, diff, change_count} — preview drift vs a
|
|
12111
|
+
declarative spec WITHOUT mutating. Spec is YAML/JSON: {project, color_preset?, settings?,
|
|
12112
|
+
timelines:[{name,fps?,settings?,markers?}], hooks?}.
|
|
12113
|
+
plan_spec(spec_path|spec) -> {dry_run, actions, diff, change_count} — same as apply with dry_run.
|
|
12114
|
+
apply_spec(spec_path|spec, dry_run?, run_hooks?, continue_on_error?) -> {success, applied, failures}
|
|
12115
|
+
Reconcile the project toward the spec (idempotent: re-runs are no-ops). Color/HDR
|
|
12116
|
+
settings are applied in dependency order; markers only added if absent. Hooks run only
|
|
12117
|
+
when run_hooks=true (executes shell from the spec — opt-in).
|
|
11800
12118
|
"""
|
|
11801
12119
|
p = params or {}
|
|
11802
12120
|
r = get_resolve()
|
|
@@ -11804,6 +12122,10 @@ def project_manager(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
11804
12122
|
return _err("Could not connect to DaVinci Resolve. It was not running and auto-launch failed. Check that Resolve Studio is installed.")
|
|
11805
12123
|
pm = r.GetProjectManager()
|
|
11806
12124
|
|
|
12125
|
+
if action == "lint":
|
|
12126
|
+
return _project_lint_live(r, pm)
|
|
12127
|
+
if action in {"diff_to_spec", "plan_spec", "apply_spec"}:
|
|
12128
|
+
return _spec_action(r, pm, action, p)
|
|
11807
12129
|
if action == "project_capabilities":
|
|
11808
12130
|
return _project_capabilities(pm, pm.GetCurrentProject(), r)
|
|
11809
12131
|
elif action == "probe_project_lifecycle":
|
|
@@ -11869,7 +12191,7 @@ def project_manager(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
|
|
|
11869
12191
|
p.get("src_media", True), p.get("render_cache", True), p.get("proxy_media", False)))}
|
|
11870
12192
|
elif action == "restore":
|
|
11871
12193
|
return {"success": bool(pm.RestoreProject(p["path"], p.get("name")))}
|
|
11872
|
-
return _unknown(action, ["list","get_current","create","load","save","close","delete","import_project","export_project","archive","restore", *_PROJECT_KERNEL_ACTIONS])
|
|
12194
|
+
return _unknown(action, ["list","get_current","create","load","save","close","delete","import_project","export_project","archive","restore","lint","diff_to_spec","plan_spec","apply_spec", *_PROJECT_KERNEL_ACTIONS])
|
|
11873
12195
|
|
|
11874
12196
|
|
|
11875
12197
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -14261,6 +14583,10 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
|
|
|
14261
14583
|
get_track_name(track_type, index) -> {name}
|
|
14262
14584
|
set_track_name(track_type, index, name) -> {success}
|
|
14263
14585
|
get_items(track_type, index) -> {items}
|
|
14586
|
+
clip_where(filters|track_type?, track_index?, name_contains?, duration_lt?, duration_gt?) -> {clips, match_count, total_clips}
|
|
14587
|
+
Find clips on the current timeline matching named filters (AND). Pass filters
|
|
14588
|
+
inline or as a `filters` dict. Live filters: track_type, track_index,
|
|
14589
|
+
name_contains, duration_lt, duration_gt (frames). No clip enumeration needed.
|
|
14264
14590
|
delete_clips(clip_ids, ripple?) -> {success} — clip_ids: list of unique IDs
|
|
14265
14591
|
DESTRUCTIVE. ripple=True is CATASTROPHIC (closes the gap; cannot be selectively undone).
|
|
14266
14592
|
set_clips_linked(clip_ids, linked) -> {success}
|
|
@@ -14402,6 +14728,8 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
|
|
|
14402
14728
|
if not tl:
|
|
14403
14729
|
return _err("No current timeline")
|
|
14404
14730
|
|
|
14731
|
+
if action == "clip_where":
|
|
14732
|
+
return _timeline_clip_where(tl, p)
|
|
14405
14733
|
if action == "get_current":
|
|
14406
14734
|
return {"name": tl.GetName(), "id": tl.GetUniqueId(), "start_frame": tl.GetStartFrame(), "end_frame": tl.GetEndFrame(), "start_timecode": tl.GetStartTimecode()}
|
|
14407
14735
|
elif action == "get_name":
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Safe declarative clip-query DSL.
|
|
2
|
+
|
|
3
|
+
The agent-facing way to ask "which clips match X?" without enumerating a whole
|
|
4
|
+
timeline call-by-call. Named filters only — no arbitrary lambdas/code cross the
|
|
5
|
+
tool boundary (the brain gets a closed vocabulary, not `eval`).
|
|
6
|
+
|
|
7
|
+
Pure: `filter_clips` takes a list of plain clip dicts + a filter dict and returns
|
|
8
|
+
the matching subset. The live MCP adapter gathers clip dicts from the timeline
|
|
9
|
+
and calls this.
|
|
10
|
+
|
|
11
|
+
Each clip dict is expected to carry (best-effort; missing keys are tolerated):
|
|
12
|
+
name, track_type, track_index, duration (frames), in_frame, out_frame,
|
|
13
|
+
clip_id / media_pool_item_id, clip_hash, marker_color, analyzed (bool),
|
|
14
|
+
has_transcription (bool), shot_type (str).
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Any, Dict, List, Tuple
|
|
19
|
+
|
|
20
|
+
# Supported filter keys and a one-line description (also used to validate input
|
|
21
|
+
# and to document the surface in the tool docstring).
|
|
22
|
+
SUPPORTED_FILTERS: Dict[str, str] = {
|
|
23
|
+
"track_type": "exact match: 'video' | 'audio' | 'subtitle'",
|
|
24
|
+
"track_index": "exact 1-based track index",
|
|
25
|
+
"name_contains": "case-insensitive substring of the clip name",
|
|
26
|
+
"duration_lt": "duration (frames) strictly less than",
|
|
27
|
+
"duration_gt": "duration (frames) strictly greater than",
|
|
28
|
+
"marker_color": "exact clip marker/flag color",
|
|
29
|
+
"shot_type": "exact analyzed shot_type",
|
|
30
|
+
"analyzed": "bool — clip has an analysis record",
|
|
31
|
+
"has_transcription": "bool — clip has transcription",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def validate_filters(filters: Dict[str, Any]) -> Tuple[bool, List[str]]:
|
|
36
|
+
"""Return (ok, unknown_keys). Unknown filter keys are rejected, not ignored,
|
|
37
|
+
so a typo never silently widens the match set."""
|
|
38
|
+
unknown = [k for k in filters if k not in SUPPORTED_FILTERS]
|
|
39
|
+
return (not unknown, unknown)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _as_bool(value: Any) -> bool:
|
|
43
|
+
if isinstance(value, bool):
|
|
44
|
+
return value
|
|
45
|
+
if isinstance(value, str):
|
|
46
|
+
return value.strip().lower() in {"1", "true", "yes", "y"}
|
|
47
|
+
return bool(value)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _matches(clip: Dict[str, Any], filters: Dict[str, Any]) -> bool:
|
|
51
|
+
if "track_type" in filters and clip.get("track_type") != filters["track_type"]:
|
|
52
|
+
return False
|
|
53
|
+
if "track_index" in filters and clip.get("track_index") != int(filters["track_index"]):
|
|
54
|
+
return False
|
|
55
|
+
if "name_contains" in filters:
|
|
56
|
+
needle = str(filters["name_contains"]).lower()
|
|
57
|
+
if needle not in str(clip.get("name") or "").lower():
|
|
58
|
+
return False
|
|
59
|
+
dur = clip.get("duration")
|
|
60
|
+
if "duration_lt" in filters:
|
|
61
|
+
if dur is None or not (dur < float(filters["duration_lt"])):
|
|
62
|
+
return False
|
|
63
|
+
if "duration_gt" in filters:
|
|
64
|
+
if dur is None or not (dur > float(filters["duration_gt"])):
|
|
65
|
+
return False
|
|
66
|
+
if "marker_color" in filters and clip.get("marker_color") != filters["marker_color"]:
|
|
67
|
+
return False
|
|
68
|
+
if "shot_type" in filters and clip.get("shot_type") != filters["shot_type"]:
|
|
69
|
+
return False
|
|
70
|
+
if "analyzed" in filters and bool(clip.get("analyzed")) != _as_bool(filters["analyzed"]):
|
|
71
|
+
return False
|
|
72
|
+
if "has_transcription" in filters and bool(clip.get("has_transcription")) != _as_bool(
|
|
73
|
+
filters["has_transcription"]
|
|
74
|
+
):
|
|
75
|
+
return False
|
|
76
|
+
return True
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def filter_clips(clips: List[Dict[str, Any]], filters: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
80
|
+
"""Return the subset of `clips` matching every supplied filter (AND semantics).
|
|
81
|
+
|
|
82
|
+
Empty/None filter values are skipped so callers can pass a sparse dict.
|
|
83
|
+
"""
|
|
84
|
+
active = {k: v for k, v in (filters or {}).items() if v is not None and v != ""}
|
|
85
|
+
return [c for c in clips if _matches(c, active)]
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Project health lint — a graded pre-flight the brain runs before editing.
|
|
2
|
+
|
|
3
|
+
Composes a plain *state* dict (gathered live by the MCP adapter from existing
|
|
4
|
+
probes) into a list of graded `Issue`s. Pure and Resolve-free so it unit-tests
|
|
5
|
+
against hand-built state.
|
|
6
|
+
|
|
7
|
+
The state dict shape (all keys optional; absence is itself a signal):
|
|
8
|
+
{
|
|
9
|
+
"project": str | None,
|
|
10
|
+
"current_timeline": str | None,
|
|
11
|
+
"timelines": [{"name": str, "fps": float|None, "item_count": int}, ...],
|
|
12
|
+
"settings": {<key>: <value>}, # project settings
|
|
13
|
+
"render": {"format": str|None, "codec": str|None},
|
|
14
|
+
"offline_media_count": int, # clips referencing missing files
|
|
15
|
+
"unanalyzed_clip_count": int, # media-pool clips w/o analysis record
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
Inspired by the MIT `mhadifilms/dvr` `lint.py` check set; extended with our
|
|
19
|
+
analysis-aware checks (offline media, unanalyzed clips).
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import Any, Dict, List
|
|
25
|
+
|
|
26
|
+
SEVERITIES = ("error", "warning", "info")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Issue:
|
|
31
|
+
severity: str # error | warning | info
|
|
32
|
+
code: str
|
|
33
|
+
message: str
|
|
34
|
+
target: str = ""
|
|
35
|
+
detail: str = ""
|
|
36
|
+
|
|
37
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
38
|
+
out = {"severity": self.severity, "code": self.code, "message": self.message}
|
|
39
|
+
if self.target:
|
|
40
|
+
out["target"] = self.target
|
|
41
|
+
if self.detail:
|
|
42
|
+
out["detail"] = self.detail
|
|
43
|
+
return out
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _color_science_unset(settings: Dict[str, Any]) -> bool:
|
|
47
|
+
# Resolve reports "davinciYRGB" (managed off) when color science is the
|
|
48
|
+
# default; ACES / managed modes set a different colorScienceMode.
|
|
49
|
+
mode = str(settings.get("colorScienceMode", "")).strip()
|
|
50
|
+
return mode in ("", "davinciYRGB")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def lint_state(state: Dict[str, Any]) -> List[Issue]:
|
|
54
|
+
"""Return graded issues for a project state dict, most-severe first."""
|
|
55
|
+
issues: List[Issue] = []
|
|
56
|
+
state = state or {}
|
|
57
|
+
|
|
58
|
+
if not state.get("project"):
|
|
59
|
+
issues.append(Issue("error", "no_project", "No project is open."))
|
|
60
|
+
return issues # nothing else is meaningful without a project
|
|
61
|
+
|
|
62
|
+
timelines = state.get("timelines") or []
|
|
63
|
+
if not state.get("current_timeline"):
|
|
64
|
+
issues.append(Issue("warning", "no_current_timeline", "No timeline is active."))
|
|
65
|
+
|
|
66
|
+
# mixed fps across timelines — a common conform foot-gun
|
|
67
|
+
fps_values = {tl.get("fps") for tl in timelines if tl.get("fps") is not None}
|
|
68
|
+
if len(fps_values) > 1:
|
|
69
|
+
issues.append(Issue(
|
|
70
|
+
"info", "mixed_fps",
|
|
71
|
+
f"Timelines use {len(fps_values)} different frame rates.",
|
|
72
|
+
detail=", ".join(str(f) for f in sorted(fps_values)),
|
|
73
|
+
))
|
|
74
|
+
|
|
75
|
+
for tl in timelines:
|
|
76
|
+
if (tl.get("item_count") or 0) == 0:
|
|
77
|
+
issues.append(Issue(
|
|
78
|
+
"warning", "empty_timeline",
|
|
79
|
+
f"Timeline '{tl.get('name')}' has no clips.",
|
|
80
|
+
target=tl.get("name", ""),
|
|
81
|
+
))
|
|
82
|
+
|
|
83
|
+
render = state.get("render") or {}
|
|
84
|
+
if not render.get("format"):
|
|
85
|
+
issues.append(Issue("info", "render_format_unset", "No render format is set."))
|
|
86
|
+
|
|
87
|
+
settings = state.get("settings") or {}
|
|
88
|
+
if _color_science_unset(settings):
|
|
89
|
+
issues.append(Issue(
|
|
90
|
+
"info", "color_science_unset",
|
|
91
|
+
"Color science is unmanaged (DaVinci YRGB).",
|
|
92
|
+
detail="Set a managed/ACES mode if a color-managed pipeline is expected.",
|
|
93
|
+
))
|
|
94
|
+
|
|
95
|
+
offline = int(state.get("offline_media_count") or 0)
|
|
96
|
+
if offline > 0:
|
|
97
|
+
issues.append(Issue(
|
|
98
|
+
"error", "offline_media",
|
|
99
|
+
f"{offline} clip(s) reference offline/missing media.",
|
|
100
|
+
))
|
|
101
|
+
|
|
102
|
+
unanalyzed = int(state.get("unanalyzed_clip_count") or 0)
|
|
103
|
+
if unanalyzed > 0:
|
|
104
|
+
issues.append(Issue(
|
|
105
|
+
"info", "unanalyzed_clips",
|
|
106
|
+
f"{unanalyzed} media-pool clip(s) have no analysis record.",
|
|
107
|
+
))
|
|
108
|
+
|
|
109
|
+
order = {"error": 0, "warning": 1, "info": 2}
|
|
110
|
+
issues.sort(key=lambda i: order.get(i.severity, 9))
|
|
111
|
+
return issues
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def lint_report(state: Dict[str, Any]) -> Dict[str, Any]:
|
|
115
|
+
"""Graded report envelope: counts + ok flag + serialized issues."""
|
|
116
|
+
issues = lint_state(state)
|
|
117
|
+
counts = {sev: sum(1 for i in issues if i.severity == sev) for sev in SEVERITIES}
|
|
118
|
+
return {
|
|
119
|
+
"ok": counts["error"] == 0,
|
|
120
|
+
"counts": counts,
|
|
121
|
+
"issues": [i.to_dict() for i in issues],
|
|
122
|
+
}
|