davinci-resolve-mcp 2.67.0 → 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.
@@ -9,6 +9,11 @@ import os
9
9
 
10
10
  from .platform import get_platform, get_resolve_paths, setup_environment
11
11
 
12
+ try: # optional: absent in minimal installs, and never required for Studio
13
+ from . import resolve_bridge_client as bridge_client
14
+ except Exception: # pragma: no cover - defensive
15
+ bridge_client = None
16
+
12
17
  logger = logging.getLogger("davinci-resolve-mcp.connection")
13
18
 
14
19
  DEFAULT_RESOLVE_SCRIPT_TIMEOUT = 5.0
@@ -33,7 +38,30 @@ def _network_timeout():
33
38
 
34
39
 
35
40
  def connect_resolve(dvr_script):
36
- """Create a Resolve handle, optionally targeting an explicit network host."""
41
+ """Create a Resolve handle. Three modes, tried in order of directness.
42
+
43
+ 1. **Bridge** (opt-in, `DAVINCI_RESOLVE_BRIDGE=1`) — a proxy over the
44
+ authenticated loopback bridge running inside Resolve. This is the only
45
+ mode that works on the **free edition**, whose external scripting API
46
+ refuses foreign processes. Tried first when enabled, because if a user has
47
+ asked for it, silently using a different transport would hide a broken
48
+ bridge.
49
+ 2. **Network** (`RESOLVE_SCRIPT_HOST`) — Studio's remote scripting.
50
+ 3. **Local** — Studio's local scripting. The default.
51
+
52
+ `dvr_script` may be None in bridge mode: the bridge does not need Blackmagic's
53
+ module at all, which is precisely why it reaches editions the module cannot.
54
+ """
55
+ if bridge_client is not None and bridge_client.bridge_enabled():
56
+ try:
57
+ return bridge_client.connect()
58
+ except bridge_client.BridgeUnavailable as exc:
59
+ # Explicitly requested and unavailable: say so instead of quietly
60
+ # falling through to a transport the user did not ask for.
61
+ logger.error("in-app bridge requested but unavailable: %s", exc)
62
+ raise
63
+ if dvr_script is None:
64
+ return None
37
65
  host = os.environ.get("RESOLVE_SCRIPT_HOST")
38
66
  if host:
39
67
  return dvr_script.scriptapp("Resolve", host, _network_timeout())
@@ -9,13 +9,50 @@ Settings map to the Resolve dialog:
9
9
  - min_strip_frames → Minimum strip length (frames)
10
10
  - pre_head_frames → Pre-head (frames kept before silence)
11
11
  - post_tail_frames → Post-tail (frames kept after silence ends)
12
+
13
+ ## Auto-calibrated threshold
14
+
15
+ A fixed threshold is the most common way this goes wrong: -30 dB under-detects on
16
+ quiet location audio and over-cuts a noisy room. When `threshold_db` is omitted,
17
+ `calibrate_silence_gate` measures the file's own noise floor and places the gate
18
+ just above it.
19
+
20
+ The levels come from ffmpeg `astats`, using **`RMS through dB`** (ffmpeg's
21
+ spelling of *trough* — the quietest analysis window) and **`RMS peak dB`** (the
22
+ loudest). Those two bracket the file's own dynamic range, and the gate is placed
23
+ inside that bracket.
24
+
25
+ Two metrics were measured and rejected on the way here, both worth not
26
+ rediscovering:
27
+
28
+ - `volumedetect`'s **`mean_volume`** tracks `RMS level` almost exactly — it is
29
+ the *programme* level, not a floor. Deriving `mean_volume - 6` reads the
30
+ content and calls it the noise floor.
31
+ - `astats`' **`Noise floor dB`** moves with how much of the slice is quiet, not
32
+ with how quiet it is. Two fixtures sharing an identical noise bed measured
33
+ -57.1 and -76.3 purely because one was 60% silent and the other 90%. Calibrated
34
+ against that, the gate lands 19 dB apart on acoustically identical material.
35
+ `RMS through` measured -56.06 and -56.12 on the same pair.
36
+
37
+ The gate is biased above the midpoint of the trough/peak bracket because
38
+ `silencedetect` compares instantaneous amplitude while `astats` reports windowed
39
+ RMS: a quiet section's samples peak some way above its own RMS, and a gate at the
40
+ exact midpoint sits near the bottom edge of the usable band.
41
+
42
+ Measuring both ends also makes the failure case detectable: when trough and peak
43
+ are not separated, no threshold distinguishes speech from room, and this says so
44
+ and falls back to the documented default instead of inventing a number that
45
+ finds nothing.
12
46
  """
13
47
 
14
48
  from __future__ import annotations
15
49
 
50
+ import math
16
51
  import os
52
+ import re
17
53
  import shutil
18
- from typing import List, Sequence, Tuple
54
+ from dataclasses import dataclass
55
+ from typing import List, Optional, Sequence, Tuple
19
56
 
20
57
  from src.utils.media_analysis import _parse_silencedetect
21
58
 
@@ -24,6 +61,67 @@ DEFAULT_MIN_STRIP_FRAMES = 10
24
61
  DEFAULT_PRE_HEAD_FRAMES = 0
25
62
  DEFAULT_POST_TAIL_FRAMES = 1
26
63
 
64
+ #: Where in the trough→peak bracket the gate sits. Above 0.5 deliberately: see
65
+ #: the module docstring — silencedetect compares instantaneous amplitude while
66
+ #: astats reports windowed RMS, so a midpoint gate hugs the usable band's floor.
67
+ GATE_BIAS = 0.6
68
+ #: Trough and peak must differ by at least this for a gate to mean anything.
69
+ MIN_SEPARATION_DB = 12.0
70
+ #: True digital silence (trough = -inf) admits any gate; sit this far under peak.
71
+ DIGITAL_SILENCE_HEADROOM_DB = 24.0
72
+ #: Absolute band. Outside it a "calibrated" gate is not credible for dialogue.
73
+ GATE_MIN_DB = -60.0
74
+ GATE_MAX_DB = -24.0
75
+
76
+ #: ffmpeg spells the trough "RMS through"; accept a corrected spelling too so a
77
+ #: future ffmpeg fixing the typo does not silently drop us to the fallback.
78
+ _ASTATS_RMS_TROUGH_RE = re.compile(r"RMS (?:through|trough) dB:\s*(-?[0-9.]+|-?inf)", re.IGNORECASE)
79
+ _ASTATS_RMS_PEAK_RE = re.compile(r"RMS peak dB:\s*(-?[0-9.]+|-?inf)", re.IGNORECASE)
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class NoiseCalibration:
84
+ """Outcome of a level measurement, with the reasoning kept visible.
85
+
86
+ **`usable` False means: do not strip this item.** The measurement happened
87
+ and produced no credible gate, so `gate_db` carries `DEFAULT_THRESHOLD_DB`
88
+ only for reporting — detection must be skipped and the item kept whole.
89
+
90
+ That is deliberately stronger than "fall back to the default". A fixed
91
+ threshold applied to material it does not suit is not a degraded answer, it
92
+ is a wrong one: a -33 dBFS programme measured against a -30 dB gate reads as
93
+ silent end to end, and the ripple would strip the whole clip. Keeping the
94
+ item whole and surfacing `reason` leaves a human able to set `threshold_db`
95
+ explicitly; cutting on an unvalidated gate does not.
96
+
97
+ `reason` is always populated when `usable` is False — a skip is never silent.
98
+ """
99
+
100
+ gate_db: float
101
+ basis: str # measured_dynamics | digital_silence | fallback_default
102
+ usable: bool
103
+ quiet_rms_db: Optional[float] = None
104
+ loud_rms_db: Optional[float] = None
105
+ separation_db: Optional[float] = None
106
+ reason: Optional[str] = None
107
+
108
+ def as_dict(self) -> dict:
109
+ def finite(value: Optional[float]) -> Optional[float]:
110
+ if value is None or not math.isfinite(value):
111
+ return None
112
+ return round(value, 2)
113
+
114
+ return {
115
+ "gate_db": round(self.gate_db, 2),
116
+ "basis": self.basis,
117
+ "usable": self.usable,
118
+ "quiet_rms_db": finite(self.quiet_rms_db),
119
+ "loud_rms_db": finite(self.loud_rms_db),
120
+ "quiet_is_digital_silence": self.quiet_rms_db == float("-inf"),
121
+ "separation_db": finite(self.separation_db),
122
+ "reason": self.reason,
123
+ }
124
+
27
125
 
28
126
  def ffmpeg_available() -> bool:
29
127
  return shutil.which("ffmpeg") is not None
@@ -46,24 +144,26 @@ def audio_stream_count(media_path: str) -> int:
46
144
  return sum(1 for s in streams if s.get("codec_type") == "audio")
47
145
 
48
146
 
49
- def build_silencedetect_args(
147
+ def build_audio_filter_args(
50
148
  media_path: str,
51
149
  start_sec: float,
52
150
  duration: float,
151
+ audio_filter: str,
53
152
  *,
54
- threshold_db: float,
55
- min_duration_sec: float,
56
153
  audio_streams: int,
57
154
  ) -> List[str]:
58
- """ffmpeg argv for silence detection over a source slice.
155
+ """ffmpeg argv applying one audio filter over a source slice.
59
156
 
60
157
  Multi-stream sources (e.g. production MXF with one mono stream per
61
158
  channel) are merged first: silencedetect's default joint mode then only
62
159
  triggers when EVERY channel is silent — matching Resolve's dialog, and
63
160
  immune to a dead scratch channel reading as all-silence. -vn skips the
64
161
  (potentially 4K) video decode entirely.
162
+
163
+ Every analysis filter routes through here so the merge and the -vn skip stay
164
+ identical across passes. A calibration measured on stream 0 alone while
165
+ detection ran on the merge would place the gate against the wrong signal.
65
166
  """
66
- detect = f"silencedetect=noise={threshold_db}dB:d={min_duration_sec}"
67
167
  args = [
68
168
  "ffmpeg", "-hide_banner", "-nostats",
69
169
  "-ss", str(start_sec),
@@ -73,13 +173,161 @@ def build_silencedetect_args(
73
173
  ]
74
174
  if audio_streams > 1:
75
175
  inputs = "".join(f"[0:a:{i}]" for i in range(audio_streams))
76
- args += ["-filter_complex", f"{inputs}amerge=inputs={audio_streams},{detect}"]
176
+ args += ["-filter_complex", f"{inputs}amerge=inputs={audio_streams},{audio_filter}"]
77
177
  else:
78
- args += ["-af", detect]
178
+ args += ["-af", audio_filter]
79
179
  args += ["-f", "null", "-"]
80
180
  return args
81
181
 
82
182
 
183
+ def build_silencedetect_args(
184
+ media_path: str,
185
+ start_sec: float,
186
+ duration: float,
187
+ *,
188
+ threshold_db: float,
189
+ min_duration_sec: float,
190
+ audio_streams: int,
191
+ ) -> List[str]:
192
+ """ffmpeg argv for silence detection over a source slice."""
193
+ return build_audio_filter_args(
194
+ media_path,
195
+ start_sec,
196
+ duration,
197
+ f"silencedetect=noise={threshold_db}dB:d={min_duration_sec}",
198
+ audio_streams=audio_streams,
199
+ )
200
+
201
+
202
+ def _parse_astats_levels(stderr: str) -> Tuple[Optional[float], Optional[float]]:
203
+ """(quiet_rms_db, loud_rms_db) from an astats summary; -inf is preserved.
204
+
205
+ -inf is a real measurement, not a parse failure: it means true digital
206
+ silence is present. Collapsing it to None would discard that.
207
+ """
208
+
209
+ def grab(pattern: "re.Pattern[str]") -> Optional[float]:
210
+ matches = pattern.findall(stderr)
211
+ if not matches:
212
+ return None
213
+ raw = matches[-1].strip().lower() # the Overall block prints last
214
+ if raw.endswith("inf"):
215
+ return float("-inf") if raw.startswith("-") else float("inf")
216
+ try:
217
+ return float(raw)
218
+ except ValueError:
219
+ return None
220
+
221
+ return grab(_ASTATS_RMS_TROUGH_RE), grab(_ASTATS_RMS_PEAK_RE)
222
+
223
+
224
+ def _gate_from_levels(quiet_rms_db: Optional[float], loud_rms_db: Optional[float]) -> NoiseCalibration:
225
+ """Pure level→gate policy, split out so it is testable without ffmpeg."""
226
+
227
+ def fallback(reason: str) -> NoiseCalibration:
228
+ return NoiseCalibration(
229
+ gate_db=DEFAULT_THRESHOLD_DB,
230
+ basis="fallback_default",
231
+ usable=False,
232
+ quiet_rms_db=quiet_rms_db,
233
+ loud_rms_db=loud_rms_db,
234
+ reason=reason,
235
+ )
236
+
237
+ if quiet_rms_db is None or loud_rms_db is None:
238
+ return fallback("astats reported no usable levels for this slice")
239
+ if not math.isfinite(loud_rms_db):
240
+ return fallback("the slice carries no programme audio to calibrate against")
241
+
242
+ if quiet_rms_db == float("-inf"):
243
+ # True digital silence: any gate between -inf and the programme works.
244
+ gate = min(max(loud_rms_db - DIGITAL_SILENCE_HEADROOM_DB, GATE_MIN_DB), GATE_MAX_DB)
245
+ return NoiseCalibration(
246
+ gate_db=gate,
247
+ basis="digital_silence",
248
+ usable=True,
249
+ quiet_rms_db=quiet_rms_db,
250
+ loud_rms_db=loud_rms_db,
251
+ separation_db=float("inf"),
252
+ reason="the slice contains true digital silence; the gate sits well under programme level",
253
+ )
254
+
255
+ separation = loud_rms_db - quiet_rms_db
256
+ if separation < MIN_SEPARATION_DB:
257
+ return NoiseCalibration(
258
+ gate_db=DEFAULT_THRESHOLD_DB,
259
+ basis="fallback_default",
260
+ usable=False,
261
+ quiet_rms_db=quiet_rms_db,
262
+ loud_rms_db=loud_rms_db,
263
+ separation_db=separation,
264
+ reason=(
265
+ f"quietest ({quiet_rms_db:.1f} dB) and loudest ({loud_rms_db:.1f} dB) windows are "
266
+ f"only {separation:.1f} dB apart; no threshold separates speech from room here, "
267
+ "so the default is used unchanged"
268
+ ),
269
+ )
270
+
271
+ gate = quiet_rms_db + GATE_BIAS * separation
272
+ gate = min(max(gate, GATE_MIN_DB), GATE_MAX_DB)
273
+ return NoiseCalibration(
274
+ gate_db=gate,
275
+ basis="measured_dynamics",
276
+ usable=True,
277
+ quiet_rms_db=quiet_rms_db,
278
+ loud_rms_db=loud_rms_db,
279
+ separation_db=separation,
280
+ )
281
+
282
+
283
+ def calibrate_silence_gate(
284
+ media_path: str,
285
+ start_sec: float,
286
+ end_sec: float,
287
+ *,
288
+ audio_streams: Optional[int] = None,
289
+ ) -> NoiseCalibration:
290
+ """Measure this slice's noise floor and place a silence gate just above it.
291
+
292
+ Falls back to `DEFAULT_THRESHOLD_DB` — always with a stated reason — when the
293
+ file cannot be measured or the floor is not separated from the programme.
294
+ """
295
+ if not media_path or not os.path.isfile(media_path):
296
+ return NoiseCalibration(
297
+ gate_db=DEFAULT_THRESHOLD_DB, basis="fallback_default", usable=False,
298
+ reason="media file is not readable",
299
+ )
300
+ if end_sec <= start_sec:
301
+ return NoiseCalibration(
302
+ gate_db=DEFAULT_THRESHOLD_DB, basis="fallback_default", usable=False,
303
+ reason="empty source range",
304
+ )
305
+ from src.utils.media_analysis import _run_command
306
+
307
+ streams = audio_stream_count(media_path) if audio_streams is None else audio_streams
308
+ if streams == 0:
309
+ return NoiseCalibration(
310
+ gate_db=DEFAULT_THRESHOLD_DB, basis="fallback_default", usable=False,
311
+ reason="no audio streams",
312
+ )
313
+ args = build_audio_filter_args(
314
+ media_path, start_sec, end_sec - start_sec, "astats", audio_streams=streams
315
+ )
316
+ code, _, stderr = _run_command(args)
317
+ if code != 0 and streams > 1:
318
+ # Same amerge fallback as detection: mismatched sample rates are refused.
319
+ args = build_audio_filter_args(
320
+ media_path, start_sec, end_sec - start_sec, "astats", audio_streams=1
321
+ )
322
+ code, _, stderr = _run_command(args)
323
+ if code != 0:
324
+ return NoiseCalibration(
325
+ gate_db=DEFAULT_THRESHOLD_DB, basis="fallback_default", usable=False,
326
+ reason="ffmpeg astats failed on this slice",
327
+ )
328
+ return _gate_from_levels(*_parse_astats_levels(stderr))
329
+
330
+
83
331
  def detect_silence_in_range(
84
332
  media_path: str,
85
333
  start_sec: float,
@@ -33,6 +33,13 @@ try: # numpy is present in most installs but is not a hard requirement.
33
33
  except ImportError: # pragma: no cover - exercised on minimal installs
34
34
  _np = None
35
35
 
36
+ #: Compute helpers below take numpy arrays as arguments and are only reachable
37
+ #: through entry points that call `_require("numpy")`. Declared so the contract
38
+ #: is greppable instead of living in one comment two hundred lines down.
39
+ _OPTIONAL_DEPENDENCY_CONTRACT = (
40
+ "numpy: every analyzer entry point gates on _require('numpy'); compute helpers assume it"
41
+ )
42
+
36
43
  PROSODY_SOURCE = "prosody_v1"
37
44
  PROSODY_VERSION = "1.0.0"
38
45
  BEATGRID_SOURCE = "beatgrid_v1"
@@ -57,6 +57,12 @@ _BROW = {"left": 105, "right": 334}
57
57
  _FACE_BOX = {"top": 10, "bottom": 152}
58
58
 
59
59
  EAR_BLINK_THRESHOLD = 0.21
60
+ #: Every entry point checks `capabilities()["available"]` or raises up front,
61
+ #: so helpers below assume cv2/mediapipe are present.
62
+ _OPTIONAL_DEPENDENCY_CONTRACT = (
63
+ "opencv-python + mediapipe: run_face_strata refuses via capabilities() before any helper runs"
64
+ )
65
+
60
66
  EAR_MIN_CLOSED_FRAMES = 1
61
67
  EAR_MAX_CLOSED_SECONDS = 0.5 # longer than this is eyes-closed, not a blink
62
68