davinci-resolve-mcp 2.42.0 → 2.44.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,668 @@
1
+ """Embeddings + similarity search (Phase C of the analysis program).
2
+
3
+ Text vectors over clip/shot summaries and transcript segments, CLIP image
4
+ vectors over sampled frames. Backends are detected, never installed (the
5
+ whisper pattern):
6
+
7
+ - text: ollama serving ``nomic-embed-text`` (preferred — works on Apple
8
+ Silicon via Metal), or ``sentence_transformers`` when installed.
9
+ - visual: ``open_clip_torch`` (ViT-B-32) when installed alongside torch.
10
+
11
+ Vectors live in the per-project DB (schema v10 ``embeddings`` table) as
12
+ float32 BLOBs, one row per (entity, kind, model). Similarity is brute-force
13
+ cosine — numpy when present, pure Python otherwise; this is thousands of
14
+ vectors, not millions. Embedding is local compute, so nothing here touches
15
+ the vision-token caps ledger; responses report wall-clock + counts instead.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import array
21
+ import hashlib
22
+ import importlib.util
23
+ import json
24
+ import math
25
+ import os
26
+ import shutil
27
+ import sqlite3
28
+ import time
29
+ import urllib.error
30
+ import urllib.request
31
+ from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
32
+
33
+ from src.utils import timeline_brain_db
34
+
35
+ OLLAMA_URL = os.environ.get("DAVINCI_RESOLVE_MCP_OLLAMA_URL", "http://127.0.0.1:11434")
36
+ OLLAMA_TEXT_MODEL = os.environ.get("DAVINCI_RESOLVE_MCP_EMBED_MODEL", "nomic-embed-text")
37
+ SENTENCE_TRANSFORMERS_MODEL = "all-MiniLM-L6-v2"
38
+ OPEN_CLIP_MODEL = ("ViT-B-32", "laion2b_s34b_b79k")
39
+
40
+ _PROBE_TIMEOUT_SECONDS = 2.0
41
+ _EMBED_TIMEOUT_SECONDS = 120.0
42
+
43
+ # Lazy singletons for heavyweight local models.
44
+ _ST_MODEL = None
45
+ _CLIP_STATE: Optional[Tuple[Any, Any, Any]] = None # (model, preprocess, torch)
46
+
47
+
48
+ def _now() -> str:
49
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
50
+
51
+
52
+ def _content_hash(value: str) -> str:
53
+ return hashlib.sha256(value.encode("utf-8")).hexdigest()[:16]
54
+
55
+
56
+ def pack_vector(vector: Sequence[float]) -> bytes:
57
+ return array.array("f", vector).tobytes()
58
+
59
+
60
+ def unpack_vector(blob: bytes) -> List[float]:
61
+ arr = array.array("f")
62
+ arr.frombytes(blob)
63
+ return list(arr)
64
+
65
+
66
+ def cosine_similarity(a: Sequence[float], b: Sequence[float]) -> float:
67
+ try:
68
+ import numpy as np
69
+
70
+ va, vb = np.asarray(a, dtype="float32"), np.asarray(b, dtype="float32")
71
+ denom = float(np.linalg.norm(va) * np.linalg.norm(vb))
72
+ if denom == 0.0:
73
+ return 0.0
74
+ return float(np.dot(va, vb) / denom)
75
+ except ImportError:
76
+ dot = sum(x * y for x, y in zip(a, b))
77
+ na = math.sqrt(sum(x * x for x in a))
78
+ nb = math.sqrt(sum(y * y for y in b))
79
+ if na == 0.0 or nb == 0.0:
80
+ return 0.0
81
+ return dot / (na * nb)
82
+
83
+
84
+ # ── backend detection (no installs, ever) ────────────────────────────────────
85
+
86
+
87
+ def _ollama_state() -> Dict[str, Any]:
88
+ binary = shutil.which("ollama")
89
+ state: Dict[str, Any] = {
90
+ "binary": binary,
91
+ "serving": False,
92
+ "model_present": False,
93
+ "model": OLLAMA_TEXT_MODEL,
94
+ }
95
+ if not binary:
96
+ return state
97
+ try:
98
+ with urllib.request.urlopen(f"{OLLAMA_URL}/api/tags", timeout=_PROBE_TIMEOUT_SECONDS) as resp:
99
+ payload = json.load(resp)
100
+ state["serving"] = True
101
+ models = [str(m.get("name") or "") for m in payload.get("models") or []]
102
+ state["model_present"] = any(
103
+ name == OLLAMA_TEXT_MODEL or name.startswith(f"{OLLAMA_TEXT_MODEL}:")
104
+ for name in models
105
+ )
106
+ except (urllib.error.URLError, OSError, ValueError, TimeoutError):
107
+ pass
108
+ return state
109
+
110
+
111
+ def detect_embedding_capabilities() -> Dict[str, Any]:
112
+ """Availability of text/visual embedding backends, with install guidance."""
113
+ ollama = _ollama_state()
114
+ st_available = importlib.util.find_spec("sentence_transformers") is not None
115
+ torch_available = importlib.util.find_spec("torch") is not None
116
+ open_clip_available = importlib.util.find_spec("open_clip") is not None
117
+
118
+ text_backends = []
119
+ if ollama["binary"] and ollama["serving"] and ollama["model_present"]:
120
+ text_backends.append("ollama")
121
+ if st_available:
122
+ text_backends.append("sentence_transformers")
123
+
124
+ guidance: Dict[str, str] = {}
125
+ if not text_backends:
126
+ if ollama["binary"] and ollama["serving"] and not ollama["model_present"]:
127
+ guidance["text"] = f"Ask the user before running: ollama pull {OLLAMA_TEXT_MODEL}"
128
+ elif ollama["binary"] and not ollama["serving"]:
129
+ guidance["text"] = f"Start ollama (ollama serve), then: ollama pull {OLLAMA_TEXT_MODEL}"
130
+ else:
131
+ guidance["text"] = (
132
+ f"Install ollama (https://ollama.com) and pull {OLLAMA_TEXT_MODEL}, "
133
+ "or pip install sentence-transformers."
134
+ )
135
+ visual_available = torch_available and open_clip_available
136
+ if not visual_available:
137
+ guidance["visual"] = (
138
+ "pip install open_clip_torch"
139
+ if torch_available
140
+ else "pip install torch open_clip_torch"
141
+ )
142
+
143
+ return {
144
+ "success": True,
145
+ "no_auto_install": True,
146
+ "text": {
147
+ "available": bool(text_backends),
148
+ "backends": text_backends,
149
+ "ollama": ollama,
150
+ "model": OLLAMA_TEXT_MODEL if "ollama" in text_backends else (
151
+ SENTENCE_TRANSFORMERS_MODEL if st_available else None
152
+ ),
153
+ },
154
+ "visual": {
155
+ "available": visual_available,
156
+ "backends": ["open_clip"] if visual_available else [],
157
+ "model": "-".join(OPEN_CLIP_MODEL) if visual_available else None,
158
+ },
159
+ "install_guidance": guidance,
160
+ }
161
+
162
+
163
+ # ── embedding calls ──────────────────────────────────────────────────────────
164
+
165
+
166
+ def _embed_texts_ollama(texts: List[str]) -> Tuple[List[List[float]], str]:
167
+ body = json.dumps({"model": OLLAMA_TEXT_MODEL, "input": texts}).encode("utf-8")
168
+ request = urllib.request.Request(
169
+ f"{OLLAMA_URL}/api/embed",
170
+ data=body,
171
+ headers={"Content-Type": "application/json"},
172
+ )
173
+ with urllib.request.urlopen(request, timeout=_EMBED_TIMEOUT_SECONDS) as resp:
174
+ payload = json.load(resp)
175
+ vectors = payload.get("embeddings")
176
+ if not isinstance(vectors, list) or len(vectors) != len(texts):
177
+ raise ValueError(f"ollama returned {len(vectors or [])} embeddings for {len(texts)} inputs")
178
+ return vectors, f"ollama:{OLLAMA_TEXT_MODEL}"
179
+
180
+
181
+ def _embed_texts_sentence_transformers(texts: List[str]) -> Tuple[List[List[float]], str]:
182
+ global _ST_MODEL
183
+ if _ST_MODEL is None:
184
+ from sentence_transformers import SentenceTransformer
185
+
186
+ _ST_MODEL = SentenceTransformer(SENTENCE_TRANSFORMERS_MODEL)
187
+ vectors = _ST_MODEL.encode(texts, convert_to_numpy=True)
188
+ return [list(map(float, v)) for v in vectors], f"sentence_transformers:{SENTENCE_TRANSFORMERS_MODEL}"
189
+
190
+
191
+ def embed_texts(texts: List[str], *, backend: Optional[str] = None) -> Dict[str, Any]:
192
+ """Embed a batch of texts with the best available local backend."""
193
+ if not texts:
194
+ return {"success": True, "vectors": [], "model": None}
195
+ caps = detect_embedding_capabilities()
196
+ backends = caps["text"]["backends"]
197
+ chosen = backend or (backends[0] if backends else None)
198
+ if chosen not in backends:
199
+ return {
200
+ "success": False,
201
+ "error": "No text-embedding backend available",
202
+ "install_guidance": caps["install_guidance"].get("text"),
203
+ }
204
+ try:
205
+ if chosen == "ollama":
206
+ vectors, model = _embed_texts_ollama(texts)
207
+ else:
208
+ vectors, model = _embed_texts_sentence_transformers(texts)
209
+ except Exception as exc: # noqa: BLE001 — backend failures surface as data
210
+ return {"success": False, "error": f"{type(exc).__name__}: {exc}", "backend": chosen}
211
+ return {"success": True, "vectors": vectors, "model": model, "backend": chosen}
212
+
213
+
214
+ def _clip_model():
215
+ global _CLIP_STATE
216
+ if _CLIP_STATE is None:
217
+ import open_clip
218
+ import torch
219
+
220
+ model, _, preprocess = open_clip.create_model_and_transforms(
221
+ OPEN_CLIP_MODEL[0], pretrained=OPEN_CLIP_MODEL[1]
222
+ )
223
+ model.eval()
224
+ _CLIP_STATE = (model, preprocess, torch)
225
+ return _CLIP_STATE
226
+
227
+
228
+ def embed_images(paths: List[str]) -> Dict[str, Any]:
229
+ """Embed image files with open_clip. Skips missing files."""
230
+ caps = detect_embedding_capabilities()
231
+ if not caps["visual"]["available"]:
232
+ return {
233
+ "success": False,
234
+ "error": "No visual-embedding backend available",
235
+ "install_guidance": caps["install_guidance"].get("visual"),
236
+ }
237
+ try:
238
+ from PIL import Image
239
+
240
+ model, preprocess, torch = _clip_model()
241
+ vectors: List[Optional[List[float]]] = []
242
+ with torch.no_grad():
243
+ for path in paths:
244
+ if not path or not os.path.isfile(path):
245
+ vectors.append(None)
246
+ continue
247
+ image = preprocess(Image.open(path).convert("RGB")).unsqueeze(0)
248
+ features = model.encode_image(image)
249
+ features = features / features.norm(dim=-1, keepdim=True)
250
+ vectors.append([float(x) for x in features[0].tolist()])
251
+ except Exception as exc: # noqa: BLE001
252
+ return {"success": False, "error": f"{type(exc).__name__}: {exc}"}
253
+ return {"success": True, "vectors": vectors, "model": f"open_clip:{'-'.join(OPEN_CLIP_MODEL)}"}
254
+
255
+
256
+ def embed_text_for_visual_query(text: str) -> Dict[str, Any]:
257
+ """CLIP text encoder — lets a free-text query search visual vectors."""
258
+ caps = detect_embedding_capabilities()
259
+ if not caps["visual"]["available"]:
260
+ return {
261
+ "success": False,
262
+ "error": "No visual-embedding backend available",
263
+ "install_guidance": caps["install_guidance"].get("visual"),
264
+ }
265
+ try:
266
+ import open_clip
267
+
268
+ model, _preprocess, torch = _clip_model()
269
+ tokenizer = open_clip.get_tokenizer(OPEN_CLIP_MODEL[0])
270
+ with torch.no_grad():
271
+ features = model.encode_text(tokenizer([text]))
272
+ features = features / features.norm(dim=-1, keepdim=True)
273
+ return {
274
+ "success": True,
275
+ "vector": [float(x) for x in features[0].tolist()],
276
+ "model": f"open_clip:{'-'.join(OPEN_CLIP_MODEL)}",
277
+ }
278
+ except Exception as exc: # noqa: BLE001
279
+ return {"success": False, "error": f"{type(exc).__name__}: {exc}"}
280
+
281
+
282
+ # ── content builders ─────────────────────────────────────────────────────────
283
+
284
+
285
+ def _shot_embed_text(shot: Dict[str, Any]) -> str:
286
+ parts: List[str] = []
287
+ if shot.get("description"):
288
+ parts.append(str(shot["description"]))
289
+ extra = shot.get("extra_json")
290
+ if extra:
291
+ try:
292
+ groups = json.loads(extra)
293
+ except (TypeError, ValueError):
294
+ groups = {}
295
+ for group in ("visual", "content", "editorial", "cuttability", "production"):
296
+ block = groups.get(group)
297
+ if isinstance(block, dict):
298
+ for key, value in sorted(block.items()):
299
+ if value in (None, "", [], {}):
300
+ continue
301
+ parts.append(f"{key}: {json.dumps(value, sort_keys=True, default=str)}")
302
+ return "\n".join(parts).strip()
303
+
304
+
305
+ def _clip_embed_text(clip: Dict[str, Any], conn: sqlite3.Connection) -> str:
306
+ parts: List[str] = []
307
+ for key in ("clip_name", "summary"):
308
+ if clip.get(key):
309
+ parts.append(str(clip[key]))
310
+ row = conn.execute(
311
+ """
312
+ SELECT value_json FROM subjective_fields
313
+ WHERE entity_type='clip' AND entity_uuid=? AND field_path='clip_summary'
314
+ AND superseded_at IS NULL
315
+ """,
316
+ (clip["clip_uuid"],),
317
+ ).fetchone()
318
+ if row:
319
+ try:
320
+ parts.append(str(json.loads(row["value_json"])))
321
+ except (TypeError, ValueError):
322
+ pass
323
+ tags = conn.execute(
324
+ """
325
+ SELECT value_json FROM subjective_fields
326
+ WHERE entity_type='clip' AND entity_uuid=? AND field_path='editing_notes.search_tags'
327
+ AND superseded_at IS NULL
328
+ """,
329
+ (clip["clip_uuid"],),
330
+ ).fetchone()
331
+ if tags:
332
+ try:
333
+ parts.append("tags: " + ", ".join(map(str, json.loads(tags["value_json"]) or [])))
334
+ except (TypeError, ValueError):
335
+ pass
336
+ return "\n".join(parts).strip()
337
+
338
+
339
+ # ── build ────────────────────────────────────────────────────────────────────
340
+
341
+
342
+ def _existing_rows(conn: sqlite3.Connection, kind: str) -> Dict[Tuple[str, str], str]:
343
+ rows = conn.execute(
344
+ "SELECT entity_type, entity_uuid, content_hash FROM embeddings WHERE embedding_kind = ?",
345
+ (kind,),
346
+ ).fetchall()
347
+ return {(str(r["entity_type"]), str(r["entity_uuid"])): str(r["content_hash"] or "") for r in rows}
348
+
349
+
350
+ def _store_vectors(
351
+ project_root: str,
352
+ kind: str,
353
+ model: str,
354
+ items: List[Tuple[str, str, str, List[float]]],
355
+ ) -> int:
356
+ """items: (entity_type, entity_uuid, content_hash, vector)."""
357
+ if not items:
358
+ return 0
359
+ now = _now()
360
+ with timeline_brain_db.transaction(project_root) as conn:
361
+ for entity_type, entity_uuid, content_hash, vector in items:
362
+ conn.execute(
363
+ """
364
+ INSERT INTO embeddings
365
+ (entity_type, entity_uuid, embedding_kind, model_name,
366
+ dimension, vector, content_hash, computed_at)
367
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
368
+ ON CONFLICT(entity_type, entity_uuid, embedding_kind, model_name)
369
+ DO UPDATE SET vector=excluded.vector, dimension=excluded.dimension,
370
+ content_hash=excluded.content_hash, computed_at=excluded.computed_at
371
+ """,
372
+ (entity_type, entity_uuid, kind, model, len(vector), pack_vector(vector), content_hash, now),
373
+ )
374
+ return len(items)
375
+
376
+
377
+ def build_embeddings(
378
+ project_root: str,
379
+ *,
380
+ kinds: Sequence[str] = ("text",),
381
+ clip_ref: Any = None,
382
+ include_segments: bool = True,
383
+ max_frames_per_clip: int = 16,
384
+ ) -> Dict[str, Any]:
385
+ """Build/refresh embeddings for a project (or one clip). Idempotent:
386
+ entities whose content hash is unchanged are skipped."""
387
+ from src.utils import analysis_store
388
+
389
+ started = time.time()
390
+ conn = timeline_brain_db.connect(project_root)
391
+ clip_filter: Optional[str] = None
392
+ if clip_ref:
393
+ clip_filter = analysis_store.resolve_clip_uuid(conn, clip_ref)
394
+ if not clip_filter:
395
+ return {"success": False, "error": f"clip not found in DB: {clip_ref!r} (run db_ingest first)"}
396
+
397
+ result: Dict[str, Any] = {"success": True, "project_root": project_root, "kinds": list(kinds)}
398
+ where = " WHERE clip_uuid = ?" if clip_filter else ""
399
+ args: Tuple[Any, ...] = (clip_filter,) if clip_filter else ()
400
+
401
+ if "text" in kinds:
402
+ texts: List[str] = []
403
+ meta: List[Tuple[str, str, str]] = [] # (entity_type, uuid, hash)
404
+ existing = _existing_rows(conn, "text")
405
+ for clip in conn.execute(f"SELECT * FROM clips{where}", args).fetchall():
406
+ clip = dict(clip)
407
+ text = _clip_embed_text(clip, conn)
408
+ if text:
409
+ h = _content_hash(text)
410
+ if existing.get(("clip", clip["clip_uuid"])) != h:
411
+ texts.append(text)
412
+ meta.append(("clip", clip["clip_uuid"], h))
413
+ for shot in conn.execute(
414
+ f"SELECT * FROM shots{where} ORDER BY clip_uuid, shot_index", args
415
+ ).fetchall():
416
+ shot = dict(shot)
417
+ text = _shot_embed_text(shot)
418
+ if text:
419
+ h = _content_hash(text)
420
+ if existing.get(("shot", shot["shot_uuid"])) != h:
421
+ texts.append(text)
422
+ meta.append(("shot", shot["shot_uuid"], h))
423
+ if include_segments:
424
+ for seg in conn.execute(
425
+ f"SELECT * FROM transcript_segments{where} ORDER BY clip_uuid, segment_index", args
426
+ ).fetchall():
427
+ seg = dict(seg)
428
+ text = str(seg.get("text") or "").strip()
429
+ if len(text) < 8:
430
+ continue
431
+ uuid = f"{seg['clip_uuid']}:{seg['segment_index']}"
432
+ h = _content_hash(text)
433
+ if existing.get(("segment", uuid)) != h:
434
+ texts.append(text)
435
+ meta.append(("segment", uuid, h))
436
+ if texts:
437
+ embedded = embed_texts(texts)
438
+ if not embedded.get("success"):
439
+ result["text"] = embedded
440
+ result["success"] = False
441
+ return result
442
+ stored = _store_vectors(
443
+ project_root,
444
+ "text",
445
+ str(embedded["model"]),
446
+ [(m[0], m[1], m[2], v) for m, v in zip(meta, embedded["vectors"])],
447
+ )
448
+ result["text"] = {"success": True, "embedded": stored, "skipped_unchanged": None, "model": embedded["model"]}
449
+ else:
450
+ result["text"] = {"success": True, "embedded": 0, "note": "all up to date"}
451
+
452
+ if "visual" in kinds:
453
+ existing = _existing_rows(conn, "visual")
454
+ frame_rows = conn.execute(
455
+ f"SELECT * FROM frames{where} ORDER BY clip_uuid, frame_index", args
456
+ ).fetchall()
457
+ by_clip: Dict[str, List[Dict[str, Any]]] = {}
458
+ for row in frame_rows:
459
+ by_clip.setdefault(str(row["clip_uuid"]), []).append(dict(row))
460
+ paths: List[str] = []
461
+ meta_v: List[Tuple[str, str, str]] = []
462
+ frame_shot: List[Optional[str]] = []
463
+ for clip_uuid, rows in by_clip.items():
464
+ on_disk = [r for r in rows if r.get("frame_path") and os.path.isfile(str(r["frame_path"]))]
465
+ if len(on_disk) > max_frames_per_clip:
466
+ step = len(on_disk) / float(max_frames_per_clip)
467
+ on_disk = [on_disk[int(i * step)] for i in range(max_frames_per_clip)]
468
+ for r in on_disk:
469
+ uuid = f"{clip_uuid}:{r['frame_index']}"
470
+ h = _content_hash(str(r["frame_path"]))
471
+ if existing.get(("frame", uuid)) == h:
472
+ continue
473
+ paths.append(str(r["frame_path"]))
474
+ meta_v.append(("frame", uuid, h))
475
+ frame_shot.append(str(r["shot_uuid"]) if r.get("shot_uuid") else None)
476
+ if paths:
477
+ embedded = embed_images(paths)
478
+ if not embedded.get("success"):
479
+ result["visual"] = embedded
480
+ result["success"] = False
481
+ return result
482
+ items = []
483
+ shot_acc: Dict[str, List[List[float]]] = {}
484
+ for m, shot_uuid, vector in zip(meta_v, frame_shot, embedded["vectors"]):
485
+ if vector is None:
486
+ continue
487
+ items.append((m[0], m[1], m[2], vector))
488
+ if shot_uuid:
489
+ shot_acc.setdefault(shot_uuid, []).append(vector)
490
+ # Per-shot visual vector = mean of its frames'.
491
+ for shot_uuid, vectors in shot_acc.items():
492
+ dim = len(vectors[0])
493
+ mean = [sum(v[i] for v in vectors) / len(vectors) for i in range(dim)]
494
+ items.append(("shot", shot_uuid, _content_hash(json.dumps(sorted(len(v) for v in vectors)) + shot_uuid), mean))
495
+ stored = _store_vectors(project_root, "visual", str(embedded["model"]), items)
496
+ result["visual"] = {"success": True, "embedded": stored, "model": embedded["model"]}
497
+ else:
498
+ result["visual"] = {"success": True, "embedded": 0, "note": "all up to date"}
499
+
500
+ result["wall_clock_ms"] = int((time.time() - started) * 1000)
501
+ counts = conn.execute(
502
+ "SELECT embedding_kind, COUNT(*) AS n FROM embeddings GROUP BY embedding_kind"
503
+ ).fetchall()
504
+ result["totals"] = {str(r["embedding_kind"]): int(r["n"]) for r in counts}
505
+ return result
506
+
507
+
508
+ # ── similarity search ────────────────────────────────────────────────────────
509
+
510
+
511
+ def _hydrate(conn: sqlite3.Connection, entity_type: str, entity_uuid: str) -> Dict[str, Any]:
512
+ if entity_type == "clip":
513
+ row = conn.execute(
514
+ "SELECT clip_uuid, clip_name, clip_dir, summary FROM clips WHERE clip_uuid = ?",
515
+ (entity_uuid,),
516
+ ).fetchone()
517
+ return dict(row) if row else {}
518
+ if entity_type == "shot":
519
+ row = conn.execute(
520
+ """
521
+ SELECT s.shot_uuid, s.clip_uuid, s.shot_index, s.time_seconds_start,
522
+ s.time_seconds_end, s.description, c.clip_name
523
+ FROM shots s LEFT JOIN clips c ON c.clip_uuid = s.clip_uuid
524
+ WHERE s.shot_uuid = ?
525
+ """,
526
+ (entity_uuid,),
527
+ ).fetchone()
528
+ return dict(row) if row else {}
529
+ if entity_type == "segment":
530
+ clip_uuid, _, seg_index = entity_uuid.rpartition(":")
531
+ row = conn.execute(
532
+ """
533
+ SELECT t.clip_uuid, t.segment_index, t.start_seconds, t.end_seconds,
534
+ t.text, c.clip_name
535
+ FROM transcript_segments t LEFT JOIN clips c ON c.clip_uuid = t.clip_uuid
536
+ WHERE t.clip_uuid = ? AND t.segment_index = ?
537
+ """,
538
+ (clip_uuid, int(seg_index) if seg_index.isdigit() else -1),
539
+ ).fetchone()
540
+ return dict(row) if row else {}
541
+ if entity_type == "frame":
542
+ clip_uuid, _, frame_index = entity_uuid.rpartition(":")
543
+ row = conn.execute(
544
+ """
545
+ SELECT f.clip_uuid, f.frame_index, f.time_seconds, f.frame_path,
546
+ f.shot_uuid, c.clip_name
547
+ FROM frames f LEFT JOIN clips c ON c.clip_uuid = f.clip_uuid
548
+ WHERE f.clip_uuid = ? AND f.frame_index = ?
549
+ """,
550
+ (clip_uuid, int(frame_index) if frame_index.lstrip("-").isdigit() else -1),
551
+ ).fetchone()
552
+ return dict(row) if row else {}
553
+ return {}
554
+
555
+
556
+ def find_similar(
557
+ project_root: str,
558
+ *,
559
+ text: Optional[str] = None,
560
+ clip_ref: Any = None,
561
+ shot_index: Optional[int] = None,
562
+ shot_uuid: Optional[str] = None,
563
+ kind: str = "text",
564
+ entity_types: Optional[Sequence[str]] = None,
565
+ limit: int = 10,
566
+ ) -> Dict[str, Any]:
567
+ """Brute-force cosine search. Query by free text, a clip, or a shot."""
568
+ from src.utils import analysis_store
569
+
570
+ conn = timeline_brain_db.connect(project_root)
571
+ kind = (kind or "text").strip().lower()
572
+ if kind not in ("text", "visual"):
573
+ return {"success": False, "error": f"kind must be 'text' or 'visual', got {kind!r}"}
574
+
575
+ exclude: Optional[Tuple[str, str]] = None
576
+ query_vector: Optional[List[float]] = None
577
+ query_model: Optional[str] = None
578
+
579
+ if text:
580
+ if kind == "text":
581
+ embedded = embed_texts([str(text)])
582
+ if not embedded.get("success"):
583
+ return embedded
584
+ query_vector = embedded["vectors"][0]
585
+ query_model = str(embedded["model"])
586
+ else:
587
+ encoded = embed_text_for_visual_query(str(text))
588
+ if not encoded.get("success"):
589
+ return encoded
590
+ query_vector = encoded["vector"]
591
+ query_model = str(encoded["model"])
592
+ else:
593
+ entity_type: Optional[str] = None
594
+ entity_uuid: Optional[str] = None
595
+ if shot_uuid:
596
+ entity_type, entity_uuid = "shot", str(shot_uuid)
597
+ elif clip_ref is not None and shot_index is not None:
598
+ clip_uuid = analysis_store.resolve_clip_uuid(conn, clip_ref)
599
+ if not clip_uuid:
600
+ return {"success": False, "error": f"clip not found in DB: {clip_ref!r}"}
601
+ row = conn.execute(
602
+ "SELECT shot_uuid FROM shots WHERE clip_uuid = ? AND shot_index = ?",
603
+ (clip_uuid, int(shot_index)),
604
+ ).fetchone()
605
+ if not row:
606
+ return {"success": False, "error": f"shot_index {shot_index} not found"}
607
+ entity_type, entity_uuid = "shot", str(row["shot_uuid"])
608
+ elif clip_ref is not None:
609
+ clip_uuid = analysis_store.resolve_clip_uuid(conn, clip_ref)
610
+ if not clip_uuid:
611
+ return {"success": False, "error": f"clip not found in DB: {clip_ref!r}"}
612
+ entity_type, entity_uuid = "clip", clip_uuid
613
+ else:
614
+ return {"success": False, "error": "find_similar requires text, clip_id/clip_dir, or shot"}
615
+ row = conn.execute(
616
+ """
617
+ SELECT vector, model_name FROM embeddings
618
+ WHERE entity_type = ? AND entity_uuid = ? AND embedding_kind = ?
619
+ """,
620
+ (entity_type, entity_uuid, kind),
621
+ ).fetchone()
622
+ if not row:
623
+ return {
624
+ "success": False,
625
+ "error": (
626
+ f"No {kind} embedding for that {entity_type} yet — run "
627
+ "media_analysis(action='build_embeddings') first."
628
+ ),
629
+ }
630
+ query_vector = unpack_vector(row["vector"])
631
+ query_model = str(row["model_name"])
632
+ exclude = (entity_type, entity_uuid)
633
+
634
+ where = "embedding_kind = ? AND model_name = ?"
635
+ args: List[Any] = [kind, query_model]
636
+ if entity_types:
637
+ placeholders = ",".join("?" for _ in entity_types)
638
+ where += f" AND entity_type IN ({placeholders})"
639
+ args.extend(entity_types)
640
+ rows = conn.execute(
641
+ f"SELECT entity_type, entity_uuid, vector FROM embeddings WHERE {where}", args
642
+ ).fetchall()
643
+
644
+ scored: List[Tuple[float, str, str]] = []
645
+ for row in rows:
646
+ key = (str(row["entity_type"]), str(row["entity_uuid"]))
647
+ if exclude and key == exclude:
648
+ continue
649
+ score = cosine_similarity(query_vector, unpack_vector(row["vector"]))
650
+ scored.append((score, key[0], key[1]))
651
+ scored.sort(key=lambda item: item[0], reverse=True)
652
+
653
+ results = []
654
+ for score, entity_type, entity_uuid in scored[: max(1, int(limit))]:
655
+ entry = {
656
+ "score": round(score, 4),
657
+ "entity_type": entity_type,
658
+ "entity_uuid": entity_uuid,
659
+ }
660
+ entry.update(_hydrate(conn, entity_type, entity_uuid))
661
+ results.append(entry)
662
+ return {
663
+ "success": True,
664
+ "kind": kind,
665
+ "model": query_model,
666
+ "candidates_scanned": len(rows),
667
+ "results": results,
668
+ }