davinci-resolve-mcp 2.27.2 → 2.28.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.27.2"
14
+ VERSION = "2.28.1"
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
+ }