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,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
|
+
}
|