davinci-resolve-mcp 2.60.0 → 2.61.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.
@@ -0,0 +1,311 @@
1
+ """Story beats — units of meaning over the transcript (host-LLM pass).
2
+
3
+ Follows the host_chat_paths contract used by the vision passes: the server
4
+ never calls an LLM itself. ``plan_story_beats`` assembles a timecoded digest
5
+ (transcript + prosody evidence + known entities) plus a JSON schema and
6
+ instructions; the host chat model produces the beats; ``commit_story_beats``
7
+ validates and persists them.
8
+
9
+ Persistence mirrors subjective_fields: append-only with supersede semantics.
10
+ A machine commit supersedes only prior machine rows for that clip; rows with
11
+ source='human' are never touched and always win.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import time
18
+ import uuid
19
+ from typing import Any, Dict, List, Optional
20
+
21
+ from src.utils import strata, timeline_brain_db
22
+
23
+ STORY_SOURCE = "story_v1"
24
+ STORY_VERSION = "1.0.0"
25
+
26
+ BEAT_TYPES = ("topic", "claim", "revelation", "emotional", "anecdote", "question", "callback")
27
+
28
+ STORY_BEAT_SCHEMA: Dict[str, Any] = {
29
+ "type": "object",
30
+ "required": ["beats"],
31
+ "properties": {
32
+ "beats": {
33
+ "type": "array",
34
+ "items": {
35
+ "type": "object",
36
+ "required": ["start_seconds", "end_seconds", "beat_type", "label", "summary"],
37
+ "properties": {
38
+ "start_seconds": {"type": "number"},
39
+ "end_seconds": {"type": "number"},
40
+ "beat_type": {"type": "string", "enum": list(BEAT_TYPES)},
41
+ "label": {"type": "string", "description": "3-6 word handle, e.g. 'father built the house'"},
42
+ "summary": {"type": "string", "description": "1-2 sentences: what this span IS, not a paraphrase"},
43
+ "entity_labels": {"type": "array", "items": {"type": "string"}},
44
+ "links": {
45
+ "type": "array",
46
+ "items": {
47
+ "type": "object",
48
+ "required": ["kind", "label"],
49
+ "properties": {
50
+ "kind": {"type": "string", "enum": ["callback_to", "contradicts", "answers", "sets_up"]},
51
+ "label": {"type": "string", "description": "label of the related beat"},
52
+ },
53
+ },
54
+ },
55
+ "confidence": {"type": "string", "enum": ["low", "medium", "high"]},
56
+ },
57
+ },
58
+ }
59
+ },
60
+ }
61
+
62
+ STORY_BEAT_INSTRUCTIONS = (
63
+ "Segment this clip's transcript into story beats — units of MEANING, not "
64
+ "paragraphs. A beat boundary is where the subject changes what they are "
65
+ "doing narratively: a new topic, a claim, a revelation, an emotional turn, "
66
+ "an anecdote, a question. Use the pause/hesitation/energy evidence: long "
67
+ "pauses and energy shifts often mark real boundaries. Label beats with "
68
+ "short handles; link beats that call back to, contradict, set up, or "
69
+ "answer one another (within this clip). Do not invent content that is not "
70
+ "in the transcript; when the evidence is thin, say confidence=low. Cover "
71
+ "the spoken regions; leave silence uncovered."
72
+ )
73
+
74
+
75
+ def _now() -> str:
76
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
77
+
78
+
79
+ def _beat_uuid() -> str:
80
+ # Random, not content-derived: every commit inserts fresh rows, so prior
81
+ # rows (machine or human) can only be superseded, never replaced through
82
+ # a primary-key collision.
83
+ return uuid.uuid4().hex[:12]
84
+
85
+
86
+ def _resolve(project_root: str, clip_ref: Any):
87
+ from src.utils import analysis_store
88
+
89
+ conn = timeline_brain_db.connect(project_root)
90
+ clip_uuid = analysis_store.resolve_clip_uuid(conn, clip_ref)
91
+ if not clip_uuid:
92
+ return None, None, {
93
+ "success": False,
94
+ "error": f"Unknown clip ref: {clip_ref!r} (older analysis root? run db_ingest first)",
95
+ }
96
+ return conn, clip_uuid, None
97
+
98
+
99
+ def plan_story_beats(project_root: str, clip_ref: Any) -> Dict[str, Any]:
100
+ """Digest + schema for the host model. No LLM call happens here."""
101
+ conn, clip_uuid, err = _resolve(project_root, clip_ref)
102
+ if err:
103
+ return err
104
+
105
+ segments = conn.execute(
106
+ "SELECT segment_index, start_seconds, end_seconds, text, speaker_id "
107
+ "FROM transcript_segments WHERE clip_uuid = ? ORDER BY segment_index",
108
+ (clip_uuid,),
109
+ ).fetchall()
110
+ if not segments:
111
+ return {
112
+ "success": False,
113
+ "error": "clip has no transcript segments — run transcription first",
114
+ "clip_uuid": clip_uuid,
115
+ }
116
+
117
+ pauses = strata.read_events(conn, clip_uuid, "pause")
118
+ hesitations = strata.read_events(conn, clip_uuid, "hesitation")
119
+ energy = strata.read_curve(conn, clip_uuid, "vocal_energy")
120
+
121
+ digest_segments: List[Dict[str, Any]] = []
122
+ for seg in segments:
123
+ start = seg["start_seconds"]
124
+ end = seg["end_seconds"]
125
+ entry: Dict[str, Any] = {
126
+ "start_seconds": start,
127
+ "end_seconds": end,
128
+ "text": seg["text"],
129
+ }
130
+ if seg["speaker_id"]:
131
+ entry["speaker"] = seg["speaker_id"]
132
+ if isinstance(start, (int, float)) and isinstance(end, (int, float)):
133
+ seg_pauses = [
134
+ {"at": p["time_seconds"], "seconds": p["duration_seconds"]}
135
+ for p in pauses
136
+ if start <= p["time_seconds"] < end
137
+ ]
138
+ if seg_pauses:
139
+ entry["pauses"] = seg_pauses
140
+ n_hes = sum(1 for h in hesitations if start <= h["time_seconds"] < end)
141
+ if n_hes:
142
+ entry["hesitations"] = n_hes
143
+ if energy:
144
+ values = [
145
+ v for v in (
146
+ strata.curve_value_at(energy, start + i * 0.5)
147
+ for i in range(int(max(0.0, (end - start)) / 0.5) + 1)
148
+ )
149
+ if v is not None
150
+ ]
151
+ if values:
152
+ entry["energy_mean"] = round(sum(values) / len(values), 3)
153
+ digest_segments.append(entry)
154
+
155
+ entity_rows = conn.execute(
156
+ """
157
+ SELECT DISTINCT e.label FROM entities e
158
+ JOIN entity_appearances a ON a.entity_uuid = e.entity_uuid
159
+ WHERE a.clip_uuid = ? AND e.label IS NOT NULL
160
+ """,
161
+ (clip_uuid,),
162
+ ).fetchall()
163
+
164
+ current = list_story_beats(project_root, clip_uuid)
165
+ return {
166
+ "success": True,
167
+ "status": "pending_host_story_beats",
168
+ "clip_uuid": clip_uuid,
169
+ "digest": {
170
+ "segments": digest_segments,
171
+ "known_entities": [r["label"] for r in entity_rows],
172
+ "pause_count": len(pauses),
173
+ },
174
+ "schema": STORY_BEAT_SCHEMA,
175
+ "instructions": STORY_BEAT_INSTRUCTIONS,
176
+ "commit_action": {"tool": "media_analysis", "action": "commit_story_beats"},
177
+ "existing_beats": current.get("beats", []),
178
+ }
179
+
180
+
181
+ def commit_story_beats(
182
+ project_root: str,
183
+ clip_ref: Any,
184
+ beats: Any,
185
+ *,
186
+ source_model: Optional[str] = None,
187
+ author: str = "host",
188
+ ) -> Dict[str, Any]:
189
+ """Validate + persist host-produced beats. Machine rows supersede machine
190
+ rows; human rows are untouched."""
191
+ conn, clip_uuid, err = _resolve(project_root, clip_ref)
192
+ if err:
193
+ return err
194
+ if isinstance(beats, dict):
195
+ beats = beats.get("beats")
196
+ if not isinstance(beats, list):
197
+ return {"success": False, "error": "beats must be a list (or {beats: [...]})"}
198
+
199
+ validated: List[Dict[str, Any]] = []
200
+ problems: List[str] = []
201
+ seen_spans: set = set()
202
+ for i, beat in enumerate(beats):
203
+ if not isinstance(beat, dict):
204
+ problems.append(f"beats[{i}] is not an object")
205
+ continue
206
+ start = beat.get("start_seconds")
207
+ end = beat.get("end_seconds")
208
+ label = str(beat.get("label") or "").strip()
209
+ summary = str(beat.get("summary") or "").strip()
210
+ beat_type = str(beat.get("beat_type") or "").strip()
211
+ if not isinstance(start, (int, float)) or not isinstance(end, (int, float)) or end <= start:
212
+ problems.append(f"beats[{i}] has invalid start/end")
213
+ continue
214
+ if beat_type not in BEAT_TYPES:
215
+ problems.append(f"beats[{i}] beat_type {beat_type!r} not in {BEAT_TYPES}")
216
+ continue
217
+ if not label or not summary:
218
+ problems.append(f"beats[{i}] needs label and summary")
219
+ continue
220
+ span_key = (round(float(start), 3), round(float(end), 3), label)
221
+ if span_key in seen_spans:
222
+ problems.append(f"beats[{i}] duplicates an earlier beat in this commit; skipped")
223
+ continue
224
+ seen_spans.add(span_key)
225
+ links = beat.get("links") if isinstance(beat.get("links"), list) else []
226
+ entity_labels = beat.get("entity_labels") if isinstance(beat.get("entity_labels"), list) else []
227
+ validated.append(
228
+ {
229
+ "start_seconds": float(start),
230
+ "end_seconds": float(end),
231
+ "beat_type": beat_type,
232
+ "label": label,
233
+ "summary": summary,
234
+ "links": links,
235
+ "entity_labels": entity_labels,
236
+ "confidence": beat.get("confidence") if beat.get("confidence") in ("low", "medium", "high") else None,
237
+ }
238
+ )
239
+ if not validated:
240
+ return {"success": False, "error": "no valid beats to commit", "problems": problems}
241
+
242
+ now = _now()
243
+ source = STORY_SOURCE
244
+ with timeline_brain_db.transaction(project_root) as txn:
245
+ txn.execute(
246
+ "UPDATE story_beats SET superseded_at = ? "
247
+ "WHERE clip_uuid = ? AND source != ? AND superseded_at IS NULL",
248
+ (now, clip_uuid, strata.HUMAN_SOURCE),
249
+ )
250
+ for beat in validated:
251
+ txn.execute(
252
+ """
253
+ INSERT INTO story_beats
254
+ (beat_uuid, clip_uuid, start_seconds, end_seconds, beat_type,
255
+ label, summary, entities_json, links_json, confidence,
256
+ source, author, timestamp, superseded_at)
257
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
258
+ """,
259
+ (
260
+ _beat_uuid(),
261
+ clip_uuid,
262
+ beat["start_seconds"],
263
+ beat["end_seconds"],
264
+ beat["beat_type"],
265
+ beat["label"],
266
+ beat["summary"],
267
+ json.dumps(beat["entity_labels"]) if beat["entity_labels"] else None,
268
+ json.dumps(beat["links"]) if beat["links"] else None,
269
+ beat["confidence"],
270
+ source,
271
+ f"{author}:{source_model}" if source_model else author,
272
+ now,
273
+ ),
274
+ )
275
+ return {
276
+ "success": True,
277
+ "clip_uuid": clip_uuid,
278
+ "beats_committed": len(validated),
279
+ "problems": problems,
280
+ }
281
+
282
+
283
+ def list_story_beats(project_root: str, clip_ref: Any) -> Dict[str, Any]:
284
+ conn, clip_uuid, err = _resolve(project_root, clip_ref)
285
+ if err:
286
+ return err
287
+ rows = conn.execute(
288
+ "SELECT * FROM story_beats WHERE clip_uuid = ? AND superseded_at IS NULL "
289
+ "ORDER BY start_seconds",
290
+ (clip_uuid,),
291
+ ).fetchall()
292
+ beats = []
293
+ for row in rows:
294
+ beat = {
295
+ "beat_uuid": row["beat_uuid"],
296
+ "start_seconds": row["start_seconds"],
297
+ "end_seconds": row["end_seconds"],
298
+ "beat_type": row["beat_type"],
299
+ "label": row["label"],
300
+ "summary": row["summary"],
301
+ "confidence": row["confidence"],
302
+ "source": row["source"],
303
+ }
304
+ for key, column in (("entity_labels", "entities_json"), ("links", "links_json")):
305
+ if row[column]:
306
+ try:
307
+ beat[key] = json.loads(row[column])
308
+ except (TypeError, ValueError):
309
+ pass
310
+ beats.append(beat)
311
+ return {"success": True, "clip_uuid": clip_uuid, "beats": beats}
@@ -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 = 12
32
+ SCHEMA_VERSION = 14
33
33
  DB_FILENAME = "timeline_brain.sqlite"
34
34
  SOUL_DIRNAME = "_soul"
35
35
 
@@ -804,6 +804,126 @@ def _migrate_v12_shot_relationships(conn: sqlite3.Connection) -> None:
804
804
  )
805
805
 
806
806
 
807
+ @register_migration(13)
808
+ def _migrate_v13_perception_strata(conn: sqlite3.Connection) -> None:
809
+ """Perception strata — the timecoded track model (strata spec §schema).
810
+
811
+ Every analyzer becomes a *track writer* into two generic shapes instead
812
+ of a table per sensor:
813
+
814
+ - ``events`` — point/span occurrences on clip time (blink, breath,
815
+ pause, beat, downbeat, gesture_boundary, …).
816
+ - ``curves`` — sampled time series on clip time (pitch, vocal_energy,
817
+ speech_rate, motion_energy, loudness, …). Values are float32 arrays
818
+ (same BLOB convention as ``embeddings.vector``); NaN encodes
819
+ "no signal at this sample" (e.g. pitch during silence).
820
+
821
+ Plus two promotions out of the report blob:
822
+
823
+ - ``transcript_words`` — per-word timestamps (already computed at
824
+ transcription time, previously blob-only) as queryable rows.
825
+ - ``story_beats`` — units of meaning over the transcript (topic /
826
+ claim / emotional spans), append-only with the same supersede
827
+ semantics as subjective_fields; human rows always win.
828
+
829
+ All times are clip-relative seconds (the v12 convention). Machine
830
+ re-runs replace their own (clip, track, source) rows; rows with
831
+ source='human' are never touched by machine writers.
832
+ """
833
+ conn.executescript(
834
+ """
835
+ CREATE TABLE IF NOT EXISTS events (
836
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
837
+ clip_uuid TEXT NOT NULL,
838
+ track TEXT NOT NULL,
839
+ time_seconds REAL NOT NULL,
840
+ duration_seconds REAL,
841
+ payload_json TEXT,
842
+ source TEXT NOT NULL,
843
+ analyzer_version TEXT NOT NULL,
844
+ created_at TEXT NOT NULL,
845
+ FOREIGN KEY (clip_uuid) REFERENCES clips(clip_uuid) ON DELETE CASCADE
846
+ );
847
+
848
+ CREATE INDEX IF NOT EXISTS ix_events_clip_track
849
+ ON events(clip_uuid, track, time_seconds);
850
+ CREATE INDEX IF NOT EXISTS ix_events_track
851
+ ON events(track);
852
+
853
+ CREATE TABLE IF NOT EXISTS curves (
854
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
855
+ clip_uuid TEXT NOT NULL,
856
+ track TEXT NOT NULL,
857
+ start_seconds REAL NOT NULL,
858
+ sample_rate REAL NOT NULL,
859
+ values_blob BLOB NOT NULL,
860
+ stats_json TEXT,
861
+ source TEXT NOT NULL,
862
+ analyzer_version TEXT NOT NULL,
863
+ created_at TEXT NOT NULL,
864
+ UNIQUE(clip_uuid, track, source),
865
+ FOREIGN KEY (clip_uuid) REFERENCES clips(clip_uuid) ON DELETE CASCADE
866
+ );
867
+
868
+ CREATE INDEX IF NOT EXISTS ix_curves_clip ON curves(clip_uuid, track);
869
+
870
+ CREATE TABLE IF NOT EXISTS transcript_words (
871
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
872
+ clip_uuid TEXT NOT NULL,
873
+ segment_index INTEGER NOT NULL,
874
+ word_index INTEGER NOT NULL,
875
+ word TEXT NOT NULL,
876
+ start_seconds REAL,
877
+ end_seconds REAL,
878
+ confidence REAL,
879
+ UNIQUE(clip_uuid, segment_index, word_index),
880
+ FOREIGN KEY (clip_uuid) REFERENCES clips(clip_uuid) ON DELETE CASCADE
881
+ );
882
+
883
+ CREATE INDEX IF NOT EXISTS ix_words_clip_time
884
+ ON transcript_words(clip_uuid, start_seconds);
885
+
886
+ CREATE TABLE IF NOT EXISTS story_beats (
887
+ beat_uuid TEXT PRIMARY KEY,
888
+ clip_uuid TEXT NOT NULL,
889
+ start_seconds REAL NOT NULL,
890
+ end_seconds REAL NOT NULL,
891
+ beat_type TEXT,
892
+ label TEXT,
893
+ summary TEXT,
894
+ entities_json TEXT,
895
+ links_json TEXT,
896
+ confidence TEXT,
897
+ source TEXT NOT NULL,
898
+ author TEXT NOT NULL,
899
+ timestamp TEXT NOT NULL,
900
+ superseded_at TEXT,
901
+ FOREIGN KEY (clip_uuid) REFERENCES clips(clip_uuid) ON DELETE CASCADE
902
+ );
903
+
904
+ CREATE INDEX IF NOT EXISTS ix_story_beats_clip
905
+ ON story_beats(clip_uuid, start_seconds) WHERE superseded_at IS NULL;
906
+ """
907
+ )
908
+
909
+
910
+ @register_migration(14)
911
+ def _migrate_v14_timeline_version_timebase(conn: sqlite3.Connection) -> None:
912
+ """Record the timeline's timebase on each version snapshot.
913
+
914
+ timeline_clip_usage rows store ABSOLUTE record frames (item.GetStart()
915
+ includes the timeline start-timecode offset, e.g. 86400 for a
916
+ 01:00:00:00 start at 24fps). Without the timeline's fps and start frame
917
+ those frames cannot be converted to timeline-relative time. Snapshot
918
+ both at archive time; readers fall back to the documented absolute
919
+ convention for rows that predate this column.
920
+ """
921
+ if not _column_exists(conn, "timeline_versions", "fps"):
922
+ conn.execute("ALTER TABLE timeline_versions ADD COLUMN fps REAL")
923
+ if not _column_exists(conn, "timeline_versions", "start_frame"):
924
+ conn.execute("ALTER TABLE timeline_versions ADD COLUMN start_frame INTEGER")
925
+
926
+
807
927
  def latest_version(conn: sqlite3.Connection, timeline_name: str) -> Optional[int]:
808
928
  """Return the highest archived version number for `timeline_name`, or None."""
809
929
  row = conn.execute(
@@ -154,13 +154,28 @@ def archive_current_timeline(
154
154
  # the working timeline as the active one so downstream mutation hits it.
155
155
  project.SetCurrentTimeline(tl)
156
156
 
157
+ # Timebase snapshot — timeline_clip_usage stores ABSOLUTE record frames
158
+ # (GetStart() includes the start-timecode offset), so readers need the
159
+ # timeline's fps and start frame to recover timeline-relative time.
160
+ timeline_fps: Optional[float] = None
161
+ timeline_start_frame: Optional[int] = None
162
+ try:
163
+ timeline_fps = float(tl.GetSetting("timelineFrameRate") or 0) or None
164
+ except Exception:
165
+ pass
166
+ try:
167
+ timeline_start_frame = int(tl.GetStartFrame())
168
+ except Exception:
169
+ pass
170
+
157
171
  with timeline_brain_db.transaction(project_root) as txn:
158
172
  cursor = txn.execute(
159
173
  """
160
174
  INSERT INTO timeline_versions(
161
175
  timeline_name, version, created_at, analysis_run_id,
162
- archived_timeline_name, archived_bin_path, reason, initiator, actor
163
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
176
+ archived_timeline_name, archived_bin_path, reason, initiator, actor,
177
+ fps, start_frame
178
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
164
179
  """,
165
180
  (
166
181
  working_name,
@@ -172,6 +187,8 @@ def archive_current_timeline(
172
187
  reason,
173
188
  initiator,
174
189
  actor_identity.actor_string(),
190
+ timeline_fps,
191
+ timeline_start_frame,
175
192
  ),
176
193
  )
177
194
  row_id = cursor.lastrowid