davinci-resolve-mcp 2.43.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.
- package/CHANGELOG.md +27 -0
- package/README.md +1 -1
- package/docs/SKILL.md +21 -1
- package/docs/guides/control-panel.md +4 -1
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/analysis_dashboard.py +49 -0
- package/src/granular/common.py +1 -1
- package/src/server.py +44 -1
- package/src/utils/entities.py +579 -0
- package/src/utils/timeline_brain_db.py +48 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,33 @@
|
|
|
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.44.0
|
|
6
|
+
|
|
7
|
+
Cross-clip entities + bin briefing v2 — Phase D of the analysis +
|
|
8
|
+
edit-engine program. Recurring people/places/props found by clustering the
|
|
9
|
+
visual embeddings, confirmed with one vision call per cluster.
|
|
10
|
+
|
|
11
|
+
- **Added** schema v11 (`entities` + `entity_appearances`) and
|
|
12
|
+
`src/utils/entities.py`: union-find clustering over the v10 CLIP frame
|
|
13
|
+
vectors (cosine threshold, no new deps), representative-frame selection,
|
|
14
|
+
ghost pruning across re-runs (labeled entities persist), and a
|
|
15
|
+
detection-state stash so `entity_index` always resolves against the exact
|
|
16
|
+
ordering the payload was issued with.
|
|
17
|
+
- **Added** `media_analysis` actions: `detect_entities` (clusters + deferred
|
|
18
|
+
one-frame-per-cluster confirmation payload, caps pre-checked),
|
|
19
|
+
`commit_entities` (kind/label/description with conservative-label rules;
|
|
20
|
+
`merge_with` collapses duplicate clusters), `list_entities`,
|
|
21
|
+
`prepare_bin_briefing` (entities + per-clip summaries, text-only), and
|
|
22
|
+
`commit_bin_summary` (host-synthesized briefing written above the v2.0
|
|
23
|
+
aggregate in `memory/bin_summary.md`).
|
|
24
|
+
- **Added** a "Recurring across this bin" card on the panel's Review page
|
|
25
|
+
(`/api/entities`), shown once labeled entities exist.
|
|
26
|
+
- **Validation**: full offline suite (1105 tests; 10 new). Live on the real
|
|
27
|
+
sample root: 3 clusters detected from 16 CLIP vectors; host-chat
|
|
28
|
+
confirmation labeled the shattered-windshield POV and the white rental
|
|
29
|
+
sedan and merged the sedan's two clusters; bin briefing synthesized and
|
|
30
|
+
committed; panel card verified and screenshots regenerated.
|
|
31
|
+
|
|
5
32
|
## What's New in v2.43.0
|
|
6
33
|
|
|
7
34
|
Embeddings + similarity search — Phase C of the analysis + edit-engine
|
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
|
@@ -496,7 +496,27 @@ Key actions: `capabilities`, `install_guidance`, `resolve_output_root`, `plan`,
|
|
|
496
496
|
`set_panel_state`, `session_start_context`, `update_clip_field`,
|
|
497
497
|
`update_shot_field`, `get_field_history`, `revert_field`,
|
|
498
498
|
`list_corrections`, `deepen`, `commit_shot_vision`, `vision_pending_sweep`,
|
|
499
|
-
`build_embeddings`,
|
|
499
|
+
`build_embeddings`, `find_similar`, `detect_entities`, `commit_entities`,
|
|
500
|
+
`list_entities`, `prepare_bin_briefing`, and `commit_bin_summary`.
|
|
501
|
+
|
|
502
|
+
**Cross-clip entities + bin briefing v2 (v2.44.0+).** Recurring
|
|
503
|
+
people/places/props across a project's media, found cheaply and confirmed
|
|
504
|
+
with ONE vision call per cluster:
|
|
505
|
+
- `detect_entities(threshold?, min_cluster_size?)` clusters the v10 CLIP
|
|
506
|
+
frame vectors (build visual embeddings first), writes provisional entity
|
|
507
|
+
rows + appearances, and returns a deferred payload with one
|
|
508
|
+
representative frame per cluster (caps pre-checked, estimate inlined).
|
|
509
|
+
The host chat reads those frames and calls
|
|
510
|
+
`commit_entities(entities=[{entity_index, kind, label, description,
|
|
511
|
+
confidence, merge_with?}], vision_token)` — conservative labels only
|
|
512
|
+
(describe what's visible; never guess names). `merge_with` collapses
|
|
513
|
+
clusters that show the same entity.
|
|
514
|
+
- `list_entities` returns labeled entities with per-clip/shot appearances;
|
|
515
|
+
the panel's Review page shows a "Recurring across this bin" card.
|
|
516
|
+
- `prepare_bin_briefing` returns entities + per-clip summaries (text-only,
|
|
517
|
+
no vision cost); the host writes a colleague-style markdown briefing and
|
|
518
|
+
calls `commit_bin_summary(briefing, briefing_token)`, which lands in
|
|
519
|
+
`memory/bin_summary.md` above the v2.0 aggregate.
|
|
500
520
|
|
|
501
521
|
**Embeddings + similarity (v2.43.0+).** Local-compute semantic search; no
|
|
502
522
|
vendor tokens, so nothing here touches the caps ledger. Backends are
|
|
@@ -82,7 +82,10 @@ transcripts once the search index is built. When a local text-embedding
|
|
|
82
82
|
backend is detected (ollama with `nomic-embed-text`, or
|
|
83
83
|
sentence-transformers), a `Semantic` toggle appears next to the search box —
|
|
84
84
|
it searches by meaning instead of exact words, ranking clips, shots, and
|
|
85
|
-
transcript lines by similarity to your query.
|
|
85
|
+
transcript lines by similarity to your query. Once cross-clip entity
|
|
86
|
+
detection has run (`detect_entities` + a one-frame-per-cluster confirmation
|
|
87
|
+
in chat), a `Recurring across this bin` card lists the labeled people,
|
|
88
|
+
places, and objects with their shot counts.
|
|
86
89
|
|
|
87
90
|
### Clip detail
|
|
88
91
|
|
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.44.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
|
@@ -2114,6 +2114,30 @@ HTML = r"""<!doctype html>
|
|
|
2114
2114
|
margin-bottom: var(--space-3);
|
|
2115
2115
|
flex-wrap: wrap;
|
|
2116
2116
|
}
|
|
2117
|
+
.review-semantic-toggle {
|
|
2118
|
+
display: inline-flex;
|
|
2119
|
+
gap: 6px;
|
|
2120
|
+
align-items: center;
|
|
2121
|
+
color: var(--text-secondary);
|
|
2122
|
+
font-size: var(--ops-text-label);
|
|
2123
|
+
white-space: nowrap;
|
|
2124
|
+
}
|
|
2125
|
+
.review-entities {
|
|
2126
|
+
margin-bottom: var(--space-3);
|
|
2127
|
+
padding: var(--space-2) var(--space-3);
|
|
2128
|
+
border: 1px solid var(--border-default);
|
|
2129
|
+
border-radius: var(--radius-md);
|
|
2130
|
+
}
|
|
2131
|
+
.review-entities-title {
|
|
2132
|
+
color: var(--text-secondary);
|
|
2133
|
+
font-size: var(--ops-text-label);
|
|
2134
|
+
margin-bottom: 6px;
|
|
2135
|
+
}
|
|
2136
|
+
.review-entities-chips {
|
|
2137
|
+
display: flex;
|
|
2138
|
+
gap: var(--space-2);
|
|
2139
|
+
flex-wrap: wrap;
|
|
2140
|
+
}
|
|
2117
2141
|
.review-bin-filters input[type="search"] {
|
|
2118
2142
|
flex: 1 1 280px;
|
|
2119
2143
|
min-width: 200px;
|
|
@@ -4077,6 +4101,7 @@ HTML = r"""<!doctype html>
|
|
|
4077
4101
|
</div>
|
|
4078
4102
|
</div>
|
|
4079
4103
|
<div id="reviewBinSummary" class="review-bin-summary">Loading analyzed clips…</div>
|
|
4104
|
+
<div id="reviewEntitiesCard" class="review-entities" style="display:none"></div>
|
|
4080
4105
|
<div id="reviewBinGrid" class="review-grid"></div>
|
|
4081
4106
|
<div id="reviewSearchResults" class="review-search-results" style="display:none"></div>
|
|
4082
4107
|
</div>
|
|
@@ -8093,6 +8118,22 @@ HTML = r"""<!doctype html>
|
|
|
8093
8118
|
renderReviewBin();
|
|
8094
8119
|
// Coverage runs in parallel — failures here must not block the clip grid.
|
|
8095
8120
|
refreshReadinessCard().catch(() => {});
|
|
8121
|
+
refreshEntitiesCard().catch(() => {});
|
|
8122
|
+
}
|
|
8123
|
+
|
|
8124
|
+
// Recurring people/places/props detected across the bin (Phase D).
|
|
8125
|
+
// Hidden until at least one labeled entity exists.
|
|
8126
|
+
async function refreshEntitiesCard() {
|
|
8127
|
+
const card = $('reviewEntitiesCard');
|
|
8128
|
+
if (!card) return;
|
|
8129
|
+
const data = await api('/api/entities').catch(() => null);
|
|
8130
|
+
const labeled = (data?.entities || []).filter(e => e.label);
|
|
8131
|
+
if (!labeled.length) { card.style.display = 'none'; return; }
|
|
8132
|
+
card.style.display = '';
|
|
8133
|
+
card.innerHTML = `<div class="review-entities-title">Recurring across this bin</div>
|
|
8134
|
+
<div class="review-entities-chips">${labeled.map(e =>
|
|
8135
|
+
`<span class="review-chip" title="${escapeHtml(e.description || '')}">${escapeHtml(e.label)} · ${e.kind || 'unknown'} · ${e.shot_count || e.cluster_size || 0} shots</span>`
|
|
8136
|
+
).join('')}</div>`;
|
|
8096
8137
|
}
|
|
8097
8138
|
|
|
8098
8139
|
async function refreshReadinessCard() {
|
|
@@ -14386,6 +14427,14 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
14386
14427
|
payload["results"] = _v2_enrich_search_results(self.state.project_root, payload["results"])
|
|
14387
14428
|
self._json(payload)
|
|
14388
14429
|
return
|
|
14430
|
+
if path == "/api/entities":
|
|
14431
|
+
try:
|
|
14432
|
+
from src.utils import entities as _entities
|
|
14433
|
+
|
|
14434
|
+
self._json(_entities.list_entities(self.state.project_root))
|
|
14435
|
+
except Exception as exc: # noqa: BLE001 — panel reads fail soft
|
|
14436
|
+
self._json({"success": False, "error": f"{type(exc).__name__}: {exc}"})
|
|
14437
|
+
return
|
|
14389
14438
|
if path == "/api/search/semantic":
|
|
14390
14439
|
q = (query.get("q") or [""])[0]
|
|
14391
14440
|
try:
|
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.44.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.44.0"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -15027,6 +15027,12 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
|
|
|
15027
15027
|
# Phase C — embeddings + similarity.
|
|
15028
15028
|
"build_embeddings",
|
|
15029
15029
|
"find_similar",
|
|
15030
|
+
# Phase D — cross-clip entities + bin briefing v2.
|
|
15031
|
+
"detect_entities",
|
|
15032
|
+
"commit_entities",
|
|
15033
|
+
"list_entities",
|
|
15034
|
+
"prepare_bin_briefing",
|
|
15035
|
+
"commit_bin_summary",
|
|
15030
15036
|
}:
|
|
15031
15037
|
root = resolve_media_analysis_output_root(
|
|
15032
15038
|
project_name=project_name,
|
|
@@ -15150,6 +15156,38 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
|
|
|
15150
15156
|
entity_types=entity_types,
|
|
15151
15157
|
limit=int(p.get("limit") or 10),
|
|
15152
15158
|
)
|
|
15159
|
+
# Phase D — cross-clip entities + bin briefing v2.
|
|
15160
|
+
if action == "detect_entities":
|
|
15161
|
+
from src.utils import entities
|
|
15162
|
+
return entities.detect_entities(
|
|
15163
|
+
project_root,
|
|
15164
|
+
threshold=float(p.get("threshold") or entities.DEFAULT_CLUSTER_THRESHOLD),
|
|
15165
|
+
min_cluster_size=int(p.get("min_cluster_size") or p.get("minClusterSize") or entities.DEFAULT_MIN_CLUSTER_SIZE),
|
|
15166
|
+
max_clusters=int(p.get("max_clusters") or p.get("maxClusters") or 24),
|
|
15167
|
+
job_id=p.get("job_id") or p.get("jobId"),
|
|
15168
|
+
)
|
|
15169
|
+
if action == "commit_entities":
|
|
15170
|
+
from src.utils import entities
|
|
15171
|
+
return entities.commit_entities(
|
|
15172
|
+
project_root,
|
|
15173
|
+
entities_payload=p.get("entities"),
|
|
15174
|
+
vision_token=p.get("vision_token") or p.get("visionToken"),
|
|
15175
|
+
author=p.get("author") or "host_chat",
|
|
15176
|
+
)
|
|
15177
|
+
if action == "list_entities":
|
|
15178
|
+
from src.utils import entities
|
|
15179
|
+
return entities.list_entities(project_root, kind=p.get("kind"))
|
|
15180
|
+
if action == "prepare_bin_briefing":
|
|
15181
|
+
from src.utils import entities
|
|
15182
|
+
return entities.prepare_bin_briefing(project_root)
|
|
15183
|
+
if action == "commit_bin_summary":
|
|
15184
|
+
from src.utils import entities
|
|
15185
|
+
return entities.commit_bin_summary(
|
|
15186
|
+
project_root,
|
|
15187
|
+
briefing=p.get("briefing") or p.get("summary"),
|
|
15188
|
+
briefing_token=p.get("briefing_token") or p.get("briefingToken"),
|
|
15189
|
+
author=p.get("author") or "host_chat",
|
|
15190
|
+
)
|
|
15153
15191
|
if action in {"build_index", "rebuild_index"}:
|
|
15154
15192
|
return build_analysis_index(project_root, index_path=p.get("index_path") or p.get("indexPath"))
|
|
15155
15193
|
if action == "index_status":
|
|
@@ -15566,6 +15604,11 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
|
|
|
15566
15604
|
"vision_pending_sweep",
|
|
15567
15605
|
"build_embeddings",
|
|
15568
15606
|
"find_similar",
|
|
15607
|
+
"detect_entities",
|
|
15608
|
+
"commit_entities",
|
|
15609
|
+
"list_entities",
|
|
15610
|
+
"prepare_bin_briefing",
|
|
15611
|
+
"commit_bin_summary",
|
|
15569
15612
|
"get_caps",
|
|
15570
15613
|
"set_caps_preset",
|
|
15571
15614
|
"get_usage",
|
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
"""Cross-clip entities (Phase D of the analysis program).
|
|
2
|
+
|
|
3
|
+
Recurring people/places/props across a project's analyzed media. The cheap
|
|
4
|
+
part is local: union-find clustering over the v10 CLIP frame vectors with a
|
|
5
|
+
cosine threshold. The expensive part is bounded: ONE host-vision call per
|
|
6
|
+
cluster representative (the deferred-payload pattern), committed via
|
|
7
|
+
``commit_entities`` with kind/label/description per cluster — conservative
|
|
8
|
+
labels, per the trust-by-default rule (hedge identity when evidence is thin).
|
|
9
|
+
|
|
10
|
+
Bin briefing v2 rides the same pattern at bin level: ``prepare_bin_briefing``
|
|
11
|
+
returns entities + per-clip summaries for the host chat to synthesize, and
|
|
12
|
+
``commit_bin_summary`` writes the result into memory/bin_summary.md (the v2.0
|
|
13
|
+
aggregate stays below as an appendix).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import time
|
|
21
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
22
|
+
|
|
23
|
+
from src.utils import analysis_memory, embeddings, timeline_brain_db
|
|
24
|
+
|
|
25
|
+
ENTITY_VISION_SOURCE = "vision_entity_v1"
|
|
26
|
+
ENTITY_SCHEMA_REFERENCE = "davinci_resolve_mcp.entity_confirmation.v1"
|
|
27
|
+
DEFAULT_CLUSTER_THRESHOLD = 0.78
|
|
28
|
+
DEFAULT_MIN_CLUSTER_SIZE = 2
|
|
29
|
+
|
|
30
|
+
ENTITY_SCHEMA = {
|
|
31
|
+
"entities": [
|
|
32
|
+
{
|
|
33
|
+
"entity_index": "<int — from the payload's clusters list>",
|
|
34
|
+
"entity_uuid": "<echo the cluster's entity_uuid when convenient>",
|
|
35
|
+
"kind": "person|place|object|unknown",
|
|
36
|
+
"label": "<short conservative label — 'man in dark hooded jacket', not a name unless visibly slated>",
|
|
37
|
+
"description": "<1-2 sentences of what recurs across these frames>",
|
|
38
|
+
"confidence": "low|medium|high",
|
|
39
|
+
"merge_with": "<optional int entity_index this duplicates, else omit>",
|
|
40
|
+
}
|
|
41
|
+
]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
ENTITY_PROMPT = (
|
|
45
|
+
"Each cluster below groups frames that look visually similar across the "
|
|
46
|
+
"project's clips. Look at each cluster's representative frame and decide "
|
|
47
|
+
"what recurring entity it shows (a person, place, or object). Use "
|
|
48
|
+
"conservative labels — describe what is visible, never guess identity or "
|
|
49
|
+
"names unless text on screen states them. If two clusters clearly show "
|
|
50
|
+
"the same entity, point the duplicate at the original with merge_with. "
|
|
51
|
+
"Return strict JSON matching `schema`."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _now() -> str:
|
|
56
|
+
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _ma():
|
|
60
|
+
from src.utils import media_analysis
|
|
61
|
+
|
|
62
|
+
return media_analysis
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class _UnionFind:
|
|
66
|
+
def __init__(self, n: int) -> None:
|
|
67
|
+
self.parent = list(range(n))
|
|
68
|
+
|
|
69
|
+
def find(self, i: int) -> int:
|
|
70
|
+
while self.parent[i] != i:
|
|
71
|
+
self.parent[i] = self.parent[self.parent[i]]
|
|
72
|
+
i = self.parent[i]
|
|
73
|
+
return i
|
|
74
|
+
|
|
75
|
+
def union(self, a: int, b: int) -> None:
|
|
76
|
+
ra, rb = self.find(a), self.find(b)
|
|
77
|
+
if ra != rb:
|
|
78
|
+
self.parent[rb] = ra
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _cluster_vectors(
|
|
82
|
+
vectors: List[List[float]], threshold: float
|
|
83
|
+
) -> List[List[int]]:
|
|
84
|
+
"""Union-find over pairwise cosine similarity. O(n²) — fine at this scale."""
|
|
85
|
+
n = len(vectors)
|
|
86
|
+
uf = _UnionFind(n)
|
|
87
|
+
try:
|
|
88
|
+
import numpy as np
|
|
89
|
+
|
|
90
|
+
matrix = np.asarray(vectors, dtype="float32")
|
|
91
|
+
norms = np.linalg.norm(matrix, axis=1, keepdims=True)
|
|
92
|
+
norms[norms == 0] = 1.0
|
|
93
|
+
normalized = matrix / norms
|
|
94
|
+
sims = normalized @ normalized.T
|
|
95
|
+
for i in range(n):
|
|
96
|
+
row = sims[i]
|
|
97
|
+
for j in range(i + 1, n):
|
|
98
|
+
if row[j] >= threshold:
|
|
99
|
+
uf.union(i, j)
|
|
100
|
+
except ImportError:
|
|
101
|
+
for i in range(n):
|
|
102
|
+
for j in range(i + 1, n):
|
|
103
|
+
if embeddings.cosine_similarity(vectors[i], vectors[j]) >= threshold:
|
|
104
|
+
uf.union(i, j)
|
|
105
|
+
groups: Dict[int, List[int]] = {}
|
|
106
|
+
for i in range(n):
|
|
107
|
+
groups.setdefault(uf.find(i), []).append(i)
|
|
108
|
+
return sorted(groups.values(), key=len, reverse=True)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _representative(vectors: List[List[float]], members: List[int]) -> Tuple[int, Dict[int, float]]:
|
|
112
|
+
"""Member with the highest mean similarity to its cluster, plus each
|
|
113
|
+
member's similarity to that representative."""
|
|
114
|
+
best_index = members[0]
|
|
115
|
+
best_mean = -1.0
|
|
116
|
+
for i in members:
|
|
117
|
+
mean = sum(embeddings.cosine_similarity(vectors[i], vectors[j]) for j in members if j != i)
|
|
118
|
+
mean = mean / max(1, len(members) - 1)
|
|
119
|
+
if mean > best_mean:
|
|
120
|
+
best_mean, best_index = mean, i
|
|
121
|
+
sims = {
|
|
122
|
+
j: (1.0 if j == best_index else embeddings.cosine_similarity(vectors[best_index], vectors[j]))
|
|
123
|
+
for j in members
|
|
124
|
+
}
|
|
125
|
+
return best_index, sims
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _entity_uuid_for(frame_refs: Sequence[str]) -> str:
|
|
129
|
+
return _ma().short_hash("entity:" + ",".join(sorted(frame_refs)), 12)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _detection_state_path(project_root: str) -> str:
|
|
133
|
+
return os.path.join(analysis_memory.memory_dir(project_root), "entity_detection_state.json")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _write_detection_state(project_root: str, token: str, ordering: List[str]) -> None:
|
|
137
|
+
analysis_memory.ensure_memory_structure(project_root)
|
|
138
|
+
path = _detection_state_path(project_root)
|
|
139
|
+
tmp = path + ".tmp"
|
|
140
|
+
with open(tmp, "w", encoding="utf-8") as handle:
|
|
141
|
+
json.dump({"vision_token": token, "ordering": ordering, "written_at": _now()}, handle, indent=2)
|
|
142
|
+
os.replace(tmp, path)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _read_detection_state(project_root: str) -> Optional[Dict[str, Any]]:
|
|
146
|
+
try:
|
|
147
|
+
with open(_detection_state_path(project_root), "r", encoding="utf-8") as handle:
|
|
148
|
+
return json.load(handle)
|
|
149
|
+
except (OSError, json.JSONDecodeError):
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def detect_entities(
|
|
154
|
+
project_root: str,
|
|
155
|
+
*,
|
|
156
|
+
threshold: float = DEFAULT_CLUSTER_THRESHOLD,
|
|
157
|
+
min_cluster_size: int = DEFAULT_MIN_CLUSTER_SIZE,
|
|
158
|
+
max_clusters: int = 24,
|
|
159
|
+
job_id: Optional[str] = None,
|
|
160
|
+
) -> Dict[str, Any]:
|
|
161
|
+
"""Cluster visual frame vectors into provisional entities and return the
|
|
162
|
+
deferred host-vision confirmation payload (one frame per cluster)."""
|
|
163
|
+
ma = _ma()
|
|
164
|
+
conn = timeline_brain_db.connect(project_root)
|
|
165
|
+
rows = conn.execute(
|
|
166
|
+
"""
|
|
167
|
+
SELECT entity_uuid, vector FROM embeddings
|
|
168
|
+
WHERE embedding_kind = 'visual' AND entity_type = 'frame'
|
|
169
|
+
"""
|
|
170
|
+
).fetchall()
|
|
171
|
+
if not rows:
|
|
172
|
+
return {
|
|
173
|
+
"success": False,
|
|
174
|
+
"error": (
|
|
175
|
+
"No visual frame embeddings yet — run "
|
|
176
|
+
"media_analysis(action='build_embeddings', params={'kinds': ['visual']}) first."
|
|
177
|
+
),
|
|
178
|
+
}
|
|
179
|
+
frame_refs = [str(r["entity_uuid"]) for r in rows]
|
|
180
|
+
vectors = [embeddings.unpack_vector(r["vector"]) for r in rows]
|
|
181
|
+
|
|
182
|
+
clusters = [
|
|
183
|
+
members for members in _cluster_vectors(vectors, float(threshold))
|
|
184
|
+
if len(members) >= int(min_cluster_size)
|
|
185
|
+
][: int(max_clusters)]
|
|
186
|
+
if not clusters:
|
|
187
|
+
return {
|
|
188
|
+
"success": True,
|
|
189
|
+
"status": "no_clusters",
|
|
190
|
+
"frame_count": len(rows),
|
|
191
|
+
"note": f"No clusters of size >= {min_cluster_size} at threshold {threshold}.",
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
# Hydrate frame rows once: frame_ref -> (clip_uuid, frame_index, path, shot_uuid).
|
|
195
|
+
frame_info: Dict[str, Dict[str, Any]] = {}
|
|
196
|
+
for ref in frame_refs:
|
|
197
|
+
clip_uuid, _, frame_index = ref.rpartition(":")
|
|
198
|
+
row = conn.execute(
|
|
199
|
+
"SELECT frame_path, shot_uuid FROM frames WHERE clip_uuid = ? AND frame_index = ?",
|
|
200
|
+
(clip_uuid, int(frame_index) if frame_index.lstrip("-").isdigit() else -1),
|
|
201
|
+
).fetchone()
|
|
202
|
+
frame_info[ref] = {
|
|
203
|
+
"clip_uuid": clip_uuid,
|
|
204
|
+
"frame_index": frame_index,
|
|
205
|
+
"frame_path": str(row["frame_path"]) if row and row["frame_path"] else None,
|
|
206
|
+
"shot_uuid": str(row["shot_uuid"]) if row and row["shot_uuid"] else None,
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
# Caps: one frame per cluster goes to the host.
|
|
210
|
+
estimated_tokens = len(clusters) * ma.AVG_VISION_TOKENS_PER_FRAME
|
|
211
|
+
refusal = ma._check_caps_pre_call(
|
|
212
|
+
project_root=project_root,
|
|
213
|
+
estimated_vision_tokens=estimated_tokens,
|
|
214
|
+
clip_id=None,
|
|
215
|
+
job_id=job_id,
|
|
216
|
+
)
|
|
217
|
+
if refusal is not None:
|
|
218
|
+
return refusal
|
|
219
|
+
|
|
220
|
+
now = _now()
|
|
221
|
+
cluster_payload: List[Dict[str, Any]] = []
|
|
222
|
+
entity_uuids: List[str] = []
|
|
223
|
+
with timeline_brain_db.transaction(project_root) as txn:
|
|
224
|
+
for index, members in enumerate(clusters, 1):
|
|
225
|
+
refs = [frame_refs[i] for i in members]
|
|
226
|
+
rep_i, sims = _representative(vectors, members)
|
|
227
|
+
rep_ref = frame_refs[rep_i]
|
|
228
|
+
rep = frame_info[rep_ref]
|
|
229
|
+
entity_uuid = _entity_uuid_for(refs)
|
|
230
|
+
entity_uuids.append(entity_uuid)
|
|
231
|
+
existing = txn.execute(
|
|
232
|
+
"SELECT created_at, kind, label, description, confidence, source FROM entities WHERE entity_uuid = ?",
|
|
233
|
+
(entity_uuid,),
|
|
234
|
+
).fetchone()
|
|
235
|
+
txn.execute(
|
|
236
|
+
"""
|
|
237
|
+
INSERT OR REPLACE INTO entities
|
|
238
|
+
(entity_uuid, kind, label, description, confidence, source,
|
|
239
|
+
representative_frame_ref, representative_frame_path,
|
|
240
|
+
cluster_size, created_at, updated_at)
|
|
241
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
242
|
+
""",
|
|
243
|
+
(
|
|
244
|
+
entity_uuid,
|
|
245
|
+
existing["kind"] if existing else "unknown",
|
|
246
|
+
existing["label"] if existing else None,
|
|
247
|
+
existing["description"] if existing else None,
|
|
248
|
+
existing["confidence"] if existing else None,
|
|
249
|
+
existing["source"] if existing else "clustering",
|
|
250
|
+
rep_ref,
|
|
251
|
+
rep["frame_path"],
|
|
252
|
+
len(members),
|
|
253
|
+
str(existing["created_at"]) if existing else now,
|
|
254
|
+
now,
|
|
255
|
+
),
|
|
256
|
+
)
|
|
257
|
+
txn.execute("DELETE FROM entity_appearances WHERE entity_uuid = ?", (entity_uuid,))
|
|
258
|
+
for i in members:
|
|
259
|
+
ref = frame_refs[i]
|
|
260
|
+
info = frame_info[ref]
|
|
261
|
+
txn.execute(
|
|
262
|
+
"""
|
|
263
|
+
INSERT OR IGNORE INTO entity_appearances
|
|
264
|
+
(entity_uuid, clip_uuid, shot_uuid, frame_ref, similarity)
|
|
265
|
+
VALUES (?, ?, ?, ?, ?)
|
|
266
|
+
""",
|
|
267
|
+
(entity_uuid, info["clip_uuid"], info["shot_uuid"], ref, round(float(sims[i]), 4)),
|
|
268
|
+
)
|
|
269
|
+
clip_count = len({frame_info[r]["clip_uuid"] for r in refs})
|
|
270
|
+
shot_count = len({frame_info[r]["shot_uuid"] for r in refs if frame_info[r]["shot_uuid"]})
|
|
271
|
+
cluster_payload.append({
|
|
272
|
+
"entity_index": index,
|
|
273
|
+
"entity_uuid": entity_uuid,
|
|
274
|
+
"frame_count": len(members),
|
|
275
|
+
"clip_count": clip_count,
|
|
276
|
+
"shot_count": shot_count,
|
|
277
|
+
"already_labeled": bool(existing and existing["label"]),
|
|
278
|
+
"current_label": existing["label"] if existing else None,
|
|
279
|
+
"representative_frame_path": rep["frame_path"],
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
# Prune unlabeled provisional ghosts from earlier runs (a different
|
|
283
|
+
# threshold re-shapes clusters → new uuids). Labeled entities persist.
|
|
284
|
+
with timeline_brain_db.transaction(project_root) as txn:
|
|
285
|
+
placeholders = ",".join("?" for _ in entity_uuids)
|
|
286
|
+
txn.execute(
|
|
287
|
+
f"""
|
|
288
|
+
DELETE FROM entity_appearances WHERE entity_uuid IN (
|
|
289
|
+
SELECT entity_uuid FROM entities
|
|
290
|
+
WHERE source = 'clustering' AND label IS NULL
|
|
291
|
+
AND entity_uuid NOT IN ({placeholders})
|
|
292
|
+
)
|
|
293
|
+
""",
|
|
294
|
+
entity_uuids,
|
|
295
|
+
)
|
|
296
|
+
txn.execute(
|
|
297
|
+
f"""
|
|
298
|
+
DELETE FROM entities
|
|
299
|
+
WHERE source = 'clustering' AND label IS NULL
|
|
300
|
+
AND entity_uuid NOT IN ({placeholders})
|
|
301
|
+
""",
|
|
302
|
+
entity_uuids,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
vision_token = _ma().short_hash("entities:" + ",".join(sorted(entity_uuids)), 16)
|
|
306
|
+
_write_detection_state(project_root, vision_token, entity_uuids)
|
|
307
|
+
return {
|
|
308
|
+
"success": True,
|
|
309
|
+
"status": "pending_host_analysis",
|
|
310
|
+
"provider": "host_chat_paths",
|
|
311
|
+
"mode": "entity_confirmation",
|
|
312
|
+
"vision_token": vision_token,
|
|
313
|
+
"threshold": threshold,
|
|
314
|
+
"frame_count": len(rows),
|
|
315
|
+
"cluster_count": len(clusters),
|
|
316
|
+
"estimate": {
|
|
317
|
+
"frames_to_review": len(clusters),
|
|
318
|
+
"estimated_vision_tokens": estimated_tokens,
|
|
319
|
+
},
|
|
320
|
+
"clusters": cluster_payload,
|
|
321
|
+
"frame_paths": [c["representative_frame_path"] for c in cluster_payload if c["representative_frame_path"]],
|
|
322
|
+
"schema": json.loads(json.dumps(ENTITY_SCHEMA)),
|
|
323
|
+
"schema_reference": ENTITY_SCHEMA_REFERENCE,
|
|
324
|
+
"prompt": ENTITY_PROMPT,
|
|
325
|
+
"commit_action": {
|
|
326
|
+
"tool": "media_analysis",
|
|
327
|
+
"action": "commit_entities",
|
|
328
|
+
"params": {
|
|
329
|
+
"vision_token": vision_token,
|
|
330
|
+
"entities": "<host chat: fill per `schema`>",
|
|
331
|
+
"analysis_root": project_root,
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
"instructions": (
|
|
335
|
+
"Read each cluster's representative_frame_path as a local image and "
|
|
336
|
+
"produce one entry per entity_index in `entities` per the schema. "
|
|
337
|
+
"Then call the tool in commit_action. Clusters marked "
|
|
338
|
+
"already_labeled keep their label unless you provide a better one."
|
|
339
|
+
),
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def commit_entities(
|
|
344
|
+
project_root: str,
|
|
345
|
+
*,
|
|
346
|
+
entities_payload: Any,
|
|
347
|
+
vision_token: Optional[str] = None,
|
|
348
|
+
author: str = "host_chat",
|
|
349
|
+
) -> Dict[str, Any]:
|
|
350
|
+
"""Apply host-vision labels to provisional entity clusters."""
|
|
351
|
+
if isinstance(entities_payload, str):
|
|
352
|
+
try:
|
|
353
|
+
entities_payload = json.loads(entities_payload)
|
|
354
|
+
except json.JSONDecodeError as exc:
|
|
355
|
+
return {"success": False, "error": f"entities was a string but not valid JSON: {exc}"}
|
|
356
|
+
if isinstance(entities_payload, dict) and isinstance(entities_payload.get("entities"), list):
|
|
357
|
+
entities_payload = entities_payload["entities"]
|
|
358
|
+
if not isinstance(entities_payload, list) or not entities_payload:
|
|
359
|
+
return {"success": False, "error": "commit_entities requires `entities`: a non-empty array"}
|
|
360
|
+
|
|
361
|
+
state = _read_detection_state(project_root)
|
|
362
|
+
if not state:
|
|
363
|
+
return {"success": False, "error": "No entity-detection state — run detect_entities first"}
|
|
364
|
+
expected = str(state.get("vision_token") or "")
|
|
365
|
+
if vision_token and str(vision_token) != expected:
|
|
366
|
+
return {
|
|
367
|
+
"success": False,
|
|
368
|
+
"error": "vision_token mismatch; entity clusters changed since the payload was issued (re-run detect_entities).",
|
|
369
|
+
"expected_vision_token": expected,
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
# entity_index resolution uses the exact ordering detect_entities issued.
|
|
373
|
+
by_index = {i + 1: uuid for i, uuid in enumerate(state.get("ordering") or [])}
|
|
374
|
+
now = _now()
|
|
375
|
+
updated = 0
|
|
376
|
+
merged = 0
|
|
377
|
+
with timeline_brain_db.transaction(project_root) as txn:
|
|
378
|
+
merges: List[Tuple[str, str]] = []
|
|
379
|
+
for entry in entities_payload:
|
|
380
|
+
if not isinstance(entry, dict):
|
|
381
|
+
continue
|
|
382
|
+
uuid = entry.get("entity_uuid") or by_index.get(_safe_int(entry.get("entity_index")))
|
|
383
|
+
if not uuid:
|
|
384
|
+
continue
|
|
385
|
+
merge_target = entry.get("merge_with")
|
|
386
|
+
if merge_target is not None:
|
|
387
|
+
target_uuid = by_index.get(_safe_int(merge_target))
|
|
388
|
+
if target_uuid and target_uuid != uuid:
|
|
389
|
+
merges.append((str(uuid), target_uuid))
|
|
390
|
+
continue
|
|
391
|
+
txn.execute(
|
|
392
|
+
"""
|
|
393
|
+
UPDATE entities
|
|
394
|
+
SET kind = ?, label = ?, description = ?, confidence = ?,
|
|
395
|
+
source = ?, updated_at = ?
|
|
396
|
+
WHERE entity_uuid = ?
|
|
397
|
+
""",
|
|
398
|
+
(
|
|
399
|
+
str(entry.get("kind") or "unknown"),
|
|
400
|
+
entry.get("label"),
|
|
401
|
+
entry.get("description"),
|
|
402
|
+
entry.get("confidence"),
|
|
403
|
+
ENTITY_VISION_SOURCE if author == "host_chat" else "human",
|
|
404
|
+
now,
|
|
405
|
+
str(uuid),
|
|
406
|
+
),
|
|
407
|
+
)
|
|
408
|
+
updated += 1
|
|
409
|
+
for duplicate, target in merges:
|
|
410
|
+
txn.execute(
|
|
411
|
+
"UPDATE OR IGNORE entity_appearances SET entity_uuid = ? WHERE entity_uuid = ?",
|
|
412
|
+
(target, duplicate),
|
|
413
|
+
)
|
|
414
|
+
txn.execute("DELETE FROM entity_appearances WHERE entity_uuid = ?", (duplicate,))
|
|
415
|
+
txn.execute("DELETE FROM entities WHERE entity_uuid = ?", (duplicate,))
|
|
416
|
+
size = txn.execute(
|
|
417
|
+
"SELECT COUNT(*) FROM entity_appearances WHERE entity_uuid = ?", (target,)
|
|
418
|
+
).fetchone()[0]
|
|
419
|
+
txn.execute(
|
|
420
|
+
"UPDATE entities SET cluster_size = ?, updated_at = ? WHERE entity_uuid = ?",
|
|
421
|
+
(int(size), now, target),
|
|
422
|
+
)
|
|
423
|
+
merged += 1
|
|
424
|
+
|
|
425
|
+
ma = _ma()
|
|
426
|
+
ma._record_caps_usage(
|
|
427
|
+
project_root=project_root,
|
|
428
|
+
clip_id=None,
|
|
429
|
+
vision_tokens=updated * ma.AVG_VISION_TOKENS_PER_FRAME,
|
|
430
|
+
frames_uploaded=updated,
|
|
431
|
+
)
|
|
432
|
+
return {"success": True, "entities_updated": updated, "entities_merged": merged}
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _safe_int(value: Any) -> Optional[int]:
|
|
436
|
+
try:
|
|
437
|
+
return int(value)
|
|
438
|
+
except (TypeError, ValueError):
|
|
439
|
+
return None
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def list_entities(project_root: str, *, kind: Optional[str] = None) -> Dict[str, Any]:
|
|
443
|
+
conn = timeline_brain_db.connect(project_root)
|
|
444
|
+
where = " WHERE kind = ?" if kind else ""
|
|
445
|
+
args: Tuple[Any, ...] = (kind,) if kind else ()
|
|
446
|
+
rows = conn.execute(
|
|
447
|
+
f"SELECT * FROM entities{where} ORDER BY cluster_size DESC, label", args
|
|
448
|
+
).fetchall()
|
|
449
|
+
entities: List[Dict[str, Any]] = []
|
|
450
|
+
for row in rows:
|
|
451
|
+
entity = dict(row)
|
|
452
|
+
appearances = conn.execute(
|
|
453
|
+
"""
|
|
454
|
+
SELECT a.clip_uuid, c.clip_name, a.shot_uuid, s.shot_index, a.frame_ref, a.similarity
|
|
455
|
+
FROM entity_appearances a
|
|
456
|
+
LEFT JOIN clips c ON c.clip_uuid = a.clip_uuid
|
|
457
|
+
LEFT JOIN shots s ON s.shot_uuid = a.shot_uuid
|
|
458
|
+
WHERE a.entity_uuid = ?
|
|
459
|
+
ORDER BY a.clip_uuid, s.shot_index
|
|
460
|
+
""",
|
|
461
|
+
(entity["entity_uuid"],),
|
|
462
|
+
).fetchall()
|
|
463
|
+
entity["appearances"] = [dict(a) for a in appearances]
|
|
464
|
+
entity["clip_count"] = len({a["clip_uuid"] for a in appearances})
|
|
465
|
+
entity["shot_count"] = len({a["shot_uuid"] for a in appearances if a["shot_uuid"]})
|
|
466
|
+
entities.append(entity)
|
|
467
|
+
return {"success": True, "count": len(entities), "entities": entities}
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
# ── bin briefing v2 ──────────────────────────────────────────────────────────
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def prepare_bin_briefing(project_root: str) -> Dict[str, Any]:
|
|
474
|
+
"""Deferred payload for a host-synthesized bin briefing: labeled entities
|
|
475
|
+
+ per-clip summaries. Text-only — no frames, no vision cost."""
|
|
476
|
+
conn = timeline_brain_db.connect(project_root)
|
|
477
|
+
clips = [dict(r) for r in conn.execute(
|
|
478
|
+
"SELECT clip_uuid, clip_name, summary, duration_seconds, shot_count, overall_motion_level FROM clips ORDER BY clip_name"
|
|
479
|
+
).fetchall()]
|
|
480
|
+
if not clips:
|
|
481
|
+
return {"success": False, "error": "No analyzed clips in the DB — analyze (or db_ingest) first."}
|
|
482
|
+
for clip in clips:
|
|
483
|
+
row = conn.execute(
|
|
484
|
+
"""
|
|
485
|
+
SELECT value_json FROM subjective_fields
|
|
486
|
+
WHERE entity_type='clip' AND entity_uuid=? AND field_path='clip_summary'
|
|
487
|
+
AND superseded_at IS NULL
|
|
488
|
+
""",
|
|
489
|
+
(clip["clip_uuid"],),
|
|
490
|
+
).fetchone()
|
|
491
|
+
if row:
|
|
492
|
+
try:
|
|
493
|
+
clip["clip_summary"] = json.loads(row["value_json"])
|
|
494
|
+
except (TypeError, ValueError):
|
|
495
|
+
pass
|
|
496
|
+
listed = list_entities(project_root)
|
|
497
|
+
entity_lines = [
|
|
498
|
+
{
|
|
499
|
+
"label": e.get("label") or "(unlabeled cluster)",
|
|
500
|
+
"kind": e.get("kind"),
|
|
501
|
+
"description": e.get("description"),
|
|
502
|
+
"clip_count": e.get("clip_count"),
|
|
503
|
+
"shot_count": e.get("shot_count"),
|
|
504
|
+
"frame_count": e.get("cluster_size"),
|
|
505
|
+
}
|
|
506
|
+
for e in listed.get("entities") or []
|
|
507
|
+
]
|
|
508
|
+
token = _ma().short_hash(
|
|
509
|
+
"briefing:" + ",".join(sorted(c["clip_uuid"] for c in clips)), 16
|
|
510
|
+
)
|
|
511
|
+
return {
|
|
512
|
+
"success": True,
|
|
513
|
+
"status": "pending_host_synthesis",
|
|
514
|
+
"briefing_token": token,
|
|
515
|
+
"clips": clips,
|
|
516
|
+
"entities": entity_lines,
|
|
517
|
+
"prompt": (
|
|
518
|
+
"Write a colleague-style bin briefing in markdown: 2-4 paragraphs "
|
|
519
|
+
"covering what this media is, who/what recurs (use the entities "
|
|
520
|
+
"list), the strongest material, and anything an editor should know "
|
|
521
|
+
"before cutting. Conservative claims only — describe, don't guess. "
|
|
522
|
+
"Then call the tool in commit_action with `briefing` set to the markdown."
|
|
523
|
+
),
|
|
524
|
+
"commit_action": {
|
|
525
|
+
"tool": "media_analysis",
|
|
526
|
+
"action": "commit_bin_summary",
|
|
527
|
+
"params": {
|
|
528
|
+
"briefing": "<host chat: markdown briefing>",
|
|
529
|
+
"briefing_token": token,
|
|
530
|
+
"analysis_root": project_root,
|
|
531
|
+
},
|
|
532
|
+
},
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def commit_bin_summary(
|
|
537
|
+
project_root: str,
|
|
538
|
+
*,
|
|
539
|
+
briefing: Any,
|
|
540
|
+
briefing_token: Optional[str] = None,
|
|
541
|
+
author: str = "host_chat",
|
|
542
|
+
) -> Dict[str, Any]:
|
|
543
|
+
"""Write the host-synthesized briefing above the v2.0 aggregate."""
|
|
544
|
+
if not isinstance(briefing, str) or not briefing.strip():
|
|
545
|
+
return {"success": False, "error": "commit_bin_summary requires `briefing` (markdown text)"}
|
|
546
|
+
conn = timeline_brain_db.connect(project_root)
|
|
547
|
+
clips = [str(r["clip_uuid"]) for r in conn.execute("SELECT clip_uuid FROM clips ORDER BY clip_name").fetchall()]
|
|
548
|
+
expected = _ma().short_hash("briefing:" + ",".join(sorted(clips)), 16)
|
|
549
|
+
if briefing_token and str(briefing_token) != expected:
|
|
550
|
+
return {
|
|
551
|
+
"success": False,
|
|
552
|
+
"error": "briefing_token mismatch; the clip set changed since the payload was issued (re-run prepare_bin_briefing).",
|
|
553
|
+
}
|
|
554
|
+
analysis_memory.ensure_memory_structure(project_root)
|
|
555
|
+
path = analysis_memory.bin_summary_path(project_root)
|
|
556
|
+
appendix = ""
|
|
557
|
+
if os.path.isfile(path):
|
|
558
|
+
try:
|
|
559
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
560
|
+
existing = handle.read()
|
|
561
|
+
# Keep only the aggregate section as an appendix (drop a previous
|
|
562
|
+
# synthesized briefing if present).
|
|
563
|
+
marker = "# Bin summary —"
|
|
564
|
+
idx = existing.find(marker)
|
|
565
|
+
if idx >= 0:
|
|
566
|
+
appendix = existing[idx:]
|
|
567
|
+
except OSError:
|
|
568
|
+
appendix = ""
|
|
569
|
+
content = (
|
|
570
|
+
f"# Bin briefing\n\n_Synthesized {_now()} by {author} "
|
|
571
|
+
f"(entities + per-clip summaries)._\n\n{briefing.strip()}\n"
|
|
572
|
+
)
|
|
573
|
+
if appendix:
|
|
574
|
+
content += f"\n\n---\n\n{appendix}"
|
|
575
|
+
tmp = path + ".tmp"
|
|
576
|
+
with open(tmp, "w", encoding="utf-8") as handle:
|
|
577
|
+
handle.write(content)
|
|
578
|
+
os.replace(tmp, path)
|
|
579
|
+
return {"success": True, "path": path, "bytes": len(content)}
|
|
@@ -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 = 11
|
|
33
33
|
DB_FILENAME = "timeline_brain.sqlite"
|
|
34
34
|
SOUL_DIRNAME = "_soul"
|
|
35
35
|
|
|
@@ -719,6 +719,53 @@ def _migrate_v10_embeddings(conn: sqlite3.Connection) -> None:
|
|
|
719
719
|
)
|
|
720
720
|
|
|
721
721
|
|
|
722
|
+
@register_migration(11)
|
|
723
|
+
def _migrate_v11_entities(conn: sqlite3.Connection) -> None:
|
|
724
|
+
"""C5 — cross-clip entities (Phase D of the analysis program).
|
|
725
|
+
|
|
726
|
+
`entities` rows start as provisional clusters over the v10 visual
|
|
727
|
+
embeddings (source='clustering', label NULL) and are enriched by one
|
|
728
|
+
host-vision call per cluster representative (source='vision_entity_v1')
|
|
729
|
+
or by humans. `entity_appearances` records every frame an entity was
|
|
730
|
+
seen in, with the clip/shot derivation and the match similarity.
|
|
731
|
+
"""
|
|
732
|
+
conn.executescript(
|
|
733
|
+
"""
|
|
734
|
+
CREATE TABLE IF NOT EXISTS entities (
|
|
735
|
+
entity_uuid TEXT PRIMARY KEY,
|
|
736
|
+
kind TEXT, -- person|place|object|unknown
|
|
737
|
+
label TEXT,
|
|
738
|
+
description TEXT,
|
|
739
|
+
confidence TEXT,
|
|
740
|
+
source TEXT NOT NULL,
|
|
741
|
+
representative_frame_ref TEXT, -- 'clip_uuid:frame_index'
|
|
742
|
+
representative_frame_path TEXT,
|
|
743
|
+
cluster_size INTEGER,
|
|
744
|
+
created_at TEXT NOT NULL,
|
|
745
|
+
updated_at TEXT NOT NULL
|
|
746
|
+
);
|
|
747
|
+
|
|
748
|
+
CREATE INDEX IF NOT EXISTS ix_entities_kind ON entities(kind);
|
|
749
|
+
|
|
750
|
+
CREATE TABLE IF NOT EXISTS entity_appearances (
|
|
751
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
752
|
+
entity_uuid TEXT NOT NULL,
|
|
753
|
+
clip_uuid TEXT NOT NULL,
|
|
754
|
+
shot_uuid TEXT,
|
|
755
|
+
frame_ref TEXT NOT NULL, -- 'clip_uuid:frame_index'
|
|
756
|
+
similarity REAL,
|
|
757
|
+
UNIQUE(entity_uuid, frame_ref),
|
|
758
|
+
FOREIGN KEY (entity_uuid) REFERENCES entities(entity_uuid) ON DELETE CASCADE
|
|
759
|
+
);
|
|
760
|
+
|
|
761
|
+
CREATE INDEX IF NOT EXISTS ix_entity_appearances_entity
|
|
762
|
+
ON entity_appearances(entity_uuid);
|
|
763
|
+
CREATE INDEX IF NOT EXISTS ix_entity_appearances_clip
|
|
764
|
+
ON entity_appearances(clip_uuid);
|
|
765
|
+
"""
|
|
766
|
+
)
|
|
767
|
+
|
|
768
|
+
|
|
722
769
|
def latest_version(conn: sqlite3.Connection, timeline_name: str) -> Optional[int]:
|
|
723
770
|
"""Return the highest archived version number for `timeline_name`, or None."""
|
|
724
771
|
row = conn.execute(
|