davinci-resolve-mcp 2.48.1 → 2.49.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,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.49.0
6
+
7
+ Cross-shot relationships (spec §4 — pattern recognition only): the shot
8
+ page's Relationships group finally fills, and the edit engine can prefer
9
+ vision-confirmed alternates.
10
+
11
+ - **Added** schema v12 `shot_relationships` (same_setup_as / alt_take_of
12
+ symmetric, continues_from directional — the source shot continues from
13
+ the target) with supersede semantics (current rows have
14
+ `superseded_at IS NULL`).
15
+ - **Added** `media_analysis` actions `detect_shot_relationships` /
16
+ `commit_shot_relationships` / `list_shot_relationships`: pairwise cosine
17
+ over the per-shot visual vectors (transcript continuity as a second
18
+ signal for continues_from) → a deferred confirmation payload with a
19
+ representative frame PAIR per candidate (caps pre-checked; candidates
20
+ live only in the detection-state stash until committed, so re-detect
21
+ never leaves ghosts) → vision-confirmed rows. Representative frames use
22
+ the shot's middle sample (first/last frames often catch fades).
23
+ - **Added** the shot page's Relationships group now renders from the DB
24
+ (`continues_from` shows on the continuing shot; cross-clip targets are
25
+ clip-qualified).
26
+ - **Changed** `plan_swap` prefers confirmed `alt_take_of` alternates over
27
+ raw cosine similarity — confirmed takes sort first (and are unioned in
28
+ even when the cosine search missed them); each alternate's rationale
29
+ states which basis ranked it.
30
+
5
31
  ## What's New in v2.48.1
6
32
 
7
33
  Bug fix surfaced by the first real-cut tighten pilot.
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.48.1-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.49.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-34%20(341%20full)-blue.svg)](#server-modes)
package/docs/SKILL.md CHANGED
@@ -497,7 +497,9 @@ Key actions: `capabilities`, `install_guidance`, `resolve_output_root`, `plan`,
497
497
  `update_shot_field`, `get_field_history`, `revert_field`,
498
498
  `list_corrections`, `deepen`, `commit_shot_vision`, `vision_pending_sweep`,
499
499
  `build_embeddings`, `find_similar`, `detect_entities`, `commit_entities`,
500
- `list_entities`, `prepare_bin_briefing`, and `commit_bin_summary`.
500
+ `list_entities`, `prepare_bin_briefing`, `commit_bin_summary`,
501
+ `detect_shot_relationships`, `commit_shot_relationships`, and
502
+ `list_shot_relationships`.
501
503
 
502
504
  **Cross-clip entities + bin briefing v2 (v2.44.0+).** Recurring
503
505
  people/places/props across a project's media, found cheaply and confirmed
@@ -518,6 +520,29 @@ with ONE vision call per cluster:
518
520
  calls `commit_bin_summary(briefing, briefing_token)`, which lands in
519
521
  `memory/bin_summary.md` above the v2.0 aggregate.
520
522
 
523
+ **Cross-shot relationships (v2.49.0+).** Pattern recognition only (spec §4 —
524
+ no editorial suggestions): `same_setup_as` / `alt_take_of` (symmetric) and
525
+ `continues_from` (directional; the source shot continues from the target).
526
+ - `detect_shot_relationships(setup_threshold?, alt_take_threshold?,
527
+ continues_band?, max_candidates?)` — pairwise cosine over the per-shot
528
+ visual vectors (build visual embeddings first; raise
529
+ `max_frames_per_clip` if shot coverage is partial), plus transcript
530
+ continuity as a second signal for `continues_from`. Returns a deferred
531
+ payload with a representative frame PAIR per candidate (caps pre-checked,
532
+ two frames per candidate). Candidates live only in the detection-state
533
+ stash until committed — re-detect replaces them.
534
+ - The host chat reads BOTH frames of each pair and calls
535
+ `commit_shot_relationships(relationships=[{candidate_index, verdict:
536
+ confirm|reject, relationship_type?, confidence?}], vision_token)`.
537
+ Confirm only what the frames show; reject lookalikes. Overriding the
538
+ suggested type is allowed. Committed rows supersede prior machine rows
539
+ for the same pair.
540
+ - `list_shot_relationships(clip_id?, shot_uuid?, relationship_type?)` —
541
+ current rows with clip/shot context on both ends. The shot page's
542
+ Relationships group fills from these rows, and `plan_swap` prefers
543
+ confirmed `alt_take_of` alternates over raw cosine (the rationale states
544
+ which basis ranked each alternate).
545
+
521
546
  **Embeddings + similarity (v2.43.0+).** Local-compute semantic search; no
522
547
  vendor tokens, so nothing here touches the caps ledger. Backends are
523
548
  detected, never installed (capabilities lists them with install guidance):
@@ -120,7 +120,11 @@ future re-analysis so human notes survive fresh vision runs. `Open in Resolve`
120
120
  jumps straight to the clip in the source viewer with the shot's mark in/out
121
121
  set. The field groups (Visual, Content, Production, Editorial, Cuttability)
122
122
  are filled by the opt-in deep vision pass — `Deepen this shot` copies a chat
123
- prompt that runs it for just this shot, estimate first.
123
+ prompt that runs it for just this shot, estimate first. The Relationships
124
+ group (same_setup_as / continues_from / alt_take_of) fills from the
125
+ cross-shot relationships pass (`detect_shot_relationships` →
126
+ vision-confirm → `commit_shot_relationships`); `continues_from` is shown on
127
+ the continuing shot.
124
128
 
125
129
  ### Media → History
126
130
 
package/install.py CHANGED
@@ -35,7 +35,7 @@ from src.utils.update_check import (
35
35
 
36
36
  # ─── Version ──────────────────────────────────────────────────────────────────
37
37
 
38
- VERSION = "2.48.1"
38
+ VERSION = "2.49.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.48.1",
3
+ "version": "2.49.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -13144,6 +13144,30 @@ def get_analyzed_clip_shot(project_root: str, clip_id: str, shot_index: int) ->
13144
13144
  })
13145
13145
  corrections = _v2_read_corrections_for_dir(clip_dir)
13146
13146
  shot_corrections = _v2_filter_corrections_for_shot(corrections, matched.get("shot_uuid"), shot_index)
13147
+ # Cross-shot relationships (spec §4) come from the DB, not the report —
13148
+ # fill the shot page's Relationships group when confirmed rows exist.
13149
+ # Exported reports don't carry shot_uuid, so derive it from the DB by
13150
+ # clip + shot_index.
13151
+ try:
13152
+ from src.utils import analysis_store as _analysis_store
13153
+ from src.utils import shot_relationships as _shot_rel
13154
+ conn = _timeline_brain_db.connect(project_root)
13155
+ shot_uuid = matched.get("shot_uuid")
13156
+ if not shot_uuid:
13157
+ clip_uuid = _analysis_store.resolve_clip_uuid(conn, clip_id)
13158
+ if clip_uuid:
13159
+ hit = conn.execute(
13160
+ "SELECT shot_uuid FROM shots WHERE clip_uuid = ? AND shot_index = ?",
13161
+ (clip_uuid, int(shot_index)),
13162
+ ).fetchone()
13163
+ shot_uuid = hit["shot_uuid"] if hit else None
13164
+ if shot_uuid:
13165
+ relationships = _shot_rel.relationships_for_shot(conn, str(shot_uuid))
13166
+ if relationships:
13167
+ matched = dict(matched)
13168
+ matched["relationships"] = relationships
13169
+ except Exception: # noqa: BLE001 — panel reads fail soft
13170
+ pass
13147
13171
  return {
13148
13172
  "success": True,
13149
13173
  "clip_id": clip_id,
@@ -80,7 +80,7 @@ if not logging.getLogger().handlers:
80
80
  handlers=[logging.StreamHandler()],
81
81
  )
82
82
 
83
- VERSION = "2.48.1"
83
+ VERSION = "2.49.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.48.1"
14
+ VERSION = "2.49.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -15772,6 +15772,10 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
15772
15772
  "list_entities",
15773
15773
  "prepare_bin_briefing",
15774
15774
  "commit_bin_summary",
15775
+ # Cross-shot relationships (spec §4).
15776
+ "detect_shot_relationships",
15777
+ "commit_shot_relationships",
15778
+ "list_shot_relationships",
15775
15779
  }:
15776
15780
  root = resolve_media_analysis_output_root(
15777
15781
  project_name=project_name,
@@ -15916,6 +15920,40 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
15916
15920
  if action == "list_entities":
15917
15921
  from src.utils import entities
15918
15922
  return entities.list_entities(project_root, kind=p.get("kind"))
15923
+ # Cross-shot relationships (spec §4): detect → one bounded vision
15924
+ # confirm (frame pairs) → commit; pattern recognition only.
15925
+ if action == "detect_shot_relationships":
15926
+ from src.utils import shot_relationships
15927
+ band = p.get("continues_band") or p.get("continuesBand")
15928
+ return shot_relationships.detect_shot_relationships(
15929
+ project_root,
15930
+ setup_threshold=float(p.get("setup_threshold") or p.get("setupThreshold")
15931
+ or shot_relationships.DEFAULT_SETUP_THRESHOLD),
15932
+ alt_take_threshold=float(p.get("alt_take_threshold") or p.get("altTakeThreshold")
15933
+ or shot_relationships.DEFAULT_ALT_TAKE_THRESHOLD),
15934
+ continues_band=tuple(band) if isinstance(band, (list, tuple)) and len(band) == 2
15935
+ else shot_relationships.DEFAULT_CONTINUES_BAND,
15936
+ max_candidates=int(p.get("max_candidates") or p.get("maxCandidates")
15937
+ or shot_relationships.DEFAULT_MAX_CANDIDATES),
15938
+ job_id=p.get("job_id") or p.get("jobId"),
15939
+ )
15940
+ if action == "commit_shot_relationships":
15941
+ from src.utils import shot_relationships
15942
+ return shot_relationships.commit_shot_relationships(
15943
+ project_root,
15944
+ relationships_payload=p.get("relationships"),
15945
+ vision_token=p.get("vision_token") or p.get("visionToken"),
15946
+ author=p.get("author") or "host_chat",
15947
+ )
15948
+ if action == "list_shot_relationships":
15949
+ from src.utils import shot_relationships
15950
+ return shot_relationships.list_shot_relationships(
15951
+ project_root,
15952
+ clip_ref=p.get("clip_id") or p.get("clipId") or p.get("clip_dir") or p.get("clipDir"),
15953
+ shot_uuid=p.get("shot_uuid") or p.get("shotUuid"),
15954
+ relationship_type=p.get("relationship_type") or p.get("relationshipType"),
15955
+ include_superseded=bool(p.get("include_superseded") or p.get("includeSuperseded")),
15956
+ )
15919
15957
  if action == "prepare_bin_briefing":
15920
15958
  from src.utils import entities
15921
15959
  return entities.prepare_bin_briefing(project_root)
@@ -16348,6 +16386,9 @@ async def media_analysis(action: str, params: Optional[Dict[str, Any]] = None, c
16348
16386
  "list_entities",
16349
16387
  "prepare_bin_briefing",
16350
16388
  "commit_bin_summary",
16389
+ "detect_shot_relationships",
16390
+ "commit_shot_relationships",
16391
+ "list_shot_relationships",
16351
16392
  "get_caps",
16352
16393
  "set_caps_preset",
16353
16394
  "get_usage",
@@ -579,37 +579,79 @@ def plan_swap(
579
579
  if not found.get("success"):
580
580
  return found
581
581
  duration_frames = tl_end - tl_start
582
- alternates: List[Dict[str, Any]] = []
583
- for hit in found.get("results") or []:
582
+ needed_seconds = duration_frames / fps
583
+
584
+ # Vision-confirmed alt takes outrank raw cosine similarity (spec §4).
585
+ from src.utils import shot_relationships as _shot_relationships
586
+ confirmed_alts = set(_shot_relationships.confirmed_alt_take_shot_uuids(conn, shot["shot_uuid"]))
587
+
588
+ def _viable_alternate(
589
+ *, clip_uuid: Any, shot_uuid_: Any, shot_index: Any, description: Any,
590
+ alt_start: Any, alt_end: Any, score: Any, rationale: str,
591
+ ) -> Optional[Dict[str, Any]]:
584
592
  alt_clip = conn.execute(
585
- "SELECT * FROM clips WHERE clip_uuid = ?", (hit.get("clip_uuid"),)
593
+ "SELECT * FROM clips WHERE clip_uuid = ?", (clip_uuid,)
586
594
  ).fetchone()
587
595
  if not alt_clip or not alt_clip["resolve_clip_id"]:
588
- continue
589
- alt = dict(alt_clip)
590
- alt_fps = _clip_fps(alt)
591
- alt_start = hit.get("time_seconds_start")
592
- alt_end = hit.get("time_seconds_end")
596
+ return None
593
597
  if alt_start is None or alt_end is None:
594
- continue
595
- needed_seconds = duration_frames / fps
598
+ return None
596
599
  if (float(alt_end) - float(alt_start)) < needed_seconds:
597
- continue # alternate too short to fill the slot
600
+ return None # alternate too short to fill the slot
601
+ alt = dict(alt_clip)
602
+ alt_fps = _clip_fps(alt)
598
603
  start_frame = int(round(float(alt_start) * alt_fps))
599
604
  end_frame = start_frame + int(round(needed_seconds * alt_fps)) - 1
600
- alternates.append({
601
- "score": hit.get("score"),
602
- "clip_uuid": hit.get("clip_uuid"),
605
+ return {
606
+ "score": score,
607
+ "clip_uuid": clip_uuid,
603
608
  "clip_name": alt.get("clip_name"),
604
609
  "resolve_clip_id": alt["resolve_clip_id"],
605
- "shot_uuid": hit.get("entity_uuid"),
606
- "shot_index": hit.get("shot_index"),
607
- "description": hit.get("description"),
610
+ "shot_uuid": shot_uuid_,
611
+ "shot_index": shot_index,
612
+ "description": description,
608
613
  "source_frame_range": [start_frame, end_frame],
609
- "rationale": f"cosine {hit.get('score')} to the current shot ({kind} embedding); long enough to fill the slot exactly",
610
- })
611
- if len(alternates) >= int(limit):
612
- break
614
+ "confirmed_alt_take": str(shot_uuid_) in confirmed_alts,
615
+ "rationale": rationale,
616
+ }
617
+
618
+ alternates: List[Dict[str, Any]] = []
619
+ seen_shot_uuids: set = set()
620
+ for hit in found.get("results") or []:
621
+ hit_uuid = str(hit.get("entity_uuid"))
622
+ is_confirmed = hit_uuid in confirmed_alts
623
+ basis = (
624
+ f"vision-confirmed alt_take_of relationship (cosine {hit.get('score')} agrees)"
625
+ if is_confirmed
626
+ else f"cosine {hit.get('score')} to the current shot ({kind} embedding)"
627
+ )
628
+ alternate = _viable_alternate(
629
+ clip_uuid=hit.get("clip_uuid"), shot_uuid_=hit.get("entity_uuid"),
630
+ shot_index=hit.get("shot_index"), description=hit.get("description"),
631
+ alt_start=hit.get("time_seconds_start"), alt_end=hit.get("time_seconds_end"),
632
+ score=hit.get("score"),
633
+ rationale=f"{basis}; long enough to fill the slot exactly",
634
+ )
635
+ if alternate:
636
+ alternates.append(alternate)
637
+ seen_shot_uuids.add(hit_uuid)
638
+ # Confirmed alt takes the cosine search missed still belong in the list.
639
+ for alt_uuid in confirmed_alts - seen_shot_uuids:
640
+ alt_shot = conn.execute("SELECT * FROM shots WHERE shot_uuid = ?", (alt_uuid,)).fetchone()
641
+ if not alt_shot:
642
+ continue
643
+ alt_shot = dict(alt_shot)
644
+ alternate = _viable_alternate(
645
+ clip_uuid=alt_shot.get("clip_uuid"), shot_uuid_=alt_uuid,
646
+ shot_index=alt_shot.get("shot_index"), description=alt_shot.get("description"),
647
+ alt_start=alt_shot.get("time_seconds_start"), alt_end=alt_shot.get("time_seconds_end"),
648
+ score=None,
649
+ rationale="vision-confirmed alt_take_of relationship (not surfaced by the cosine search); long enough to fill the slot exactly",
650
+ )
651
+ if alternate:
652
+ alternates.append(alternate)
653
+ alternates.sort(key=lambda a: (not a.get("confirmed_alt_take"), -(a.get("score") or 0.0)))
654
+ alternates = alternates[: int(limit)]
613
655
  if not alternates:
614
656
  return {
615
657
  "success": False,
@@ -0,0 +1,544 @@
1
+ """Cross-shot relationships (spec §4 — pattern recognition only).
2
+
3
+ Detect → one bounded vision confirm → commit, the Phase D entities pattern:
4
+ the cheap part is local (pairwise cosine over the v10 per-shot visual
5
+ vectors, plus transcript continuity as a second signal for continues_from);
6
+ the expensive part is bounded (the host chat reviews a representative frame
7
+ PAIR per candidate). NO editorial suggestions are stored — only the three
8
+ spec §4 relationship types:
9
+
10
+ - same_setup_as (symmetric) — same camera + framing of the same action
11
+ - alt_take_of (symmetric) — different take of the same setup
12
+ - continues_from (directional)— the SOURCE shot continues from the TARGET
13
+ shot (target precedes source)
14
+
15
+ Symmetric rows are stored once with the pair canonically ordered by
16
+ (clip_name, shot_index); readers must check both columns. Candidates live
17
+ only in the detection-state stash until committed — re-detect overwrites
18
+ the stash, so unconfirmed candidates can never linger as ghosts. Commit
19
+ supersedes any current machine row for the same (pair, type) before
20
+ inserting, so human corrections (a later C4-style author='human' row)
21
+ always stay newest.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import os
28
+ import time
29
+ from typing import Any, Dict, List, Optional, Tuple
30
+
31
+ from src.utils import analysis_memory, embeddings, timeline_brain_db
32
+
33
+ RELATIONSHIP_VISION_SOURCE = "vision_relationship_v1"
34
+ RELATIONSHIP_SCHEMA_REFERENCE = "davinci_resolve_mcp.relationship_confirmation.v1"
35
+ RELATIONSHIP_TYPES = ("same_setup_as", "continues_from", "alt_take_of")
36
+
37
+ # Tuned on the sample root (34-shot montage) during Phase 4 live validation.
38
+ DEFAULT_SETUP_THRESHOLD = 0.90 # same-clip non-adjacent or cross-clip: same_setup_as
39
+ DEFAULT_ALT_TAKE_THRESHOLD = 0.88 # cross-clip + comparable duration: alt_take_of
40
+ DEFAULT_CONTINUES_BAND = (0.70, 0.90) # adjacent same-clip shots: continues_from
41
+ DEFAULT_MAX_CANDIDATES = 24
42
+ ALT_TAKE_DURATION_RATIO = 2.0 # durations within 2x of each other
43
+
44
+ RELATIONSHIP_SCHEMA = {
45
+ "relationships": [
46
+ {
47
+ "candidate_index": "<int — from the payload's candidates list>",
48
+ "verdict": "confirm|reject",
49
+ "relationship_type": "same_setup_as|continues_from|alt_take_of (override the suggestion when the frames say otherwise)",
50
+ "confidence": "low|medium|high",
51
+ }
52
+ ]
53
+ }
54
+
55
+ RELATIONSHIP_PROMPT = (
56
+ "Each candidate below pairs two shots that the local heuristics think are "
57
+ "related. Look at BOTH frames of each pair and judge the suggested "
58
+ "relationship: same_setup_as = same camera setup + framing of the same "
59
+ "action; alt_take_of = a different take of the same setup; continues_from "
60
+ "= the first shot visibly continues the second shot's action across a "
61
+ "cut. Confirm only what the frames actually show — reject lookalikes "
62
+ "(similar palette or subject is NOT the same setup). You may override the "
63
+ "suggested type when the frames support a different one. Return strict "
64
+ "JSON matching `schema`."
65
+ )
66
+
67
+
68
+ def _now() -> str:
69
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
70
+
71
+
72
+ def _now_precise() -> str:
73
+ """Microsecond timestamps — `timestamp` is part of the row's UNIQUE key,
74
+ so two commits of the same pair within one second must not collide."""
75
+ now = time.time()
76
+ return time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(now)) + f".{int((now % 1) * 1e6):06d}Z"
77
+
78
+
79
+ def _ma():
80
+ from src.utils import media_analysis
81
+
82
+ return media_analysis
83
+
84
+
85
+ def _state_path(project_root: str) -> str:
86
+ return os.path.join(analysis_memory.memory_dir(project_root), "relationship_detection_state.json")
87
+
88
+
89
+ def _write_state(project_root: str, token: str, candidates: List[Dict[str, Any]]) -> None:
90
+ analysis_memory.ensure_memory_structure(project_root)
91
+ path = _state_path(project_root)
92
+ tmp = path + ".tmp"
93
+ with open(tmp, "w", encoding="utf-8") as handle:
94
+ json.dump({"vision_token": token, "candidates": candidates, "written_at": _now()}, handle, indent=2)
95
+ os.replace(tmp, path)
96
+
97
+
98
+ def _read_state(project_root: str) -> Optional[Dict[str, Any]]:
99
+ try:
100
+ with open(_state_path(project_root), "r", encoding="utf-8") as handle:
101
+ return json.load(handle)
102
+ except (OSError, json.JSONDecodeError):
103
+ return None
104
+
105
+
106
+ def _shot_rows(conn) -> Dict[str, Dict[str, Any]]:
107
+ rows = conn.execute(
108
+ """
109
+ SELECT s.shot_uuid, s.clip_uuid, s.shot_index, s.time_seconds_start,
110
+ s.time_seconds_end, s.description, c.clip_name
111
+ FROM shots s LEFT JOIN clips c ON c.clip_uuid = s.clip_uuid
112
+ """
113
+ ).fetchall()
114
+ return {str(r["shot_uuid"]): dict(r) for r in rows}
115
+
116
+
117
+ def _rep_frame_path(conn, shot_uuid: str) -> Optional[str]:
118
+ """The shot's MIDDLE sampled frame — first/last frames often catch fades
119
+ or transition black, which makes pair review impossible."""
120
+ rows = conn.execute(
121
+ """
122
+ SELECT frame_path FROM frames
123
+ WHERE shot_uuid = ? AND frame_path IS NOT NULL
124
+ ORDER BY frame_index
125
+ """,
126
+ (str(shot_uuid),),
127
+ ).fetchall()
128
+ if not rows:
129
+ return None
130
+ return str(rows[len(rows) // 2]["frame_path"])
131
+
132
+
133
+ def _transcript_spans_boundary(conn, clip_uuid: str, boundary_seconds: float) -> bool:
134
+ """A transcript segment spanning the cut boundary is continuity evidence."""
135
+ row = conn.execute(
136
+ """
137
+ SELECT 1 FROM transcript_segments
138
+ WHERE clip_uuid = ? AND start_seconds < ? AND end_seconds > ?
139
+ LIMIT 1
140
+ """,
141
+ (clip_uuid, float(boundary_seconds), float(boundary_seconds)),
142
+ ).fetchone()
143
+ return row is not None
144
+
145
+
146
+ def _canonical_pair(a: Dict[str, Any], b: Dict[str, Any]) -> Tuple[Dict[str, Any], Dict[str, Any]]:
147
+ key = lambda s: (str(s.get("clip_name") or ""), int(s.get("shot_index") or 0)) # noqa: E731
148
+ return (a, b) if key(a) <= key(b) else (b, a)
149
+
150
+
151
+ def detect_shot_relationships(
152
+ project_root: str,
153
+ *,
154
+ setup_threshold: float = DEFAULT_SETUP_THRESHOLD,
155
+ alt_take_threshold: float = DEFAULT_ALT_TAKE_THRESHOLD,
156
+ continues_band: Tuple[float, float] = DEFAULT_CONTINUES_BAND,
157
+ max_candidates: int = DEFAULT_MAX_CANDIDATES,
158
+ job_id: Optional[str] = None,
159
+ ) -> Dict[str, Any]:
160
+ """Pairwise heuristics over per-shot visual vectors → deferred
161
+ confirmation payload with a representative frame PAIR per candidate."""
162
+ ma = _ma()
163
+ conn = timeline_brain_db.connect(project_root)
164
+ vec_rows = conn.execute(
165
+ """
166
+ SELECT entity_uuid, vector FROM embeddings
167
+ WHERE embedding_kind = 'visual' AND entity_type = 'shot'
168
+ """
169
+ ).fetchall()
170
+ if not vec_rows:
171
+ return {
172
+ "success": False,
173
+ "error": (
174
+ "No per-shot visual embeddings yet — run "
175
+ "media_analysis(action='build_embeddings', params={'kinds': ['visual']}) first."
176
+ ),
177
+ }
178
+ shots = _shot_rows(conn)
179
+ uuids = [str(r["entity_uuid"]) for r in vec_rows if str(r["entity_uuid"]) in shots]
180
+ vectors = {u: embeddings.unpack_vector(r["vector"]) for u, r in zip(
181
+ [str(r["entity_uuid"]) for r in vec_rows], vec_rows) if u in shots}
182
+
183
+ low_cont, high_cont = float(continues_band[0]), float(continues_band[1])
184
+ candidates: List[Dict[str, Any]] = []
185
+ for i in range(len(uuids)):
186
+ for j in range(i + 1, len(uuids)):
187
+ a, b = shots[uuids[i]], shots[uuids[j]]
188
+ sim = embeddings.cosine_similarity(vectors[uuids[i]], vectors[uuids[j]])
189
+ same_clip = a["clip_uuid"] == b["clip_uuid"]
190
+ suggestion = None
191
+ evidence: List[str] = []
192
+ if same_clip:
193
+ index_gap = abs(int(a["shot_index"]) - int(b["shot_index"]))
194
+ if index_gap == 1 and low_cont <= sim < high_cont:
195
+ suggestion = "continues_from"
196
+ earlier, later = (a, b) if int(a["shot_index"]) < int(b["shot_index"]) else (b, a)
197
+ boundary = earlier.get("time_seconds_end")
198
+ if boundary is not None and _transcript_spans_boundary(
199
+ conn, str(a["clip_uuid"]), float(boundary)
200
+ ):
201
+ evidence.append("a transcript segment spans the cut boundary")
202
+ elif index_gap > 1 and sim >= float(setup_threshold):
203
+ suggestion = "same_setup_as"
204
+ else:
205
+ dur_a = (a.get("time_seconds_end") or 0) - (a.get("time_seconds_start") or 0)
206
+ dur_b = (b.get("time_seconds_end") or 0) - (b.get("time_seconds_start") or 0)
207
+ comparable = (
208
+ dur_a > 0 and dur_b > 0
209
+ and max(dur_a, dur_b) / max(min(dur_a, dur_b), 0.001) <= ALT_TAKE_DURATION_RATIO
210
+ )
211
+ if sim >= float(alt_take_threshold) and comparable:
212
+ suggestion = "alt_take_of"
213
+ elif sim >= float(setup_threshold):
214
+ suggestion = "same_setup_as"
215
+ if not suggestion:
216
+ continue
217
+ evidence.insert(0, f"visual cosine {round(float(sim), 3)} between per-shot vectors")
218
+ if suggestion == "continues_from":
219
+ # Directional: the LATER shot continues from the EARLIER one.
220
+ earlier, later = (a, b) if int(a["shot_index"]) < int(b["shot_index"]) else (b, a)
221
+ source, target = later, earlier
222
+ else:
223
+ source, target = _canonical_pair(a, b)
224
+ candidates.append({
225
+ "source_shot_uuid": source["shot_uuid"],
226
+ "target_shot_uuid": target["shot_uuid"],
227
+ "suggested_type": suggestion,
228
+ "similarity": round(float(sim), 4),
229
+ "evidence": evidence,
230
+ "_sim": float(sim),
231
+ })
232
+
233
+ candidates.sort(key=lambda c: -c["_sim"])
234
+ candidates = candidates[: max(1, int(max_candidates))]
235
+ for candidate in candidates:
236
+ candidate.pop("_sim", None)
237
+ if not candidates:
238
+ return {
239
+ "success": True,
240
+ "status": "no_candidates",
241
+ "shot_count": len(uuids),
242
+ "note": (
243
+ f"No relationship candidates at setup>={setup_threshold}, "
244
+ f"alt_take>={alt_take_threshold}, continues {continues_band}."
245
+ ),
246
+ }
247
+
248
+ # Caps: two frames per candidate go to the host.
249
+ estimated_tokens = len(candidates) * 2 * ma.AVG_VISION_TOKENS_PER_FRAME
250
+ refusal = ma._check_caps_pre_call(
251
+ project_root=project_root,
252
+ estimated_vision_tokens=estimated_tokens,
253
+ clip_id=None,
254
+ job_id=job_id,
255
+ )
256
+ if refusal is not None:
257
+ return refusal
258
+
259
+ payload: List[Dict[str, Any]] = []
260
+ state_rows: List[Dict[str, Any]] = []
261
+ for index, candidate in enumerate(candidates, 1):
262
+ src = shots[candidate["source_shot_uuid"]]
263
+ dst = shots[candidate["target_shot_uuid"]]
264
+ src_frame = _rep_frame_path(conn, candidate["source_shot_uuid"])
265
+ dst_frame = _rep_frame_path(conn, candidate["target_shot_uuid"])
266
+ payload.append({
267
+ "candidate_index": index,
268
+ "suggested_type": candidate["suggested_type"],
269
+ "similarity": candidate["similarity"],
270
+ "evidence": candidate["evidence"],
271
+ "source_shot": {
272
+ "clip_name": src.get("clip_name"), "shot_index": src.get("shot_index"),
273
+ "description": src.get("description"), "frame_path": src_frame,
274
+ },
275
+ "target_shot": {
276
+ "clip_name": dst.get("clip_name"), "shot_index": dst.get("shot_index"),
277
+ "description": dst.get("description"), "frame_path": dst_frame,
278
+ },
279
+ })
280
+ state_rows.append({
281
+ "candidate_index": index,
282
+ "source_shot_uuid": candidate["source_shot_uuid"],
283
+ "target_shot_uuid": candidate["target_shot_uuid"],
284
+ "suggested_type": candidate["suggested_type"],
285
+ "similarity": candidate["similarity"],
286
+ })
287
+
288
+ vision_token = ma.short_hash(
289
+ "relationships:" + ",".join(f"{r['source_shot_uuid']}>{r['target_shot_uuid']}" for r in state_rows), 16,
290
+ )
291
+ # Candidates live ONLY here until committed — re-detect overwrites the
292
+ # stash, so unconfirmed candidates never linger as ghost rows.
293
+ _write_state(project_root, vision_token, state_rows)
294
+ frame_paths = [
295
+ p for c in payload for p in (c["source_shot"]["frame_path"], c["target_shot"]["frame_path"]) if p
296
+ ]
297
+ return {
298
+ "success": True,
299
+ "status": "pending_host_analysis",
300
+ "provider": "host_chat_paths",
301
+ "mode": "relationship_confirmation",
302
+ "vision_token": vision_token,
303
+ "candidate_count": len(payload),
304
+ "estimate": {
305
+ "frames_to_review": len(frame_paths),
306
+ "estimated_vision_tokens": estimated_tokens,
307
+ },
308
+ "thresholds": {
309
+ "setup_threshold": setup_threshold,
310
+ "alt_take_threshold": alt_take_threshold,
311
+ "continues_band": list(continues_band),
312
+ },
313
+ "candidates": payload,
314
+ "frame_paths": frame_paths,
315
+ "schema": json.loads(json.dumps(RELATIONSHIP_SCHEMA)),
316
+ "schema_reference": RELATIONSHIP_SCHEMA_REFERENCE,
317
+ "prompt": RELATIONSHIP_PROMPT,
318
+ "commit_action": {
319
+ "tool": "media_analysis",
320
+ "action": "commit_shot_relationships",
321
+ "params": {
322
+ "vision_token": vision_token,
323
+ "relationships": "<host chat: fill per `schema`>",
324
+ "analysis_root": project_root,
325
+ },
326
+ },
327
+ "instructions": (
328
+ "For each candidate read BOTH frame paths as local images, judge "
329
+ "the suggested relationship, and return one entry per "
330
+ "candidate_index in `relationships` per the schema. Then call the "
331
+ "tool in commit_action. Reject anything the frames don't support."
332
+ ),
333
+ }
334
+
335
+
336
+ def commit_shot_relationships(
337
+ project_root: str,
338
+ *,
339
+ relationships_payload: Any,
340
+ vision_token: Optional[str] = None,
341
+ author: str = "host_chat",
342
+ ) -> Dict[str, Any]:
343
+ """Write vision-confirmed relationship rows (machine source label).
344
+
345
+ Supersedes any current machine row for the same (pair, type) first, so
346
+ re-running the pass never duplicates and never outranks human rows added
347
+ later (human edits ride the same supersede semantics with author='human')."""
348
+ if isinstance(relationships_payload, str):
349
+ try:
350
+ relationships_payload = json.loads(relationships_payload)
351
+ except json.JSONDecodeError as exc:
352
+ return {"success": False, "error": f"relationships was a string but not valid JSON: {exc}"}
353
+ if isinstance(relationships_payload, dict) and isinstance(relationships_payload.get("relationships"), list):
354
+ relationships_payload = relationships_payload["relationships"]
355
+ if not isinstance(relationships_payload, list) or not relationships_payload:
356
+ return {"success": False, "error": "commit_shot_relationships requires `relationships`: a non-empty array"}
357
+
358
+ state = _read_state(project_root)
359
+ if not state:
360
+ return {"success": False, "error": "No relationship-detection state — run detect_shot_relationships first"}
361
+ expected = str(state.get("vision_token") or "")
362
+ if vision_token and str(vision_token) != expected:
363
+ return {
364
+ "success": False,
365
+ "error": (
366
+ "vision_token mismatch; candidates changed since the payload was "
367
+ "issued (re-run detect_shot_relationships)."
368
+ ),
369
+ "expected_vision_token": expected,
370
+ }
371
+ by_index = {int(c["candidate_index"]): c for c in state.get("candidates") or []}
372
+
373
+ now = _now_precise()
374
+ confirmed = 0
375
+ rejected = 0
376
+ skipped: List[Dict[str, Any]] = []
377
+ frames_reviewed = len(by_index) * 2
378
+ with timeline_brain_db.transaction(project_root) as txn:
379
+ for entry in relationships_payload:
380
+ if not isinstance(entry, dict):
381
+ continue
382
+ try:
383
+ candidate = by_index.get(int(entry.get("candidate_index")))
384
+ except (TypeError, ValueError):
385
+ candidate = None
386
+ if candidate is None:
387
+ skipped.append({"entry": entry, "reason": "unknown candidate_index"})
388
+ continue
389
+ verdict = str(entry.get("verdict") or "").strip().lower()
390
+ if verdict != "confirm":
391
+ rejected += 1
392
+ continue
393
+ rel_type = str(entry.get("relationship_type") or candidate["suggested_type"])
394
+ if rel_type not in RELATIONSHIP_TYPES:
395
+ skipped.append({"entry": entry, "reason": f"invalid relationship_type {rel_type!r}"})
396
+ continue
397
+ source_uuid = str(candidate["source_shot_uuid"])
398
+ target_uuid = str(candidate["target_shot_uuid"])
399
+ txn.execute(
400
+ """
401
+ UPDATE shot_relationships SET superseded_at = ?
402
+ WHERE relationship_type = ? AND superseded_at IS NULL
403
+ AND source = ?
404
+ AND ((source_shot_uuid = ? AND target_shot_uuid = ?)
405
+ OR (source_shot_uuid = ? AND target_shot_uuid = ?))
406
+ """,
407
+ (now, rel_type, RELATIONSHIP_VISION_SOURCE,
408
+ source_uuid, target_uuid, target_uuid, source_uuid),
409
+ )
410
+ txn.execute(
411
+ """
412
+ INSERT INTO shot_relationships
413
+ (source_shot_uuid, target_shot_uuid, relationship_type,
414
+ confidence, source, author, timestamp, superseded_at)
415
+ VALUES (?, ?, ?, ?, ?, ?, ?, NULL)
416
+ """,
417
+ (
418
+ source_uuid,
419
+ target_uuid,
420
+ rel_type,
421
+ entry.get("confidence"),
422
+ RELATIONSHIP_VISION_SOURCE if author == "host_chat" else "human",
423
+ author,
424
+ now,
425
+ ),
426
+ )
427
+ confirmed += 1
428
+
429
+ ma = _ma()
430
+ ma._record_caps_usage(
431
+ project_root=project_root,
432
+ clip_id=None,
433
+ vision_tokens=frames_reviewed * ma.AVG_VISION_TOKENS_PER_FRAME,
434
+ frames_uploaded=frames_reviewed,
435
+ )
436
+ return {
437
+ "success": True,
438
+ "confirmed": confirmed,
439
+ "rejected": rejected,
440
+ "skipped": skipped,
441
+ }
442
+
443
+
444
+ def list_shot_relationships(
445
+ project_root: str,
446
+ *,
447
+ clip_ref: Optional[str] = None,
448
+ shot_uuid: Optional[str] = None,
449
+ relationship_type: Optional[str] = None,
450
+ include_superseded: bool = False,
451
+ ) -> Dict[str, Any]:
452
+ """Current relationship rows, hydrated with clip/shot context on both ends."""
453
+ conn = timeline_brain_db.connect(project_root)
454
+ where = [] if include_superseded else ["r.superseded_at IS NULL"]
455
+ args: List[Any] = []
456
+ if relationship_type:
457
+ where.append("r.relationship_type = ?")
458
+ args.append(relationship_type)
459
+ if shot_uuid:
460
+ where.append("(r.source_shot_uuid = ? OR r.target_shot_uuid = ?)")
461
+ args.extend([str(shot_uuid), str(shot_uuid)])
462
+ if clip_ref:
463
+ from src.utils import analysis_store
464
+
465
+ clip_uuid = analysis_store.resolve_clip_uuid(conn, clip_ref)
466
+ if not clip_uuid:
467
+ return {"success": False, "error": f"No analyzed clip found for {clip_ref!r}"}
468
+ where.append(
469
+ "(src.clip_uuid = ? OR dst.clip_uuid = ?)"
470
+ )
471
+ args.extend([clip_uuid, clip_uuid])
472
+ where_sql = (" WHERE " + " AND ".join(where)) if where else ""
473
+ rows = conn.execute(
474
+ f"""
475
+ SELECT r.*, src.clip_uuid AS source_clip_uuid, src.shot_index AS source_shot_index,
476
+ sc.clip_name AS source_clip_name,
477
+ dst.clip_uuid AS target_clip_uuid, dst.shot_index AS target_shot_index,
478
+ dc.clip_name AS target_clip_name
479
+ FROM shot_relationships r
480
+ LEFT JOIN shots src ON src.shot_uuid = r.source_shot_uuid
481
+ LEFT JOIN clips sc ON sc.clip_uuid = src.clip_uuid
482
+ LEFT JOIN shots dst ON dst.shot_uuid = r.target_shot_uuid
483
+ LEFT JOIN clips dc ON dc.clip_uuid = dst.clip_uuid
484
+ {where_sql}
485
+ ORDER BY r.relationship_type, r.id
486
+ """,
487
+ args,
488
+ ).fetchall()
489
+ return {"success": True, "count": len(rows), "relationships": [dict(r) for r in rows]}
490
+
491
+
492
+ def relationships_for_shot(conn, shot_uuid: str) -> Dict[str, List[str]]:
493
+ """Panel block for one shot: {type: ["Clip · shot N", ...]} from current
494
+ rows, reading both directions of the symmetric types. continues_from is
495
+ shown only on the SOURCE shot (the one that continues)."""
496
+ out: Dict[str, List[str]] = {}
497
+ rows = conn.execute(
498
+ """
499
+ SELECT r.relationship_type, r.source_shot_uuid, r.target_shot_uuid,
500
+ src.shot_index AS source_shot_index, sc.clip_name AS source_clip_name,
501
+ src.clip_uuid AS source_clip_uuid,
502
+ dst.shot_index AS target_shot_index, dc.clip_name AS target_clip_name,
503
+ dst.clip_uuid AS target_clip_uuid
504
+ FROM shot_relationships r
505
+ LEFT JOIN shots src ON src.shot_uuid = r.source_shot_uuid
506
+ LEFT JOIN clips sc ON sc.clip_uuid = src.clip_uuid
507
+ LEFT JOIN shots dst ON dst.shot_uuid = r.target_shot_uuid
508
+ LEFT JOIN clips dc ON dc.clip_uuid = dst.clip_uuid
509
+ WHERE r.superseded_at IS NULL
510
+ AND (r.source_shot_uuid = ? OR r.target_shot_uuid = ?)
511
+ """,
512
+ (str(shot_uuid), str(shot_uuid)),
513
+ ).fetchall()
514
+ me = str(shot_uuid)
515
+ for row in rows:
516
+ rel_type = str(row["relationship_type"])
517
+ is_source = str(row["source_shot_uuid"]) == me
518
+ if rel_type == "continues_from" and not is_source:
519
+ continue # shown on the continuing shot only
520
+ other_index = row["target_shot_index"] if is_source else row["source_shot_index"]
521
+ other_clip = row["target_clip_name"] if is_source else row["source_clip_name"]
522
+ same_clip = (row["source_clip_uuid"] == row["target_clip_uuid"])
523
+ label = f"shot {other_index}" if same_clip else f"{other_clip} · shot {other_index}"
524
+ out.setdefault(rel_type, []).append(label)
525
+ return out
526
+
527
+
528
+ def confirmed_alt_take_shot_uuids(conn, shot_uuid: str) -> List[str]:
529
+ """Shot uuids confirmed as alt takes of `shot_uuid` (either direction)."""
530
+ rows = conn.execute(
531
+ """
532
+ SELECT source_shot_uuid, target_shot_uuid FROM shot_relationships
533
+ WHERE relationship_type = 'alt_take_of' AND superseded_at IS NULL
534
+ AND (source_shot_uuid = ? OR target_shot_uuid = ?)
535
+ """,
536
+ (str(shot_uuid), str(shot_uuid)),
537
+ ).fetchall()
538
+ me = str(shot_uuid)
539
+ out: List[str] = []
540
+ for row in rows:
541
+ other = str(row["target_shot_uuid"]) if str(row["source_shot_uuid"]) == me else str(row["source_shot_uuid"])
542
+ if other != me and other not in out:
543
+ out.append(other)
544
+ return out
@@ -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 = 11
32
+ SCHEMA_VERSION = 12
33
33
  DB_FILENAME = "timeline_brain.sqlite"
34
34
  SOUL_DIRNAME = "_soul"
35
35
 
@@ -766,6 +766,44 @@ def _migrate_v11_entities(conn: sqlite3.Connection) -> None:
766
766
  )
767
767
 
768
768
 
769
+ @register_migration(12)
770
+ def _migrate_v12_shot_relationships(conn: sqlite3.Connection) -> None:
771
+ """Cross-shot relationships (spec §4 — pattern recognition only).
772
+
773
+ Three types: same_setup_as / alt_take_of (symmetric — stored once with
774
+ the canonically-ordered pair) and continues_from (directional — the
775
+ SOURCE shot continues from the TARGET shot, i.e. target precedes source).
776
+ Rows are append-only with supersede semantics: current rows have
777
+ superseded_at IS NULL.
778
+ """
779
+ conn.executescript(
780
+ """
781
+ CREATE TABLE IF NOT EXISTS shot_relationships (
782
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
783
+ source_shot_uuid TEXT NOT NULL,
784
+ target_shot_uuid TEXT NOT NULL,
785
+ relationship_type TEXT NOT NULL CHECK(relationship_type IN
786
+ ('same_setup_as', 'continues_from', 'alt_take_of')),
787
+ confidence TEXT,
788
+ source TEXT NOT NULL,
789
+ author TEXT NOT NULL,
790
+ timestamp TEXT NOT NULL,
791
+ superseded_at TEXT,
792
+ UNIQUE(source_shot_uuid, target_shot_uuid, relationship_type, timestamp),
793
+ FOREIGN KEY (source_shot_uuid) REFERENCES shots(shot_uuid) ON DELETE CASCADE,
794
+ FOREIGN KEY (target_shot_uuid) REFERENCES shots(shot_uuid) ON DELETE CASCADE
795
+ );
796
+
797
+ CREATE INDEX IF NOT EXISTS idx_relationships_source
798
+ ON shot_relationships(source_shot_uuid) WHERE superseded_at IS NULL;
799
+ CREATE INDEX IF NOT EXISTS idx_relationships_target
800
+ ON shot_relationships(target_shot_uuid) WHERE superseded_at IS NULL;
801
+ CREATE INDEX IF NOT EXISTS idx_relationships_type
802
+ ON shot_relationships(relationship_type) WHERE superseded_at IS NULL;
803
+ """
804
+ )
805
+
806
+
769
807
  def latest_version(conn: sqlite3.Connection, timeline_name: str) -> Optional[int]:
770
808
  """Return the highest archived version number for `timeline_name`, or None."""
771
809
  row = conn.execute(