davinci-resolve-mcp 2.68.1 → 2.69.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +213 -0
  2. package/README.md +46 -3
  3. package/docs/SKILL.md +181 -1
  4. package/docs/install.md +27 -1
  5. package/install.py +26 -1
  6. package/package.json +1 -1
  7. package/resolve-advanced/LICENSE +21 -0
  8. package/resolve-advanced/package.json +2 -1
  9. package/resolve-advanced/vendor/conform-qc/package.json +1 -0
  10. package/resolve-advanced/vendor/drp-format/package.json +1 -0
  11. package/resolve-advanced/vendor/drt-format/package.json +1 -0
  12. package/scripts/bridge_differential.py +22 -4
  13. package/scripts/doctor.py +129 -3
  14. package/scripts/install_resolve_bridge.py +13 -3
  15. package/src/granular/common.py +1 -1
  16. package/src/server.py +639 -18
  17. package/src/utils/beat_detection.py +242 -0
  18. package/src/utils/bridge_differential.py +27 -1
  19. package/src/utils/broll_placement.py +201 -0
  20. package/src/utils/conform_lint.py +437 -0
  21. package/src/utils/edit_engine.py +633 -1
  22. package/src/utils/edit_handles.py +206 -0
  23. package/src/utils/edit_report.py +360 -0
  24. package/src/utils/first_impression.py +212 -0
  25. package/src/utils/prebalance.py +471 -0
  26. package/src/utils/project_journal.py +360 -0
  27. package/src/utils/reference_match.py +203 -0
  28. package/src/utils/rhythm_audit.py +252 -0
  29. package/src/utils/rule_of_six.py +217 -0
  30. package/src/utils/setup_sheet.py +121 -0
  31. package/src/utils/shot_assembly.py +259 -0
  32. package/src/utils/silence_ripple.py +89 -2
  33. package/src/utils/sound_density.py +253 -0
  34. package/src/utils/split_edits.py +180 -0
  35. package/src/utils/take_ranking.py +206 -0
  36. package/src/utils/transcript_edit.py +101 -5
  37. package/src/utils/turnover.py +187 -0
package/src/server.py CHANGED
@@ -11,7 +11,7 @@ Usage:
11
11
  python src/server.py --full # Start the 341-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.68.1"
14
+ VERSION = "2.69.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -124,6 +124,10 @@ from src.utils.fusion_group_settings import (
124
124
  from src.utils import analysis_runs as _analysis_runs
125
125
  from src.utils import brain_edits as _brain_edits
126
126
  from src.utils import edit_engine as _edit_engine_mod
127
+ from src.utils import edit_report as _edit_report_mod
128
+ from src.utils import conform_lint as _conform_lint_mod
129
+ from src.utils import first_impression as _first_impression_mod
130
+ from src.utils import project_journal as _journal_mod
127
131
  from src.utils import transcript_edit as _transcript_edit_defaults
128
132
  from src.utils import media_pool_changes as _media_pool_changes
129
133
  from src.utils import timeline_versioning as _timeline_versioning
@@ -163,8 +167,13 @@ mcp = FastMCP(
163
167
  "DaVinciResolveMCP",
164
168
  instructions=(
165
169
  "DaVinci Resolve MCP Server — controls Resolve via its Scripting API. "
166
- "Tools automatically launch Resolve if it is not running (may take up to 60s on first call). "
167
- "If a tool returns a connection error, Resolve Studio may not be installed or external scripting is disabled."
170
+ "If no Resolve is running, tools launch one (up to 60s on the first call); "
171
+ "if one is already running they never launch a second. "
172
+ "On a connection error, read the error's own remediation field — it names the "
173
+ "fix that applies. External scripting is Studio-only, but the free edition is "
174
+ "reachable via the in-app bridge (Workspace > Scripts > resolve_bridge, with "
175
+ "DAVINCI_RESOLVE_BRIDGE=1), so a connection error does NOT mean the free "
176
+ "edition is unsupported."
168
177
  ),
169
178
  )
170
179
 
@@ -296,7 +305,7 @@ def davinci_resolve_workflow() -> str:
296
305
  return """Use this DaVinci Resolve MCP server as a guarded post-production control surface.
297
306
 
298
307
  Core pattern:
299
- - Prefer the 32 compound tools and their action names over raw scripting.
308
+ - Prefer the 34 compound tools and their action names over raw scripting.
300
309
  - Start by probing state: resolve_control.get_version/get_page, project_manager.get_current, timeline.get_current, and media_pool.probe_media_pool.
301
310
  - Before mutating timelines, media pools, render settings, grades, projects, databases, or extensions, prefer the matching probe, capabilities, boundary_report, safe_*, or dry_run action when one exists.
302
311
  - Preserve source media integrity. Never transcode, proxy, rewrite, move, rename, or create derivatives of source media unless the user explicitly asks. Analysis output belongs in sidecars or analysis directories.
@@ -899,6 +908,37 @@ def _launch_resolve():
899
908
  logger.warning("Resolve did not respond within 60s after launch")
900
909
  return False
901
910
 
911
+ #: Process names that mean a DaVinci Resolve application is already running.
912
+ #: Deliberately not the full path: the App Store build lives at
913
+ #: `/Applications/DaVinci Resolve.app` and the installer build inside
914
+ #: `/Applications/DaVinci Resolve/`, and either one counts.
915
+ _RESOLVE_PROCESS_PATTERNS = (
916
+ "DaVinci Resolve.app/Contents/MacOS/Resolve", # macOS, both editions
917
+ "Resolve.exe", # Windows
918
+ "/opt/resolve/bin/resolve", # Linux
919
+ )
920
+
921
+
922
+ def resolve_is_running() -> Optional[bool]:
923
+ """Is a Resolve application already up? None when it cannot be determined.
924
+
925
+ Best-effort and dependency-free. `None` rather than `False` on failure, so an
926
+ unanswerable question never silently becomes "nothing is running" — which is
927
+ the answer that leads to launching something.
928
+ """
929
+ try:
930
+ if platform.system().lower() == "windows":
931
+ out = subprocess.run(["tasklist"], capture_output=True, text=True, timeout=10, check=False)
932
+ else:
933
+ out = subprocess.run(["ps", "-Ao", "command="], capture_output=True, text=True, timeout=10, check=False)
934
+ if out.returncode != 0 and not out.stdout:
935
+ return None
936
+ listing = out.stdout or ""
937
+ except Exception: # pragma: no cover - defensive; an unknown answer is None
938
+ return None
939
+ return any(pattern in listing for pattern in _RESOLVE_PROCESS_PATTERNS)
940
+
941
+
902
942
  def get_resolve():
903
943
  """Lazy connection to Resolve — connects on first tool call, auto-launches if needed."""
904
944
  global resolve
@@ -909,12 +949,84 @@ def get_resolve():
909
949
  # Try to connect to an already-running Resolve.
910
950
  if _try_connect():
911
951
  return resolve
912
- # Not running launch it automatically.
952
+ # Failing to connect is NOT the same as nothing running, and conflating
953
+ # them opened an application nobody asked for. The free edition refuses
954
+ # external scripting by design, so on a machine where only it is running
955
+ # `_try_connect` always fails — and this then launched *Studio*, a second,
956
+ # different application, on every tool call. Reported three times before
957
+ # it was traced.
958
+ already_running = resolve_is_running()
959
+ if already_running:
960
+ logger.error(
961
+ "DaVinci Resolve is already running but is not answering the scripting API, "
962
+ "so it will NOT be launched again. Either enable Preferences > General > "
963
+ "'External scripting using' = Local (Studio only), or, on the free edition, "
964
+ "use the in-app bridge: install it, run Workspace > Scripts > resolve_bridge, "
965
+ "and set DAVINCI_RESOLVE_BRIDGE=1."
966
+ )
967
+ return None
968
+ if already_running is None:
969
+ logger.warning("Could not determine whether Resolve is running; not launching it.")
970
+ return None
913
971
  logger.info("Resolve not running, attempting to launch automatically...")
914
972
  _launch_resolve()
915
973
  return resolve
916
974
 
917
975
 
976
+ def _not_connected_error():
977
+ """The caller-facing "no Resolve" error, describing what is actually the case.
978
+
979
+ Eleven call sites used to assert that Resolve was not running, that starting it
980
+ had been tried and failed, and that the reader should check their Studio
981
+ install. Once `get_resolve` stopped launching a second application, all three
982
+ claims were wrong: nothing was launched, Resolve *is* running, and a
983
+ free-edition user was being sent to check an install they may not have. The
984
+ accurate guidance was only reaching the log.
985
+
986
+ So the message is derived from the situation instead of asserted, and it names
987
+ the fix that applies: enable external scripting on Studio, or use the in-app
988
+ bridge on the free edition.
989
+ """
990
+ running = resolve_is_running()
991
+ bridge_on = _bridge_requested()
992
+ if bridge_on:
993
+ return _err(
994
+ "The in-app bridge is enabled but not answering.",
995
+ code="BRIDGE_UNAVAILABLE", category="not_connected",
996
+ # `not_connected` defaults to retryable because auto-launch may
997
+ # succeed next time. Nothing here will: the listener only appears once
998
+ # someone runs the script inside Resolve, so an agent retrying on a
999
+ # loop it cannot win is strictly worse than being told to stop.
1000
+ retryable=False,
1001
+ reason="DAVINCI_RESOLVE_BRIDGE is set, so no other transport is tried.",
1002
+ remediation="In Resolve, run Workspace > Scripts > resolve_bridge. If it is not in "
1003
+ "that menu, run `python scripts/install_resolve_bridge.py` and restart "
1004
+ "Resolve; a framework Python from python.org is required for Resolve to "
1005
+ "list .py scripts at all.",
1006
+ state={"resolve_running": running, "bridge_enabled": True},
1007
+ )
1008
+ if running:
1009
+ return _err(
1010
+ "DaVinci Resolve is running but is not answering the scripting API.",
1011
+ code="SCRIPTING_UNAVAILABLE", category="not_connected",
1012
+ # Not retryable: a preference has to change, or the bridge has to be
1013
+ # started. Retrying the same call cannot make either happen.
1014
+ retryable=False,
1015
+ reason="External scripting is a Studio feature; the free edition refuses it "
1016
+ "regardless of the preference. Resolve was NOT launched again.",
1017
+ remediation="On Studio: Preferences > General > 'External scripting using' = Local. "
1018
+ "On the free edition: install the in-app bridge, run "
1019
+ "Workspace > Scripts > resolve_bridge, and set DAVINCI_RESOLVE_BRIDGE=1.",
1020
+ state={"resolve_running": True, "bridge_enabled": False},
1021
+ )
1022
+ return _err(
1023
+ "DaVinci Resolve is not running and could not be started.",
1024
+ code="RESOLVE_NOT_RUNNING", category="not_connected",
1025
+ remediation="Start DaVinci Resolve and open a project, then retry.",
1026
+ state={"resolve_running": bool(running), "bridge_enabled": False},
1027
+ )
1028
+
1029
+
918
1030
  def _destructive_versioning_provider() -> Optional[Tuple[Any, Any, str, Optional[str]]]:
919
1031
  """Provider used by the C6 version-on-mutate hook.
920
1032
 
@@ -13065,11 +13177,11 @@ def resolve_control(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
13065
13177
  r = get_resolve() # auto-launches if not running
13066
13178
  if r is not None:
13067
13179
  return _ok(message="DaVinci Resolve is running and connected.")
13068
- return _err("Could not connect to DaVinci Resolve. Check that Resolve Studio is installed and 'External scripting using' is set to Local in Preferences.")
13180
+ return _not_connected_error()
13069
13181
 
13070
13182
  r = get_resolve() # auto-launches if not running
13071
13183
  if r is None:
13072
- return _err("Could not connect to DaVinci Resolve after auto-launch attempt. Check that Resolve Studio is installed.")
13184
+ return _not_connected_error()
13073
13185
 
13074
13186
  if action == "get_version":
13075
13187
  update_env = _setup_update_env()
@@ -13992,7 +14104,7 @@ def layout_presets(action: str, params: Optional[Dict[str, Any]] = None) -> Dict
13992
14104
  p = _params(params)
13993
14105
  r = get_resolve()
13994
14106
  if r is None:
13995
- return _err("Could not connect to DaVinci Resolve. It was not running and auto-launch failed. Check that Resolve Studio is installed.")
14107
+ return _not_connected_error()
13996
14108
 
13997
14109
  if action == "save":
13998
14110
  if not p.get("name"):
@@ -14037,7 +14149,7 @@ def render_presets(action: str, params: Optional[Dict[str, Any]] = None) -> Dict
14037
14149
  p = _params(params)
14038
14150
  r = get_resolve()
14039
14151
  if r is None:
14040
- return _err("Could not connect to DaVinci Resolve. It was not running and auto-launch failed. Check that Resolve Studio is installed.")
14152
+ return _not_connected_error()
14041
14153
 
14042
14154
  if action == "import_render":
14043
14155
  return {"success": bool(r.ImportRenderPreset(p["path"]))}
@@ -14980,7 +15092,7 @@ def project_manager(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
14980
15092
  p = _params(params)
14981
15093
  r = get_resolve()
14982
15094
  if r is None:
14983
- return _err("Could not connect to DaVinci Resolve. It was not running and auto-launch failed. Check that Resolve Studio is installed.")
15095
+ return _not_connected_error()
14984
15096
  pm = r.GetProjectManager()
14985
15097
 
14986
15098
  if action == "lint":
@@ -15100,7 +15212,7 @@ def project_manager_folders(action: str, params: Optional[Dict[str, Any]] = None
15100
15212
  p = _params(params)
15101
15213
  r = get_resolve()
15102
15214
  if r is None:
15103
- return _err("Could not connect to DaVinci Resolve. It was not running and auto-launch failed. Check that Resolve Studio is installed.")
15215
+ return _not_connected_error()
15104
15216
  pm = r.GetProjectManager()
15105
15217
 
15106
15218
  if action == "list":
@@ -15142,7 +15254,7 @@ def project_manager_cloud(action: str, params: Optional[Dict[str, Any]] = None)
15142
15254
  p = _params(params)
15143
15255
  r = get_resolve()
15144
15256
  if r is None:
15145
- return _err("Could not connect to DaVinci Resolve. It was not running and auto-launch failed. Check that Resolve Studio is installed.")
15257
+ return _not_connected_error()
15146
15258
  pm = r.GetProjectManager()
15147
15259
 
15148
15260
  # cloudSettings is enum-keyed (CLOUD_SETTING_*/CLOUD_SYNC_*); resolve string
@@ -15180,7 +15292,7 @@ def project_manager_database(action: str, params: Optional[Dict[str, Any]] = Non
15180
15292
  p = _params(params)
15181
15293
  r = get_resolve()
15182
15294
  if r is None:
15183
- return _err("Could not connect to DaVinci Resolve. It was not running and auto-launch failed. Check that Resolve Studio is installed.")
15295
+ return _not_connected_error()
15184
15296
  pm = r.GetProjectManager()
15185
15297
 
15186
15298
  if action == "get_current":
@@ -16190,7 +16302,7 @@ def media_storage(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[
16190
16302
  p = _params(params)
16191
16303
  r = get_resolve()
16192
16304
  if r is None:
16193
- return _err("Could not connect to DaVinci Resolve. It was not running and auto-launch failed. Check that Resolve Studio is installed.")
16305
+ return _not_connected_error()
16194
16306
  ms = r.GetMediaStorage()
16195
16307
 
16196
16308
  if action == "get_volumes":
@@ -18725,10 +18837,137 @@ def _edit_engine_collect_items(tl, *, track_index: Optional[int] = None) -> List
18725
18837
  row["audio_track_indices"] = audio_indices
18726
18838
  except Exception:
18727
18839
  row["audio_track_indices"] = []
18840
+ _edit_engine_add_conform_fields(item, row)
18728
18841
  rows.append(row)
18729
18842
  return rows
18730
18843
 
18731
18844
 
18845
+ def _edit_engine_add_conform_fields(item, row: Dict[str, Any]) -> None:
18846
+ """Extra per-item metadata the conform lint needs.
18847
+
18848
+ Best-effort and individually guarded: a field this API does not expose on a
18849
+ given build must leave the rest of the row intact. Absent fields are
18850
+ reported by the lint as *not checked* rather than silently passing, so a
18851
+ missing value degrades coverage honestly instead of manufacturing a clean
18852
+ bill of health.
18853
+ """
18854
+ mpi = None
18855
+ try:
18856
+ mpi = item.GetMediaPoolItem()
18857
+ except Exception:
18858
+ mpi = None
18859
+ if mpi is not None:
18860
+ for key, prop in (
18861
+ ("source_start_timecode", "Start TC"),
18862
+ ("reel_name", "Reel Name"),
18863
+ ("clip_fps", "FPS"),
18864
+ ):
18865
+ try:
18866
+ value = mpi.GetClipProperty(prop)
18867
+ except Exception:
18868
+ continue
18869
+ if value in (None, ""):
18870
+ continue
18871
+ if key == "clip_fps":
18872
+ try:
18873
+ row[key] = float(value)
18874
+ except (TypeError, ValueError):
18875
+ continue
18876
+ else:
18877
+ row[key] = value
18878
+ try:
18879
+ fusion_names = [c.GetName() for c in (item.GetFusionCompNameList() or [])] \
18880
+ if _has_method(item, "GetFusionCompNameList") else []
18881
+ except Exception:
18882
+ fusion_names = []
18883
+ effects: List[str] = [n for n in fusion_names if n]
18884
+ for getter in ("GetVersionNameList",):
18885
+ if not _has_method(item, getter):
18886
+ continue
18887
+ try:
18888
+ extra = getattr(item, getter)(0) or []
18889
+ effects.extend(str(e) for e in extra if e)
18890
+ except Exception:
18891
+ pass
18892
+ if effects:
18893
+ row["effects"] = effects
18894
+
18895
+
18896
+ def _attach_audit_report(result: Dict[str, Any], params: Dict[str, Any]) -> Dict[str, Any]:
18897
+ """Give every audit a human-readable form on request (invariant I5).
18898
+
18899
+ Off by default because a rendered report costs tokens on every call and the
18900
+ structured payload is what an agent acts on. But it must always be one
18901
+ parameter away: an audit a human cannot read is an audit they will re-do by
18902
+ hand, which is the cost the tool exists to remove.
18903
+ """
18904
+ if not isinstance(result, dict) or not result.get("success"):
18905
+ return result
18906
+ result["report_available"] = "pass include_report=true for a Markdown report"
18907
+ flag = params.get("include_report", params.get("includeReport", False))
18908
+ if str(flag).strip().lower() in {"true", "1", "yes", "on"}:
18909
+ try:
18910
+ result["report_markdown"] = _edit_report_mod.render_any(result)
18911
+ except Exception as exc: # a renderer must never break the audit
18912
+ result["report_error"] = f"report rendering failed: {exc}"
18913
+ return result
18914
+
18915
+
18916
+ def _edit_engine_collect_audio_items(tl) -> List[Dict[str, Any]]:
18917
+ """Audio-track item boundaries, for split-edit analysis.
18918
+
18919
+ Only the boundaries are needed — a split edit is defined by where the audio
18920
+ cut sits relative to the picture cut, not by what is in the clip.
18921
+ """
18922
+ rows: List[Dict[str, Any]] = []
18923
+ try:
18924
+ track_count = int(tl.GetTrackCount("audio") or 0)
18925
+ except Exception:
18926
+ return rows
18927
+ for index in range(1, track_count + 1):
18928
+ try:
18929
+ items = tl.GetItemListInTrack("audio", index) or []
18930
+ except Exception:
18931
+ continue
18932
+ for item in items:
18933
+ try:
18934
+ rows.append({
18935
+ "track_index": index,
18936
+ "item_name": item.GetName(),
18937
+ "timeline_start_frame": _frame_int(item.GetStart()),
18938
+ "timeline_end_frame": _frame_int(item.GetEnd()),
18939
+ })
18940
+ except Exception:
18941
+ continue
18942
+ return rows
18943
+
18944
+
18945
+ def _edit_engine_marker_beats(tl, fps: float) -> List[float]:
18946
+ """Timeline markers as story beats, in seconds.
18947
+
18948
+ Markers are where an editor has already said "something happens here", which
18949
+ makes them the best available proxy for a story beat. Absent markers are
18950
+ reported by the rhythm criterion as beats-not-supplied rather than as an
18951
+ absence of beats.
18952
+ """
18953
+ beats: List[float] = []
18954
+ try:
18955
+ markers = tl.GetMarkers() or {}
18956
+ except Exception:
18957
+ return beats
18958
+ start = 0
18959
+ try:
18960
+ start = int(tl.GetStartFrame() or 0)
18961
+ except Exception:
18962
+ pass
18963
+ for frame in markers:
18964
+ try:
18965
+ beats.append((float(frame) + start) / (fps or 24.0))
18966
+ except (TypeError, ValueError):
18967
+ continue
18968
+ return sorted(beats)
18969
+
18970
+
18732
18971
  def _edit_engine_timeline_fps(tl) -> float:
18733
18972
  try:
18734
18973
  fps = float(tl.GetSetting("timelineFrameRate") or 0)
@@ -18966,6 +19205,30 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
18966
19205
  return _err("plan file failed its fingerprint check — re-plan")
18967
19206
  return {"success": True, "plan": plan}
18968
19207
 
19208
+ if action == "plan_report":
19209
+ # A 340-entry keep_ranges array is not reviewable, and the rational
19210
+ # response to a machine you cannot audit is to re-check it by hand —
19211
+ # which is the cost the tool was supposed to remove. This renders any
19212
+ # plan as Markdown: what changes, why, what was deliberately left alone,
19213
+ # what could NOT be checked, and what needs a human.
19214
+ _r, _proj, project_root, err = _project_context(need_resolve=False)
19215
+ if err:
19216
+ return err
19217
+ plan = _edit_engine_mod.load_plan(project_root, str(p.get("plan_id") or p.get("planId") or ""))
19218
+ if not plan:
19219
+ return _err("plan not found")
19220
+ if plan.get("_corrupt"):
19221
+ return _err("plan file failed its fingerprint check — re-plan")
19222
+ _rows = p.get("max_detail_rows") if p.get("max_detail_rows") is not None else p.get("maxDetailRows")
19223
+ return {
19224
+ "success": True,
19225
+ "plan_id": plan.get("plan_id"),
19226
+ "summary": _edit_report_mod.summarize_for_chat(plan),
19227
+ "report_markdown": _edit_report_mod.render_plan_report(
19228
+ plan, max_detail_rows=int(_rows) if _rows is not None else 25
19229
+ ),
19230
+ }
19231
+
18969
19232
  if action == "plan_selects":
18970
19233
  _r, _proj, project_root, err = _project_context(need_resolve=False)
18971
19234
  if err:
@@ -19047,6 +19310,22 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
19047
19310
  min_cut=float(p.get("min_cut", p.get("minCut", _transcript_edit_defaults.DEFAULT_MIN_CUT_S))),
19048
19311
  )
19049
19312
 
19313
+ if action == "rank_takes":
19314
+ _r, proj, project_root, err = _project_context(need_resolve=False)
19315
+ if err:
19316
+ return err
19317
+ refs = p.get("clip_refs") or p.get("clipRefs") or p.get("clip_ids") or []
19318
+ if isinstance(refs, str):
19319
+ refs = [refs]
19320
+ if not isinstance(refs, (list, tuple)) or not refs:
19321
+ return _err("clip_refs is required (a list of clips to compare)",
19322
+ code="MISSING_CLIP_REFS", category="invalid_input")
19323
+ return _edit_engine_mod.rank_takes(
19324
+ project_root,
19325
+ clip_refs=list(refs),
19326
+ script=p.get("script") if isinstance(p.get("script"), str) else None,
19327
+ )
19328
+
19050
19329
  if action == "search_spoken_content":
19051
19330
  _r, proj, project_root, err = _project_context(need_resolve=False)
19052
19331
  if err:
@@ -19090,6 +19369,332 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
19090
19369
  include_audio=str(p.get("include_audio", p.get("includeAudio", True))).strip().lower() not in {"false", "0", "no", "none", "off"},
19091
19370
  )
19092
19371
 
19372
+ if action == "rule_of_six_audit":
19373
+ _r, proj, project_root, err = _project_context(need_resolve=True)
19374
+ if err:
19375
+ return err
19376
+ tl = (_find_timeline_by_name(proj, p.get("timeline_name") or p.get("timelineName"))[0] if (p.get("timeline_name") or p.get("timelineName")) else proj.GetCurrentTimeline())
19377
+ if not tl:
19378
+ return _err("Timeline not found")
19379
+ _fps = _edit_engine_timeline_fps(tl)
19380
+ return _attach_audit_report(_edit_engine_mod.rule_of_six_audit(
19381
+ items=_edit_engine_collect_items(tl, track_index=p.get("track_index") or p.get("trackIndex")),
19382
+ timeline_name=tl.GetName(),
19383
+ timeline_fps=_fps,
19384
+ story_beats=_edit_engine_marker_beats(tl, _fps),
19385
+ ), p)
19386
+
19387
+ if action == "split_edit_audit":
19388
+ _r, proj, project_root, err = _project_context(need_resolve=True)
19389
+ if err:
19390
+ return err
19391
+ tl = (_find_timeline_by_name(proj, p.get("timeline_name") or p.get("timelineName"))[0] if (p.get("timeline_name") or p.get("timelineName")) else proj.GetCurrentTimeline())
19392
+ if not tl:
19393
+ return _err("Timeline not found")
19394
+ return _edit_engine_mod.split_edit_audit(
19395
+ video_items=_edit_engine_collect_items(tl, track_index=p.get("track_index") or p.get("trackIndex")),
19396
+ audio_items=_edit_engine_collect_audio_items(tl),
19397
+ timeline_fps=_edit_engine_timeline_fps(tl),
19398
+ )
19399
+
19400
+ if action == "sound_density_audit":
19401
+ _r, _proj, project_root, err = _project_context(need_resolve=False)
19402
+ if err:
19403
+ return err
19404
+ media = p.get("track_media") or p.get("trackMedia") or {}
19405
+ if not isinstance(media, dict):
19406
+ return _err("track_media must be a mapping of {name: path}",
19407
+ code="INVALID_TRACK_MEDIA", category="invalid_input")
19408
+ _limit = p.get("stream_limit") if p.get("stream_limit") is not None else p.get("streamLimit")
19409
+ _dur = p.get("duration_seconds") if p.get("duration_seconds") is not None else p.get("durationSeconds")
19410
+ return _attach_audit_report(_edit_engine_mod.sound_density_audit(
19411
+ track_media={str(k): str(v) for k, v in media.items()},
19412
+ stream_limit=float(_limit) if _limit is not None else None,
19413
+ duration_seconds=float(_dur) if _dur is not None else None,
19414
+ ), p)
19415
+
19416
+ if action == "setup_sheet":
19417
+ _r, proj, project_root, err = _project_context(need_resolve=True)
19418
+ if err:
19419
+ return err
19420
+ tl = (_find_timeline_by_name(proj, p.get("timeline_name") or p.get("timelineName"))[0] if (p.get("timeline_name") or p.get("timelineName")) else proj.GetCurrentTimeline())
19421
+ if not tl:
19422
+ return _err("Timeline not found")
19423
+ return _attach_audit_report(_edit_engine_mod.setup_sheet(
19424
+ items=_edit_engine_collect_items(tl, track_index=p.get("track_index") or p.get("trackIndex")),
19425
+ timeline_name=tl.GetName(),
19426
+ timeline_fps=_edit_engine_timeline_fps(tl),
19427
+ ), p)
19428
+
19429
+ if action == "first_impression":
19430
+ # Measures nothing. Captures the one perception that cannot be recovered
19431
+ # once the editor has seen the film too many times to feel it fresh.
19432
+ _r, proj, project_root, err = _project_context(need_resolve=False)
19433
+ if err:
19434
+ return err
19435
+ sub = str(p.get("op") or p.get("sub_action") or "").strip().lower()
19436
+ log_id = p.get("log_id") or p.get("logId")
19437
+ if sub == "list":
19438
+ return {"success": True, "logs": _first_impression_mod.list_logs(project_root)}
19439
+ if sub in ("start", "record", "lock", "get") and not log_id:
19440
+ return _err("log_id is required", code="MISSING_LOG_ID", category="invalid_input")
19441
+ if sub == "start":
19442
+ return _first_impression_mod.start_pass(
19443
+ project_root,
19444
+ timeline_name=str(p.get("timeline_name") or p.get("timelineName") or ""),
19445
+ log_id=str(log_id),
19446
+ viewer=p.get("viewer"),
19447
+ )
19448
+ if sub == "record":
19449
+ _t = p.get("time_seconds") if p.get("time_seconds") is not None else p.get("timeSeconds")
19450
+ try:
19451
+ return _first_impression_mod.record(
19452
+ project_root, log_id=str(log_id),
19453
+ time_seconds=float(_t or 0.0), text=str(p.get("text") or ""),
19454
+ )
19455
+ except _first_impression_mod.LogLocked as exc:
19456
+ return _err(str(exc), code="LOG_LOCKED", category="invalid_input", retryable=False)
19457
+ if sub == "lock":
19458
+ return _first_impression_mod.lock(project_root, log_id=str(log_id))
19459
+ if sub == "get":
19460
+ log = _first_impression_mod.load(project_root, log_id=str(log_id))
19461
+ return {"success": bool(log), "log": log} if log else _err("log not found")
19462
+ if sub == "diff":
19463
+ first = _first_impression_mod.load(project_root, log_id=str(p.get("first_log_id") or p.get("firstLogId") or ""))
19464
+ later = _first_impression_mod.load(project_root, log_id=str(p.get("later_log_id") or p.get("laterLogId") or ""))
19465
+ if not first or not later:
19466
+ return _err("both first_log_id and later_log_id must name existing logs")
19467
+ return {"success": True, **_first_impression_mod.diff(first, later)}
19468
+ return _err("op must be one of: start, record, lock, get, list, diff",
19469
+ code="UNKNOWN_OP", category="invalid_input")
19470
+
19471
+ if action == "journal":
19472
+ # The paperwork every craft role keeps and every tool skips: ingest log,
19473
+ # accumulating known issues, session prep, handoff, status.
19474
+ _r, proj, project_root, err = _project_context(need_resolve=False)
19475
+ if err:
19476
+ return err
19477
+ op = str(p.get("op") or p.get("sub_action") or "").strip().lower()
19478
+ if op == "append":
19479
+ return _journal_mod.append(
19480
+ project_root,
19481
+ kind=str(p.get("kind") or "note"),
19482
+ summary=str(p.get("summary") or ""),
19483
+ detail=p.get("detail"),
19484
+ ref=p.get("ref"),
19485
+ data=p.get("data") if isinstance(p.get("data"), dict) else None,
19486
+ timestamp=time.strftime("%Y-%m-%dT%H:%M:%S"),
19487
+ )
19488
+ if op == "read":
19489
+ return {"success": True,
19490
+ "records": _journal_mod.read(project_root, kind=p.get("kind"))}
19491
+ if op == "known_issues":
19492
+ state = _journal_mod.open_issues(project_root)
19493
+ return {"success": True, **state,
19494
+ "report_markdown": _journal_mod.render_known_issues(project_root)}
19495
+ if op == "ingest_log":
19496
+ return {"success": True,
19497
+ "report_markdown": _journal_mod.render_ingest_log(project_root)}
19498
+ if op == "session_prep":
19499
+ _f = lambda k: (float(p[k]) if p.get(k) is not None else None)
19500
+ return {"success": True, "report_markdown": _journal_mod.render_session_prep(
19501
+ project_root,
19502
+ session_kind=str(p.get("session_kind") or "colour"),
19503
+ prep_hours=_f("prep_hours"),
19504
+ estimated_hours_saved=_f("estimated_hours_saved"),
19505
+ hourly_rate=_f("hourly_rate"),
19506
+ ready=p.get("ready") if isinstance(p.get("ready"), list) else None,
19507
+ outstanding=p.get("outstanding") if isinstance(p.get("outstanding"), list) else None,
19508
+ )}
19509
+ if op == "handoff":
19510
+ return {"success": True, "report_markdown": _journal_mod.render_handoff(
19511
+ project=str(p.get("project") or (proj.GetName() if proj else "project")),
19512
+ to=str(p.get("to") or "receiving facility"),
19513
+ timeline_name=p.get("timeline_name") or p.get("timelineName"),
19514
+ technical=p.get("technical") if isinstance(p.get("technical"), dict) else None,
19515
+ included=p.get("included") if isinstance(p.get("included"), list) else None,
19516
+ known_issues=[i.get("summary") for i in _journal_mod.open_issues(project_root)["open"]],
19517
+ notes=p.get("notes"),
19518
+ )}
19519
+ if op == "picture_lock":
19520
+ _r2, proj2, _pr, err2 = _project_context(need_resolve=True)
19521
+ if err2:
19522
+ return err2
19523
+ tl = (_find_timeline_by_name(proj2, p.get("timeline_name") or p.get("timelineName"))[0] if (p.get("timeline_name") or p.get("timelineName")) else proj2.GetCurrentTimeline())
19524
+ if not tl:
19525
+ return _err("Timeline not found")
19526
+ return _journal_mod.set_picture_lock(
19527
+ project_root, timeline_name=tl.GetName(),
19528
+ items=_edit_engine_collect_items(tl),
19529
+ timestamp=time.strftime("%Y-%m-%dT%H:%M:%S"),
19530
+ )
19531
+ if op == "check_lock":
19532
+ _r2, proj2, _pr, err2 = _project_context(need_resolve=True)
19533
+ if err2:
19534
+ return err2
19535
+ tl = (_find_timeline_by_name(proj2, p.get("timeline_name") or p.get("timelineName"))[0] if (p.get("timeline_name") or p.get("timelineName")) else proj2.GetCurrentTimeline())
19536
+ if not tl:
19537
+ return _err("Timeline not found")
19538
+ return {"success": True, **_journal_mod.check_picture_lock(
19539
+ project_root, timeline_name=tl.GetName(),
19540
+ items=_edit_engine_collect_items(tl),
19541
+ )}
19542
+ if op == "status":
19543
+ _pc = p.get("percent_complete") if p.get("percent_complete") is not None else p.get("percentComplete")
19544
+ return {"success": True, "report_markdown": _journal_mod.render_status(
19545
+ project=str(p.get("project") or (proj.GetName() if proj else "project")),
19546
+ phase=str(p.get("phase") or "unspecified"),
19547
+ percent_complete=float(_pc) if _pc is not None else None,
19548
+ blockers=p.get("blockers") if isinstance(p.get("blockers"), list) else None,
19549
+ next_milestone=p.get("next_milestone") or p.get("nextMilestone"),
19550
+ open_issue_count=len(_journal_mod.open_issues(project_root)["open"]),
19551
+ )}
19552
+ return _err("op must be one of: append, read, known_issues, ingest_log, "
19553
+ "session_prep, handoff, status, picture_lock, check_lock",
19554
+ code="UNKNOWN_OP", category="invalid_input")
19555
+
19556
+ if action == "plan_reference_match":
19557
+ _r, proj, project_root, err = _project_context(need_resolve=True)
19558
+ if err:
19559
+ return err
19560
+ ref = p.get("reference_media") or p.get("referenceMedia")
19561
+ if not ref:
19562
+ return _err("reference_media is required (path to the graded reference still or clip)",
19563
+ code="MISSING_REFERENCE", category="invalid_input")
19564
+ tl = (_find_timeline_by_name(proj, p.get("timeline_name") or p.get("timelineName"))[0] if (p.get("timeline_name") or p.get("timelineName")) else proj.GetCurrentTimeline())
19565
+ if not tl:
19566
+ return _err("Timeline not found")
19567
+ _at = p.get("reference_at_seconds") if p.get("reference_at_seconds") is not None else p.get("referenceAtSeconds")
19568
+ _max = p.get("max_items") if p.get("max_items") is not None else p.get("maxItems")
19569
+ return _edit_engine_mod.plan_reference_match(
19570
+ project_root,
19571
+ reference_media=str(ref),
19572
+ reference_at_seconds=float(_at) if _at is not None else 0.0,
19573
+ items=_edit_engine_collect_items(tl, track_index=p.get("track_index") or p.get("trackIndex")),
19574
+ timeline_fps=_edit_engine_timeline_fps(tl),
19575
+ max_items=int(_max) if _max is not None else 200,
19576
+ )
19577
+
19578
+ if action == "plan_string_out":
19579
+ shots = p.get("shots")
19580
+ if not isinstance(shots, list) or not shots:
19581
+ return _err("shots is required: [{name, start_seconds, end_seconds, motion_energy?}]",
19582
+ code="MISSING_SHOTS", category="invalid_input")
19583
+ return _edit_engine_mod.plan_string_out(
19584
+ shots=shots, order=str(p.get("order") or "chronological"))
19585
+
19586
+ if action == "propose_structure":
19587
+ topics = p.get("topics")
19588
+ if not isinstance(topics, list) or not topics:
19589
+ return _err("topics is required: [{label, total_seconds, clip_count}]",
19590
+ code="MISSING_TOPICS", category="invalid_input")
19591
+ return _edit_engine_mod.propose_structure(topics=topics)
19592
+
19593
+ if action == "plan_broll":
19594
+ beats = p.get("beats")
19595
+ candidates = p.get("candidates")
19596
+ if not isinstance(beats, list) or not beats:
19597
+ return _err("beats is required: [{start_seconds, end_seconds, text?, protected?}]",
19598
+ code="MISSING_BEATS", category="invalid_input")
19599
+ if not isinstance(candidates, list) or not candidates:
19600
+ return _err("candidates is required: [{name, duration_seconds, relevance?, beat_index?}]",
19601
+ code="MISSING_CANDIDATES", category="invalid_input")
19602
+ return _edit_engine_mod.plan_broll(
19603
+ beats=beats, candidates=candidates,
19604
+ allow_reuse=str(p.get("allow_reuse", p.get("allowReuse", False))).strip().lower() in {"true", "1", "yes", "on"},
19605
+ )
19606
+
19607
+ if action == "plan_turnover":
19608
+ dests = p.get("destinations") or p.get("destination")
19609
+ if isinstance(dests, str):
19610
+ dests = [dests]
19611
+ if not isinstance(dests, list) or not dests:
19612
+ return _err("destinations is required: any of sound, vfx, color",
19613
+ code="MISSING_DESTINATIONS", category="invalid_input")
19614
+ _hf = p.get("handle_frames") if p.get("handle_frames") is not None else p.get("handleFrames")
19615
+ return _edit_engine_mod.plan_turnover(
19616
+ destinations=dests,
19617
+ contents=p.get("contents") if isinstance(p.get("contents"), dict) else {},
19618
+ version=str(p.get("version") or "v01"),
19619
+ handle_frames=int(_hf) if _hf is not None else None,
19620
+ )
19621
+
19622
+ if action == "conform_lint":
19623
+ # The online editor's checklist, run before turnover instead of
19624
+ # discovered after picture lock in someone else's suite.
19625
+ _r, proj, project_root, err = _project_context(need_resolve=True)
19626
+ if err:
19627
+ return err
19628
+ tl = (_find_timeline_by_name(proj, p.get("timeline_name") or p.get("timelineName"))[0] if (p.get("timeline_name") or p.get("timelineName")) else proj.GetCurrentTimeline())
19629
+ if not tl:
19630
+ return _err("Timeline not found")
19631
+ items = _edit_engine_collect_items(tl, track_index=p.get("track_index") or p.get("trackIndex"))
19632
+ return _attach_audit_report(_conform_lint_mod.lint_timeline({
19633
+ "timeline_name": tl.GetName(),
19634
+ "timeline_fps": _edit_engine_timeline_fps(tl),
19635
+ "items": items,
19636
+ }), p)
19637
+
19638
+ if action == "plan_beat_cuts":
19639
+ _r, proj, project_root, err = _project_context(need_resolve=False)
19640
+ if err:
19641
+ return err
19642
+ _fps = p.get("timeline_fps") if p.get("timeline_fps") is not None else p.get("timelineFps")
19643
+ return _edit_engine_mod.plan_beat_cuts(
19644
+ project_root,
19645
+ clip_ref=p.get("clip_ref") or p.get("clipRef"),
19646
+ media_path=p.get("media_path") or p.get("mediaPath"),
19647
+ timeline_fps=float(_fps) if _fps is not None else 24.0,
19648
+ mode=str(p.get("mode") or "phrase"),
19649
+ beats_per_bar=int(p.get("beats_per_bar") or p.get("beatsPerBar") or 4),
19650
+ bars_per_phrase=int(p.get("bars_per_phrase") or p.get("barsPerPhrase") or 8),
19651
+ beat_offset=int(p.get("beat_offset") or p.get("beatOffset") or 0),
19652
+ min_shot_seconds=float(p.get("min_shot_seconds") or p.get("minShotSeconds") or 0.0),
19653
+ )
19654
+
19655
+ if action == "plan_prebalance":
19656
+ _r, proj, project_root, err = _project_context(need_resolve=True)
19657
+ if err:
19658
+ return err
19659
+ tl = (_find_timeline_by_name(proj, p.get("timeline_name") or p.get("timelineName"))[0] if (p.get("timeline_name") or p.get("timelineName")) else proj.GetCurrentTimeline())
19660
+ if not tl:
19661
+ return _err("Timeline not found")
19662
+ items = _edit_engine_collect_items(tl, track_index=p.get("track_index") or p.get("trackIndex"))
19663
+ _max = p.get("max_items") if p.get("max_items") is not None else p.get("maxItems")
19664
+ return _edit_engine_mod.plan_prebalance(
19665
+ project_root,
19666
+ items=items,
19667
+ timeline_name=tl.GetName(),
19668
+ timeline_fps=_edit_engine_timeline_fps(tl),
19669
+ max_items=int(_max) if _max is not None else 200,
19670
+ )
19671
+
19672
+ if action == "plan_dead_space_markers":
19673
+ _r, proj, project_root, err = _project_context(need_resolve=True)
19674
+ if err:
19675
+ return err
19676
+ tl = (_find_timeline_by_name(proj, p.get("timeline_name") or p.get("timelineName"))[0] if (p.get("timeline_name") or p.get("timelineName")) else proj.GetCurrentTimeline())
19677
+ if not tl:
19678
+ return _err("Timeline not found")
19679
+ items = _edit_engine_collect_items(tl, track_index=p.get("track_index") or p.get("trackIndex"))
19680
+ return _edit_engine_mod.plan_dead_space_markers(
19681
+ project_root,
19682
+ items=items,
19683
+ timeline_name=tl.GetName(),
19684
+ timeline_fps=_edit_engine_timeline_fps(tl),
19685
+ # Same calibrate-by-default contract as plan_silence_ripple: what you
19686
+ # review here must be what that would remove.
19687
+ threshold_db=(
19688
+ float(_th)
19689
+ if (_th := (p.get("threshold_db") if p.get("threshold_db") is not None else p.get("thresholdDb"))) is not None
19690
+ else None
19691
+ ),
19692
+ tightness=p.get("tightness"),
19693
+ min_strip_frames=float(p.get("min_strip_frames") if p.get("min_strip_frames") is not None else p.get("minStripFrames") if p.get("minStripFrames") is not None else _edit_engine_mod.DEFAULT_SILENCE_MIN_STRIP_FRAMES),
19694
+ pre_head_frames=float(p.get("pre_head_frames") if p.get("pre_head_frames") is not None else p.get("preHeadFrames") if p.get("preHeadFrames") is not None else _edit_engine_mod.DEFAULT_SILENCE_PRE_HEAD_FRAMES),
19695
+ post_tail_frames=float(p.get("post_tail_frames") if p.get("post_tail_frames") is not None else p.get("postTailFrames") if p.get("postTailFrames") is not None else _edit_engine_mod.DEFAULT_SILENCE_POST_TAIL_FRAMES),
19696
+ )
19697
+
19093
19698
  if action == "plan_swap":
19094
19699
  _r, proj, project_root, err = _project_context(need_resolve=True)
19095
19700
  if err:
@@ -19662,7 +20267,23 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
19662
20267
  "plan_tighten",
19663
20268
  "execute_tighten",
19664
20269
  "plan_silence_ripple",
20270
+ "plan_dead_space_markers",
20271
+ "conform_lint",
20272
+ "rule_of_six_audit",
20273
+ "split_edit_audit",
20274
+ "sound_density_audit",
20275
+ "setup_sheet",
20276
+ "first_impression",
20277
+ "journal",
20278
+ "plan_prebalance",
20279
+ "plan_reference_match",
20280
+ "plan_beat_cuts",
20281
+ "plan_string_out",
20282
+ "propose_structure",
20283
+ "plan_broll",
20284
+ "plan_turnover",
19665
20285
  "plan_transcript_tighten",
20286
+ "rank_takes",
19666
20287
  "generate_captions",
19667
20288
  "search_spoken_content",
19668
20289
  "execute_silence_ripple",
@@ -19670,6 +20291,7 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
19670
20291
  "execute_swap",
19671
20292
  "list_plans",
19672
20293
  "get_plan",
20294
+ "plan_report",
19673
20295
  ])
19674
20296
 
19675
20297
 
@@ -24936,8 +25558,7 @@ def _execute_python_script(path: str, args: List[str],
24936
25558
  def _execute_lua_script(path: str) -> Dict[str, Any]:
24937
25559
  r = get_resolve()
24938
25560
  if r is None:
24939
- return _err("Cannot run Lua script — Resolve isn't running and "
24940
- "auto-launch failed.")
25561
+ return _not_connected_error()
24941
25562
  fusion = r.Fusion()
24942
25563
  if fusion is None:
24943
25564
  return _err("handle.Fusion() returned None — cannot run Lua scripts.")
@@ -25002,7 +25623,7 @@ def _run_inline_lua(source: str) -> Dict[str, Any]:
25002
25623
  """
25003
25624
  r = get_resolve()
25004
25625
  if r is None:
25005
- return _err("Cannot run Lua — Resolve isn't running and auto-launch failed.")
25626
+ return _not_connected_error()
25006
25627
  fusion = r.Fusion()
25007
25628
  if fusion is None:
25008
25629
  return _err("handle.Fusion() returned None — cannot run inline Lua.")
@@ -26112,5 +26733,5 @@ if __name__ == "__main__":
26112
26733
  logger.error(f"Unknown --transport {transport!r}; use stdio|sse|streamable-http")
26113
26734
  sys.exit(2)
26114
26735
 
26115
- logger.info(f"Starting DaVinci Resolve MCP Server (32 compound tools)")
26736
+ logger.info("Starting DaVinci Resolve MCP Server (34 compound tools)")
26116
26737
  run_fastmcp_stdio(mcp)