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.
- package/CHANGELOG.md +99 -0
- package/README.md +27 -2
- package/docs/SKILL.md +20 -0
- package/install.py +1 -1
- package/package.json +1 -1
- package/scripts/bridge_differential.py +431 -0
- package/scripts/install_resolve_bridge.py +260 -0
- package/scripts/resolve_bridge_launcher.py +202 -0
- package/scripts/resolve_bridge_probe.py +162 -0
- package/scripts/resolve_capability_probe.py +270 -0
- package/src/granular/common.py +6 -1
- package/src/server.py +193 -6
- package/src/utils/bridge_differential.py +375 -0
- package/src/utils/captions.py +323 -0
- package/src/utils/colorimetry.py +196 -0
- package/src/utils/delivery_targets.py +225 -0
- package/src/utils/edit_engine.py +181 -7
- package/src/utils/image_qc.py +499 -0
- package/src/utils/page_lock.py +5 -1
- package/src/utils/resolve_bridge.py +557 -0
- package/src/utils/resolve_bridge_client.py +423 -0
- package/src/utils/resolve_bridge_ops.py +714 -0
- package/src/utils/resolve_connection.py +29 -1
- package/src/utils/silence_ripple.py +256 -8
- package/src/utils/strata_analyzers.py +7 -0
- package/src/utils/strata_faces.py +6 -0
- package/src/utils/transcript_edit.py +372 -0
|
@@ -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,
|
|
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
|
|
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
|
|
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
|
|
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},{
|
|
176
|
+
args += ["-filter_complex", f"{inputs}amerge=inputs={audio_streams},{audio_filter}"]
|
|
77
177
|
else:
|
|
78
|
-
args += ["-af",
|
|
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
|
|