davinci-resolve-mcp 2.42.0 → 2.43.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 +26 -0
- package/README.md +1 -1
- package/docs/SKILL.md +18 -2
- package/docs/guides/control-panel.md +5 -1
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/analysis_dashboard.py +79 -1
- package/src/granular/common.py +1 -1
- package/src/server.py +34 -1
- package/src/utils/embeddings.py +668 -0
- package/src/utils/media_analysis.py +44 -0
- package/src/utils/timeline_brain_db.py +33 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
Release history for the DaVinci Resolve MCP Server. The latest release is summarized in the root README; older entries live here to keep the README focused.
|
|
4
4
|
|
|
5
|
+
## What's New in v2.43.0
|
|
6
|
+
|
|
7
|
+
Embeddings + similarity search — Phase C of the analysis + edit-engine
|
|
8
|
+
program. "Find clips/shots like this," locally, with no vendor token cost.
|
|
9
|
+
|
|
10
|
+
- **Added** `src/utils/embeddings.py` and schema v10 (`embeddings` table:
|
|
11
|
+
one float32 vector per entity/kind/model, with content hashes so re-runs
|
|
12
|
+
only re-embed what changed). Brute-force cosine — numpy when present.
|
|
13
|
+
- **Added** `media_analysis` actions `build_embeddings` (idempotent; clip
|
|
14
|
+
summaries, shot descriptions + deep field groups, transcript segments,
|
|
15
|
+
sampled frames with per-shot mean vectors) and `find_similar` (query by
|
|
16
|
+
free text, clip, or shot; `kind="text"|"visual"`; free-text visual queries
|
|
17
|
+
go through the CLIP text encoder).
|
|
18
|
+
- **Added** backend detection, never installation (the whisper pattern):
|
|
19
|
+
text = ollama `nomic-embed-text` (serving probe) or sentence-transformers;
|
|
20
|
+
visual = open_clip ViT-B-32. Capabilities and the Diagnostics → Tools page
|
|
21
|
+
list availability with install guidance.
|
|
22
|
+
- **Added** a `Semantic` toggle on the panel's Review search (shown only
|
|
23
|
+
when a text backend is detected) backed by `/api/search/semantic`, which
|
|
24
|
+
returns rows in the existing search-card shape.
|
|
25
|
+
- **Validation**: full offline suite (1095 tests; 14 new, backends mocked).
|
|
26
|
+
Live on the 2026-05-17 sample root: 54 text vectors in 1.2s via ollama
|
|
27
|
+
with correct top hits for three editorial queries; 27 CLIP vectors with
|
|
28
|
+
"cracked broken windshield glass" ranking the shattered-windshield frame
|
|
29
|
+
first; panel endpoint verified and screenshots regenerated.
|
|
30
|
+
|
|
5
31
|
## What's New in v2.42.0
|
|
6
32
|
|
|
7
33
|
Deep shot-level vision tier — Phase B of the analysis + edit-engine program.
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# DaVinci Resolve MCP Server
|
|
2
2
|
|
|
3
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
4
4
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
5
5
|
[](docs/reference/api-coverage.md)
|
|
6
6
|
[-blue.svg)](#server-modes)
|
package/docs/SKILL.md
CHANGED
|
@@ -495,8 +495,24 @@ Key actions: `capabilities`, `install_guidance`, `resolve_output_root`, `plan`,
|
|
|
495
495
|
`cleanup_artifacts`, `db_status`, `db_ingest`, `get_panel_state`,
|
|
496
496
|
`set_panel_state`, `session_start_context`, `update_clip_field`,
|
|
497
497
|
`update_shot_field`, `get_field_history`, `revert_field`,
|
|
498
|
-
`list_corrections`, `deepen`, `commit_shot_vision`,
|
|
499
|
-
`
|
|
498
|
+
`list_corrections`, `deepen`, `commit_shot_vision`, `vision_pending_sweep`,
|
|
499
|
+
`build_embeddings`, and `find_similar`.
|
|
500
|
+
|
|
501
|
+
**Embeddings + similarity (v2.43.0+).** Local-compute semantic search; no
|
|
502
|
+
vendor tokens, so nothing here touches the caps ledger. Backends are
|
|
503
|
+
detected, never installed (capabilities lists them with install guidance):
|
|
504
|
+
text = ollama serving `nomic-embed-text` or sentence-transformers; visual =
|
|
505
|
+
open_clip (ViT-B-32, needs torch).
|
|
506
|
+
- `build_embeddings(kinds=["text","visual"]?, clip_id?)` — idempotent;
|
|
507
|
+
embeds clip summaries, shot descriptions (+ deep field groups), transcript
|
|
508
|
+
segments, and sampled frames (per-shot visual vector = mean of its
|
|
509
|
+
frames'). Only re-embeds entities whose content changed.
|
|
510
|
+
- `find_similar(text=… | clip_id=… | clip_id+shot_index, kind="text"|"visual",
|
|
511
|
+
entity_types?, limit?)` — brute-force cosine over the project's vectors.
|
|
512
|
+
Free-text visual queries use the CLIP text encoder ("cracked windshield"
|
|
513
|
+
finds the frame). Results carry scores plus clip/shot/segment context.
|
|
514
|
+
The panel search box gains a `Semantic` toggle when a text backend is
|
|
515
|
+
detected. Vectors live in the per-project DB (schema v10).
|
|
500
516
|
|
|
501
517
|
**Deep shot-level vision tier (v2.42.0+).** Opt-in, estimate-first. Two
|
|
502
518
|
entry points share one per-shot schema (Visual / Content / Production /
|
|
@@ -78,7 +78,11 @@ evidence base (analyzed / superseded / vision-pending / warnings) with
|
|
|
78
78
|
humanized source-trust and analysis-layer chips. Cards show a representative
|
|
79
79
|
thumbnail, duration, shot count, status, and summary one-liner; grid and list
|
|
80
80
|
layouts are available, and full-text search covers clips, summaries, tags, and
|
|
81
|
-
transcripts once the search index is built.
|
|
81
|
+
transcripts once the search index is built. When a local text-embedding
|
|
82
|
+
backend is detected (ollama with `nomic-embed-text`, or
|
|
83
|
+
sentence-transformers), a `Semantic` toggle appears next to the search box —
|
|
84
|
+
it searches by meaning instead of exact words, ranking clips, shots, and
|
|
85
|
+
transcript lines by similarity to your query.
|
|
82
86
|
|
|
83
87
|
### Clip detail
|
|
84
88
|
|
package/install.py
CHANGED
|
@@ -35,7 +35,7 @@ from src.utils.update_check import (
|
|
|
35
35
|
|
|
36
36
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
37
37
|
|
|
38
|
-
VERSION = "2.
|
|
38
|
+
VERSION = "2.43.0"
|
|
39
39
|
# Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
|
|
40
40
|
# Resolve's scripting bridge loads into newer interpreters on recent builds
|
|
41
41
|
# (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
|
package/package.json
CHANGED
|
@@ -4065,6 +4065,9 @@ HTML = r"""<!doctype html>
|
|
|
4065
4065
|
</div>
|
|
4066
4066
|
<div class="review-bin-filters">
|
|
4067
4067
|
<input id="reviewSearchInput" type="search" placeholder="Search clips, summaries, tags, transcripts…" autocomplete="off">
|
|
4068
|
+
<label id="reviewSemanticToggle" class="review-semantic-toggle" style="display:none" title="Search by meaning using local text embeddings instead of exact words.">
|
|
4069
|
+
<input id="reviewSemanticCheckbox" type="checkbox"> Semantic
|
|
4070
|
+
</label>
|
|
4068
4071
|
<select id="reviewBinFilter" aria-label="Filter by bin">
|
|
4069
4072
|
<option value="">All bins</option>
|
|
4070
4073
|
</select>
|
|
@@ -6201,6 +6204,13 @@ HTML = r"""<!doctype html>
|
|
|
6201
6204
|
syncPreferencesPanel();
|
|
6202
6205
|
applyPreferencesToControls(prefs);
|
|
6203
6206
|
renderVersionBadge();
|
|
6207
|
+
// Semantic search rides on a local text-embedding backend; only show
|
|
6208
|
+
// the toggle when one is detected (ollama nomic-embed-text or
|
|
6209
|
+
// sentence-transformers).
|
|
6210
|
+
if (state.boot?.capabilities?.embeddings?.text?.available) {
|
|
6211
|
+
const toggle = $('reviewSemanticToggle');
|
|
6212
|
+
if (toggle) toggle.style.display = '';
|
|
6213
|
+
}
|
|
6204
6214
|
// Paint the previous inventory immediately (stale) so a slow first
|
|
6205
6215
|
// /api/resolve/media — common with network source media — doesn't leave the
|
|
6206
6216
|
// panel blank. The live fetch below replaces it and clears the stale flag.
|
|
@@ -8258,7 +8268,11 @@ HTML = r"""<!doctype html>
|
|
|
8258
8268
|
renderReviewBin();
|
|
8259
8269
|
return;
|
|
8260
8270
|
}
|
|
8261
|
-
const
|
|
8271
|
+
const semantic = !!$('reviewSemanticCheckbox')?.checked;
|
|
8272
|
+
const endpoint = semantic
|
|
8273
|
+
? `/api/search/semantic?q=${encodeURIComponent(query)}&limit=40`
|
|
8274
|
+
: `/api/index/query?q=${encodeURIComponent(query)}&limit=40`;
|
|
8275
|
+
const payload = await api(endpoint)
|
|
8262
8276
|
.catch(err => ({ success: false, error: String(err) }));
|
|
8263
8277
|
state.review.searchResults = payload;
|
|
8264
8278
|
renderReviewBin();
|
|
@@ -11083,6 +11097,11 @@ HTML = r"""<!doctype html>
|
|
|
11083
11097
|
runReviewSearch(q).catch(alertError);
|
|
11084
11098
|
}, 250);
|
|
11085
11099
|
});
|
|
11100
|
+
// Semantic toggle re-runs the current query through the embeddings index.
|
|
11101
|
+
$('reviewSemanticCheckbox')?.addEventListener('change', () => {
|
|
11102
|
+
const q = $('reviewSearchInput').value.trim();
|
|
11103
|
+
if (q) runReviewSearch(q).catch(alertError);
|
|
11104
|
+
});
|
|
11086
11105
|
// Bin dropdown.
|
|
11087
11106
|
$('reviewBinFilter').addEventListener('change', event => {
|
|
11088
11107
|
state.review.binFilter = event.target.value || '';
|
|
@@ -12470,6 +12489,57 @@ def _v2_load_analysis_db_first(project_root: str, clip_dir: str) -> Optional[Dic
|
|
|
12470
12489
|
return _v2_load_analysis(clip_dir)
|
|
12471
12490
|
|
|
12472
12491
|
|
|
12492
|
+
def _v2_semantic_search(project_root: str, q: str, *, limit: int = 20) -> Dict[str, Any]:
|
|
12493
|
+
"""Semantic search over the embeddings index, shaped like /api/index/query
|
|
12494
|
+
rows so the existing search-card renderer works unchanged."""
|
|
12495
|
+
text = (q or "").strip()
|
|
12496
|
+
if not text:
|
|
12497
|
+
return {"success": True, "results": []}
|
|
12498
|
+
try:
|
|
12499
|
+
from src.utils import embeddings, timeline_brain_db as tbd
|
|
12500
|
+
|
|
12501
|
+
found = embeddings.find_similar(project_root, text=text, kind="text", limit=limit)
|
|
12502
|
+
if not found.get("success"):
|
|
12503
|
+
return found
|
|
12504
|
+
conn = tbd.connect(project_root)
|
|
12505
|
+
resolve_ids: Dict[str, Optional[str]] = {}
|
|
12506
|
+
|
|
12507
|
+
def resolve_clip_id(clip_uuid: Optional[str]) -> Optional[str]:
|
|
12508
|
+
if not clip_uuid:
|
|
12509
|
+
return None
|
|
12510
|
+
if clip_uuid not in resolve_ids:
|
|
12511
|
+
row = conn.execute(
|
|
12512
|
+
"SELECT resolve_clip_id, clip_dir FROM clips WHERE clip_uuid = ?",
|
|
12513
|
+
(clip_uuid,),
|
|
12514
|
+
).fetchone()
|
|
12515
|
+
resolve_ids[clip_uuid] = (row["resolve_clip_id"] or row["clip_dir"]) if row else None
|
|
12516
|
+
return resolve_ids[clip_uuid]
|
|
12517
|
+
|
|
12518
|
+
rows: List[Dict[str, Any]] = []
|
|
12519
|
+
for hit in found.get("results") or []:
|
|
12520
|
+
entity_type = hit.get("entity_type")
|
|
12521
|
+
clip_uuid = hit.get("clip_uuid") or (hit.get("entity_uuid") if entity_type == "clip" else None)
|
|
12522
|
+
row: Dict[str, Any] = {
|
|
12523
|
+
"result_type": "transcript" if entity_type == "segment" else "semantic",
|
|
12524
|
+
"score": hit.get("score"),
|
|
12525
|
+
"clip_id": resolve_clip_id(clip_uuid),
|
|
12526
|
+
"clip_name": hit.get("clip_name"),
|
|
12527
|
+
}
|
|
12528
|
+
if entity_type == "shot":
|
|
12529
|
+
row["start_seconds"] = hit.get("time_seconds_start")
|
|
12530
|
+
row["summary"] = hit.get("description")
|
|
12531
|
+
elif entity_type == "segment":
|
|
12532
|
+
row["start_seconds"] = hit.get("start_seconds")
|
|
12533
|
+
row["summary"] = hit.get("text")
|
|
12534
|
+
else:
|
|
12535
|
+
row["summary"] = hit.get("summary")
|
|
12536
|
+
rows.append(row)
|
|
12537
|
+
rows = _v2_enrich_search_results(project_root, rows)
|
|
12538
|
+
return {"success": True, "query": text, "model": found.get("model"), "results": rows}
|
|
12539
|
+
except Exception as exc: # noqa: BLE001 — search must fail soft in the panel
|
|
12540
|
+
return {"success": False, "error": f"{type(exc).__name__}: {exc}"}
|
|
12541
|
+
|
|
12542
|
+
|
|
12473
12543
|
def _v2_clip_duration(report: Dict[str, Any]) -> Optional[float]:
|
|
12474
12544
|
marker_plan = report.get("clip_analysis_markers") if isinstance(report.get("clip_analysis_markers"), dict) else {}
|
|
12475
12545
|
duration = marker_plan.get("duration_seconds")
|
|
@@ -14316,6 +14386,14 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
14316
14386
|
payload["results"] = _v2_enrich_search_results(self.state.project_root, payload["results"])
|
|
14317
14387
|
self._json(payload)
|
|
14318
14388
|
return
|
|
14389
|
+
if path == "/api/search/semantic":
|
|
14390
|
+
q = (query.get("q") or [""])[0]
|
|
14391
|
+
try:
|
|
14392
|
+
limit = int((query.get("limit") or ["20"])[0])
|
|
14393
|
+
except (TypeError, ValueError):
|
|
14394
|
+
limit = 20
|
|
14395
|
+
self._json(_v2_semantic_search(self.state.project_root, q, limit=limit))
|
|
14396
|
+
return
|
|
14319
14397
|
# ─── C6 timeline-history surface ───────────────────────────────
|
|
14320
14398
|
if path == "/api/timeline_versions":
|
|
14321
14399
|
self._json(list_timelines_with_versions(self.state.project_root))
|
package/src/granular/common.py
CHANGED
|
@@ -80,7 +80,7 @@ if not logging.getLogger().handlers:
|
|
|
80
80
|
handlers=[logging.StreamHandler()],
|
|
81
81
|
)
|
|
82
82
|
|
|
83
|
-
VERSION = "2.
|
|
83
|
+
VERSION = "2.43.0"
|
|
84
84
|
logger = logging.getLogger("davinci-resolve-mcp")
|
|
85
85
|
logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
|
|
86
86
|
logger.info(f"Detected platform: {get_platform()}")
|
package/src/server.py
CHANGED
|
@@ -11,7 +11,7 @@ Usage:
|
|
|
11
11
|
python src/server.py --full # Start the 341-tool granular server instead
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
|
-
VERSION = "2.
|
|
14
|
+
VERSION = "2.43.0"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -15024,6 +15024,9 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
|
|
|
15024
15024
|
"deepen",
|
|
15025
15025
|
"commit_shot_vision",
|
|
15026
15026
|
"vision_pending_sweep",
|
|
15027
|
+
# Phase C — embeddings + similarity.
|
|
15028
|
+
"build_embeddings",
|
|
15029
|
+
"find_similar",
|
|
15027
15030
|
}:
|
|
15028
15031
|
root = resolve_media_analysis_output_root(
|
|
15029
15032
|
project_name=project_name,
|
|
@@ -15119,6 +15122,34 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
|
|
|
15119
15122
|
max_age_days=p.get("max_age_days") or p.get("maxAgeDays"),
|
|
15120
15123
|
reoffer=_media_analysis_bool(p.get("reoffer"), False),
|
|
15121
15124
|
)
|
|
15125
|
+
# Phase C — embeddings + similarity (local compute; no token caps).
|
|
15126
|
+
if action == "build_embeddings":
|
|
15127
|
+
from src.utils import embeddings
|
|
15128
|
+
kinds = p.get("kinds") or p.get("kind") or ["text"]
|
|
15129
|
+
if isinstance(kinds, str):
|
|
15130
|
+
kinds = [kinds]
|
|
15131
|
+
return embeddings.build_embeddings(
|
|
15132
|
+
project_root,
|
|
15133
|
+
kinds=[str(k).strip().lower() for k in kinds],
|
|
15134
|
+
clip_ref=p.get("clip_id") or p.get("clipId") or p.get("clip_dir") or p.get("clipDir"),
|
|
15135
|
+
include_segments=_media_analysis_bool(p.get("include_segments", p.get("includeSegments")), True),
|
|
15136
|
+
max_frames_per_clip=int(p.get("max_frames_per_clip") or p.get("maxFramesPerClip") or 16),
|
|
15137
|
+
)
|
|
15138
|
+
if action == "find_similar":
|
|
15139
|
+
from src.utils import embeddings
|
|
15140
|
+
entity_types = p.get("entity_types") or p.get("entityTypes")
|
|
15141
|
+
if isinstance(entity_types, str):
|
|
15142
|
+
entity_types = [entity_types]
|
|
15143
|
+
return embeddings.find_similar(
|
|
15144
|
+
project_root,
|
|
15145
|
+
text=p.get("text") or p.get("query"),
|
|
15146
|
+
clip_ref=p.get("clip_id") or p.get("clipId") or p.get("clip_dir") or p.get("clipDir"),
|
|
15147
|
+
shot_index=p.get("shot_index") if p.get("shot_index") is not None else p.get("shotIndex"),
|
|
15148
|
+
shot_uuid=p.get("shot_uuid") or p.get("shotUuid"),
|
|
15149
|
+
kind=p.get("kind") or "text",
|
|
15150
|
+
entity_types=entity_types,
|
|
15151
|
+
limit=int(p.get("limit") or 10),
|
|
15152
|
+
)
|
|
15122
15153
|
if action in {"build_index", "rebuild_index"}:
|
|
15123
15154
|
return build_analysis_index(project_root, index_path=p.get("index_path") or p.get("indexPath"))
|
|
15124
15155
|
if action == "index_status":
|
|
@@ -15533,6 +15564,8 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
|
|
|
15533
15564
|
"deepen",
|
|
15534
15565
|
"commit_shot_vision",
|
|
15535
15566
|
"vision_pending_sweep",
|
|
15567
|
+
"build_embeddings",
|
|
15568
|
+
"find_similar",
|
|
15536
15569
|
"get_caps",
|
|
15537
15570
|
"set_caps_preset",
|
|
15538
15571
|
"get_usage",
|
|
@@ -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
|
+
}
|
|
@@ -1436,6 +1436,28 @@ TOOL_INSTALL: Dict[str, Dict[str, Any]] = {
|
|
|
1436
1436
|
"verify": "whisper --help",
|
|
1437
1437
|
"notes": "Pure-Python reference implementation. Choose this OR whisper_cpp OR mlx_whisper.",
|
|
1438
1438
|
},
|
|
1439
|
+
"ollama_embeddings": {
|
|
1440
|
+
"label": "ollama + nomic-embed-text",
|
|
1441
|
+
"bundle": "embeddings",
|
|
1442
|
+
"required_for": ["semantic search (text embeddings)", "find_similar"],
|
|
1443
|
+
"commands": {
|
|
1444
|
+
"macos": "brew install ollama && ollama pull nomic-embed-text",
|
|
1445
|
+
"linux": "curl -fsSL https://ollama.com/install.sh | sh && ollama pull nomic-embed-text",
|
|
1446
|
+
"windows": "winget install Ollama.Ollama, then: ollama pull nomic-embed-text",
|
|
1447
|
+
},
|
|
1448
|
+
"verify": "ollama list",
|
|
1449
|
+
"notes": "Local embedding model (~270 MB). sentence-transformers is an alternative text backend.",
|
|
1450
|
+
},
|
|
1451
|
+
"open_clip": {
|
|
1452
|
+
"label": "open_clip (CLIP visual embeddings)",
|
|
1453
|
+
"bundle": "embeddings",
|
|
1454
|
+
"required_for": ["visual similarity (find_similar kind=visual)", "cross-clip entity clustering"],
|
|
1455
|
+
"commands": {
|
|
1456
|
+
"all": "pip install open_clip_torch",
|
|
1457
|
+
},
|
|
1458
|
+
"verify": "python -c \"import open_clip\"",
|
|
1459
|
+
"notes": "Needs torch. Model weights (~350 MB) download on first use.",
|
|
1460
|
+
},
|
|
1439
1461
|
"whisper_cpp": {
|
|
1440
1462
|
"label": "whisper.cpp",
|
|
1441
1463
|
"bundle": "transcription",
|
|
@@ -1585,6 +1607,17 @@ def detect_capabilities(env: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
|
|
|
1585
1607
|
|
|
1586
1608
|
sync_events = detect_sync_event_capabilities()
|
|
1587
1609
|
|
|
1610
|
+
# Phase C — embedding backends (detected like the whisper backends; the
|
|
1611
|
+
# ollama probe is a short local HTTP call and fails fast when not serving).
|
|
1612
|
+
try:
|
|
1613
|
+
from src.utils import embeddings as _embeddings
|
|
1614
|
+
|
|
1615
|
+
embedding_caps = _embeddings.detect_embedding_capabilities()
|
|
1616
|
+
except Exception: # noqa: BLE001 — detection must never break capabilities
|
|
1617
|
+
embedding_caps = {"text": {"available": False, "backends": []},
|
|
1618
|
+
"visual": {"available": False, "backends": []},
|
|
1619
|
+
"install_guidance": {}}
|
|
1620
|
+
|
|
1588
1621
|
platform_id, machine = _runtime_platform_id()
|
|
1589
1622
|
|
|
1590
1623
|
def _tool_entry(name: str, available: bool, extra: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
@@ -1607,7 +1640,18 @@ def detect_capabilities(env: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
|
|
|
1607
1640
|
"whisper_cpp": _tool_entry("whisper_cpp", bool(whisper_cpp), {"path": whisper_cpp}),
|
|
1608
1641
|
"mlx_whisper": _tool_entry("mlx_whisper", bool(mlx_whisper), {"python_module": "mlx_whisper"}),
|
|
1609
1642
|
"opencv": _tool_entry("opencv", bool(cv2), {"python_module": "cv2"}),
|
|
1643
|
+
"ollama_embeddings": _tool_entry(
|
|
1644
|
+
"ollama_embeddings",
|
|
1645
|
+
bool(embedding_caps.get("text", {}).get("available")),
|
|
1646
|
+
{"backends": embedding_caps.get("text", {}).get("backends", [])},
|
|
1647
|
+
),
|
|
1648
|
+
"open_clip": _tool_entry(
|
|
1649
|
+
"open_clip",
|
|
1650
|
+
bool(embedding_caps.get("visual", {}).get("available")),
|
|
1651
|
+
{"python_module": "open_clip"},
|
|
1652
|
+
),
|
|
1610
1653
|
},
|
|
1654
|
+
"embeddings": embedding_caps,
|
|
1611
1655
|
"transcription": {
|
|
1612
1656
|
"available": bool(whisper_cli or whisper_cpp or mlx_whisper),
|
|
1613
1657
|
"backends": [
|
|
@@ -29,7 +29,7 @@ from typing import Callable, Dict, Iterator, Optional, Tuple
|
|
|
29
29
|
|
|
30
30
|
logger = logging.getLogger("resolve-mcp.timeline-brain-db")
|
|
31
31
|
|
|
32
|
-
SCHEMA_VERSION =
|
|
32
|
+
SCHEMA_VERSION = 10
|
|
33
33
|
DB_FILENAME = "timeline_brain.sqlite"
|
|
34
34
|
SOUL_DIRNAME = "_soul"
|
|
35
35
|
|
|
@@ -687,6 +687,38 @@ def _migrate_v9_analysis_core(conn: sqlite3.Connection) -> None:
|
|
|
687
687
|
)
|
|
688
688
|
|
|
689
689
|
|
|
690
|
+
@register_migration(10)
|
|
691
|
+
def _migrate_v10_embeddings(conn: sqlite3.Connection) -> None:
|
|
692
|
+
"""C3 — embeddings for similarity search (Phase C of the analysis program).
|
|
693
|
+
|
|
694
|
+
One row per (entity, kind, model). Vectors are float32 BLOBs; similarity
|
|
695
|
+
is brute-force cosine in the app layer (thousands of rows, not millions).
|
|
696
|
+
`content_hash` fingerprints the embedded content so re-runs only re-embed
|
|
697
|
+
entities whose text/frames changed.
|
|
698
|
+
"""
|
|
699
|
+
conn.executescript(
|
|
700
|
+
"""
|
|
701
|
+
CREATE TABLE IF NOT EXISTS embeddings (
|
|
702
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
703
|
+
entity_type TEXT NOT NULL CHECK(entity_type IN ('clip', 'shot', 'frame', 'segment')),
|
|
704
|
+
entity_uuid TEXT NOT NULL,
|
|
705
|
+
embedding_kind TEXT NOT NULL, -- 'text' | 'visual'
|
|
706
|
+
model_name TEXT NOT NULL,
|
|
707
|
+
dimension INTEGER NOT NULL,
|
|
708
|
+
vector BLOB NOT NULL,
|
|
709
|
+
content_hash TEXT,
|
|
710
|
+
computed_at TEXT NOT NULL,
|
|
711
|
+
UNIQUE(entity_type, entity_uuid, embedding_kind, model_name)
|
|
712
|
+
);
|
|
713
|
+
|
|
714
|
+
CREATE INDEX IF NOT EXISTS ix_embeddings_kind
|
|
715
|
+
ON embeddings(embedding_kind, model_name);
|
|
716
|
+
CREATE INDEX IF NOT EXISTS ix_embeddings_entity
|
|
717
|
+
ON embeddings(entity_type, entity_uuid);
|
|
718
|
+
"""
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
|
|
690
722
|
def latest_version(conn: sqlite3.Connection, timeline_name: str) -> Optional[int]:
|
|
691
723
|
"""Return the highest archived version number for `timeline_name`, or None."""
|
|
692
724
|
row = conn.execute(
|