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.
- package/CHANGELOG.md +125 -0
- package/README.md +2 -2
- package/docs/SKILL.md +38 -2
- 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 +179 -1
- package/src/utils/analysis_store.py +75 -1
- package/src/utils/api_truth.py +32 -0
- package/src/utils/deep_vision.py +1 -30
- package/src/utils/strata.py +632 -0
- package/src/utils/strata_analyzers.py +728 -0
- package/src/utils/strata_faces.py +347 -0
- package/src/utils/strata_queries.py +663 -0
- package/src/utils/strata_story.py +305 -0
- package/src/utils/timeline_brain_db.py +138 -1
- package/src/utils/timeline_versioning.py +19 -2
package/src/utils/deep_vision.py
CHANGED
|
@@ -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.
|
|
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
|
+
}
|