davinci-resolve-mcp 2.60.0 → 2.61.1

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.
@@ -123,37 +123,8 @@ def _now() -> str:
123
123
  def _clip_context(project_root: str, clip_ref: Any) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
124
124
  """Resolve a clip to (context, error). Auto-ingests the JSON report when
125
125
  the DB has no rows yet (pre-v9 analysis roots)."""
126
- ma = _ma()
127
126
  conn = timeline_brain_db.connect(project_root)
128
- clip_uuid = analysis_store.resolve_clip_uuid(conn, clip_ref)
129
- if not clip_uuid:
130
- # Pre-v9 report? Find the analysis.json by walking clips/ and ingest it.
131
- clips_root = os.path.join(project_root, "clips")
132
- candidate = str(clip_ref or "")
133
- if os.path.isdir(clips_root):
134
- for entry in sorted(os.listdir(clips_root)):
135
- report_path = os.path.join(clips_root, entry, "analysis.json")
136
- if not os.path.isfile(report_path):
137
- continue
138
- try:
139
- with open(report_path, "r", encoding="utf-8") as handle:
140
- report = json.load(handle)
141
- except (OSError, json.JSONDecodeError):
142
- continue
143
- clip_block = report.get("clip") or {}
144
- if candidate not in (
145
- entry,
146
- str(clip_block.get("clip_id") or ""),
147
- str(clip_block.get("media_id") or ""),
148
- ma.normalize_path(clip_block.get("file_path") or ""),
149
- ):
150
- continue
151
- ingest = analysis_store.ingest_report(
152
- project_root, report, clip_dir=os.path.join(clips_root, entry)
153
- )
154
- if ingest.get("success"):
155
- clip_uuid = ingest["clip_uuid"]
156
- break
127
+ clip_uuid = analysis_store.resolve_clip_uuid_ingesting(project_root, conn, clip_ref)
157
128
  if not clip_uuid:
158
129
  return None, f"No analyzed clip found for {clip_ref!r} (run db_ingest if this is an older analysis root)"
159
130
  clip_row = conn.execute("SELECT * FROM clips WHERE clip_uuid = ?", (clip_uuid,)).fetchone()
@@ -0,0 +1,632 @@
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 os
25
+ import sqlite3
26
+ import time
27
+ from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
28
+
29
+ from src.utils import timeline_brain_db
30
+ from src.utils.embeddings import pack_vector as _pack_vector
31
+ from src.utils.embeddings import unpack_vector as _unpack_vector
32
+
33
+ logger = logging.getLogger("resolve-mcp.strata")
34
+
35
+ HUMAN_SOURCE = "human"
36
+
37
+ # Well-known track names. Not enforced by the schema (new analyzers may add
38
+ # tracks freely); listed so agents and the dashboard have a stable vocabulary.
39
+ EVENT_TRACKS = (
40
+ "pause", # speech gap inside a spoken region (duration = gap length)
41
+ "breath", # audible inhale candidate inside a speech gap
42
+ "hesitation", # filler word (uh/um/er) from the transcript
43
+ "beat", # musical beat (payload: {"tempo_bpm": float})
44
+ "downbeat", # low-confidence bar-start estimate
45
+ "blink", # eye blink (payload may carry entity_uuid)
46
+ "gesture_boundary",
47
+ )
48
+ CURVE_TRACKS = (
49
+ "pitch", # Hz; NaN where unvoiced/silent
50
+ "vocal_energy", # RMS 0..1
51
+ "speech_rate", # words/sec smoothed
52
+ "motion_energy", # mean abs frame difference 0..1
53
+ "loudness", # momentary LUFS-like envelope
54
+ )
55
+
56
+
57
+ def _now() -> str:
58
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
59
+
60
+
61
+ def _dumps(value: Any) -> str:
62
+ return json.dumps(value, sort_keys=True, default=str)
63
+
64
+
65
+ # ── clip resolution — the ONE resolver for every strata surface ──────────────
66
+
67
+
68
+ def resolve_clip(
69
+ project_root: str,
70
+ clip_ref: Any,
71
+ *,
72
+ require_media: bool = False,
73
+ ) -> Tuple[Optional[sqlite3.Connection], Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
74
+ """Resolve a clip ref to ``(conn, clip, error)`` — exactly one of
75
+ ``clip``/``error`` is set (conn is always usable when clip is set).
76
+
77
+ ``clip`` carries {clip_uuid, clip_name, file_path, duration_seconds, fps}.
78
+ Uses the same pre-v9 auto-ingest fallback as deep_vision, so a ref that
79
+ resolves for deepen resolves identically for every strata action. With
80
+ ``require_media=True`` the clip's media file must exist on disk.
81
+ """
82
+ from src.utils import analysis_store
83
+
84
+ conn = timeline_brain_db.connect(project_root)
85
+ clip_uuid = analysis_store.resolve_clip_uuid_ingesting(project_root, conn, clip_ref)
86
+ if not clip_uuid:
87
+ return conn, None, {
88
+ "success": False,
89
+ "error": f"Unknown clip ref: {clip_ref!r} (older analysis root? run db_ingest first)",
90
+ }
91
+ row = conn.execute(
92
+ "SELECT clip_uuid, clip_name, file_path, duration_seconds, fps FROM clips WHERE clip_uuid = ?",
93
+ (clip_uuid,),
94
+ ).fetchone()
95
+ if row is None:
96
+ return conn, None, {"success": False, "error": f"No clips row for {clip_uuid}"}
97
+ clip = {
98
+ "clip_uuid": clip_uuid,
99
+ "clip_name": row["clip_name"],
100
+ "file_path": row["file_path"],
101
+ "duration_seconds": row["duration_seconds"],
102
+ "fps": row["fps"],
103
+ }
104
+ if require_media:
105
+ file_path = clip["file_path"]
106
+ if not file_path or not os.path.isfile(file_path):
107
+ return conn, None, {
108
+ "success": False,
109
+ "error": f"Media file not accessible for clip {clip['clip_name']!r}: {file_path!r}",
110
+ "clip_uuid": clip_uuid,
111
+ }
112
+ return conn, clip, None
113
+
114
+
115
+ # ── float32 blob convention — ONE codec, shared with embeddings.vector ───────
116
+
117
+
118
+ def pack_curve(values: Sequence[float]) -> bytes:
119
+ """Encode a float sequence as a little-endian float32 BLOB."""
120
+ return _pack_vector([float(v) for v in values])
121
+
122
+
123
+ def unpack_curve(blob: bytes) -> List[float]:
124
+ """Decode a float32 BLOB back to a list of floats."""
125
+ return _unpack_vector(blob)
126
+
127
+
128
+ def curve_stats(values: Sequence[float]) -> Dict[str, Any]:
129
+ """min/max/mean over the finite samples — cheap query pre-filter."""
130
+ finite = [v for v in values if not math.isnan(v)]
131
+ if not finite:
132
+ return {"count": len(values), "finite_count": 0}
133
+ return {
134
+ "count": len(values),
135
+ "finite_count": len(finite),
136
+ "min": min(finite),
137
+ "max": max(finite),
138
+ "mean": sum(finite) / len(finite),
139
+ }
140
+
141
+
142
+ # ── writers ──────────────────────────────────────────────────────────────────
143
+
144
+
145
+ def replace_track_events(
146
+ conn: sqlite3.Connection,
147
+ clip_uuid: str,
148
+ track: str,
149
+ events: Iterable[Dict[str, Any]],
150
+ *,
151
+ source: str,
152
+ analyzer_version: str,
153
+ ) -> int:
154
+ """Replace this writer's rows for (clip, track): idempotent re-runs.
155
+
156
+ Each event: {time_seconds, duration_seconds?, payload?}. Human rows on
157
+ the same track are left untouched.
158
+ """
159
+ if source == HUMAN_SOURCE:
160
+ raise ValueError("machine writer API; human events go through record_human_event")
161
+ conn.execute(
162
+ "DELETE FROM events WHERE clip_uuid = ? AND track = ? AND source = ?",
163
+ (clip_uuid, track, source),
164
+ )
165
+ now = _now()
166
+ written = 0
167
+ for event in events:
168
+ t = event.get("time_seconds")
169
+ if not isinstance(t, (int, float)) or math.isnan(float(t)):
170
+ continue
171
+ duration = event.get("duration_seconds")
172
+ payload = event.get("payload")
173
+ conn.execute(
174
+ """
175
+ INSERT INTO events
176
+ (clip_uuid, track, time_seconds, duration_seconds,
177
+ payload_json, source, analyzer_version, created_at)
178
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
179
+ """,
180
+ (
181
+ clip_uuid,
182
+ track,
183
+ float(t),
184
+ float(duration) if isinstance(duration, (int, float)) else None,
185
+ _dumps(payload) if payload is not None else None,
186
+ source,
187
+ analyzer_version,
188
+ now,
189
+ ),
190
+ )
191
+ written += 1
192
+ return written
193
+
194
+
195
+ def record_human_event(
196
+ conn: sqlite3.Connection,
197
+ clip_uuid: str,
198
+ track: str,
199
+ time_seconds: float,
200
+ *,
201
+ duration_seconds: Optional[float] = None,
202
+ payload: Any = None,
203
+ author: str = "human",
204
+ ) -> int:
205
+ """Append one human-judged event (never bulk-replaced by machines)."""
206
+ cur = conn.execute(
207
+ """
208
+ INSERT INTO events
209
+ (clip_uuid, track, time_seconds, duration_seconds,
210
+ payload_json, source, analyzer_version, created_at)
211
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
212
+ """,
213
+ (
214
+ clip_uuid,
215
+ track,
216
+ float(time_seconds),
217
+ float(duration_seconds) if duration_seconds is not None else None,
218
+ _dumps({"author": author, **(payload if isinstance(payload, dict) else {"value": payload} if payload is not None else {})}),
219
+ HUMAN_SOURCE,
220
+ "human",
221
+ _now(),
222
+ ),
223
+ )
224
+ return int(cur.lastrowid or 0)
225
+
226
+
227
+ def write_curve(
228
+ conn: sqlite3.Connection,
229
+ clip_uuid: str,
230
+ track: str,
231
+ values: Sequence[float],
232
+ *,
233
+ sample_rate: float,
234
+ start_seconds: float = 0.0,
235
+ source: str,
236
+ analyzer_version: str,
237
+ ) -> Dict[str, Any]:
238
+ """Upsert one sampled series for (clip, track, source)."""
239
+ stats = curve_stats(values)
240
+ conn.execute(
241
+ """
242
+ INSERT OR REPLACE INTO curves
243
+ (clip_uuid, track, start_seconds, sample_rate, values_blob,
244
+ stats_json, source, analyzer_version, created_at)
245
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
246
+ """,
247
+ (
248
+ clip_uuid,
249
+ track,
250
+ float(start_seconds),
251
+ float(sample_rate),
252
+ pack_curve(values),
253
+ _dumps(stats),
254
+ source,
255
+ analyzer_version,
256
+ _now(),
257
+ ),
258
+ )
259
+ return stats
260
+
261
+
262
+ # ── readers ──────────────────────────────────────────────────────────────────
263
+
264
+
265
+ def read_events(
266
+ conn: sqlite3.Connection,
267
+ clip_uuid: str,
268
+ track: Optional[str] = None,
269
+ *,
270
+ start_seconds: Optional[float] = None,
271
+ end_seconds: Optional[float] = None,
272
+ ) -> List[Dict[str, Any]]:
273
+ sql = "SELECT * FROM events WHERE clip_uuid = ?"
274
+ args: List[Any] = [clip_uuid]
275
+ if track:
276
+ sql += " AND track = ?"
277
+ args.append(track)
278
+ # Window semantics: a span event overlaps the window, not merely starts
279
+ # inside it — a 3 s pause that began before the window is still a pause.
280
+ # Point events (no duration) use the half-open [start, end) convention.
281
+ if start_seconds is not None:
282
+ start = float(start_seconds)
283
+ # A bare OR on the overlap test is non-sargable (ix_events_clip_track
284
+ # could never range-seek on time_seconds). Bound the lower edge by the
285
+ # track's longest span instead: no overlapping event can start earlier
286
+ # than start - MAX(duration). The MAX probe is a single b-tree descent
287
+ # via ix_events_clip_track_span (v15).
288
+ span_sql = "SELECT MAX(duration_seconds) FROM events WHERE clip_uuid = ?"
289
+ span_args: List[Any] = [clip_uuid]
290
+ if track:
291
+ span_sql += " AND track = ?"
292
+ span_args.append(track)
293
+ max_span = conn.execute(span_sql, span_args).fetchone()[0] or 0.0
294
+ sql += (
295
+ " AND time_seconds >= ?"
296
+ " AND (time_seconds >= ? OR time_seconds + COALESCE(duration_seconds, 0) > ?)"
297
+ )
298
+ args.extend([start - float(max_span), start, start])
299
+ if end_seconds is not None:
300
+ sql += " AND time_seconds < ?"
301
+ args.append(float(end_seconds))
302
+ sql += " ORDER BY time_seconds"
303
+ out = []
304
+ for row in conn.execute(sql, args).fetchall():
305
+ item = {
306
+ "track": row["track"],
307
+ "time_seconds": row["time_seconds"],
308
+ "duration_seconds": row["duration_seconds"],
309
+ "source": row["source"],
310
+ "analyzer_version": row["analyzer_version"],
311
+ }
312
+ if row["payload_json"]:
313
+ try:
314
+ item["payload"] = json.loads(row["payload_json"])
315
+ except (TypeError, ValueError):
316
+ pass
317
+ out.append(item)
318
+ return out
319
+
320
+
321
+ def read_curve(
322
+ conn: sqlite3.Connection,
323
+ clip_uuid: str,
324
+ track: str,
325
+ *,
326
+ source: Optional[str] = None,
327
+ ) -> Optional[Dict[str, Any]]:
328
+ sql = "SELECT * FROM curves WHERE clip_uuid = ? AND track = ?"
329
+ args: List[Any] = [clip_uuid, track]
330
+ if source:
331
+ sql += " AND source = ?"
332
+ args.append(source)
333
+ row = conn.execute(sql + " ORDER BY created_at DESC LIMIT 1", args).fetchone()
334
+ if row is None:
335
+ return None
336
+ result = {
337
+ "track": row["track"],
338
+ "start_seconds": row["start_seconds"],
339
+ "sample_rate": row["sample_rate"],
340
+ "values": unpack_curve(row["values_blob"]),
341
+ "source": row["source"],
342
+ "analyzer_version": row["analyzer_version"],
343
+ }
344
+ if row["stats_json"]:
345
+ try:
346
+ result["stats"] = json.loads(row["stats_json"])
347
+ except (TypeError, ValueError):
348
+ pass
349
+ return result
350
+
351
+
352
+ def curve_value_at(curve: Dict[str, Any], time_seconds: float) -> Optional[float]:
353
+ """Sample a curve dict (from read_curve) at a clip time. None off-range/NaN."""
354
+ values = curve.get("values") or []
355
+ if not values:
356
+ return None
357
+ idx = int(round((time_seconds - float(curve.get("start_seconds") or 0.0)) * float(curve["sample_rate"])))
358
+ if idx < 0 or idx >= len(values):
359
+ return None
360
+ v = values[idx]
361
+ return None if math.isnan(v) else v
362
+
363
+
364
+ def read_words(
365
+ conn: sqlite3.Connection,
366
+ clip_uuid: str,
367
+ *,
368
+ start_seconds: Optional[float] = None,
369
+ end_seconds: Optional[float] = None,
370
+ match: Optional[str] = None,
371
+ ) -> List[Dict[str, Any]]:
372
+ sql = "SELECT * FROM transcript_words WHERE clip_uuid = ?"
373
+ args: List[Any] = [clip_uuid]
374
+ if start_seconds is not None:
375
+ sql += " AND start_seconds >= ?"
376
+ args.append(float(start_seconds))
377
+ if end_seconds is not None:
378
+ sql += " AND start_seconds < ?"
379
+ args.append(float(end_seconds))
380
+ if match:
381
+ escaped = match.replace("\\", "\\\\").replace("%", r"\%").replace("_", r"\_")
382
+ sql += " AND word LIKE ? ESCAPE '\\'"
383
+ args.append(f"%{escaped}%")
384
+ sql += " ORDER BY segment_index, word_index"
385
+ return [
386
+ {
387
+ "segment_index": row["segment_index"],
388
+ "word_index": row["word_index"],
389
+ "word": row["word"],
390
+ "start_seconds": row["start_seconds"],
391
+ "end_seconds": row["end_seconds"],
392
+ "confidence": row["confidence"],
393
+ }
394
+ for row in conn.execute(sql, args).fetchall()
395
+ ]
396
+
397
+
398
+ def list_tracks(conn: sqlite3.Connection, clip_uuid: str) -> Dict[str, Any]:
399
+ """Per-clip inventory: which tracks exist, from which writers."""
400
+ events = [
401
+ {
402
+ "track": row["track"],
403
+ "source": row["source"],
404
+ "analyzer_version": row["analyzer_version"],
405
+ "count": row["n"],
406
+ }
407
+ for row in conn.execute(
408
+ """
409
+ SELECT track, source, analyzer_version, COUNT(*) AS n
410
+ FROM events WHERE clip_uuid = ?
411
+ GROUP BY track, source, analyzer_version ORDER BY track
412
+ """,
413
+ (clip_uuid,),
414
+ ).fetchall()
415
+ ]
416
+ curves = [
417
+ {
418
+ "track": row["track"],
419
+ "source": row["source"],
420
+ "analyzer_version": row["analyzer_version"],
421
+ "sample_rate": row["sample_rate"],
422
+ "stats": json.loads(row["stats_json"]) if row["stats_json"] else None,
423
+ }
424
+ for row in conn.execute(
425
+ "SELECT track, source, analyzer_version, sample_rate, stats_json FROM curves WHERE clip_uuid = ? ORDER BY track",
426
+ (clip_uuid,),
427
+ ).fetchall()
428
+ ]
429
+ word_count = conn.execute(
430
+ "SELECT COUNT(*) AS n FROM transcript_words WHERE clip_uuid = ?", (clip_uuid,)
431
+ ).fetchone()["n"]
432
+ beat_count = conn.execute(
433
+ "SELECT COUNT(*) AS n FROM story_beats WHERE clip_uuid = ? AND superseded_at IS NULL",
434
+ (clip_uuid,),
435
+ ).fetchone()["n"]
436
+ return {
437
+ "events": events,
438
+ "curves": curves,
439
+ "word_count": int(word_count),
440
+ "story_beat_count": int(beat_count),
441
+ }
442
+
443
+
444
+ # ── transcript words: ingest + backfill ──────────────────────────────────────
445
+
446
+
447
+ def _word_confidence(word: Dict[str, Any]) -> Optional[float]:
448
+ for key in ("probability", "confidence", "score"):
449
+ value = word.get(key)
450
+ if isinstance(value, (int, float)):
451
+ return float(value)
452
+ return None
453
+
454
+
455
+ def ingest_transcript_words(
456
+ conn: sqlite3.Connection,
457
+ clip_uuid: str,
458
+ transcription: Dict[str, Any],
459
+ ) -> int:
460
+ """Rebuild transcript_words for a clip from a report's transcription block.
461
+
462
+ Words normally live per-segment (``segments[*].words``); segments without
463
+ their own words fall back to the top-level ``words`` list, bucketed by
464
+ start time. When the block yields no words at all, existing rows are left
465
+ untouched — a words-less re-analysis must not silently wipe previously
466
+ ingested or backfilled words. Returns the number of word rows written.
467
+ """
468
+ if not isinstance(transcription, dict):
469
+ return 0
470
+ segments = transcription.get("segments") if isinstance(transcription.get("segments"), list) else []
471
+
472
+ per_segment: List[List[Dict[str, Any]]] = []
473
+ for seg in segments:
474
+ words = seg.get("words") if isinstance(seg, dict) and isinstance(seg.get("words"), list) else []
475
+ per_segment.append([w for w in words if isinstance(w, dict)])
476
+
477
+ top_words = transcription.get("words") if isinstance(transcription.get("words"), list) else []
478
+ top_words = [w for w in top_words if isinstance(w, dict)]
479
+ if top_words:
480
+ if not segments:
481
+ per_segment = [top_words]
482
+ else:
483
+ bounds = []
484
+ for seg in segments:
485
+ start = seg.get("start") if isinstance(seg, dict) else None
486
+ bounds.append(float(start) if isinstance(start, (int, float)) else 0.0)
487
+ fallback: List[List[Dict[str, Any]]] = [[] for _ in segments]
488
+ for word in top_words:
489
+ t = word.get("start")
490
+ t = float(t) if isinstance(t, (int, float)) else 0.0
491
+ idx = 0
492
+ for i, b in enumerate(bounds):
493
+ if t >= b:
494
+ idx = i
495
+ fallback[idx].append(word)
496
+ for i, words in enumerate(per_segment):
497
+ if not words:
498
+ per_segment[i] = fallback[i]
499
+
500
+ rows: List[tuple] = []
501
+ for seg_idx, words in enumerate(per_segment):
502
+ for word_idx, word in enumerate(words):
503
+ text = str(word.get("word", word.get("text", ""))).strip()
504
+ if not text:
505
+ continue
506
+ start = word.get("start")
507
+ end = word.get("end")
508
+ rows.append(
509
+ (
510
+ clip_uuid,
511
+ seg_idx,
512
+ word_idx,
513
+ text,
514
+ float(start) if isinstance(start, (int, float)) else None,
515
+ float(end) if isinstance(end, (int, float)) else None,
516
+ _word_confidence(word),
517
+ )
518
+ )
519
+ if not rows:
520
+ return 0
521
+ conn.execute("DELETE FROM transcript_words WHERE clip_uuid = ?", (clip_uuid,))
522
+ conn.executemany(
523
+ """
524
+ INSERT OR REPLACE INTO transcript_words
525
+ (clip_uuid, segment_index, word_index, word,
526
+ start_seconds, end_seconds, confidence)
527
+ VALUES (?, ?, ?, ?, ?, ?, ?)
528
+ """,
529
+ rows,
530
+ )
531
+ return len(rows)
532
+
533
+
534
+ def backfill_transcript_words(project_root: str) -> Dict[str, Any]:
535
+ """Populate transcript_words from every stored report blob.
536
+
537
+ Reports written before v13 already carry per-word timestamps inside
538
+ ``transcription.segments[*].words`` — this promotes them to rows without
539
+ re-running any analysis. Idempotent.
540
+ """
541
+ conn = timeline_brain_db.connect(project_root)
542
+ clips_seen = int(conn.execute("SELECT COUNT(*) FROM analysis_reports").fetchone()[0])
543
+ clips_with_words = 0
544
+ words_written = 0
545
+ with timeline_brain_db.transaction(project_root) as txn:
546
+ # Lazy cursor + substring prefilter: never hold every multi-MB report
547
+ # blob in memory at once, and skip parsing reports that cannot carry
548
+ # words. Reads and writes share the cached connection but touch
549
+ # different tables, so interleaving is safe.
550
+ cursor = txn.execute(
551
+ "SELECT clip_uuid, report_json FROM analysis_reports "
552
+ "WHERE report_json LIKE '%\"transcription\"%'"
553
+ )
554
+ for row in cursor:
555
+ try:
556
+ report = json.loads(row["report_json"])
557
+ except (TypeError, ValueError):
558
+ continue
559
+ transcription = report.get("transcription") if isinstance(report, dict) else None
560
+ if not isinstance(transcription, dict):
561
+ continue
562
+ n = ingest_transcript_words(txn, str(row["clip_uuid"]), transcription)
563
+ if n:
564
+ clips_with_words += 1
565
+ words_written += n
566
+ return {
567
+ "success": True,
568
+ "clips_seen": clips_seen,
569
+ "clips_with_words": clips_with_words,
570
+ "words_written": words_written,
571
+ }
572
+
573
+
574
+ # ── status ───────────────────────────────────────────────────────────────────
575
+
576
+
577
+ def strata_status(project_root: str, clip_ref: Any = None) -> Dict[str, Any]:
578
+ """Project-level (or per-clip) strata inventory."""
579
+ conn = timeline_brain_db.connect(project_root)
580
+ if clip_ref is not None:
581
+ conn, clip, err = resolve_clip(project_root, clip_ref)
582
+ if err:
583
+ return err
584
+ return {"success": True, "clip_uuid": clip["clip_uuid"], **list_tracks(conn, clip["clip_uuid"])}
585
+
586
+ def _count(sql: str) -> int:
587
+ return int(conn.execute(sql).fetchone()[0])
588
+
589
+ track_rows = conn.execute(
590
+ "SELECT track, COUNT(*) AS n, COUNT(DISTINCT clip_uuid) AS clips FROM events GROUP BY track"
591
+ ).fetchall()
592
+ curve_rows = conn.execute(
593
+ "SELECT track, COUNT(DISTINCT clip_uuid) AS clips FROM curves GROUP BY track"
594
+ ).fetchall()
595
+ clip_rows = conn.execute(
596
+ """
597
+ SELECT c.clip_uuid, c.clip_name, c.duration_seconds, c.fps, c.media_type,
598
+ (SELECT COUNT(*) FROM transcript_words w WHERE w.clip_uuid = c.clip_uuid) AS word_count,
599
+ (SELECT COUNT(DISTINCT track) FROM events e WHERE e.clip_uuid = c.clip_uuid) AS event_track_count,
600
+ (SELECT COUNT(DISTINCT track) FROM curves v WHERE v.clip_uuid = c.clip_uuid) AS curve_track_count,
601
+ (SELECT COUNT(*) FROM story_beats b WHERE b.clip_uuid = c.clip_uuid AND b.superseded_at IS NULL) AS story_beat_count
602
+ FROM clips c ORDER BY c.clip_name LIMIT 500
603
+ """
604
+ ).fetchall()
605
+ return {
606
+ "success": True,
607
+ "schema_version": timeline_brain_db.SCHEMA_VERSION,
608
+ "clips": _count("SELECT COUNT(*) FROM clips"),
609
+ "clips_with_words": _count("SELECT COUNT(DISTINCT clip_uuid) FROM transcript_words"),
610
+ "word_count": _count("SELECT COUNT(*) FROM transcript_words"),
611
+ "event_tracks": [
612
+ {"track": r["track"], "events": r["n"], "clips": r["clips"]} for r in track_rows
613
+ ],
614
+ "curve_tracks": [{"track": r["track"], "clips": r["clips"]} for r in curve_rows],
615
+ "story_beat_count": _count(
616
+ "SELECT COUNT(*) FROM story_beats WHERE superseded_at IS NULL"
617
+ ),
618
+ "clip_rows": [
619
+ {
620
+ "clip_uuid": r["clip_uuid"],
621
+ "clip_name": r["clip_name"],
622
+ "duration_seconds": r["duration_seconds"],
623
+ "fps": r["fps"],
624
+ "media_type": r["media_type"],
625
+ "word_count": int(r["word_count"]),
626
+ "event_track_count": int(r["event_track_count"]),
627
+ "curve_track_count": int(r["curve_track_count"]),
628
+ "story_beat_count": int(r["story_beat_count"]),
629
+ }
630
+ for r in clip_rows
631
+ ],
632
+ }