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.
- package/CHANGELOG.md +85 -0
- package/README.md +2 -2
- package/docs/SKILL.md +38 -2
- package/docs/install.md +8 -0
- package/docs/reference/api-limitations.md +9 -1
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/granular/common.py +1 -1
- package/src/server.py +179 -1
- package/src/utils/analysis_store.py +36 -1
- package/src/utils/api_truth.py +32 -0
- package/src/utils/strata.py +565 -0
- package/src/utils/strata_analyzers.py +684 -0
- package/src/utils/strata_faces.py +347 -0
- package/src/utils/strata_queries.py +643 -0
- package/src/utils/strata_story.py +311 -0
- package/src/utils/timeline_brain_db.py +121 -1
- package/src/utils/timeline_versioning.py +19 -2
|
@@ -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 =
|
|
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
|
-
|
|
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
|