davinci-resolve-mcp 2.41.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 CHANGED
@@ -2,6 +2,60 @@
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
+
31
+ ## What's New in v2.42.0
32
+
33
+ Deep shot-level vision tier — Phase B of the analysis + edit-engine program.
34
+ Opt-in, estimate-first per-shot field filling for the Visual / Content /
35
+ Production / Editorial / Cuttability groups the shot pages already render.
36
+
37
+ - **Added** `src/utils/deep_vision.py` and three `media_analysis` actions:
38
+ `deepen` (estimate → confirm_token → deferred host-vision payload per
39
+ clip/shot), `commit_shot_vision` (writes `vision_deep_v1` provenance rows,
40
+ updates the canonical blob, re-exports analysis.json in lockstep), and
41
+ `vision_pending_sweep` (lists clips stuck in `pending_host_analysis`;
42
+ `reoffer=true` returns the stored payload, `expire=true` stamps them).
43
+ - **Added** deep depth to the analyze flow: `depth="deep"` extends the
44
+ deferred payload with the per-shot schema and requires `confirm_deep=true`
45
+ after a token-cost estimate. Caps pre-call refusal applies on both paths.
46
+ - **Added** panel affordances: `Deepen analysis` (clip view) and `Deepen this
47
+ shot` (shot view) copy ready-made chat prompts, per the chat-first UX.
48
+ Shots with no sampled frames on disk get 1–2 frames re-extracted via
49
+ ffmpeg, downscaled per caps (source media stays read-only).
50
+ - **Fixed** a provenance bug in the analysis store: a source re-deriving an
51
+ unchanged value no longer re-attributes the row (a deep pass would
52
+ otherwise claim every untouched field as `vision_deep_v1`).
53
+ - **Validation**: full offline suite (1081 tests; 15 new), and a real
54
+ end-to-end deep pass on the 2026-05-17 sample clip — estimate → confirm →
55
+ frames read by the host chat → commit → rows/blob/export parity → fields
56
+ visible in the panel shot view. Control-panel guide screenshots
57
+ regenerated from the live panel.
58
+
5
59
  ## What's New in v2.41.0
6
60
 
7
61
  DB-canonical clip analysis (C1) — Phase A of the analysis + edit-engine
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # DaVinci Resolve MCP Server
2
2
 
3
- [![Version](https://img.shields.io/badge/version-2.41.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.43.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
4
4
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
5
5
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
6
6
  [![Tools](https://img.shields.io/badge/MCP%20Tools-33%20(341%20full)-blue.svg)](#server-modes)
package/docs/SKILL.md CHANGED
@@ -494,8 +494,45 @@ Key actions: `capabilities`, `install_guidance`, `resolve_output_root`, `plan`,
494
494
  `cancel_batch_job`, `resume_batch_job`, `review_timeline_markers`,
495
495
  `cleanup_artifacts`, `db_status`, `db_ingest`, `get_panel_state`,
496
496
  `set_panel_state`, `session_start_context`, `update_clip_field`,
497
- `update_shot_field`, `get_field_history`, `revert_field`, and
498
- `list_corrections`.
497
+ `update_shot_field`, `get_field_history`, `revert_field`,
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).
516
+
517
+ **Deep shot-level vision tier (v2.42.0+).** Opt-in, estimate-first. Two
518
+ entry points share one per-shot schema (Visual / Content / Production /
519
+ Editorial / Cuttability / description / confidence):
520
+ - `depth="deep"` on any analyze action extends the deferred host-vision
521
+ payload with `deep_shot_schema`; each `shot_descriptions` entry must carry
522
+ the field groups. The first deep run returns `confirmation_required` with
523
+ a token-cost estimate — re-call with `confirm_deep=true`. Caps still apply.
524
+ - `deepen(clip_id|clip_dir, shot_index?|shot_indices?)` runs the pass
525
+ post-hoc on an already-analyzed clip. First call returns the estimate +
526
+ `confirm_token`; re-call with the token to get the deferred payload, read
527
+ its `frame_paths`, and commit via
528
+ `commit_shot_vision(clip_id, shots=[{shot_index, ...groups...}],
529
+ vision_token)`. Deep fields land as `vision_deep_v1` provenance rows;
530
+ human corrections always survive. Shots with no sampled frames on disk get
531
+ 1–2 frames re-extracted via ffmpeg (read-only on source media).
532
+ `vision_pending_sweep(expire?, max_age_days?, reoffer?)` lists clips stuck
533
+ in `pending_host_analysis`; `reoffer=true` returns each clip's stored
534
+ deferred payload to finish the run, `expire=true` stamps them
535
+ `expired_host_analysis` so pendings never linger silently.
499
536
 
500
537
  **DB-canonical analysis store (v2.41.0+).** The per-project SQLite DB
501
538
  (`_soul/timeline_brain.sqlite`, schema v9+) is the source of truth for clip
@@ -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
 
@@ -86,7 +90,10 @@ transcripts once the search index is built.
86
90
 
87
91
  The clip view shows the full summary, tags, star rating, editorial notes, and
88
92
  a contact sheet of every detected shot. From here you can open the clip in
89
- Resolve, jump to the transcript, or click into any shot.
93
+ Resolve, jump to the transcript, or click into any shot. `Deepen analysis`
94
+ copies a ready-made chat prompt that asks your MCP session to run the opt-in
95
+ deep shot pass (cost estimate first, then per-shot Visual / Content /
96
+ Editorial / Cuttability fields).
90
97
 
91
98
  ### Transcript
92
99
 
@@ -107,7 +114,9 @@ start", "After cut", "Flash frame", "Motion peak"). Subjective fields are
107
114
  editable inline; edits are kept with the clip's analysis and merged on top of
108
115
  future re-analysis so human notes survive fresh vision runs. `Open in Resolve`
109
116
  jumps straight to the clip in the source viewer with the shot's mark in/out
110
- set.
117
+ set. The field groups (Visual, Content, Production, Editorial, Cuttability)
118
+ are filled by the opt-in deep vision pass — `Deepen this shot` copies a chat
119
+ prompt that runs it for just this shot, estimate first.
111
120
 
112
121
  ### Media → History
113
122
 
package/install.py CHANGED
@@ -35,7 +35,7 @@ from src.utils.update_check import (
35
35
 
36
36
  # ─── Version ──────────────────────────────────────────────────────────────────
37
37
 
38
- VERSION = "2.41.0"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.41.0",
3
+ "version": "2.43.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -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 payload = await api(`/api/index/query?q=${encodeURIComponent(query)}&limit=40`)
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();
@@ -8390,7 +8404,7 @@ HTML = r"""<!doctype html>
8390
8404
  <span class="meta">${formatDuration(card.duration_seconds)} · ${shots.length} shots</span>
8391
8405
  ${cls.primary_use ? `<span class="review-chip">${escapeHtml(cls.primary_use)}</span>` : ''}
8392
8406
  ${cls.select_potential ? `<span class="review-chip ${selectChipClass(cls.select_potential)}">${escapeHtml(cls.select_potential)}</span>` : ''}
8393
- <div class="actions"><button class="secondary" id="reviewClipTranscriptBtn">Transcript</button><button class="secondary" id="reviewClipOpenInResolveBtn">Open in Resolve</button></div>
8407
+ <div class="actions"><button class="secondary" id="reviewClipTranscriptBtn">Transcript</button><button class="secondary" id="reviewClipOpenInResolveBtn">Open in Resolve</button><button class="secondary" data-copy-chat-prompt="${escapeHtml(`Deepen the analysis of clip “${card.clip_name || state.review.currentClipId}”: call media_analysis(action="deepen", params={"clip_id": "${card.clip_id || state.review.currentClipId}"}), show me the cost estimate, and after I confirm, read the frames and commit the per-shot fields via commit_shot_vision.`)}">Deepen analysis</button></div>
8394
8408
  `;
8395
8409
  $('reviewClipSummary').textContent = data.clip_summary || data.clip_summary_oneliner || '';
8396
8410
  $('reviewClipTags').innerHTML = tags.map(t => `<span class="review-chip">${escapeHtml(t)}</span>`).join('');
@@ -9440,6 +9454,7 @@ HTML = r"""<!doctype html>
9440
9454
  <span class="meta">${escapeHtml(shot.description || '')}</span>
9441
9455
  <div class="actions">
9442
9456
  <button class="secondary" id="reviewShotOpenInResolveBtn">Open in Resolve</button>
9457
+ <button class="secondary" data-copy-chat-prompt="${escapeHtml(`Deepen shot ${shotIndex} of clip “${(state.review.currentClipData && state.review.currentClipData.card && state.review.currentClipData.card.clip_name) || clipId}”: call media_analysis(action="deepen", params={"clip_id": "${clipId}", "shot_index": ${shotIndex}}), show me the cost estimate, and after I confirm, read the frames and commit the per-shot fields via commit_shot_vision.`)}">Deepen this shot</button>
9443
9458
  <button id="reviewShotEditToggleBtn" ${editing ? '' : 'class="secondary"'}>${editToggleLabel}</button>
9444
9459
  </div>
9445
9460
  `;
@@ -11082,6 +11097,11 @@ HTML = r"""<!doctype html>
11082
11097
  runReviewSearch(q).catch(alertError);
11083
11098
  }, 250);
11084
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
+ });
11085
11105
  // Bin dropdown.
11086
11106
  $('reviewBinFilter').addEventListener('change', event => {
11087
11107
  state.review.binFilter = event.target.value || '';
@@ -12469,6 +12489,57 @@ def _v2_load_analysis_db_first(project_root: str, clip_dir: str) -> Optional[Dic
12469
12489
  return _v2_load_analysis(clip_dir)
12470
12490
 
12471
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
+
12472
12543
  def _v2_clip_duration(report: Dict[str, Any]) -> Optional[float]:
12473
12544
  marker_plan = report.get("clip_analysis_markers") if isinstance(report.get("clip_analysis_markers"), dict) else {}
12474
12545
  duration = marker_plan.get("duration_seconds")
@@ -14315,6 +14386,14 @@ class Handler(BaseHTTPRequestHandler):
14315
14386
  payload["results"] = _v2_enrich_search_results(self.state.project_root, payload["results"])
14316
14387
  self._json(payload)
14317
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
14318
14397
  # ─── C6 timeline-history surface ───────────────────────────────
14319
14398
  if path == "/api/timeline_versions":
14320
14399
  self._json(list_timelines_with_versions(self.state.project_root))
@@ -80,7 +80,7 @@ if not logging.getLogger().handlers:
80
80
  handlers=[logging.StreamHandler()],
81
81
  )
82
82
 
83
- VERSION = "2.41.0"
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.41.0"
14
+ VERSION = "2.43.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -15020,6 +15020,13 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
15020
15020
  # C1 — DB-canonical analysis store.
15021
15021
  "db_status",
15022
15022
  "db_ingest",
15023
+ # Phase B — deep shot-level vision tier.
15024
+ "deepen",
15025
+ "commit_shot_vision",
15026
+ "vision_pending_sweep",
15027
+ # Phase C — embeddings + similarity.
15028
+ "build_embeddings",
15029
+ "find_similar",
15023
15030
  }:
15024
15031
  root = resolve_media_analysis_output_root(
15025
15032
  project_name=project_name,
@@ -15079,6 +15086,70 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
15079
15086
  if action == "db_ingest":
15080
15087
  from src.utils import analysis_store
15081
15088
  return analysis_store.ingest_project(project_root)
15089
+ # Phase B — deep shot-level vision tier.
15090
+ if action == "deepen":
15091
+ from src.utils import deep_vision
15092
+ clip_ref = p.get("clip_id") or p.get("clipId") or p.get("clip_dir") or p.get("clipDir") or p.get("file_path") or p.get("filePath")
15093
+ if not clip_ref:
15094
+ return _err("deepen requires clip_id, clip_dir, or file_path")
15095
+ raw_indices = p.get("shot_indices") or p.get("shotIndices")
15096
+ if raw_indices is None and p.get("shot_index") is not None:
15097
+ raw_indices = [p.get("shot_index")]
15098
+ return deep_vision.deepen_clip(
15099
+ project_root,
15100
+ clip_ref=clip_ref,
15101
+ shot_indices=[int(i) for i in raw_indices] if raw_indices else None,
15102
+ confirm_token=p.get("confirm_token") or p.get("confirmToken"),
15103
+ job_id=p.get("job_id") or p.get("jobId"),
15104
+ )
15105
+ if action == "commit_shot_vision":
15106
+ from src.utils import deep_vision
15107
+ clip_ref = p.get("clip_id") or p.get("clipId") or p.get("clip_dir") or p.get("clipDir") or p.get("file_path") or p.get("filePath")
15108
+ if not clip_ref:
15109
+ return _err("commit_shot_vision requires clip_id, clip_dir, or file_path")
15110
+ return deep_vision.commit_shot_vision(
15111
+ project_root,
15112
+ shots=p.get("shots"),
15113
+ vision_token=p.get("vision_token") or p.get("visionToken"),
15114
+ clip_ref=clip_ref,
15115
+ author=p.get("author") or "host_chat",
15116
+ )
15117
+ if action == "vision_pending_sweep":
15118
+ from src.utils import deep_vision
15119
+ return deep_vision.vision_pending_sweep(
15120
+ project_root,
15121
+ expire=_media_analysis_bool(p.get("expire"), False),
15122
+ max_age_days=p.get("max_age_days") or p.get("maxAgeDays"),
15123
+ reoffer=_media_analysis_bool(p.get("reoffer"), False),
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
+ )
15082
15153
  if action in {"build_index", "rebuild_index"}:
15083
15154
  return build_analysis_index(project_root, index_path=p.get("index_path") or p.get("indexPath"))
15084
15155
  if action == "index_status":
@@ -15490,6 +15561,11 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
15490
15561
  "list_corrections",
15491
15562
  "db_status",
15492
15563
  "db_ingest",
15564
+ "deepen",
15565
+ "commit_shot_vision",
15566
+ "vision_pending_sweep",
15567
+ "build_embeddings",
15568
+ "find_similar",
15493
15569
  "get_caps",
15494
15570
  "set_caps_preset",
15495
15571
  "get_usage",
@@ -253,7 +253,10 @@ def _write_subjective(
253
253
  value_json = _dumps(value)
254
254
  existing = current.get(field_path)
255
255
  if existing is not None:
256
- if str(existing["value_json"]) == value_json and str(existing["source"]) == source:
256
+ # Identical value keep the existing row and its provenance. A
257
+ # different source re-deriving the same value must not re-attribute
258
+ # it (a deep pass would otherwise claim every unchanged field).
259
+ if str(existing["value_json"]) == value_json:
257
260
  continue
258
261
  if (
259
262
  str(existing["source"]) == HUMAN_SOURCE