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 +26 -0
- package/README.md +1 -1
- package/docs/SKILL.md +26 -1
- package/docs/guides/control-panel.md +5 -1
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/analysis_dashboard.py +24 -0
- package/src/granular/common.py +1 -1
- package/src/server.py +42 -1
- package/src/utils/edit_engine.py +63 -21
- package/src/utils/shot_relationships.py +544 -0
- package/src/utils/timeline_brain_db.py +39 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
Release history for the DaVinci Resolve MCP Server. The latest release is summarized in the root README; older entries live here to keep the README focused.
|
|
4
4
|
|
|
5
|
+
## What's New in v2.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
|
-
[](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
|
@@ -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`,
|
|
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.
|
|
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
|
@@ -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,
|
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.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.
|
|
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",
|
package/src/utils/edit_engine.py
CHANGED
|
@@ -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
|
-
|
|
583
|
-
|
|
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 = ?", (
|
|
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
|
-
|
|
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
|
-
|
|
595
|
-
needed_seconds = duration_frames / fps
|
|
598
|
+
return None
|
|
596
599
|
if (float(alt_end) - float(alt_start)) < needed_seconds:
|
|
597
|
-
|
|
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
|
-
|
|
601
|
-
"score":
|
|
602
|
-
"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":
|
|
606
|
-
"shot_index":
|
|
607
|
-
"description":
|
|
610
|
+
"shot_uuid": shot_uuid_,
|
|
611
|
+
"shot_index": shot_index,
|
|
612
|
+
"description": description,
|
|
608
613
|
"source_frame_range": [start_frame, end_frame],
|
|
609
|
-
"
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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 =
|
|
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(
|