davinci-resolve-mcp 2.59.0 → 2.61.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 +134 -0
- package/README.md +2 -2
- package/docs/SKILL.md +42 -4
- package/docs/install.md +8 -0
- package/docs/reference/api-limitations.md +9 -1
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/granular/common.py +1 -1
- package/src/server.py +268 -6
- package/src/utils/analysis_store.py +36 -1
- package/src/utils/api_truth.py +56 -0
- package/src/utils/destructive_hook.py +4 -1
- package/src/utils/edit_engine.py +34 -3
- package/src/utils/strata.py +565 -0
- package/src/utils/strata_analyzers.py +684 -0
- package/src/utils/strata_faces.py +347 -0
- package/src/utils/strata_queries.py +643 -0
- package/src/utils/strata_story.py +311 -0
- package/src/utils/timeline_brain_db.py +121 -1
- package/src/utils/timeline_versioning.py +19 -2
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""Face strata — blink events + gaze/expression curves (optional capability).
|
|
2
|
+
|
|
3
|
+
Requires mediapipe + opencv (``pip install mediapipe opencv-python``). Both
|
|
4
|
+
are optional extras: when absent this module still imports, reports itself
|
|
5
|
+
unavailable, and run_face_strata refuses honestly. Nothing else in the
|
|
6
|
+
strata stack depends on it — cut_candidates simply notes the blink track as
|
|
7
|
+
missing.
|
|
8
|
+
|
|
9
|
+
What it writes (geometric measurements, not emotion classification):
|
|
10
|
+
- events ``blink`` — eye-aspect-ratio dip (Soukupová/Čech EAR)
|
|
11
|
+
- curves ``gaze_x`` / ``gaze_y`` — iris offset within the eye box, -1..1
|
|
12
|
+
- curves ``expression_mouth_open`` — mouth aspect ratio 0..~1
|
|
13
|
+
- curves ``expression_brow_raise`` — brow-to-eye distance, face-height units
|
|
14
|
+
|
|
15
|
+
The compute layer is pure (landmark series in, tracks out) so blink/gaze
|
|
16
|
+
logic is unit-testable without mediapipe; only the capture loop needs it.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
import math
|
|
23
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
24
|
+
|
|
25
|
+
from src.utils import strata, timeline_brain_db
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger("resolve-mcp.strata-faces")
|
|
28
|
+
|
|
29
|
+
FACE_SOURCE = "face_v1"
|
|
30
|
+
FACE_VERSION = "1.0.0"
|
|
31
|
+
|
|
32
|
+
FACE_CURVE_RATE_DEFAULT = 12.0 # analysis fps; blinks need >=10 to be caught
|
|
33
|
+
|
|
34
|
+
# Broad excepts on purpose: mediapipe (and cv2) can raise non-ImportError
|
|
35
|
+
# exceptions at import time (e.g. protobuf version mismatches raise TypeError).
|
|
36
|
+
# A broken optional dependency must degrade to "face analyzer unavailable",
|
|
37
|
+
# never crash strata_status / strata_run.
|
|
38
|
+
try: # pragma: no cover - environment-dependent
|
|
39
|
+
import cv2 as _cv2
|
|
40
|
+
except Exception as _exc: # pragma: no cover
|
|
41
|
+
logger.debug("cv2 unavailable: %s", _exc)
|
|
42
|
+
_cv2 = None
|
|
43
|
+
|
|
44
|
+
try: # pragma: no cover - environment-dependent
|
|
45
|
+
import mediapipe as _mp
|
|
46
|
+
except Exception as _exc: # pragma: no cover
|
|
47
|
+
logger.debug("mediapipe unavailable: %s", _exc)
|
|
48
|
+
_mp = None
|
|
49
|
+
|
|
50
|
+
# FaceMesh landmark indices (canonical mediapipe topology).
|
|
51
|
+
_LEFT_EYE = {"outer": 33, "inner": 133, "top1": 160, "top2": 158, "bot1": 144, "bot2": 153}
|
|
52
|
+
_RIGHT_EYE = {"outer": 362, "inner": 263, "top1": 385, "top2": 387, "bot1": 380, "bot2": 373}
|
|
53
|
+
_LEFT_IRIS = (468, 469, 470, 471, 472)
|
|
54
|
+
_RIGHT_IRIS = (473, 474, 475, 476, 477)
|
|
55
|
+
_MOUTH = {"left": 61, "right": 291, "top": 13, "bottom": 14}
|
|
56
|
+
_BROW = {"left": 105, "right": 334}
|
|
57
|
+
_FACE_BOX = {"top": 10, "bottom": 152}
|
|
58
|
+
|
|
59
|
+
EAR_BLINK_THRESHOLD = 0.21
|
|
60
|
+
EAR_MIN_CLOSED_FRAMES = 1
|
|
61
|
+
EAR_MAX_CLOSED_SECONDS = 0.5 # longer than this is eyes-closed, not a blink
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def capabilities() -> Dict[str, Any]:
|
|
65
|
+
available = _cv2 is not None and _mp is not None
|
|
66
|
+
return {
|
|
67
|
+
"available": available,
|
|
68
|
+
"requires": ["opencv-python", "mediapipe"],
|
|
69
|
+
"missing": [
|
|
70
|
+
name
|
|
71
|
+
for name, mod in (("opencv-python", _cv2), ("mediapipe", _mp))
|
|
72
|
+
if mod is None
|
|
73
|
+
],
|
|
74
|
+
"writes": {
|
|
75
|
+
"events": ["blink"],
|
|
76
|
+
"curves": ["gaze_x", "gaze_y", "expression_mouth_open", "expression_brow_raise"],
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ── pure compute: landmark series → tracks ───────────────────────────────────
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _dist(a: Tuple[float, float], b: Tuple[float, float]) -> float:
|
|
85
|
+
return math.hypot(a[0] - b[0], a[1] - b[1])
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def eye_aspect_ratio(pts: Dict[str, Tuple[float, float]]) -> float:
|
|
89
|
+
"""EAR = (‖top1−bot1‖ + ‖top2−bot2‖) / (2·‖outer−inner‖)."""
|
|
90
|
+
horiz = _dist(pts["outer"], pts["inner"])
|
|
91
|
+
if horiz <= 0:
|
|
92
|
+
return 0.0
|
|
93
|
+
return (_dist(pts["top1"], pts["bot1"]) + _dist(pts["top2"], pts["bot2"])) / (2.0 * horiz)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def detect_blinks(
|
|
97
|
+
ear_series: Sequence[Optional[float]],
|
|
98
|
+
rate_hz: float,
|
|
99
|
+
threshold: float = EAR_BLINK_THRESHOLD,
|
|
100
|
+
) -> List[Dict[str, Any]]:
|
|
101
|
+
"""Blink events from an EAR time series (None = no face that frame).
|
|
102
|
+
|
|
103
|
+
A blink is a run of frames with EAR below threshold, at least
|
|
104
|
+
EAR_MIN_CLOSED_FRAMES long and shorter than EAR_MAX_CLOSED_SECONDS
|
|
105
|
+
(longer runs are eyes-closed, recorded with kind='eyes_closed').
|
|
106
|
+
"""
|
|
107
|
+
blinks: List[Dict[str, Any]] = []
|
|
108
|
+
run_start: Optional[int] = None
|
|
109
|
+
max_frames = int(EAR_MAX_CLOSED_SECONDS * rate_hz)
|
|
110
|
+
|
|
111
|
+
def flush(end_index: int) -> None:
|
|
112
|
+
nonlocal run_start
|
|
113
|
+
if run_start is None:
|
|
114
|
+
return
|
|
115
|
+
length = end_index - run_start
|
|
116
|
+
if length >= EAR_MIN_CLOSED_FRAMES:
|
|
117
|
+
event = {
|
|
118
|
+
"time_seconds": run_start / rate_hz,
|
|
119
|
+
"duration_seconds": length / rate_hz,
|
|
120
|
+
"payload": {"kind": "blink" if length <= max_frames else "eyes_closed"},
|
|
121
|
+
}
|
|
122
|
+
blinks.append(event)
|
|
123
|
+
run_start = None
|
|
124
|
+
|
|
125
|
+
for i, ear in enumerate(ear_series):
|
|
126
|
+
below = ear is not None and ear < threshold
|
|
127
|
+
if below and run_start is None:
|
|
128
|
+
run_start = i
|
|
129
|
+
elif not below:
|
|
130
|
+
flush(i)
|
|
131
|
+
flush(len(ear_series))
|
|
132
|
+
return blinks
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def iris_offset(
|
|
136
|
+
eye_pts: Dict[str, Tuple[float, float]],
|
|
137
|
+
iris_center: Tuple[float, float],
|
|
138
|
+
) -> Tuple[float, float]:
|
|
139
|
+
"""Iris position inside the eye box, each axis -1..1 (0 = centered)."""
|
|
140
|
+
cx = (eye_pts["outer"][0] + eye_pts["inner"][0]) / 2.0
|
|
141
|
+
cy = (eye_pts["top1"][1] + eye_pts["top2"][1] + eye_pts["bot1"][1] + eye_pts["bot2"][1]) / 4.0
|
|
142
|
+
half_w = abs(eye_pts["inner"][0] - eye_pts["outer"][0]) / 2.0 or 1.0
|
|
143
|
+
half_h = (
|
|
144
|
+
abs((eye_pts["bot1"][1] + eye_pts["bot2"][1]) / 2.0 - (eye_pts["top1"][1] + eye_pts["top2"][1]) / 2.0)
|
|
145
|
+
) / 2.0 or 1.0
|
|
146
|
+
return (
|
|
147
|
+
max(-1.0, min(1.0, (iris_center[0] - cx) / half_w)),
|
|
148
|
+
max(-1.0, min(1.0, (iris_center[1] - cy) / half_h)),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def landmarks_to_tracks(
|
|
153
|
+
frames: Sequence[Optional[Dict[str, Any]]],
|
|
154
|
+
rate_hz: float,
|
|
155
|
+
) -> Dict[str, Any]:
|
|
156
|
+
"""Per-frame landmark dicts → blink events + gaze/expression curves.
|
|
157
|
+
|
|
158
|
+
Each frame entry (or None when no face): {
|
|
159
|
+
left_eye/right_eye: {outer,inner,top1,top2,bot1,bot2: (x,y)},
|
|
160
|
+
left_iris/right_iris: (x,y),
|
|
161
|
+
mouth: {left,right,top,bottom: (x,y)},
|
|
162
|
+
brow: {left,right: (x,y)}, face: {top,bottom: (x,y)},
|
|
163
|
+
}
|
|
164
|
+
"""
|
|
165
|
+
nan = float("nan")
|
|
166
|
+
ear_series: List[Optional[float]] = []
|
|
167
|
+
gaze_x: List[float] = []
|
|
168
|
+
gaze_y: List[float] = []
|
|
169
|
+
mouth_open: List[float] = []
|
|
170
|
+
brow_raise: List[float] = []
|
|
171
|
+
|
|
172
|
+
for frame in frames:
|
|
173
|
+
if not frame:
|
|
174
|
+
ear_series.append(None)
|
|
175
|
+
gaze_x.append(nan)
|
|
176
|
+
gaze_y.append(nan)
|
|
177
|
+
mouth_open.append(nan)
|
|
178
|
+
brow_raise.append(nan)
|
|
179
|
+
continue
|
|
180
|
+
ears = []
|
|
181
|
+
offsets = []
|
|
182
|
+
for eye_key, iris_key in (("left_eye", "left_iris"), ("right_eye", "right_iris")):
|
|
183
|
+
eye = frame.get(eye_key)
|
|
184
|
+
if not eye:
|
|
185
|
+
continue
|
|
186
|
+
ears.append(eye_aspect_ratio(eye))
|
|
187
|
+
iris = frame.get(iris_key)
|
|
188
|
+
if iris:
|
|
189
|
+
offsets.append(iris_offset(eye, iris))
|
|
190
|
+
ear_series.append(sum(ears) / len(ears) if ears else None)
|
|
191
|
+
if offsets:
|
|
192
|
+
gaze_x.append(sum(o[0] for o in offsets) / len(offsets))
|
|
193
|
+
gaze_y.append(sum(o[1] for o in offsets) / len(offsets))
|
|
194
|
+
else:
|
|
195
|
+
gaze_x.append(nan)
|
|
196
|
+
gaze_y.append(nan)
|
|
197
|
+
|
|
198
|
+
mouth = frame.get("mouth")
|
|
199
|
+
if mouth:
|
|
200
|
+
width = _dist(mouth["left"], mouth["right"]) or 1.0
|
|
201
|
+
mouth_open.append(_dist(mouth["top"], mouth["bottom"]) / width)
|
|
202
|
+
else:
|
|
203
|
+
mouth_open.append(nan)
|
|
204
|
+
|
|
205
|
+
brow = frame.get("brow")
|
|
206
|
+
face = frame.get("face")
|
|
207
|
+
eye = frame.get("left_eye") or frame.get("right_eye")
|
|
208
|
+
if brow and face and eye:
|
|
209
|
+
face_h = _dist(face["top"], face["bottom"]) or 1.0
|
|
210
|
+
eye_top_y = (eye["top1"][1] + eye["top2"][1]) / 2.0
|
|
211
|
+
brow_y = (brow["left"][1] + brow["right"][1]) / 2.0
|
|
212
|
+
brow_raise.append(abs(eye_top_y - brow_y) / face_h)
|
|
213
|
+
else:
|
|
214
|
+
brow_raise.append(nan)
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
"blinks": detect_blinks(ear_series, rate_hz),
|
|
218
|
+
"curves": {
|
|
219
|
+
"gaze_x": gaze_x,
|
|
220
|
+
"gaze_y": gaze_y,
|
|
221
|
+
"expression_mouth_open": mouth_open,
|
|
222
|
+
"expression_brow_raise": brow_raise,
|
|
223
|
+
},
|
|
224
|
+
"face_frame_count": sum(1 for e in ear_series if e is not None),
|
|
225
|
+
"frame_count": len(frames),
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ── capture loop (mediapipe/cv2 required) ────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _landmark_frame(landmarks, width: int, height: int) -> Dict[str, Any]:
|
|
233
|
+
def pt(idx: int) -> Tuple[float, float]:
|
|
234
|
+
lm = landmarks[idx]
|
|
235
|
+
return (lm.x * width, lm.y * height)
|
|
236
|
+
|
|
237
|
+
def group(spec: Dict[str, int]) -> Dict[str, Tuple[float, float]]:
|
|
238
|
+
return {name: pt(idx) for name, idx in spec.items()}
|
|
239
|
+
|
|
240
|
+
frame: Dict[str, Any] = {
|
|
241
|
+
"left_eye": group(_LEFT_EYE),
|
|
242
|
+
"right_eye": group(_RIGHT_EYE),
|
|
243
|
+
"mouth": group(_MOUTH),
|
|
244
|
+
"brow": group(_BROW),
|
|
245
|
+
"face": group(_FACE_BOX),
|
|
246
|
+
}
|
|
247
|
+
if len(landmarks) > _RIGHT_IRIS[-1]:
|
|
248
|
+
for key, indices in (("left_iris", _LEFT_IRIS), ("right_iris", _RIGHT_IRIS)):
|
|
249
|
+
xs = [pt(i)[0] for i in indices]
|
|
250
|
+
ys = [pt(i)[1] for i in indices]
|
|
251
|
+
frame[key] = (sum(xs) / len(xs), sum(ys) / len(ys))
|
|
252
|
+
return frame
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def extract_landmark_frames(
|
|
256
|
+
video_path: str,
|
|
257
|
+
rate_hz: float = FACE_CURVE_RATE_DEFAULT,
|
|
258
|
+
max_seconds: Optional[float] = None,
|
|
259
|
+
) -> Tuple[List[Optional[Dict[str, Any]]], float]:
|
|
260
|
+
"""Sample the video at ~rate_hz and run FaceMesh per sampled frame."""
|
|
261
|
+
if _cv2 is None or _mp is None: # pragma: no cover
|
|
262
|
+
raise RuntimeError("face strata require opencv-python + mediapipe")
|
|
263
|
+
cap = _cv2.VideoCapture(video_path)
|
|
264
|
+
if not cap.isOpened():
|
|
265
|
+
raise RuntimeError(f"could not open video: {video_path}")
|
|
266
|
+
native_fps = cap.get(_cv2.CAP_PROP_FPS) or 24.0
|
|
267
|
+
step = max(1, int(round(native_fps / rate_hz)))
|
|
268
|
+
effective_rate = native_fps / step
|
|
269
|
+
frames: List[Optional[Dict[str, Any]]] = []
|
|
270
|
+
with _mp.solutions.face_mesh.FaceMesh(
|
|
271
|
+
static_image_mode=False,
|
|
272
|
+
max_num_faces=1,
|
|
273
|
+
refine_landmarks=True,
|
|
274
|
+
min_detection_confidence=0.5,
|
|
275
|
+
min_tracking_confidence=0.5,
|
|
276
|
+
) as mesh:
|
|
277
|
+
index = 0
|
|
278
|
+
while True:
|
|
279
|
+
ok, image = cap.read()
|
|
280
|
+
if not ok:
|
|
281
|
+
break
|
|
282
|
+
if index % step == 0:
|
|
283
|
+
if max_seconds is not None and (index / native_fps) > max_seconds:
|
|
284
|
+
break
|
|
285
|
+
result = mesh.process(_cv2.cvtColor(image, _cv2.COLOR_BGR2RGB))
|
|
286
|
+
if result.multi_face_landmarks:
|
|
287
|
+
height, width = image.shape[:2]
|
|
288
|
+
frames.append(
|
|
289
|
+
_landmark_frame(result.multi_face_landmarks[0].landmark, width, height)
|
|
290
|
+
)
|
|
291
|
+
else:
|
|
292
|
+
frames.append(None)
|
|
293
|
+
index += 1
|
|
294
|
+
cap.release()
|
|
295
|
+
return frames, effective_rate
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def run_face_strata(
|
|
299
|
+
project_root: str,
|
|
300
|
+
clip_ref: Any,
|
|
301
|
+
*,
|
|
302
|
+
rate_hz: float = FACE_CURVE_RATE_DEFAULT,
|
|
303
|
+
max_seconds: Optional[float] = None,
|
|
304
|
+
) -> Dict[str, Any]:
|
|
305
|
+
"""Compute + persist face strata for one clip (refuses without deps)."""
|
|
306
|
+
caps = capabilities()
|
|
307
|
+
if not caps["available"]:
|
|
308
|
+
return {
|
|
309
|
+
"success": False,
|
|
310
|
+
"error": f"face strata unavailable — install: {', '.join(caps['missing'])}",
|
|
311
|
+
"missing": caps["missing"],
|
|
312
|
+
}
|
|
313
|
+
from src.utils.strata_analyzers import _clip_row
|
|
314
|
+
|
|
315
|
+
clip, err = _clip_row(project_root, clip_ref)
|
|
316
|
+
if err:
|
|
317
|
+
return err
|
|
318
|
+
try:
|
|
319
|
+
frames, effective_rate = extract_landmark_frames(
|
|
320
|
+
clip["file_path"], rate_hz=rate_hz, max_seconds=max_seconds
|
|
321
|
+
)
|
|
322
|
+
except RuntimeError as exc:
|
|
323
|
+
return {"success": False, "error": str(exc), "clip_uuid": clip["clip_uuid"]}
|
|
324
|
+
if not frames:
|
|
325
|
+
return {"success": False, "error": "no video frames sampled", "clip_uuid": clip["clip_uuid"]}
|
|
326
|
+
|
|
327
|
+
tracks = landmarks_to_tracks(frames, effective_rate)
|
|
328
|
+
with timeline_brain_db.transaction(project_root) as txn:
|
|
329
|
+
strata.replace_track_events(
|
|
330
|
+
txn, clip["clip_uuid"], "blink", tracks["blinks"],
|
|
331
|
+
source=FACE_SOURCE, analyzer_version=FACE_VERSION,
|
|
332
|
+
)
|
|
333
|
+
for track_name, values in tracks["curves"].items():
|
|
334
|
+
strata.write_curve(
|
|
335
|
+
txn, clip["clip_uuid"], track_name, values,
|
|
336
|
+
sample_rate=effective_rate, source=FACE_SOURCE, analyzer_version=FACE_VERSION,
|
|
337
|
+
)
|
|
338
|
+
return {
|
|
339
|
+
"success": True,
|
|
340
|
+
"clip_uuid": clip["clip_uuid"],
|
|
341
|
+
"clip_name": clip["clip_name"],
|
|
342
|
+
"sample_rate": round(effective_rate, 3),
|
|
343
|
+
"frames_sampled": tracks["frame_count"],
|
|
344
|
+
"frames_with_face": tracks["face_frame_count"],
|
|
345
|
+
"blink_count": len(tracks["blinks"]),
|
|
346
|
+
"analyzer_version": FACE_VERSION,
|
|
347
|
+
}
|