davinci-resolve-mcp 2.67.1 → 2.68.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.
@@ -0,0 +1,270 @@
1
+ """Resolve Scripts-menu capability probe: what does THIS edition's API expose?
2
+
3
+ Run from **Workspace ▸ Scripts ▸ resolve_capability_probe**, on whichever
4
+ edition you want to characterise.
5
+
6
+ The free edition gates *features* (noise reduction, Magic Mask, Super Scale,
7
+ some codecs, some resolutions). What it does to the *scripting API* is a
8
+ separate question and is not documented anywhere authoritative: a method may be
9
+ absent, or present and always return False, or present and work. Those three
10
+ cases need completely different handling in a connector, and guessing which is
11
+ which is how you ship a tool that lies about what it did.
12
+
13
+ So this asks the running application, on the edition in front of you.
14
+
15
+ **Strictly read-only.** It enumerates methods, calls only getters, and creates,
16
+ modifies and deletes nothing. Safe to run on a real project — though it reads
17
+ whatever project is open, so use a scratch one if you would rather not have your
18
+ timeline names in the report.
19
+
20
+ Writes to ``~/.config/davinci-resolve-mcp/capability-probe-<edition>.json`` and
21
+ prints a summary. Self-contained: no repository imports.
22
+ """
23
+
24
+ import json
25
+ import os
26
+ import sys
27
+ import time
28
+
29
+ REPORT_DIR = os.path.expanduser("~/.config/davinci-resolve-mcp")
30
+
31
+ #: Methods worth knowing about, grouped by the object they live on. Chosen for
32
+ #: what a connector actually needs, not for completeness.
33
+ SURFACE = {
34
+ "Resolve": [
35
+ "GetProductName", "GetVersionString", "GetCurrentPage", "OpenPage",
36
+ "GetProjectManager", "GetMediaStorage", "ImportRenderPreset",
37
+ "ExportRenderPreset", "ImportBurnInPreset", "Quit",
38
+ ],
39
+ "ProjectManager": [
40
+ "GetCurrentProject", "GetProjectListInCurrentFolder", "GetCurrentFolder",
41
+ "GetCurrentDatabase", "GetDatabaseList", "CreateProject", "LoadProject",
42
+ "SaveProject", "CloseProject", "DeleteProject", "ExportProject",
43
+ "ImportProject", "CreateFolder", "ArchiveProject",
44
+ ],
45
+ "Project": [
46
+ "GetName", "GetTimelineCount", "GetTimelineByIndex", "GetCurrentTimeline",
47
+ "SetCurrentTimeline", "GetMediaPool", "GetGallery", "GetSetting",
48
+ "SetSetting", "GetRenderFormats", "GetRenderCodecs",
49
+ "GetCurrentRenderFormatAndCodec", "GetRenderPresetList", "AddRenderJob",
50
+ "StartRendering", "IsRenderingInProgress", "GetRenderJobList",
51
+ "GetQuickExportRenderPresets", "RefreshLUTList", "GetUniqueId",
52
+ ],
53
+ "MediaPool": [
54
+ "GetRootFolder", "GetCurrentFolder", "AddSubFolder", "ImportMedia",
55
+ "CreateEmptyTimeline", "CreateTimelineFromClips", "AppendToTimeline",
56
+ "DeleteTimelines", "DeleteClips", "ImportTimelineFromFile",
57
+ "GetTimelineMatte", "RefreshFolders",
58
+ ],
59
+ "Timeline": [
60
+ "GetName", "SetName", "GetStartFrame", "GetEndFrame", "GetTrackCount",
61
+ "GetItemListInTrack", "GetTrackName", "GetIsTrackEnabled",
62
+ "AddTrack", "DeleteTrack", "AddMarker", "GetMarkers",
63
+ "DuplicateTimeline", "Export", "GetCurrentTimecode", "SetCurrentTimecode",
64
+ "InsertFusionTitleIntoTimeline", "InsertTitleIntoTimeline",
65
+ "CreateSubtitlesFromAudio", "DetectSceneCuts", "GetSetting",
66
+ ],
67
+ "TimelineItem": [
68
+ "GetName", "GetDuration", "GetStart", "GetEnd", "GetProperty",
69
+ "SetProperty", "GetNumNodes", "SetLUT", "GetLUT", "SetCDL",
70
+ "AddMarker", "GetMarkers", "GetFusionCompCount", "AddFusionComp",
71
+ "SetClipEnabled", "GetMediaPoolItem", "CreateMagicMask",
72
+ "SmartReframe", "GetNodeLabel",
73
+ ],
74
+ "MediaPoolItem": [
75
+ "GetName", "GetClipProperty", "SetClipProperty", "GetMetadata",
76
+ "SetMetadata", "GetMediaId", "AddMarker", "GetMarkers", "LinkProxyMedia",
77
+ "TranscribeAudio", "ClearTranscription",
78
+ ],
79
+ }
80
+
81
+ #: Getters that are safe to actually call, to separate "method exists" from
82
+ #: "method works". A method can be present and still be inert on this edition.
83
+ SAFE_CALLS = {
84
+ "Resolve": ["GetProductName", "GetVersionString", "GetCurrentPage"],
85
+ "ProjectManager": ["GetProjectListInCurrentFolder", "GetCurrentFolder"],
86
+ "Project": [
87
+ "GetName", "GetTimelineCount", "GetRenderFormats",
88
+ "GetCurrentRenderFormatAndCodec", "GetRenderPresetList",
89
+ "IsRenderingInProgress", "GetRenderJobList",
90
+ ],
91
+ "MediaPool": ["GetRootFolder", "GetCurrentFolder"],
92
+ "Timeline": [
93
+ "GetName", "GetStartFrame", "GetEndFrame", "GetCurrentTimecode", "GetMarkers",
94
+ ],
95
+ }
96
+
97
+
98
+ def acquire_resolve():
99
+ candidate = globals().get("resolve")
100
+ if candidate is not None:
101
+ return candidate
102
+ bmd = globals().get("bmd")
103
+ if bmd is not None:
104
+ try:
105
+ return bmd.scriptapp("Resolve")
106
+ except Exception:
107
+ return None
108
+ try:
109
+ import DaVinciResolveScript as script_module
110
+ return script_module.scriptapp("Resolve")
111
+ except Exception:
112
+ return None
113
+
114
+
115
+ def classify(obj, names):
116
+ """Which of `names` exist on `obj`."""
117
+ if obj is None:
118
+ return {"reachable": False, "note": "object not sampled in this context",
119
+ "present": [], "missing": sorted(names)}
120
+ present, missing = [], []
121
+ for name in names:
122
+ (present if callable(getattr(obj, name, None)) else missing).append(name)
123
+ return {"reachable": True, "present": sorted(present), "missing": sorted(missing)}
124
+
125
+
126
+ def try_calls(obj, names):
127
+ """Call safe getters; record value shape or the failure. Never mutates."""
128
+ out = {}
129
+ if obj is None:
130
+ return out
131
+ for name in names:
132
+ fn = getattr(obj, name, None)
133
+ if not callable(fn):
134
+ out[name] = {"ok": False, "why": "absent"}
135
+ continue
136
+ try:
137
+ value = fn()
138
+ except Exception as exc:
139
+ out[name] = {"ok": False, "why": "raised: %s" % type(exc).__name__}
140
+ continue
141
+ if value is None:
142
+ out[name] = {"ok": False, "why": "returned None"}
143
+ elif isinstance(value, bool):
144
+ # A bool getter answering False is a real answer, not a failure.
145
+ # Scoring it as one is how "nothing is rendering" reads as "broken".
146
+ out[name] = {"ok": True, "type": "bool", "value": value}
147
+ elif isinstance(value, dict):
148
+ out[name] = {"ok": True, "type": "dict", "size": len(value),
149
+ "sample": sorted(list(value)[:8])}
150
+ elif isinstance(value, (list, tuple)):
151
+ out[name] = {"ok": True, "type": "list", "size": len(value),
152
+ "sample": [str(v)[:40] for v in list(value)[:8]]}
153
+ else:
154
+ out[name] = {"ok": True, "type": type(value).__name__, "value": str(value)[:120]}
155
+ return out
156
+
157
+
158
+ def main():
159
+ resolve = acquire_resolve()
160
+ if resolve is None:
161
+ print("Could not get the resolve object. Run this from Workspace > Scripts.")
162
+ return None
163
+
164
+ report = {"probed_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
165
+ "interpreter": sys.executable}
166
+ try:
167
+ product = resolve.GetProductName()
168
+ report["product"] = product
169
+ report["version"] = resolve.GetVersionString()
170
+ report["edition"] = "studio" if "studio" in str(product).lower() else "free"
171
+ except Exception as exc:
172
+ report["product_error"] = type(exc).__name__
173
+ report["edition"] = "unknown"
174
+
175
+ manager = getattr(resolve, "GetProjectManager", lambda: None)()
176
+ project = getattr(manager, "GetCurrentProject", lambda: None)() if manager else None
177
+ pool = getattr(project, "GetMediaPool", lambda: None)() if project else None
178
+ timeline = getattr(project, "GetCurrentTimeline", lambda: None)() if project else None
179
+
180
+ item = None
181
+ if timeline is not None:
182
+ try:
183
+ for index in range(1, int(timeline.GetTrackCount("video") or 0) + 1):
184
+ items = timeline.GetItemListInTrack("video", index) or []
185
+ if items:
186
+ item = items[0]
187
+ break
188
+ except Exception:
189
+ item = None
190
+ media_item = None
191
+ if pool is not None:
192
+ try:
193
+ clips = pool.GetRootFolder().GetClipList() or []
194
+ media_item = clips[0] if clips else None
195
+ except Exception:
196
+ media_item = None
197
+
198
+ objects = {
199
+ "Resolve": resolve, "ProjectManager": manager, "Project": project,
200
+ "MediaPool": pool, "Timeline": timeline, "TimelineItem": item,
201
+ "MediaPoolItem": media_item,
202
+ }
203
+ report["surface"] = {k: classify(objects.get(k), v) for k, v in SURFACE.items()}
204
+ report["calls"] = {k: try_calls(objects.get(k), v) for k, v in SAFE_CALLS.items()}
205
+ report["context"] = {
206
+ "project_open": project is not None,
207
+ "timeline_open": timeline is not None,
208
+ "timeline_item_sampled": item is not None,
209
+ "media_item_sampled": media_item is not None,
210
+ }
211
+
212
+ # The render matrix is where free/Studio differ most visibly.
213
+ if project is not None:
214
+ try:
215
+ formats = project.GetRenderFormats() or {}
216
+ matrix = {}
217
+ for label in sorted(formats)[:40]:
218
+ try:
219
+ codecs = project.GetRenderCodecs(formats[label]) or {}
220
+ matrix[label] = sorted(codecs)
221
+ except Exception:
222
+ matrix[label] = ["<error>"]
223
+ report["render_matrix"] = matrix
224
+ report["render_format_count"] = len(formats)
225
+ report["render_pair_count"] = sum(len(v) for v in matrix.values())
226
+ except Exception as exc:
227
+ report["render_matrix_error"] = type(exc).__name__
228
+
229
+ edition = report.get("edition", "unknown")
230
+ path = os.path.join(REPORT_DIR, "capability-probe-%s.json" % edition)
231
+ try:
232
+ if not os.path.isdir(REPORT_DIR):
233
+ os.makedirs(REPORT_DIR)
234
+ with open(path, "w") as handle:
235
+ json.dump(report, handle, indent=2, sort_keys=True)
236
+ written = path
237
+ except (OSError, IOError) as exc:
238
+ written = "could not write (%s)" % type(exc).__name__
239
+
240
+ print("=" * 70)
241
+ print("Resolve capability probe - %s %s (%s)" % (
242
+ report.get("product", "?"), report.get("version", "?"), edition))
243
+ print("=" * 70)
244
+ for group, result in report["surface"].items():
245
+ if not result.get("reachable"):
246
+ print(" %-16s not sampled (%s)" % (group, result.get("note")))
247
+ continue
248
+ total = len(result["present"]) + len(result["missing"])
249
+ print(" %-16s %d/%d present" % (group, len(result["present"]), total))
250
+ if result["missing"]:
251
+ print(" missing: %s" % ", ".join(result["missing"]))
252
+ print()
253
+ failed = [(g, n, r["why"]) for g, calls in report["calls"].items()
254
+ for n, r in calls.items() if not r["ok"]]
255
+ print(" safe getter calls: %d ok, %d not" % (
256
+ sum(1 for g, c in report["calls"].items() for r in c.values() if r["ok"]), len(failed)))
257
+ for group, name, why in failed:
258
+ print(" %s.%s -> %s" % (group, name, why))
259
+ if "render_format_count" in report:
260
+ print()
261
+ print(" render matrix: %d formats, %d format/codec pairs" % (
262
+ report["render_format_count"], report["render_pair_count"]))
263
+ print()
264
+ print(" context: %s" % json.dumps(report["context"]))
265
+ print(" report: %s" % written)
266
+ print("=" * 70)
267
+ return report
268
+
269
+
270
+ main()
@@ -85,7 +85,7 @@ if not logging.getLogger().handlers:
85
85
  handlers=[logging.StreamHandler()],
86
86
  )
87
87
 
88
- VERSION = "2.67.1"
88
+ VERSION = "2.68.0"
89
89
  logger = logging.getLogger("davinci-resolve-mcp")
90
90
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
91
91
  logger.info(f"Detected platform: {get_platform()}")
@@ -234,6 +234,11 @@ mcp.tool = _tool_with_default_annotations
234
234
 
235
235
  resolve = None
236
236
  dvr_script = None
237
+ #: `dvr_script` may be None when Resolve is not installed; every use passes it
238
+ #: to `connect_resolve()`, which treats None as "not available" and returns None.
239
+ _OPTIONAL_DEPENDENCY_CONTRACT = (
240
+ "DaVinciResolveScript: always routed through connect_resolve(), which is None-tolerant"
241
+ )
237
242
 
238
243
  try:
239
244
  import DaVinciResolveScript as dvr_script # type: ignore
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.67.1"
14
+ VERSION = "2.68.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -122,6 +122,7 @@ from src.utils.fusion_group_settings import (
122
122
  from src.utils import analysis_runs as _analysis_runs
123
123
  from src.utils import brain_edits as _brain_edits
124
124
  from src.utils import edit_engine as _edit_engine_mod
125
+ from src.utils import transcript_edit as _transcript_edit_defaults
125
126
  from src.utils import media_pool_changes as _media_pool_changes
126
127
  from src.utils import timeline_versioning as _timeline_versioning
127
128
  from src.utils import project_spec as _project_spec
@@ -803,11 +804,32 @@ def _is_resolve_handle_live(candidate) -> bool:
803
804
  return False
804
805
 
805
806
 
807
+ def _bridge_requested() -> bool:
808
+ """Has the operator asked for the in-app bridge?
809
+
810
+ Read at call time rather than at import, so a server started before the
811
+ variable was set still honours it.
812
+ """
813
+ try:
814
+ from src.utils import resolve_bridge_client
815
+
816
+ return resolve_bridge_client.bridge_enabled()
817
+ except Exception: # pragma: no cover - the client is optional
818
+ return False
819
+
820
+
806
821
  def _try_connect():
807
822
  """Attempt to connect to Resolve once. Returns resolve object or None."""
808
823
  global resolve
809
824
  with _resolve_lock:
810
- if dvr_script is None:
825
+ # `dvr_script` is Blackmagic's module, and it ships with the *installer*,
826
+ # not the App Store build — a free-edition-only machine has no
827
+ # Developer/Scripting/Modules tree at all, so the import fails. Bailing
828
+ # here made the bridge unreachable on precisely the configuration it
829
+ # exists for: `connect_resolve` accepts None in bridge mode, and this
830
+ # returned before ever calling it. Verified by blocking the import with a
831
+ # healthy bridge listening — get_resolve() answered None.
832
+ if dvr_script is None and not _bridge_requested():
811
833
  return None
812
834
  try:
813
835
  candidate = connect_resolve(dvr_script)
@@ -822,13 +844,34 @@ def _try_connect():
822
844
  resolve = None
823
845
  return None
824
846
 
847
+ #: macOS puts the two editions in different places, and only the first was ever
848
+ #: checked — so an auto-launch on a machine with both would start Studio even
849
+ #: when the free edition was the one in use. Ordered installer-first to keep the
850
+ #: previous behaviour where Studio is present.
851
+ _MACOS_RESOLVE_APPS = (
852
+ "/Applications/DaVinci Resolve/DaVinci Resolve.app", # installer / Studio
853
+ "/Applications/DaVinci Resolve.app", # App Store, free
854
+ )
855
+
856
+
825
857
  def _launch_resolve():
826
858
  """Launch DaVinci Resolve and wait for it to become available."""
859
+ if _bridge_requested():
860
+ # Launching cannot produce a bridge. The listener only exists once
861
+ # someone runs Workspace > Scripts > resolve_bridge inside an already
862
+ # running Resolve, so starting the application here would open a window
863
+ # nobody asked for and still fail to connect.
864
+ logger.error(
865
+ "The in-app bridge is enabled but not answering. Start it from "
866
+ "Workspace > Scripts > resolve_bridge inside the running Resolve; "
867
+ "launching the application cannot start it."
868
+ )
869
+ return False
827
870
  sys_name = platform.system().lower()
828
871
  if sys_name == "darwin":
829
- app_path = "/Applications/DaVinci Resolve/DaVinci Resolve.app"
830
- if not os.path.exists(app_path):
831
- logger.error(f"DaVinci Resolve not found at {app_path}")
872
+ app_path = next((p for p in _MACOS_RESOLVE_APPS if os.path.exists(p)), None)
873
+ if app_path is None:
874
+ logger.error("DaVinci Resolve not found in %s", list(_MACOS_RESOLVE_APPS))
832
875
  return False
833
876
  subprocess.Popen(["open", app_path], stdin=subprocess.DEVNULL)
834
877
  elif sys_name == "windows":
@@ -15240,6 +15283,7 @@ _RENDER_KERNEL_ACTIONS = [
15240
15283
  "export_render_boundary_report",
15241
15284
  "list_delivery_targets",
15242
15285
  "resolve_delivery_target",
15286
+ "list_loudness_standards",
15243
15287
  "prepare_delivery_job",
15244
15288
  ]
15245
15289
 
@@ -15618,6 +15662,9 @@ def _resolve_delivery_target_live(proj, p: Dict[str, Any]):
15618
15662
 
15619
15663
  fps = _delivery_timeline_fps(proj)
15620
15664
  qc_spec = _delivery_targets.to_qc_spec(target, timeline_fps=fps)
15665
+ # Loudness is a separate projection feeding advanced `loudness_qc`, not a
15666
+ # deliverable_qc field — the mix sets programme loudness, not the render.
15667
+ loudness = _delivery_targets.to_loudness_target(target)
15621
15668
  return (
15622
15669
  {
15623
15670
  "target": target.id,
@@ -15633,12 +15680,40 @@ def _resolve_delivery_target_live(proj, p: Dict[str, Any]):
15633
15680
  if qc_spec
15634
15681
  else "This target renders an image sequence; deliverable_qc probes a single file, so it has no QC spec."
15635
15682
  ),
15683
+ "loudness_target": loudness,
15684
+ "loudness_note": (
15685
+ None
15686
+ if loudness
15687
+ else "This target pins no programme loudness. Name one with "
15688
+ "overrides={'loudness_standard': ...}; see render(action='list_loudness_standards')."
15689
+ ),
15636
15690
  "notes": list(target.notes),
15637
15691
  },
15638
15692
  None,
15639
15693
  )
15640
15694
 
15641
15695
 
15696
+ def _list_loudness_standards(proj, p: Dict[str, Any]):
15697
+ """Named programme-loudness contracts, so an agent cites one rather than inventing numbers."""
15698
+ return (
15699
+ {
15700
+ "standards": _delivery_targets.list_loudness_standards(),
15701
+ "lra_advisory_lu": _delivery_targets.LRA_ADVISORY_LU,
15702
+ "usage": (
15703
+ "Attach with render(action='resolve_delivery_target', params={'target': ..., "
15704
+ "'overrides': {'loudness_standard': '<id>'}}), then hand the returned "
15705
+ "loudness_target.target to the advanced server's loudness_qc."
15706
+ ),
15707
+ "note": (
15708
+ "Dialogue-gated standards emit no gradeable integrated value: loudness_qc "
15709
+ "measures full-program loudness, so that figure travels in meta for a "
15710
+ "dialogue-gated meter and only true peak is asserted."
15711
+ ),
15712
+ },
15713
+ None,
15714
+ )
15715
+
15716
+
15642
15717
  def _list_delivery_targets(proj, p: Dict[str, Any]):
15643
15718
  tier = p.get("tier")
15644
15719
  if tier is not None and tier not in _delivery_targets.TIERS:
@@ -15864,6 +15939,9 @@ def render(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
15864
15939
  elif action == "resolve_delivery_target":
15865
15940
  _resolved, _target_err = _resolve_delivery_target_live(proj, p)
15866
15941
  return _target_err if _target_err else _ok(**_resolved)
15942
+ elif action == "list_loudness_standards":
15943
+ _standards, _standards_err = _list_loudness_standards(proj, p)
15944
+ return _standards_err if _standards_err else _ok(**_standards)
15867
15945
  elif action == "prepare_delivery_job":
15868
15946
  return _prepare_delivery_job(proj, p)
15869
15947
  elif action == "set_format_and_codec":
@@ -17288,6 +17366,38 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
17288
17366
 
17289
17367
  if action == "capabilities":
17290
17368
  return _media_analysis_capabilities_for_request(ctx)
17369
+ if action == "assess_grade":
17370
+ # Numeric grade-damage QC. Distinct from advanced `verify_grade`, which
17371
+ # asks whether Resolve applied what was asked; this asks whether the
17372
+ # resulting image is damaged. Free and deterministic — the vision block
17373
+ # in the result says whether a paid second opinion is warranted.
17374
+ from src.utils import image_qc as _image_qc_mod
17375
+
17376
+ try:
17377
+ return _ok(**_image_qc_mod.assess_grade(
17378
+ str(p.get("source_path") or p.get("sourcePath") or ""),
17379
+ time_seconds=float(p.get("time_seconds", p.get("timeSeconds", 0.0)) or 0.0),
17380
+ graded_path=(p.get("graded_path") or p.get("gradedPath")) or None,
17381
+ lut_path=(p.get("lut_path") or p.get("lutPath")) or None,
17382
+ working_space=str(p.get("working_space") or p.get("workingSpace") or "rec709"),
17383
+ cost_tier=str(p.get("cost_tier") or p.get("costTier") or _image_qc_mod.DEFAULT_COST_TIER),
17384
+ ))
17385
+ except _image_qc_mod.ImageQcError as exc:
17386
+ return _err(
17387
+ str(exc),
17388
+ code="IMAGE_QC_REFUSED",
17389
+ category="invalid_input",
17390
+ remediation=(
17391
+ "Supply exactly one of graded_path or lut_path, and declare a display-referred "
17392
+ "working_space. Log/scene-referred frames must be converted first — these "
17393
+ "metrics are undefined on them and will not be guessed at."
17394
+ ),
17395
+ )
17396
+ if action == "image_qc_capabilities":
17397
+ from src.utils import image_qc as _image_qc_mod
17398
+
17399
+ return _ok(**_image_qc_mod.capabilities(), cost_tiers=list(_image_qc_mod.COST_TIERS),
17400
+ default_cost_tier=_image_qc_mod.DEFAULT_COST_TIER)
17291
17401
  if action == "recheck_capabilities":
17292
17402
  # Re-runs detection (shutil.which / importlib.util.find_spec) so a tool
17293
17403
  # an agent just installed flips from missing → available without the
@@ -18197,6 +18307,8 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
18197
18307
  return _unknown(action, [
18198
18308
  "capabilities",
18199
18309
  "recheck_capabilities",
18310
+ "assess_grade",
18311
+ "image_qc_capabilities",
18200
18312
  "install_guidance",
18201
18313
  "resolve_output_root",
18202
18314
  "plan",
@@ -18752,6 +18864,71 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
18752
18864
  include_audio=str(p.get("include_audio", p.get("includeAudio", True))).strip().lower() not in {"false", "0", "no", "none", "off"},
18753
18865
  )
18754
18866
 
18867
+ if action == "generate_captions":
18868
+ _r, proj, project_root, err = _project_context(need_resolve=False)
18869
+ if err:
18870
+ return err
18871
+ from src.utils import captions as _captions_mod
18872
+ from src.utils import strata as _strata_mod
18873
+
18874
+ conn, clip, clip_err = _strata_mod.resolve_clip(
18875
+ project_root, p.get("clip_ref") or p.get("clipRef") or p.get("clip_id"), require_media=False
18876
+ )
18877
+ if clip_err:
18878
+ return clip_err
18879
+ _words = _strata_mod.read_words(conn, clip["clip_uuid"])
18880
+ _opts = {}
18881
+ for _key, _param in (
18882
+ ("max_chars_per_line", "maxCharsPerLine"), ("max_lines", "maxLines"),
18883
+ ("max_block_seconds", "maxBlockSeconds"), ("min_block_seconds", "minBlockSeconds"),
18884
+ ("min_gap_seconds", "minGapSeconds"), ("pause_break_seconds", "pauseBreakSeconds"),
18885
+ ):
18886
+ _value = p.get(_key, p.get(_param))
18887
+ if _value is not None:
18888
+ _opts[_key] = int(_value) if _key in {"max_chars_per_line", "max_lines"} else float(_value)
18889
+ try:
18890
+ _result = _captions_mod.generate(
18891
+ _words,
18892
+ fmt=str(p.get("format") or p.get("fmt") or "srt").lower(),
18893
+ with_chapters=str(p.get("with_chapters", p.get("withChapters", False))).strip().lower() in {"true", "1", "yes", "on"},
18894
+ **_opts,
18895
+ )
18896
+ except _captions_mod.CaptionError as exc:
18897
+ return _err(str(exc), code="CAPTION_PARAMS_INVALID", category="invalid_input")
18898
+ _result["clip"] = {"clip_uuid": clip["clip_uuid"], "clip_name": clip.get("clip_name")}
18899
+ return _result
18900
+
18901
+ if action == "plan_transcript_tighten":
18902
+ _r, proj, project_root, err = _project_context(need_resolve=False)
18903
+ if err:
18904
+ return err
18905
+ return _edit_engine_mod.plan_transcript_tighten(
18906
+ project_root,
18907
+ clip_ref=p.get("clip_ref") or p.get("clipRef") or p.get("clip_id"),
18908
+ remove_fillers=str(p.get("remove_fillers", p.get("removeFillers", True))).strip().lower() not in {"false", "0", "no", "off"},
18909
+ remove_false_starts=str(p.get("remove_false_starts", p.get("removeFalseStarts", True))).strip().lower() not in {"false", "0", "no", "off"},
18910
+ collapse_pauses=str(p.get("collapse_pauses", p.get("collapsePauses", True))).strip().lower() not in {"false", "0", "no", "off"},
18911
+ max_pause=float(p.get("max_pause", p.get("maxPause", _transcript_edit_defaults.DEFAULT_MAX_PAUSE_S))),
18912
+ handle=float(p.get("handle", _transcript_edit_defaults.DEFAULT_HANDLE_S)),
18913
+ min_cut=float(p.get("min_cut", p.get("minCut", _transcript_edit_defaults.DEFAULT_MIN_CUT_S))),
18914
+ )
18915
+
18916
+ if action == "search_spoken_content":
18917
+ _r, proj, project_root, err = _project_context(need_resolve=False)
18918
+ if err:
18919
+ return err
18920
+ query = p.get("query") or p.get("q")
18921
+ if not isinstance(query, str) or not query.strip():
18922
+ return _err("query is required", code="MISSING_QUERY", category="invalid_input")
18923
+ return _edit_engine_mod.search_spoken_content(
18924
+ project_root,
18925
+ query=query,
18926
+ mode=str(p.get("mode") or "phrase"),
18927
+ context_seconds=float(p.get("context_seconds", p.get("contextSeconds", 1.5))),
18928
+ handle_seconds=float(p.get("handle_seconds", p.get("handleSeconds", 0.5))),
18929
+ max_hits=int(p.get("max_hits", p.get("maxHits", 200))),
18930
+ )
18931
+
18755
18932
  if action == "plan_silence_ripple":
18756
18933
  _r, proj, project_root, err = _project_context(need_resolve=True)
18757
18934
  if err:
@@ -18765,7 +18942,14 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
18765
18942
  items=items,
18766
18943
  timeline_name=tl.GetName(),
18767
18944
  timeline_fps=_edit_engine_timeline_fps(tl),
18768
- threshold_db=float(p.get("threshold_db") if p.get("threshold_db") is not None else p.get("thresholdDb") if p.get("thresholdDb") is not None else _edit_engine_mod.DEFAULT_SILENCE_THRESHOLD_DB),
18945
+ # Omitted => None => the gate is calibrated per item from that
18946
+ # slice's own dynamics. Passing the old fixed default here would
18947
+ # make auto-calibration unreachable through the MCP surface.
18948
+ threshold_db=(
18949
+ float(_th)
18950
+ if (_th := (p.get("threshold_db") if p.get("threshold_db") is not None else p.get("thresholdDb"))) is not None
18951
+ else None
18952
+ ),
18769
18953
  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),
18770
18954
  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),
18771
18955
  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),
@@ -19344,6 +19528,9 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
19344
19528
  "plan_tighten",
19345
19529
  "execute_tighten",
19346
19530
  "plan_silence_ripple",
19531
+ "plan_transcript_tighten",
19532
+ "generate_captions",
19533
+ "search_spoken_content",
19347
19534
  "execute_silence_ripple",
19348
19535
  "plan_swap",
19349
19536
  "execute_swap",