davinci-resolve-mcp 2.60.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.
@@ -0,0 +1,565 @@
1
+ """Perception strata — store + query layer for the timecoded track model.
2
+
3
+ The strata are per-clip, timecoded annotation tracks in the per-project
4
+ timeline-brain DB (schema v13+): ``events`` (point/span occurrences),
5
+ ``curves`` (sampled float32 series), ``transcript_words`` (word-level
6
+ timestamps promoted out of the report blob), and ``story_beats`` (units of
7
+ meaning with supersede semantics).
8
+
9
+ Design rules (mirrors analysis_store):
10
+ - All times are clip-relative seconds. Timeline/record-time projection is a
11
+ query-layer concern (via timeline_clip_usage), never an analyzer concern.
12
+ - Analyzers are *track writers*: a machine re-run replaces its own
13
+ (clip, track, source) rows. Rows with source='human' are never touched by
14
+ machine writers and always win.
15
+ - No heavy imports at module level. The store layer is stdlib-only; numpy
16
+ and ffmpeg live in strata_analyzers and are optional.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import logging
23
+ import math
24
+ import sqlite3
25
+ import time
26
+ from array import array
27
+ from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
28
+
29
+ from src.utils import timeline_brain_db
30
+
31
+ logger = logging.getLogger("resolve-mcp.strata")
32
+
33
+ HUMAN_SOURCE = "human"
34
+
35
+ # Well-known track names. Not enforced by the schema (new analyzers may add
36
+ # tracks freely); listed so agents and the dashboard have a stable vocabulary.
37
+ EVENT_TRACKS = (
38
+ "pause", # speech gap inside a spoken region (duration = gap length)
39
+ "breath", # audible inhale candidate inside a speech gap
40
+ "hesitation", # filler word (uh/um/er) from the transcript
41
+ "beat", # musical beat (payload: {"tempo_bpm": float})
42
+ "downbeat", # low-confidence bar-start estimate
43
+ "blink", # eye blink (payload may carry entity_uuid)
44
+ "gesture_boundary",
45
+ )
46
+ CURVE_TRACKS = (
47
+ "pitch", # Hz; NaN where unvoiced/silent
48
+ "vocal_energy", # RMS 0..1
49
+ "speech_rate", # words/sec smoothed
50
+ "motion_energy", # mean abs frame difference 0..1
51
+ "loudness", # momentary LUFS-like envelope
52
+ )
53
+
54
+
55
+ def _now() -> str:
56
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
57
+
58
+
59
+ def _dumps(value: Any) -> str:
60
+ return json.dumps(value, sort_keys=True, default=str)
61
+
62
+
63
+ # ── float32 blob convention (shared with embeddings.vector) ──────────────────
64
+
65
+
66
+ def pack_curve(values: Sequence[float]) -> bytes:
67
+ """Encode a float sequence as a little-endian float32 BLOB."""
68
+ arr = array("f", (float(v) for v in values))
69
+ return arr.tobytes()
70
+
71
+
72
+ def unpack_curve(blob: bytes) -> List[float]:
73
+ """Decode a float32 BLOB back to a list of floats."""
74
+ arr = array("f")
75
+ arr.frombytes(blob)
76
+ return list(arr)
77
+
78
+
79
+ def curve_stats(values: Sequence[float]) -> Dict[str, Any]:
80
+ """min/max/mean over the finite samples — cheap query pre-filter."""
81
+ finite = [v for v in values if not math.isnan(v)]
82
+ if not finite:
83
+ return {"count": len(values), "finite_count": 0}
84
+ return {
85
+ "count": len(values),
86
+ "finite_count": len(finite),
87
+ "min": min(finite),
88
+ "max": max(finite),
89
+ "mean": sum(finite) / len(finite),
90
+ }
91
+
92
+
93
+ # ── writers ──────────────────────────────────────────────────────────────────
94
+
95
+
96
+ def replace_track_events(
97
+ conn: sqlite3.Connection,
98
+ clip_uuid: str,
99
+ track: str,
100
+ events: Iterable[Dict[str, Any]],
101
+ *,
102
+ source: str,
103
+ analyzer_version: str,
104
+ ) -> int:
105
+ """Replace this writer's rows for (clip, track): idempotent re-runs.
106
+
107
+ Each event: {time_seconds, duration_seconds?, payload?}. Human rows on
108
+ the same track are left untouched.
109
+ """
110
+ if source == HUMAN_SOURCE:
111
+ raise ValueError("machine writer API; human events go through record_human_event")
112
+ conn.execute(
113
+ "DELETE FROM events WHERE clip_uuid = ? AND track = ? AND source = ?",
114
+ (clip_uuid, track, source),
115
+ )
116
+ now = _now()
117
+ written = 0
118
+ for event in events:
119
+ t = event.get("time_seconds")
120
+ if not isinstance(t, (int, float)) or math.isnan(float(t)):
121
+ continue
122
+ duration = event.get("duration_seconds")
123
+ payload = event.get("payload")
124
+ conn.execute(
125
+ """
126
+ INSERT INTO events
127
+ (clip_uuid, track, time_seconds, duration_seconds,
128
+ payload_json, source, analyzer_version, created_at)
129
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
130
+ """,
131
+ (
132
+ clip_uuid,
133
+ track,
134
+ float(t),
135
+ float(duration) if isinstance(duration, (int, float)) else None,
136
+ _dumps(payload) if payload is not None else None,
137
+ source,
138
+ analyzer_version,
139
+ now,
140
+ ),
141
+ )
142
+ written += 1
143
+ return written
144
+
145
+
146
+ def record_human_event(
147
+ conn: sqlite3.Connection,
148
+ clip_uuid: str,
149
+ track: str,
150
+ time_seconds: float,
151
+ *,
152
+ duration_seconds: Optional[float] = None,
153
+ payload: Any = None,
154
+ author: str = "human",
155
+ ) -> int:
156
+ """Append one human-judged event (never bulk-replaced by machines)."""
157
+ cur = conn.execute(
158
+ """
159
+ INSERT INTO events
160
+ (clip_uuid, track, time_seconds, duration_seconds,
161
+ payload_json, source, analyzer_version, created_at)
162
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
163
+ """,
164
+ (
165
+ clip_uuid,
166
+ track,
167
+ float(time_seconds),
168
+ float(duration_seconds) if duration_seconds is not None else None,
169
+ _dumps({"author": author, **(payload if isinstance(payload, dict) else {"value": payload} if payload is not None else {})}),
170
+ HUMAN_SOURCE,
171
+ "human",
172
+ _now(),
173
+ ),
174
+ )
175
+ return int(cur.lastrowid or 0)
176
+
177
+
178
+ def write_curve(
179
+ conn: sqlite3.Connection,
180
+ clip_uuid: str,
181
+ track: str,
182
+ values: Sequence[float],
183
+ *,
184
+ sample_rate: float,
185
+ start_seconds: float = 0.0,
186
+ source: str,
187
+ analyzer_version: str,
188
+ ) -> Dict[str, Any]:
189
+ """Upsert one sampled series for (clip, track, source)."""
190
+ stats = curve_stats(values)
191
+ conn.execute(
192
+ """
193
+ INSERT OR REPLACE INTO curves
194
+ (clip_uuid, track, start_seconds, sample_rate, values_blob,
195
+ stats_json, source, analyzer_version, created_at)
196
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
197
+ """,
198
+ (
199
+ clip_uuid,
200
+ track,
201
+ float(start_seconds),
202
+ float(sample_rate),
203
+ pack_curve(values),
204
+ _dumps(stats),
205
+ source,
206
+ analyzer_version,
207
+ _now(),
208
+ ),
209
+ )
210
+ return stats
211
+
212
+
213
+ # ── readers ──────────────────────────────────────────────────────────────────
214
+
215
+
216
+ def read_events(
217
+ conn: sqlite3.Connection,
218
+ clip_uuid: str,
219
+ track: Optional[str] = None,
220
+ *,
221
+ start_seconds: Optional[float] = None,
222
+ end_seconds: Optional[float] = None,
223
+ ) -> List[Dict[str, Any]]:
224
+ sql = "SELECT * FROM events WHERE clip_uuid = ?"
225
+ args: List[Any] = [clip_uuid]
226
+ if track:
227
+ sql += " AND track = ?"
228
+ args.append(track)
229
+ # Window semantics: a span event overlaps the window, not merely starts
230
+ # inside it — a 3 s pause that began before the window is still a pause.
231
+ # Point events (no duration) use the half-open [start, end) convention.
232
+ if start_seconds is not None:
233
+ sql += " AND (time_seconds >= ? OR time_seconds + COALESCE(duration_seconds, 0) > ?)"
234
+ args.append(float(start_seconds))
235
+ args.append(float(start_seconds))
236
+ if end_seconds is not None:
237
+ sql += " AND time_seconds < ?"
238
+ args.append(float(end_seconds))
239
+ sql += " ORDER BY time_seconds"
240
+ out = []
241
+ for row in conn.execute(sql, args).fetchall():
242
+ item = {
243
+ "track": row["track"],
244
+ "time_seconds": row["time_seconds"],
245
+ "duration_seconds": row["duration_seconds"],
246
+ "source": row["source"],
247
+ "analyzer_version": row["analyzer_version"],
248
+ }
249
+ if row["payload_json"]:
250
+ try:
251
+ item["payload"] = json.loads(row["payload_json"])
252
+ except (TypeError, ValueError):
253
+ pass
254
+ out.append(item)
255
+ return out
256
+
257
+
258
+ def read_curve(
259
+ conn: sqlite3.Connection,
260
+ clip_uuid: str,
261
+ track: str,
262
+ *,
263
+ source: Optional[str] = None,
264
+ ) -> Optional[Dict[str, Any]]:
265
+ sql = "SELECT * FROM curves WHERE clip_uuid = ? AND track = ?"
266
+ args: List[Any] = [clip_uuid, track]
267
+ if source:
268
+ sql += " AND source = ?"
269
+ args.append(source)
270
+ row = conn.execute(sql + " ORDER BY created_at DESC LIMIT 1", args).fetchone()
271
+ if row is None:
272
+ return None
273
+ result = {
274
+ "track": row["track"],
275
+ "start_seconds": row["start_seconds"],
276
+ "sample_rate": row["sample_rate"],
277
+ "values": unpack_curve(row["values_blob"]),
278
+ "source": row["source"],
279
+ "analyzer_version": row["analyzer_version"],
280
+ }
281
+ if row["stats_json"]:
282
+ try:
283
+ result["stats"] = json.loads(row["stats_json"])
284
+ except (TypeError, ValueError):
285
+ pass
286
+ return result
287
+
288
+
289
+ def curve_value_at(curve: Dict[str, Any], time_seconds: float) -> Optional[float]:
290
+ """Sample a curve dict (from read_curve) at a clip time. None off-range/NaN."""
291
+ values = curve.get("values") or []
292
+ if not values:
293
+ return None
294
+ idx = int(round((time_seconds - float(curve.get("start_seconds") or 0.0)) * float(curve["sample_rate"])))
295
+ if idx < 0 or idx >= len(values):
296
+ return None
297
+ v = values[idx]
298
+ return None if math.isnan(v) else v
299
+
300
+
301
+ def read_words(
302
+ conn: sqlite3.Connection,
303
+ clip_uuid: str,
304
+ *,
305
+ start_seconds: Optional[float] = None,
306
+ end_seconds: Optional[float] = None,
307
+ match: Optional[str] = None,
308
+ ) -> List[Dict[str, Any]]:
309
+ sql = "SELECT * FROM transcript_words WHERE clip_uuid = ?"
310
+ args: List[Any] = [clip_uuid]
311
+ if start_seconds is not None:
312
+ sql += " AND start_seconds >= ?"
313
+ args.append(float(start_seconds))
314
+ if end_seconds is not None:
315
+ sql += " AND start_seconds < ?"
316
+ args.append(float(end_seconds))
317
+ if match:
318
+ escaped = match.replace("\\", "\\\\").replace("%", r"\%").replace("_", r"\_")
319
+ sql += " AND word LIKE ? ESCAPE '\\'"
320
+ args.append(f"%{escaped}%")
321
+ sql += " ORDER BY segment_index, word_index"
322
+ return [
323
+ {
324
+ "segment_index": row["segment_index"],
325
+ "word_index": row["word_index"],
326
+ "word": row["word"],
327
+ "start_seconds": row["start_seconds"],
328
+ "end_seconds": row["end_seconds"],
329
+ "confidence": row["confidence"],
330
+ }
331
+ for row in conn.execute(sql, args).fetchall()
332
+ ]
333
+
334
+
335
+ def list_tracks(conn: sqlite3.Connection, clip_uuid: str) -> Dict[str, Any]:
336
+ """Per-clip inventory: which tracks exist, from which writers."""
337
+ events = [
338
+ {
339
+ "track": row["track"],
340
+ "source": row["source"],
341
+ "analyzer_version": row["analyzer_version"],
342
+ "count": row["n"],
343
+ }
344
+ for row in conn.execute(
345
+ """
346
+ SELECT track, source, analyzer_version, COUNT(*) AS n
347
+ FROM events WHERE clip_uuid = ?
348
+ GROUP BY track, source, analyzer_version ORDER BY track
349
+ """,
350
+ (clip_uuid,),
351
+ ).fetchall()
352
+ ]
353
+ curves = [
354
+ {
355
+ "track": row["track"],
356
+ "source": row["source"],
357
+ "analyzer_version": row["analyzer_version"],
358
+ "sample_rate": row["sample_rate"],
359
+ "stats": json.loads(row["stats_json"]) if row["stats_json"] else None,
360
+ }
361
+ for row in conn.execute(
362
+ "SELECT track, source, analyzer_version, sample_rate, stats_json FROM curves WHERE clip_uuid = ? ORDER BY track",
363
+ (clip_uuid,),
364
+ ).fetchall()
365
+ ]
366
+ word_count = conn.execute(
367
+ "SELECT COUNT(*) AS n FROM transcript_words WHERE clip_uuid = ?", (clip_uuid,)
368
+ ).fetchone()["n"]
369
+ beat_count = conn.execute(
370
+ "SELECT COUNT(*) AS n FROM story_beats WHERE clip_uuid = ? AND superseded_at IS NULL",
371
+ (clip_uuid,),
372
+ ).fetchone()["n"]
373
+ return {
374
+ "events": events,
375
+ "curves": curves,
376
+ "word_count": int(word_count),
377
+ "story_beat_count": int(beat_count),
378
+ }
379
+
380
+
381
+ # ── transcript words: ingest + backfill ──────────────────────────────────────
382
+
383
+
384
+ def _word_confidence(word: Dict[str, Any]) -> Optional[float]:
385
+ for key in ("probability", "confidence", "score"):
386
+ value = word.get(key)
387
+ if isinstance(value, (int, float)):
388
+ return float(value)
389
+ return None
390
+
391
+
392
+ def ingest_transcript_words(
393
+ conn: sqlite3.Connection,
394
+ clip_uuid: str,
395
+ transcription: Dict[str, Any],
396
+ ) -> int:
397
+ """Rebuild transcript_words for a clip from a report's transcription block.
398
+
399
+ Words normally live per-segment (``segments[*].words``); segments without
400
+ their own words fall back to the top-level ``words`` list, bucketed by
401
+ start time. When the block yields no words at all, existing rows are left
402
+ untouched — a words-less re-analysis must not silently wipe previously
403
+ ingested or backfilled words. Returns the number of word rows written.
404
+ """
405
+ if not isinstance(transcription, dict):
406
+ return 0
407
+ segments = transcription.get("segments") if isinstance(transcription.get("segments"), list) else []
408
+
409
+ per_segment: List[List[Dict[str, Any]]] = []
410
+ for seg in segments:
411
+ words = seg.get("words") if isinstance(seg, dict) and isinstance(seg.get("words"), list) else []
412
+ per_segment.append([w for w in words if isinstance(w, dict)])
413
+
414
+ top_words = transcription.get("words") if isinstance(transcription.get("words"), list) else []
415
+ top_words = [w for w in top_words if isinstance(w, dict)]
416
+ if top_words:
417
+ if not segments:
418
+ per_segment = [top_words]
419
+ else:
420
+ bounds = []
421
+ for seg in segments:
422
+ start = seg.get("start") if isinstance(seg, dict) else None
423
+ bounds.append(float(start) if isinstance(start, (int, float)) else 0.0)
424
+ fallback: List[List[Dict[str, Any]]] = [[] for _ in segments]
425
+ for word in top_words:
426
+ t = word.get("start")
427
+ t = float(t) if isinstance(t, (int, float)) else 0.0
428
+ idx = 0
429
+ for i, b in enumerate(bounds):
430
+ if t >= b:
431
+ idx = i
432
+ fallback[idx].append(word)
433
+ for i, words in enumerate(per_segment):
434
+ if not words:
435
+ per_segment[i] = fallback[i]
436
+
437
+ rows: List[tuple] = []
438
+ for seg_idx, words in enumerate(per_segment):
439
+ for word_idx, word in enumerate(words):
440
+ text = str(word.get("word", word.get("text", ""))).strip()
441
+ if not text:
442
+ continue
443
+ start = word.get("start")
444
+ end = word.get("end")
445
+ rows.append(
446
+ (
447
+ clip_uuid,
448
+ seg_idx,
449
+ word_idx,
450
+ text,
451
+ float(start) if isinstance(start, (int, float)) else None,
452
+ float(end) if isinstance(end, (int, float)) else None,
453
+ _word_confidence(word),
454
+ )
455
+ )
456
+ if not rows:
457
+ return 0
458
+ conn.execute("DELETE FROM transcript_words WHERE clip_uuid = ?", (clip_uuid,))
459
+ conn.executemany(
460
+ """
461
+ INSERT OR REPLACE INTO transcript_words
462
+ (clip_uuid, segment_index, word_index, word,
463
+ start_seconds, end_seconds, confidence)
464
+ VALUES (?, ?, ?, ?, ?, ?, ?)
465
+ """,
466
+ rows,
467
+ )
468
+ return len(rows)
469
+
470
+
471
+ def backfill_transcript_words(project_root: str) -> Dict[str, Any]:
472
+ """Populate transcript_words from every stored report blob.
473
+
474
+ Reports written before v13 already carry per-word timestamps inside
475
+ ``transcription.segments[*].words`` — this promotes them to rows without
476
+ re-running any analysis. Idempotent.
477
+ """
478
+ conn = timeline_brain_db.connect(project_root)
479
+ rows = conn.execute("SELECT clip_uuid, report_json FROM analysis_reports").fetchall()
480
+ clips_seen = 0
481
+ clips_with_words = 0
482
+ words_written = 0
483
+ with timeline_brain_db.transaction(project_root) as txn:
484
+ for row in rows:
485
+ clips_seen += 1
486
+ try:
487
+ report = json.loads(row["report_json"])
488
+ except (TypeError, ValueError):
489
+ continue
490
+ transcription = report.get("transcription") if isinstance(report, dict) else None
491
+ if not isinstance(transcription, dict):
492
+ continue
493
+ n = ingest_transcript_words(txn, str(row["clip_uuid"]), transcription)
494
+ if n:
495
+ clips_with_words += 1
496
+ words_written += n
497
+ return {
498
+ "success": True,
499
+ "clips_seen": clips_seen,
500
+ "clips_with_words": clips_with_words,
501
+ "words_written": words_written,
502
+ }
503
+
504
+
505
+ # ── status ───────────────────────────────────────────────────────────────────
506
+
507
+
508
+ def strata_status(project_root: str, clip_ref: Any = None) -> Dict[str, Any]:
509
+ """Project-level (or per-clip) strata inventory."""
510
+ from src.utils import analysis_store
511
+
512
+ conn = timeline_brain_db.connect(project_root)
513
+ if clip_ref is not None:
514
+ clip_uuid = analysis_store.resolve_clip_uuid(conn, clip_ref)
515
+ if not clip_uuid:
516
+ return {"success": False, "error": f"Unknown clip ref: {clip_ref!r}"}
517
+ return {"success": True, "clip_uuid": clip_uuid, **list_tracks(conn, clip_uuid)}
518
+
519
+ def _count(sql: str) -> int:
520
+ return int(conn.execute(sql).fetchone()[0])
521
+
522
+ track_rows = conn.execute(
523
+ "SELECT track, COUNT(*) AS n, COUNT(DISTINCT clip_uuid) AS clips FROM events GROUP BY track"
524
+ ).fetchall()
525
+ curve_rows = conn.execute(
526
+ "SELECT track, COUNT(DISTINCT clip_uuid) AS clips FROM curves GROUP BY track"
527
+ ).fetchall()
528
+ clip_rows = conn.execute(
529
+ """
530
+ SELECT c.clip_uuid, c.clip_name, c.duration_seconds, c.fps, c.media_type,
531
+ (SELECT COUNT(*) FROM transcript_words w WHERE w.clip_uuid = c.clip_uuid) AS word_count,
532
+ (SELECT COUNT(DISTINCT track) FROM events e WHERE e.clip_uuid = c.clip_uuid) AS event_track_count,
533
+ (SELECT COUNT(DISTINCT track) FROM curves v WHERE v.clip_uuid = c.clip_uuid) AS curve_track_count,
534
+ (SELECT COUNT(*) FROM story_beats b WHERE b.clip_uuid = c.clip_uuid AND b.superseded_at IS NULL) AS story_beat_count
535
+ FROM clips c ORDER BY c.clip_name LIMIT 500
536
+ """
537
+ ).fetchall()
538
+ return {
539
+ "success": True,
540
+ "schema_version": timeline_brain_db.SCHEMA_VERSION,
541
+ "clips": _count("SELECT COUNT(*) FROM clips"),
542
+ "clips_with_words": _count("SELECT COUNT(DISTINCT clip_uuid) FROM transcript_words"),
543
+ "word_count": _count("SELECT COUNT(*) FROM transcript_words"),
544
+ "event_tracks": [
545
+ {"track": r["track"], "events": r["n"], "clips": r["clips"]} for r in track_rows
546
+ ],
547
+ "curve_tracks": [{"track": r["track"], "clips": r["clips"]} for r in curve_rows],
548
+ "story_beat_count": _count(
549
+ "SELECT COUNT(*) FROM story_beats WHERE superseded_at IS NULL"
550
+ ),
551
+ "clip_rows": [
552
+ {
553
+ "clip_uuid": r["clip_uuid"],
554
+ "clip_name": r["clip_name"],
555
+ "duration_seconds": r["duration_seconds"],
556
+ "fps": r["fps"],
557
+ "media_type": r["media_type"],
558
+ "word_count": int(r["word_count"]),
559
+ "event_track_count": int(r["event_track_count"]),
560
+ "curve_track_count": int(r["curve_track_count"]),
561
+ "story_beat_count": int(r["story_beat_count"]),
562
+ }
563
+ for r in clip_rows
564
+ ],
565
+ }