davinci-resolve-mcp 2.40.0 → 2.42.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 +68 -0
- package/README.md +1 -1
- package/docs/SKILL.md +39 -2
- package/docs/guides/control-panel.md +7 -2
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/analysis_dashboard.py +25 -5
- package/src/granular/common.py +1 -1
- package/src/server.py +133 -1
- package/src/utils/analysis_store.py +927 -0
- package/src/utils/deep_vision.py +643 -0
- package/src/utils/media_analysis.py +77 -1
- package/src/utils/timeline_brain_db.py +179 -1
|
@@ -0,0 +1,927 @@
|
|
|
1
|
+
"""DB-canonical clip-analysis store (C1 / Phase A of the analysis program).
|
|
2
|
+
|
|
3
|
+
The per-project SQLite DB (timeline_brain.sqlite, schema v9+) is the source of
|
|
4
|
+
truth for clip analysis. analysis.json on disk is a derived export written in
|
|
5
|
+
lockstep after every ingest. Shape is hybrid:
|
|
6
|
+
|
|
7
|
+
- ``analysis_reports.report_json`` — the canonical full payload per clip.
|
|
8
|
+
- Normalized tables for what downstream phases query: ``clips`` (identity +
|
|
9
|
+
headline columns), ``clip_aliases`` (any stable id → clip_uuid), ``shots``,
|
|
10
|
+
``subjective_fields``/``field_changelog`` (per-field provenance),
|
|
11
|
+
``transcript_segments``, ``frames``, ``qc_observations``.
|
|
12
|
+
|
|
13
|
+
Export contract: export = report blob + current *human* subjective fields
|
|
14
|
+
overlaid. Machine values are already inside the blob because the blob is
|
|
15
|
+
rewritten on every ingest; human rows always win and survive re-analysis.
|
|
16
|
+
|
|
17
|
+
Reader contract: readers go DB-first and fall back to analysis.json when the
|
|
18
|
+
clip has no rows (reports written before v9, or job-linked report dirs that
|
|
19
|
+
live under another project root).
|
|
20
|
+
|
|
21
|
+
This module must not import media_analysis at module level (media_analysis
|
|
22
|
+
calls into here from the analysis write path) — helpers are imported lazily.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import logging
|
|
29
|
+
import os
|
|
30
|
+
import sqlite3
|
|
31
|
+
import time
|
|
32
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
33
|
+
|
|
34
|
+
from src.utils import timeline_brain_db
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger("resolve-mcp.analysis-store")
|
|
37
|
+
|
|
38
|
+
# Source label for machine-derived subjective rows written at ingest time.
|
|
39
|
+
MACHINE_SOURCE = "vision_v0.2"
|
|
40
|
+
HUMAN_SOURCE = "human"
|
|
41
|
+
|
|
42
|
+
# Clip-level subjective groups inside report["visual"] that get flattened into
|
|
43
|
+
# subjective_fields rows. Structural/computed visual keys stay blob-only.
|
|
44
|
+
_SUBJECTIVE_CLIP_GROUPS = (
|
|
45
|
+
"clip_summary",
|
|
46
|
+
"clip_summary_oneliner",
|
|
47
|
+
"editorial_classification",
|
|
48
|
+
"content",
|
|
49
|
+
"shot_and_style",
|
|
50
|
+
"slate",
|
|
51
|
+
"editing_notes",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Shot keys that are structural (identity/geometry), not subjective fields.
|
|
55
|
+
_SHOT_STRUCTURAL_KEYS = {
|
|
56
|
+
"shot_index",
|
|
57
|
+
"shot_uuid",
|
|
58
|
+
"time_seconds_start",
|
|
59
|
+
"time_seconds_end",
|
|
60
|
+
"frame_indices_used",
|
|
61
|
+
"frame_indices",
|
|
62
|
+
"qc_flags",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _now() -> str:
|
|
67
|
+
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _dumps(value: Any) -> str:
|
|
71
|
+
return json.dumps(value, sort_keys=True, default=str)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _ma():
|
|
75
|
+
"""Lazy import of media_analysis helpers (avoids a circular import)."""
|
|
76
|
+
from src.utils import media_analysis
|
|
77
|
+
|
|
78
|
+
return media_analysis
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def shot_uuid_for(clip_uuid: str, start: Any, end: Any) -> str:
|
|
82
|
+
"""Stable shot id: clip + time region rounded to the nearest second.
|
|
83
|
+
|
|
84
|
+
Survives small boundary jitter on re-analysis so corrections and timeline
|
|
85
|
+
references keep pointing at the same shot.
|
|
86
|
+
"""
|
|
87
|
+
try:
|
|
88
|
+
start_r = int(round(float(start)))
|
|
89
|
+
except (TypeError, ValueError):
|
|
90
|
+
start_r = -1
|
|
91
|
+
try:
|
|
92
|
+
end_r = int(round(float(end)))
|
|
93
|
+
except (TypeError, ValueError):
|
|
94
|
+
end_r = -1
|
|
95
|
+
return _ma().short_hash(f"shot:{clip_uuid}:{start_r}:{end_r}", 12)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _flatten_fields(value: Any, prefix: str, out: Dict[str, Any]) -> None:
|
|
99
|
+
"""Flatten nested dicts to dot-path leaves; lists/scalars are leaves."""
|
|
100
|
+
if isinstance(value, dict):
|
|
101
|
+
for key, child in value.items():
|
|
102
|
+
child_prefix = f"{prefix}.{key}" if prefix else str(key)
|
|
103
|
+
_flatten_fields(child, child_prefix, out)
|
|
104
|
+
else:
|
|
105
|
+
if prefix:
|
|
106
|
+
out[prefix] = value
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def clip_subjective_fields(visual: Dict[str, Any]) -> Dict[str, Any]:
|
|
110
|
+
"""Flatten the clip-level subjective groups of a visual block."""
|
|
111
|
+
out: Dict[str, Any] = {}
|
|
112
|
+
if not isinstance(visual, dict):
|
|
113
|
+
return out
|
|
114
|
+
for group in _SUBJECTIVE_CLIP_GROUPS:
|
|
115
|
+
if group in visual:
|
|
116
|
+
_flatten_fields(visual.get(group), group, out)
|
|
117
|
+
return out
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def shot_subjective_fields(shot_entry: Dict[str, Any]) -> Dict[str, Any]:
|
|
121
|
+
"""Flatten a shot entry's subjective fields (everything non-structural)."""
|
|
122
|
+
out: Dict[str, Any] = {}
|
|
123
|
+
if not isinstance(shot_entry, dict):
|
|
124
|
+
return out
|
|
125
|
+
for key, value in shot_entry.items():
|
|
126
|
+
if key in _SHOT_STRUCTURAL_KEYS:
|
|
127
|
+
continue
|
|
128
|
+
_flatten_fields(value, str(key), out)
|
|
129
|
+
return out
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ── identity ──────────────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def clip_identity(report: Dict[str, Any], *, clip_dir: Optional[str] = None) -> Tuple[str, List[Tuple[str, str]]]:
|
|
136
|
+
"""Return (clip_uuid, [(alias, kind), ...]) for a report.
|
|
137
|
+
|
|
138
|
+
clip_uuid is the canonical rename-stable hash (normalized file_path basis);
|
|
139
|
+
aliases cover legacy hashes, clip_id, media_id, raw/normalized file path,
|
|
140
|
+
and the report folder's name + embedded hash.
|
|
141
|
+
"""
|
|
142
|
+
ma = _ma()
|
|
143
|
+
record = report.get("clip") if isinstance(report.get("clip"), dict) else {}
|
|
144
|
+
hashes = ma.stable_clip_match_hashes(record)
|
|
145
|
+
aliases: List[Tuple[str, str]] = []
|
|
146
|
+
folder_hash = None
|
|
147
|
+
folder_name = os.path.basename(str(clip_dir).rstrip("/\\")) if clip_dir else None
|
|
148
|
+
if folder_name:
|
|
149
|
+
folder_hash = ma.clip_directory_hash(folder_name)
|
|
150
|
+
clip_uuid = hashes[0] if hashes else (folder_hash or ma.short_hash(folder_name or "clip", 12))
|
|
151
|
+
for h in hashes:
|
|
152
|
+
aliases.append((h, "hash"))
|
|
153
|
+
if folder_hash:
|
|
154
|
+
aliases.append((folder_hash, "hash"))
|
|
155
|
+
if folder_name:
|
|
156
|
+
aliases.append((folder_name, "clip_dir"))
|
|
157
|
+
for key, kind in (("clip_id", "clip_id"), ("media_id", "media_id")):
|
|
158
|
+
value = record.get(key)
|
|
159
|
+
if value:
|
|
160
|
+
aliases.append((str(value), kind))
|
|
161
|
+
file_path = record.get("file_path")
|
|
162
|
+
if file_path:
|
|
163
|
+
aliases.append((ma.normalize_path(file_path), "file_path"))
|
|
164
|
+
aliases.append((str(file_path), "file_path"))
|
|
165
|
+
deduped: List[Tuple[str, str]] = []
|
|
166
|
+
seen = set()
|
|
167
|
+
for alias, kind in aliases:
|
|
168
|
+
if alias and alias not in seen:
|
|
169
|
+
seen.add(alias)
|
|
170
|
+
deduped.append((alias, kind))
|
|
171
|
+
return clip_uuid, deduped
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def resolve_clip_uuid(conn: sqlite3.Connection, ref: Any) -> Optional[str]:
|
|
175
|
+
"""Resolve any clip reference (uuid, hash, clip_id, path, folder) to clip_uuid."""
|
|
176
|
+
if not ref:
|
|
177
|
+
return None
|
|
178
|
+
candidate = str(ref)
|
|
179
|
+
row = conn.execute("SELECT clip_uuid FROM clips WHERE clip_uuid = ?", (candidate,)).fetchone()
|
|
180
|
+
if row:
|
|
181
|
+
return str(row["clip_uuid"])
|
|
182
|
+
row = conn.execute("SELECT clip_uuid FROM clip_aliases WHERE alias = ?", (candidate,)).fetchone()
|
|
183
|
+
if row:
|
|
184
|
+
return str(row["clip_uuid"])
|
|
185
|
+
# Folder names carry the hash as a suffix; absolute paths reduce to basename.
|
|
186
|
+
base = os.path.basename(candidate.rstrip("/\\"))
|
|
187
|
+
if base != candidate:
|
|
188
|
+
for probe in (base, _ma().clip_directory_hash(base) or ""):
|
|
189
|
+
if not probe:
|
|
190
|
+
continue
|
|
191
|
+
row = conn.execute("SELECT clip_uuid FROM clip_aliases WHERE alias = ?", (probe,)).fetchone()
|
|
192
|
+
if row:
|
|
193
|
+
return str(row["clip_uuid"])
|
|
194
|
+
else:
|
|
195
|
+
probe = _ma().clip_directory_hash(base)
|
|
196
|
+
if probe:
|
|
197
|
+
row = conn.execute("SELECT clip_uuid FROM clip_aliases WHERE alias = ?", (probe,)).fetchone()
|
|
198
|
+
if row:
|
|
199
|
+
return str(row["clip_uuid"])
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ── ingest ────────────────────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _duration_seconds(report: Dict[str, Any]) -> Optional[float]:
|
|
207
|
+
for path in (
|
|
208
|
+
("clip_analysis_markers", "duration_seconds"),
|
|
209
|
+
("cut_analysis", "duration_seconds"),
|
|
210
|
+
):
|
|
211
|
+
node: Any = report
|
|
212
|
+
for key in path:
|
|
213
|
+
node = node.get(key) if isinstance(node, dict) else None
|
|
214
|
+
if isinstance(node, (int, float)):
|
|
215
|
+
return float(node)
|
|
216
|
+
technical = report.get("technical") if isinstance(report.get("technical"), dict) else {}
|
|
217
|
+
fmt = technical.get("format") if isinstance(technical.get("format"), dict) else {}
|
|
218
|
+
if isinstance(fmt.get("duration_seconds"), (int, float)):
|
|
219
|
+
return float(fmt["duration_seconds"])
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _current_subjective(conn: sqlite3.Connection, entity_type: str, entity_uuid: str) -> Dict[str, sqlite3.Row]:
|
|
224
|
+
rows = conn.execute(
|
|
225
|
+
"""
|
|
226
|
+
SELECT * FROM subjective_fields
|
|
227
|
+
WHERE entity_type = ? AND entity_uuid = ? AND superseded_at IS NULL
|
|
228
|
+
""",
|
|
229
|
+
(entity_type, entity_uuid),
|
|
230
|
+
).fetchall()
|
|
231
|
+
return {str(r["field_path"]): r for r in rows}
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _write_subjective(
|
|
235
|
+
conn: sqlite3.Connection,
|
|
236
|
+
entity_type: str,
|
|
237
|
+
entity_uuid: str,
|
|
238
|
+
fields: Dict[str, Any],
|
|
239
|
+
*,
|
|
240
|
+
source: str,
|
|
241
|
+
author: str,
|
|
242
|
+
confidence: Optional[str] = None,
|
|
243
|
+
reason: Optional[str] = None,
|
|
244
|
+
overwrite_human: bool = False,
|
|
245
|
+
) -> Dict[str, int]:
|
|
246
|
+
"""Upsert current values for an entity. Machine writes never replace a
|
|
247
|
+
current human row unless overwrite_human is set (explicit revert path)."""
|
|
248
|
+
now = _now()
|
|
249
|
+
current = _current_subjective(conn, entity_type, entity_uuid)
|
|
250
|
+
written = 0
|
|
251
|
+
skipped_human = 0
|
|
252
|
+
for field_path, value in fields.items():
|
|
253
|
+
value_json = _dumps(value)
|
|
254
|
+
existing = current.get(field_path)
|
|
255
|
+
if existing is not None:
|
|
256
|
+
# Identical value → keep the existing row and its provenance. A
|
|
257
|
+
# different source re-deriving the same value must not re-attribute
|
|
258
|
+
# it (a deep pass would otherwise claim every unchanged field).
|
|
259
|
+
if str(existing["value_json"]) == value_json:
|
|
260
|
+
continue
|
|
261
|
+
if (
|
|
262
|
+
str(existing["source"]) == HUMAN_SOURCE
|
|
263
|
+
and source != HUMAN_SOURCE
|
|
264
|
+
and not overwrite_human
|
|
265
|
+
):
|
|
266
|
+
skipped_human += 1
|
|
267
|
+
continue
|
|
268
|
+
conn.execute(
|
|
269
|
+
"UPDATE subjective_fields SET superseded_at = ? WHERE id = ?",
|
|
270
|
+
(now, existing["id"]),
|
|
271
|
+
)
|
|
272
|
+
conn.execute(
|
|
273
|
+
"""
|
|
274
|
+
INSERT INTO subjective_fields
|
|
275
|
+
(entity_type, entity_uuid, field_path, value_json, confidence,
|
|
276
|
+
source, author, timestamp)
|
|
277
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
278
|
+
""",
|
|
279
|
+
(entity_type, entity_uuid, field_path, value_json, confidence, source, author, now),
|
|
280
|
+
)
|
|
281
|
+
conn.execute(
|
|
282
|
+
"""
|
|
283
|
+
INSERT INTO field_changelog
|
|
284
|
+
(entity_type, entity_uuid, field_path, previous_value_json,
|
|
285
|
+
new_value_json, previous_source, new_source, previous_author,
|
|
286
|
+
new_author, change_reason, timestamp)
|
|
287
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
288
|
+
""",
|
|
289
|
+
(
|
|
290
|
+
entity_type,
|
|
291
|
+
entity_uuid,
|
|
292
|
+
field_path,
|
|
293
|
+
existing["value_json"] if existing is not None else None,
|
|
294
|
+
value_json,
|
|
295
|
+
existing["source"] if existing is not None else None,
|
|
296
|
+
source,
|
|
297
|
+
existing["author"] if existing is not None else None,
|
|
298
|
+
author,
|
|
299
|
+
reason,
|
|
300
|
+
now,
|
|
301
|
+
),
|
|
302
|
+
)
|
|
303
|
+
written += 1
|
|
304
|
+
return {"written": written, "skipped_human": skipped_human}
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def ingest_report(
|
|
308
|
+
project_root: str,
|
|
309
|
+
report: Dict[str, Any],
|
|
310
|
+
*,
|
|
311
|
+
clip_dir: Optional[str] = None,
|
|
312
|
+
author: str = "system",
|
|
313
|
+
source: str = MACHINE_SOURCE,
|
|
314
|
+
) -> Dict[str, Any]:
|
|
315
|
+
"""Write one analysis report into the DB (rows + canonical blob).
|
|
316
|
+
|
|
317
|
+
Idempotent: re-ingesting the same report leaves identical state. Human
|
|
318
|
+
subjective rows are never replaced by machine values.
|
|
319
|
+
"""
|
|
320
|
+
if not isinstance(report, dict):
|
|
321
|
+
return {"success": False, "error": "report must be a dict"}
|
|
322
|
+
clip_uuid, aliases = clip_identity(report, clip_dir=clip_dir)
|
|
323
|
+
record = report.get("clip") if isinstance(report.get("clip"), dict) else {}
|
|
324
|
+
visual = report.get("visual") if isinstance(report.get("visual"), dict) else {}
|
|
325
|
+
shot_entries = visual.get("shot_descriptions") if isinstance(visual.get("shot_descriptions"), list) else []
|
|
326
|
+
motion = report.get("motion") if isinstance(report.get("motion"), dict) else {}
|
|
327
|
+
cut_analysis = report.get("cut_analysis") if isinstance(report.get("cut_analysis"), dict) else {}
|
|
328
|
+
transcription = report.get("transcription") if isinstance(report.get("transcription"), dict) else {}
|
|
329
|
+
signature = report.get("analysis_signature") if isinstance(report.get("analysis_signature"), dict) else {}
|
|
330
|
+
profile = report.get("analysis_profile") if isinstance(report.get("analysis_profile"), dict) else {}
|
|
331
|
+
now = _now()
|
|
332
|
+
|
|
333
|
+
with timeline_brain_db.transaction(project_root) as conn:
|
|
334
|
+
existing = conn.execute(
|
|
335
|
+
"SELECT created_at FROM clips WHERE clip_uuid = ?", (clip_uuid,)
|
|
336
|
+
).fetchone()
|
|
337
|
+
created_at = str(existing["created_at"]) if existing else now
|
|
338
|
+
conn.execute(
|
|
339
|
+
"""
|
|
340
|
+
INSERT OR REPLACE INTO clips
|
|
341
|
+
(clip_uuid, clip_dir, resolve_clip_id, media_id, clip_name,
|
|
342
|
+
file_path, bin_path, duration_seconds, fps, resolution,
|
|
343
|
+
media_type, summary, overall_motion_level, cut_count,
|
|
344
|
+
shot_count, analysis_version, depth, signature_hash,
|
|
345
|
+
analyzed_at, vision_status, vision_committed_at,
|
|
346
|
+
created_at, updated_at)
|
|
347
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
348
|
+
""",
|
|
349
|
+
(
|
|
350
|
+
clip_uuid,
|
|
351
|
+
os.path.basename(str(clip_dir).rstrip("/\\")) if clip_dir else None,
|
|
352
|
+
record.get("clip_id"),
|
|
353
|
+
record.get("media_id"),
|
|
354
|
+
record.get("clip_name"),
|
|
355
|
+
record.get("file_path"),
|
|
356
|
+
record.get("bin_path"),
|
|
357
|
+
_duration_seconds(report),
|
|
358
|
+
record.get("fps") if isinstance(record.get("fps"), (int, float)) else None,
|
|
359
|
+
record.get("resolution"),
|
|
360
|
+
record.get("media_type"),
|
|
361
|
+
report.get("summary"),
|
|
362
|
+
motion.get("overall_motion_level"),
|
|
363
|
+
cut_analysis.get("cut_count") if isinstance(cut_analysis.get("cut_count"), int) else None,
|
|
364
|
+
len(shot_entries),
|
|
365
|
+
report.get("analysis_version"),
|
|
366
|
+
profile.get("depth"),
|
|
367
|
+
signature.get("signature_hash"),
|
|
368
|
+
report.get("analyzed_at"),
|
|
369
|
+
report.get("vision_status"),
|
|
370
|
+
report.get("vision_committed_at"),
|
|
371
|
+
created_at,
|
|
372
|
+
now,
|
|
373
|
+
),
|
|
374
|
+
)
|
|
375
|
+
for alias, kind in aliases:
|
|
376
|
+
conn.execute(
|
|
377
|
+
"INSERT OR IGNORE INTO clip_aliases (alias, clip_uuid, kind) VALUES (?, ?, ?)",
|
|
378
|
+
(alias, clip_uuid, kind),
|
|
379
|
+
)
|
|
380
|
+
conn.execute(
|
|
381
|
+
"""
|
|
382
|
+
INSERT OR REPLACE INTO analysis_reports
|
|
383
|
+
(clip_uuid, report_json, signature_hash, analyzed_at, written_at)
|
|
384
|
+
VALUES (?, ?, ?, ?, ?)
|
|
385
|
+
""",
|
|
386
|
+
(clip_uuid, _dumps(report), signature.get("signature_hash"), report.get("analyzed_at"), now),
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
# Shots: rebuild rows, preserving created_at for stable shot_uuids.
|
|
390
|
+
prior_shots = {
|
|
391
|
+
str(r["shot_uuid"]): r
|
|
392
|
+
for r in conn.execute("SELECT * FROM shots WHERE clip_uuid = ?", (clip_uuid,)).fetchall()
|
|
393
|
+
}
|
|
394
|
+
conn.execute("DELETE FROM shots WHERE clip_uuid = ?", (clip_uuid,))
|
|
395
|
+
frame_to_shot: Dict[int, str] = {}
|
|
396
|
+
subj_written = 0
|
|
397
|
+
subj_skipped_human = 0
|
|
398
|
+
for entry in shot_entries:
|
|
399
|
+
if not isinstance(entry, dict):
|
|
400
|
+
continue
|
|
401
|
+
start = entry.get("time_seconds_start")
|
|
402
|
+
end = entry.get("time_seconds_end")
|
|
403
|
+
s_uuid = shot_uuid_for(clip_uuid, start, end)
|
|
404
|
+
prior = prior_shots.get(s_uuid)
|
|
405
|
+
frame_indices = entry.get("frame_indices_used") or entry.get("frame_indices") or []
|
|
406
|
+
if not isinstance(frame_indices, list):
|
|
407
|
+
frame_indices = []
|
|
408
|
+
extra = {
|
|
409
|
+
k: v
|
|
410
|
+
for k, v in entry.items()
|
|
411
|
+
if k not in _SHOT_STRUCTURAL_KEYS and k != "description"
|
|
412
|
+
}
|
|
413
|
+
conn.execute(
|
|
414
|
+
"""
|
|
415
|
+
INSERT OR REPLACE INTO shots
|
|
416
|
+
(shot_uuid, clip_uuid, shot_index, time_seconds_start,
|
|
417
|
+
time_seconds_end, description, qc_flags_json,
|
|
418
|
+
frame_indices_json, extra_json, created_at, updated_at)
|
|
419
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
420
|
+
""",
|
|
421
|
+
(
|
|
422
|
+
s_uuid,
|
|
423
|
+
clip_uuid,
|
|
424
|
+
int(entry.get("shot_index") or 0),
|
|
425
|
+
start if isinstance(start, (int, float)) else None,
|
|
426
|
+
end if isinstance(end, (int, float)) else None,
|
|
427
|
+
entry.get("description"),
|
|
428
|
+
_dumps(entry.get("qc_flags") or []),
|
|
429
|
+
_dumps(frame_indices),
|
|
430
|
+
_dumps(extra) if extra else None,
|
|
431
|
+
str(prior["created_at"]) if prior else now,
|
|
432
|
+
now,
|
|
433
|
+
),
|
|
434
|
+
)
|
|
435
|
+
for raw in frame_indices:
|
|
436
|
+
try:
|
|
437
|
+
frame_to_shot[int(raw)] = s_uuid
|
|
438
|
+
except (TypeError, ValueError):
|
|
439
|
+
continue
|
|
440
|
+
stats = _write_subjective(
|
|
441
|
+
conn, "shot", s_uuid, shot_subjective_fields(entry),
|
|
442
|
+
source=source, author=author,
|
|
443
|
+
)
|
|
444
|
+
subj_written += stats["written"]
|
|
445
|
+
subj_skipped_human += stats["skipped_human"]
|
|
446
|
+
|
|
447
|
+
stats = _write_subjective(
|
|
448
|
+
conn, "clip", clip_uuid, clip_subjective_fields(visual),
|
|
449
|
+
source=source, author=author,
|
|
450
|
+
)
|
|
451
|
+
subj_written += stats["written"]
|
|
452
|
+
subj_skipped_human += stats["skipped_human"]
|
|
453
|
+
|
|
454
|
+
# Transcript segments.
|
|
455
|
+
conn.execute("DELETE FROM transcript_segments WHERE clip_uuid = ?", (clip_uuid,))
|
|
456
|
+
segments = transcription.get("segments") if isinstance(transcription.get("segments"), list) else []
|
|
457
|
+
for idx, seg in enumerate(segments):
|
|
458
|
+
if not isinstance(seg, dict):
|
|
459
|
+
continue
|
|
460
|
+
conn.execute(
|
|
461
|
+
"""
|
|
462
|
+
INSERT OR REPLACE INTO transcript_segments
|
|
463
|
+
(clip_uuid, segment_index, start_seconds, end_seconds, text, speaker_id)
|
|
464
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
465
|
+
""",
|
|
466
|
+
(
|
|
467
|
+
clip_uuid,
|
|
468
|
+
idx,
|
|
469
|
+
seg.get("start") if isinstance(seg.get("start"), (int, float)) else None,
|
|
470
|
+
seg.get("end") if isinstance(seg.get("end"), (int, float)) else None,
|
|
471
|
+
seg.get("text"),
|
|
472
|
+
seg.get("speaker") or seg.get("speaker_id"),
|
|
473
|
+
),
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
# Sampled frames.
|
|
477
|
+
conn.execute("DELETE FROM frames WHERE clip_uuid = ?", (clip_uuid,))
|
|
478
|
+
keyframes = motion.get("analysis_keyframes") if isinstance(motion.get("analysis_keyframes"), list) else []
|
|
479
|
+
for kf in keyframes:
|
|
480
|
+
if not isinstance(kf, dict):
|
|
481
|
+
continue
|
|
482
|
+
try:
|
|
483
|
+
frame_index = int(kf.get("index"))
|
|
484
|
+
except (TypeError, ValueError):
|
|
485
|
+
continue
|
|
486
|
+
conn.execute(
|
|
487
|
+
"""
|
|
488
|
+
INSERT OR REPLACE INTO frames
|
|
489
|
+
(clip_uuid, shot_uuid, frame_index, time_seconds, frame_path,
|
|
490
|
+
selection_reason, motion_peak)
|
|
491
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
492
|
+
""",
|
|
493
|
+
(
|
|
494
|
+
clip_uuid,
|
|
495
|
+
frame_to_shot.get(frame_index),
|
|
496
|
+
frame_index,
|
|
497
|
+
kf.get("time_seconds") if isinstance(kf.get("time_seconds"), (int, float)) else None,
|
|
498
|
+
kf.get("frame_path") or kf.get("path"),
|
|
499
|
+
kf.get("selection_reason"),
|
|
500
|
+
1 if kf.get("motion_peak") else 0,
|
|
501
|
+
),
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
# QC observations: machine rows rebuild; human-resolved rows persist.
|
|
505
|
+
conn.execute(
|
|
506
|
+
"DELETE FROM qc_observations WHERE clip_uuid = ? AND resolved = 0 AND source = ?",
|
|
507
|
+
(clip_uuid, source),
|
|
508
|
+
)
|
|
509
|
+
resolved_keys = {
|
|
510
|
+
(str(r["observation_type"]), str(r["message"]))
|
|
511
|
+
for r in conn.execute(
|
|
512
|
+
"SELECT observation_type, message FROM qc_observations WHERE clip_uuid = ? AND resolved = 1",
|
|
513
|
+
(clip_uuid,),
|
|
514
|
+
).fetchall()
|
|
515
|
+
}
|
|
516
|
+
qc = visual.get("qc") if isinstance(visual.get("qc"), dict) else {}
|
|
517
|
+
|
|
518
|
+
def _insert_qc(obs_type: str, severity: str, message: Any, related: Any = None, confidence: Any = None) -> None:
|
|
519
|
+
msg = str(message or "").strip()
|
|
520
|
+
if not msg or (obs_type, msg) in resolved_keys:
|
|
521
|
+
return
|
|
522
|
+
conn.execute(
|
|
523
|
+
"""
|
|
524
|
+
INSERT INTO qc_observations
|
|
525
|
+
(clip_uuid, observation_type, severity, message,
|
|
526
|
+
related_shot_indices_json, confidence, source, created_at)
|
|
527
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
528
|
+
""",
|
|
529
|
+
(
|
|
530
|
+
clip_uuid,
|
|
531
|
+
obs_type,
|
|
532
|
+
severity,
|
|
533
|
+
msg,
|
|
534
|
+
_dumps(related) if related else None,
|
|
535
|
+
str(confidence) if confidence else None,
|
|
536
|
+
source,
|
|
537
|
+
now,
|
|
538
|
+
),
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
for warning in qc.get("warnings") or []:
|
|
542
|
+
_insert_qc("warning", "warn", warning)
|
|
543
|
+
for obs in qc.get("continuity_observations") or []:
|
|
544
|
+
if isinstance(obs, dict):
|
|
545
|
+
_insert_qc(
|
|
546
|
+
str(obs.get("kind") or "continuity"),
|
|
547
|
+
"info",
|
|
548
|
+
obs.get("observation"),
|
|
549
|
+
related=obs.get("shot_indices"),
|
|
550
|
+
confidence=obs.get("confidence"),
|
|
551
|
+
)
|
|
552
|
+
for gap in qc.get("coverage_gaps") or []:
|
|
553
|
+
_insert_qc("coverage_gap", "info", gap)
|
|
554
|
+
|
|
555
|
+
return {
|
|
556
|
+
"success": True,
|
|
557
|
+
"clip_uuid": clip_uuid,
|
|
558
|
+
"shot_count": len(shot_entries),
|
|
559
|
+
"subjective_fields_written": subj_written,
|
|
560
|
+
"subjective_fields_preserved_human": subj_skipped_human,
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
# ── export / readers ─────────────────────────────────────────────────────────
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _overlay_human_fields(conn: sqlite3.Connection, clip_uuid: str, report: Dict[str, Any]) -> int:
|
|
568
|
+
"""Apply current human subjective rows onto a report dict. Returns count."""
|
|
569
|
+
ma = _ma()
|
|
570
|
+
visual = report.get("visual")
|
|
571
|
+
if not isinstance(visual, dict):
|
|
572
|
+
return 0
|
|
573
|
+
shots_by_uuid: Dict[str, int] = {}
|
|
574
|
+
for row in conn.execute(
|
|
575
|
+
"SELECT shot_uuid, shot_index FROM shots WHERE clip_uuid = ?", (clip_uuid,)
|
|
576
|
+
).fetchall():
|
|
577
|
+
shots_by_uuid[str(row["shot_uuid"])] = int(row["shot_index"])
|
|
578
|
+
shot_entries = visual.get("shot_descriptions") if isinstance(visual.get("shot_descriptions"), list) else []
|
|
579
|
+
applied = 0
|
|
580
|
+
|
|
581
|
+
rows = conn.execute(
|
|
582
|
+
"""
|
|
583
|
+
SELECT entity_type, entity_uuid, field_path, value_json
|
|
584
|
+
FROM subjective_fields
|
|
585
|
+
WHERE superseded_at IS NULL AND source = ?
|
|
586
|
+
AND (
|
|
587
|
+
(entity_type = 'clip' AND entity_uuid = ?)
|
|
588
|
+
OR (entity_type = 'shot' AND entity_uuid IN
|
|
589
|
+
(SELECT shot_uuid FROM shots WHERE clip_uuid = ?))
|
|
590
|
+
)
|
|
591
|
+
""",
|
|
592
|
+
(HUMAN_SOURCE, clip_uuid, clip_uuid),
|
|
593
|
+
).fetchall()
|
|
594
|
+
for row in rows:
|
|
595
|
+
try:
|
|
596
|
+
value = json.loads(row["value_json"])
|
|
597
|
+
except (TypeError, ValueError):
|
|
598
|
+
continue
|
|
599
|
+
path_parts = [p for p in str(row["field_path"]).split(".") if p]
|
|
600
|
+
if not path_parts:
|
|
601
|
+
continue
|
|
602
|
+
if str(row["entity_type"]) == "clip":
|
|
603
|
+
target: Any = visual
|
|
604
|
+
else:
|
|
605
|
+
shot_index = shots_by_uuid.get(str(row["entity_uuid"]))
|
|
606
|
+
target = None
|
|
607
|
+
if shot_index is not None:
|
|
608
|
+
target = ma._find_shot_entry(shot_entries, str(shot_index))
|
|
609
|
+
if target is None:
|
|
610
|
+
target = ma._find_shot_entry(shot_entries, str(row["entity_uuid"]))
|
|
611
|
+
if target is None:
|
|
612
|
+
continue
|
|
613
|
+
if ma._walk_set(target, path_parts, value):
|
|
614
|
+
applied += 1
|
|
615
|
+
return applied
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def export_report(project_root: str, clip_ref: Any) -> Optional[Dict[str, Any]]:
|
|
619
|
+
"""Return the canonical report (blob + human overlay) or None if absent."""
|
|
620
|
+
try:
|
|
621
|
+
conn = timeline_brain_db.connect(project_root)
|
|
622
|
+
clip_uuid = resolve_clip_uuid(conn, clip_ref)
|
|
623
|
+
if not clip_uuid:
|
|
624
|
+
return None
|
|
625
|
+
row = conn.execute(
|
|
626
|
+
"SELECT report_json FROM analysis_reports WHERE clip_uuid = ?", (clip_uuid,)
|
|
627
|
+
).fetchone()
|
|
628
|
+
if not row:
|
|
629
|
+
return None
|
|
630
|
+
report = json.loads(row["report_json"])
|
|
631
|
+
if not isinstance(report, dict):
|
|
632
|
+
return None
|
|
633
|
+
_overlay_human_fields(conn, clip_uuid, report)
|
|
634
|
+
return report
|
|
635
|
+
except (sqlite3.Error, ValueError, OSError) as exc:
|
|
636
|
+
logger.debug("export_report fell back to JSON for %r: %s", clip_ref, exc)
|
|
637
|
+
return None
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def load_db_report(
|
|
641
|
+
project_root: str,
|
|
642
|
+
*,
|
|
643
|
+
clip_dir: Optional[str] = None,
|
|
644
|
+
clip_id: Optional[str] = None,
|
|
645
|
+
clip_uuid: Optional[str] = None,
|
|
646
|
+
file_path: Optional[str] = None,
|
|
647
|
+
) -> Optional[Dict[str, Any]]:
|
|
648
|
+
"""DB-first reader used by panel/MCP readers. None means: fall back to JSON."""
|
|
649
|
+
for ref in (clip_uuid, clip_id, clip_dir, file_path):
|
|
650
|
+
if not ref:
|
|
651
|
+
continue
|
|
652
|
+
report = export_report(project_root, ref)
|
|
653
|
+
if report is not None:
|
|
654
|
+
return report
|
|
655
|
+
return None
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def export_report_file(project_root: str, clip_ref: Any, path: str) -> Optional[str]:
|
|
659
|
+
"""Write the derived analysis.json export for a clip. Returns the path."""
|
|
660
|
+
report = export_report(project_root, clip_ref)
|
|
661
|
+
if report is None:
|
|
662
|
+
return None
|
|
663
|
+
_ma()._write_json(path, report)
|
|
664
|
+
return path
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def list_clip_rows(project_root: str) -> List[Dict[str, Any]]:
|
|
668
|
+
"""Headline clip rows for list endpoints (no blobs)."""
|
|
669
|
+
conn = timeline_brain_db.connect(project_root)
|
|
670
|
+
rows = conn.execute("SELECT * FROM clips ORDER BY clip_name COLLATE NOCASE").fetchall()
|
|
671
|
+
return [dict(r) for r in rows]
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def db_status(project_root: str) -> Dict[str, Any]:
|
|
675
|
+
"""Counts + schema version for the analysis store of a project."""
|
|
676
|
+
try:
|
|
677
|
+
conn = timeline_brain_db.connect(project_root)
|
|
678
|
+
version = timeline_brain_db._read_schema_version(conn)
|
|
679
|
+
counts = {}
|
|
680
|
+
for table in (
|
|
681
|
+
"clips",
|
|
682
|
+
"analysis_reports",
|
|
683
|
+
"shots",
|
|
684
|
+
"subjective_fields",
|
|
685
|
+
"transcript_segments",
|
|
686
|
+
"frames",
|
|
687
|
+
"qc_observations",
|
|
688
|
+
):
|
|
689
|
+
counts[table] = int(conn.execute(f"SELECT COUNT(*) AS n FROM {table}").fetchone()["n"])
|
|
690
|
+
human = conn.execute(
|
|
691
|
+
"SELECT COUNT(*) AS n FROM subjective_fields WHERE source = ? AND superseded_at IS NULL",
|
|
692
|
+
(HUMAN_SOURCE,),
|
|
693
|
+
).fetchone()
|
|
694
|
+
return {
|
|
695
|
+
"success": True,
|
|
696
|
+
"project_root": project_root,
|
|
697
|
+
"db_path": timeline_brain_db.db_path_for_project(project_root),
|
|
698
|
+
"schema_version": version,
|
|
699
|
+
"canonical": version >= 9,
|
|
700
|
+
"counts": counts,
|
|
701
|
+
"current_human_fields": int(human["n"]),
|
|
702
|
+
}
|
|
703
|
+
except (sqlite3.Error, OSError) as exc:
|
|
704
|
+
return {"success": False, "error": f"{type(exc).__name__}: {exc}"}
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
# ── corrections (row level) ──────────────────────────────────────────────────
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def record_human_correction(
|
|
711
|
+
project_root: str,
|
|
712
|
+
*,
|
|
713
|
+
clip_ref: Any,
|
|
714
|
+
entity_type: str,
|
|
715
|
+
entity_uuid: Any,
|
|
716
|
+
field_path: str,
|
|
717
|
+
value: Any,
|
|
718
|
+
author: str = "unknown",
|
|
719
|
+
reason: Optional[str] = None,
|
|
720
|
+
confidence: Optional[str] = None,
|
|
721
|
+
) -> Dict[str, Any]:
|
|
722
|
+
"""Row-level human correction: supersede current value, append changelog.
|
|
723
|
+
|
|
724
|
+
`entity_uuid` accepts a shot_uuid OR a shot_index (resolved against the
|
|
725
|
+
clip's shots), matching the sidecar contract.
|
|
726
|
+
"""
|
|
727
|
+
with timeline_brain_db.transaction(project_root) as conn:
|
|
728
|
+
clip_uuid = resolve_clip_uuid(conn, clip_ref)
|
|
729
|
+
if not clip_uuid:
|
|
730
|
+
return {"success": False, "error": f"clip not found in DB: {clip_ref}"}
|
|
731
|
+
target_uuid = clip_uuid
|
|
732
|
+
if entity_type == "shot":
|
|
733
|
+
candidate = str(entity_uuid)
|
|
734
|
+
row = conn.execute(
|
|
735
|
+
"SELECT shot_uuid FROM shots WHERE clip_uuid = ? AND shot_uuid = ?",
|
|
736
|
+
(clip_uuid, candidate),
|
|
737
|
+
).fetchone()
|
|
738
|
+
if row is None:
|
|
739
|
+
try:
|
|
740
|
+
row = conn.execute(
|
|
741
|
+
"SELECT shot_uuid FROM shots WHERE clip_uuid = ? AND shot_index = ?",
|
|
742
|
+
(clip_uuid, int(candidate)),
|
|
743
|
+
).fetchone()
|
|
744
|
+
except (TypeError, ValueError):
|
|
745
|
+
row = None
|
|
746
|
+
if row is None:
|
|
747
|
+
return {"success": False, "error": f"shot not found in DB: {entity_uuid}"}
|
|
748
|
+
target_uuid = str(row["shot_uuid"])
|
|
749
|
+
stats = _write_subjective(
|
|
750
|
+
conn,
|
|
751
|
+
entity_type,
|
|
752
|
+
target_uuid,
|
|
753
|
+
{field_path: value},
|
|
754
|
+
source=HUMAN_SOURCE,
|
|
755
|
+
author=author,
|
|
756
|
+
confidence=confidence,
|
|
757
|
+
reason=reason,
|
|
758
|
+
overwrite_human=True,
|
|
759
|
+
)
|
|
760
|
+
return {
|
|
761
|
+
"success": True,
|
|
762
|
+
"clip_uuid": clip_uuid,
|
|
763
|
+
"entity_type": entity_type,
|
|
764
|
+
"entity_uuid": target_uuid,
|
|
765
|
+
"field_path": field_path,
|
|
766
|
+
"written": stats["written"],
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def clear_human_field(
|
|
771
|
+
project_root: str,
|
|
772
|
+
*,
|
|
773
|
+
clip_ref: Any,
|
|
774
|
+
entity_type: str,
|
|
775
|
+
entity_uuid: Any,
|
|
776
|
+
field_path: str,
|
|
777
|
+
author: str = "unknown",
|
|
778
|
+
reason: Optional[str] = None,
|
|
779
|
+
) -> Dict[str, Any]:
|
|
780
|
+
"""Revert a field to machine-derived: supersede the current human row.
|
|
781
|
+
|
|
782
|
+
The machine value lives in the report blob, so superseding the human row
|
|
783
|
+
is all the export overlay needs to fall back to it.
|
|
784
|
+
"""
|
|
785
|
+
now = _now()
|
|
786
|
+
with timeline_brain_db.transaction(project_root) as conn:
|
|
787
|
+
clip_uuid = resolve_clip_uuid(conn, clip_ref)
|
|
788
|
+
if not clip_uuid:
|
|
789
|
+
return {"success": False, "error": f"clip not found in DB: {clip_ref}"}
|
|
790
|
+
target_uuid = clip_uuid
|
|
791
|
+
if entity_type == "shot":
|
|
792
|
+
candidate = str(entity_uuid)
|
|
793
|
+
row = conn.execute(
|
|
794
|
+
"SELECT shot_uuid FROM shots WHERE clip_uuid = ? AND shot_uuid = ?",
|
|
795
|
+
(clip_uuid, candidate),
|
|
796
|
+
).fetchone()
|
|
797
|
+
if row is None:
|
|
798
|
+
try:
|
|
799
|
+
row = conn.execute(
|
|
800
|
+
"SELECT shot_uuid FROM shots WHERE clip_uuid = ? AND shot_index = ?",
|
|
801
|
+
(clip_uuid, int(candidate)),
|
|
802
|
+
).fetchone()
|
|
803
|
+
except (TypeError, ValueError):
|
|
804
|
+
row = None
|
|
805
|
+
if row is None:
|
|
806
|
+
return {"success": False, "error": f"shot not found in DB: {entity_uuid}"}
|
|
807
|
+
target_uuid = str(row["shot_uuid"])
|
|
808
|
+
existing = conn.execute(
|
|
809
|
+
"""
|
|
810
|
+
SELECT * FROM subjective_fields
|
|
811
|
+
WHERE entity_type = ? AND entity_uuid = ? AND field_path = ?
|
|
812
|
+
AND superseded_at IS NULL AND source = ?
|
|
813
|
+
""",
|
|
814
|
+
(entity_type, target_uuid, field_path, HUMAN_SOURCE),
|
|
815
|
+
).fetchone()
|
|
816
|
+
if existing is None:
|
|
817
|
+
return {"success": True, "cleared": 0, "note": "no current human row"}
|
|
818
|
+
conn.execute(
|
|
819
|
+
"UPDATE subjective_fields SET superseded_at = ? WHERE id = ?",
|
|
820
|
+
(now, existing["id"]),
|
|
821
|
+
)
|
|
822
|
+
conn.execute(
|
|
823
|
+
"""
|
|
824
|
+
INSERT INTO field_changelog
|
|
825
|
+
(entity_type, entity_uuid, field_path, previous_value_json,
|
|
826
|
+
new_value_json, previous_source, new_source, previous_author,
|
|
827
|
+
new_author, change_reason, timestamp)
|
|
828
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
829
|
+
""",
|
|
830
|
+
(
|
|
831
|
+
entity_type,
|
|
832
|
+
target_uuid,
|
|
833
|
+
field_path,
|
|
834
|
+
existing["value_json"],
|
|
835
|
+
_dumps(None),
|
|
836
|
+
existing["source"],
|
|
837
|
+
"machine_revert",
|
|
838
|
+
existing["author"],
|
|
839
|
+
author,
|
|
840
|
+
reason or "reverted to machine-derived",
|
|
841
|
+
now,
|
|
842
|
+
),
|
|
843
|
+
)
|
|
844
|
+
return {"success": True, "cleared": 1, "clip_uuid": clip_uuid, "entity_uuid": target_uuid}
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def _ingest_corrections_sidecar(project_root: str, clip_dir_path: str, clip_uuid: str) -> int:
|
|
848
|
+
"""Import human rows from a corrections.json sidecar (db_ingest path)."""
|
|
849
|
+
path = os.path.join(clip_dir_path, "corrections.json")
|
|
850
|
+
if not os.path.isfile(path):
|
|
851
|
+
return 0
|
|
852
|
+
try:
|
|
853
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
854
|
+
data = json.load(handle)
|
|
855
|
+
except (OSError, json.JSONDecodeError):
|
|
856
|
+
return 0
|
|
857
|
+
current = data.get("current") if isinstance(data.get("current"), dict) else {}
|
|
858
|
+
imported = 0
|
|
859
|
+
for key, entry in current.items():
|
|
860
|
+
if not isinstance(entry, dict) or entry.get("source") != HUMAN_SOURCE:
|
|
861
|
+
continue
|
|
862
|
+
parts = str(key).split(":", 2)
|
|
863
|
+
if len(parts) != 3:
|
|
864
|
+
continue
|
|
865
|
+
entity_type, entity_uuid, field_path = parts
|
|
866
|
+
if entity_type not in ("clip", "shot"):
|
|
867
|
+
continue
|
|
868
|
+
result = record_human_correction(
|
|
869
|
+
project_root,
|
|
870
|
+
clip_ref=clip_uuid,
|
|
871
|
+
entity_type=entity_type,
|
|
872
|
+
entity_uuid=clip_uuid if entity_type == "clip" else entity_uuid,
|
|
873
|
+
field_path=field_path,
|
|
874
|
+
value=entry.get("value"),
|
|
875
|
+
author=str(entry.get("author") or "unknown"),
|
|
876
|
+
reason="imported from corrections.json",
|
|
877
|
+
confidence=entry.get("confidence"),
|
|
878
|
+
)
|
|
879
|
+
if result.get("success") and result.get("written"):
|
|
880
|
+
imported += 1
|
|
881
|
+
return imported
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def ingest_project(project_root: str) -> Dict[str, Any]:
|
|
885
|
+
"""Walk clips/ and ingest every analysis.json + corrections.json sidecar.
|
|
886
|
+
|
|
887
|
+
The migration entry point for projects analyzed before v9. Idempotent.
|
|
888
|
+
"""
|
|
889
|
+
ma = _ma()
|
|
890
|
+
root = ma.normalize_path(project_root)
|
|
891
|
+
clips_root = os.path.join(root, "clips")
|
|
892
|
+
ingested: List[Dict[str, Any]] = []
|
|
893
|
+
errors: List[Dict[str, Any]] = []
|
|
894
|
+
corrections_imported = 0
|
|
895
|
+
if os.path.isdir(clips_root):
|
|
896
|
+
for entry in sorted(os.listdir(clips_root)):
|
|
897
|
+
clip_dir_path = os.path.join(clips_root, entry)
|
|
898
|
+
report_path = os.path.join(clip_dir_path, "analysis.json")
|
|
899
|
+
if not os.path.isfile(report_path):
|
|
900
|
+
continue
|
|
901
|
+
try:
|
|
902
|
+
with open(report_path, "r", encoding="utf-8") as handle:
|
|
903
|
+
report = json.load(handle)
|
|
904
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
905
|
+
errors.append({"clip_dir": entry, "error": f"{type(exc).__name__}: {exc}"})
|
|
906
|
+
continue
|
|
907
|
+
try:
|
|
908
|
+
result = ingest_report(root, report, clip_dir=clip_dir_path)
|
|
909
|
+
except (sqlite3.Error, ValueError) as exc:
|
|
910
|
+
errors.append({"clip_dir": entry, "error": f"{type(exc).__name__}: {exc}"})
|
|
911
|
+
continue
|
|
912
|
+
if result.get("success"):
|
|
913
|
+
corrections_imported += _ingest_corrections_sidecar(
|
|
914
|
+
root, clip_dir_path, str(result["clip_uuid"])
|
|
915
|
+
)
|
|
916
|
+
ingested.append({"clip_dir": entry, "clip_uuid": result["clip_uuid"]})
|
|
917
|
+
else:
|
|
918
|
+
errors.append({"clip_dir": entry, "error": result.get("error")})
|
|
919
|
+
return {
|
|
920
|
+
"success": not errors,
|
|
921
|
+
"project_root": root,
|
|
922
|
+
"ingested_count": len(ingested),
|
|
923
|
+
"ingested": ingested,
|
|
924
|
+
"corrections_imported": corrections_imported,
|
|
925
|
+
"errors": errors,
|
|
926
|
+
"status": db_status(root),
|
|
927
|
+
}
|